Java高并发专题之14、JUC中的LockSupport工具类

本文主要内容

  1. 讲解3种让线程等待和唤醒的方法,每种方法配合具体的示例
  2. 介绍LockSupport主要用法
  3. 对比3种方式,了解他们之间的区别

LockSupport位于java.util.concurrent简称juc)包中,算是juc中一个基础类,juc中很多地方都会使用LockSupport,非常重要,希望大家一定要掌握。

关于线程等待/唤醒的方法,前面的文章中我们已经讲过2种了:

  1. 方式1:使用Object中的wait()方法让线程等待,使用Object中的notify()方法唤醒线程
  2. 方式2:使用juc包中Condition的await()方法让线程等待,使用signal()方法唤醒线程

这2种方式,我们先来看一下示例。

使用Object类中的方法实现线程等待和唤醒

示例1:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. /**
  4. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  5. */
  6. public class Demo1 {
  7. static Object lock = new Object();
  8. public static void main(String[] args) throws InterruptedException {
  9. Thread t1 = new Thread(() -> {
  10. synchronized (lock) {
  11. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  12. try {
  13. lock.wait();
  14. } catch (InterruptedException e) {
  15. e.printStackTrace();
  16. }
  17. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  18. }
  19. });
  20. t1.setName("t1");
  21. t1.start();
  22. //休眠5秒
  23. TimeUnit.SECONDS.sleep(5);
  24. synchronized (lock) {
  25. lock.notify();
  26. }
  27. }
  28. }

输出:

  1. 1563592938744,t1 start!
  2. 1563592943745,t1 被唤醒!

t1线程中调用lock.wait()方法让t1线程等待,主线程中休眠5秒之后,调用lock.notify()方法唤醒了t1线程,输出的结果中,两行结果相差5秒左右,程序正常退出。

示例2

我们把上面代码中main方法内部改一下,删除了synchronized关键字,看看有什么效果:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. /**
  4. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  5. */
  6. public class Demo2 {
  7. static Object lock = new Object();
  8. public static void main(String[] args) throws InterruptedException {
  9. Thread t1 = new Thread(() -> {
  10. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  11. try {
  12. lock.wait();
  13. } catch (InterruptedException e) {
  14. e.printStackTrace();
  15. }
  16. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  17. });
  18. t1.setName("t1");
  19. t1.start();
  20. //休眠5秒
  21. TimeUnit.SECONDS.sleep(5);
  22. lock.notify();
  23. }
  24. }

运行结果:

  1. Exception in thread "t1" java.lang.IllegalMonitorStateException
  2. 1563593178811,t1 start!
  3. at java.lang.Object.wait(Native Method)
  4. at java.lang.Object.wait(Object.java:502)
  5. at com.itsoku.chat10.Demo2.lambda$main$0(Demo2.java:16)
  6. at java.lang.Thread.run(Thread.java:745)
  7. Exception in thread "main" java.lang.IllegalMonitorStateException
  8. at java.lang.Object.notify(Native Method)
  9. at com.itsoku.chat10.Demo2.main(Demo2.java:26)

上面代码中将synchronized去掉了,发现调用wait()方法和调用notify()方法都抛出了IllegalMonitorStateException异常,原因:Object类中的wait、notify、notifyAll用于线程等待和唤醒的方法,都必须在同步代码中运行(必须用到关键字synchronized)

示例3

唤醒方法在等待方法之前执行,线程能够被唤醒么?代码如下:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. /**
  4. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  5. */
  6. public class Demo3 {
  7. static Object lock = new Object();
  8. public static void main(String[] args) throws InterruptedException {
  9. Thread t1 = new Thread(() -> {
  10. try {
  11. TimeUnit.SECONDS.sleep(5);
  12. } catch (InterruptedException e) {
  13. e.printStackTrace();
  14. }
  15. synchronized (lock) {
  16. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  17. try {
  18. //休眠3秒
  19. lock.wait();
  20. } catch (InterruptedException e) {
  21. e.printStackTrace();
  22. }
  23. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  24. }
  25. });
  26. t1.setName("t1");
  27. t1.start();
  28. //休眠1秒之后唤醒lock对象上等待的线程
  29. TimeUnit.SECONDS.sleep(1);
  30. synchronized (lock) {
  31. lock.notify();
  32. }
  33. System.out.println("lock.notify()执行完毕");
  34. }
  35. }

运行代码,输出结果:

  1. lock.notify()执行完毕
  2. 1563593869797,t1 start!

输出了上面2行之后,程序一直无法结束,t1线程调用wait()方法之后无法被唤醒了,从输出中可见,notify()方法在wait()方法之前执行了,等待的线程无法被唤醒了。说明:唤醒方法在等待方法之前执行,线程无法被唤醒。

关于Object类中的用户线程等待和唤醒的方法,总结一下:

  1. wait()/notify()/notifyAll()方法都必须放在同步代码(必须在synchronized内部执行)中执行,需要先获取锁
  2. 线程唤醒的方法(notify、notifyAll)需要在等待的方法(wait)之后执行,等待中的线程才可能会被唤醒,否则无法唤醒

使用Condition实现线程的等待和唤醒

Condition的使用,前面的文章讲过,对这块不熟悉的可以移步JUC中Condition的使用,关于Condition我们准备了3个示例。

示例1

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. import java.util.concurrent.locks.Condition;
  4. import java.util.concurrent.locks.ReentrantLock;
  5. /**
  6. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  7. */
  8. public class Demo4 {
  9. static ReentrantLock lock = new ReentrantLock();
  10. static Condition condition = lock.newCondition();
  11. public static void main(String[] args) throws InterruptedException {
  12. Thread t1 = new Thread(() -> {
  13. lock.lock();
  14. try {
  15. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  16. try {
  17. condition.await();
  18. } catch (InterruptedException e) {
  19. e.printStackTrace();
  20. }
  21. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  22. } finally {
  23. lock.unlock();
  24. }
  25. });
  26. t1.setName("t1");
  27. t1.start();
  28. //休眠5秒
  29. TimeUnit.SECONDS.sleep(5);
  30. lock.lock();
  31. try {
  32. condition.signal();
  33. } finally {
  34. lock.unlock();
  35. }
  36. }
  37. }

输出:

  1. 1563594349632,t1 start!
  2. 1563594354634,t1 被唤醒!

t1线程启动之后调用condition.await()方法将线程处于等待中,主线程休眠5秒之后调用condition.signal()方法将t1线程唤醒成功,输出结果中2个时间戳相差5秒。

示例2

我们将上面代码中的lock.lock()、lock.unlock()去掉,看看会发生什么。代码:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. import java.util.concurrent.locks.Condition;
  4. import java.util.concurrent.locks.ReentrantLock;
  5. /**
  6. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  7. */
  8. public class Demo5 {
  9. static ReentrantLock lock = new ReentrantLock();
  10. static Condition condition = lock.newCondition();
  11. public static void main(String[] args) throws InterruptedException {
  12. Thread t1 = new Thread(() -> {
  13. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  14. try {
  15. condition.await();
  16. } catch (InterruptedException e) {
  17. e.printStackTrace();
  18. }
  19. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  20. });
  21. t1.setName("t1");
  22. t1.start();
  23. //休眠5秒
  24. TimeUnit.SECONDS.sleep(5);
  25. condition.signal();
  26. }
  27. }

输出:

  1. Exception in thread "t1" java.lang.IllegalMonitorStateException
  2. 1563594654865,t1 start!
  3. at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
  4. at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
  5. at java.util.concurrent.locks.AbstractQueuedSynchronizer.fullyRelease(AbstractQueuedSynchronizer.java:1723)
  6. at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2036)
  7. at com.itsoku.chat10.Demo5.lambda$main$0(Demo5.java:19)
  8. at java.lang.Thread.run(Thread.java:745)
  9. Exception in thread "main" java.lang.IllegalMonitorStateException
  10. at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.signal(AbstractQueuedSynchronizer.java:1939)
  11. at com.itsoku.chat10.Demo5.main(Demo5.java:29)

有异常发生,condition.await();condition.signal();都触发了IllegalMonitorStateException异常。原因:调用condition中线程等待和唤醒的方法的前提是必须要先获取lock的锁

示例3

唤醒代码在等待之前执行,线程能够被唤醒么?代码如下:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. import java.util.concurrent.locks.Condition;
  4. import java.util.concurrent.locks.ReentrantLock;
  5. /**
  6. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  7. */
  8. public class Demo6 {
  9. static ReentrantLock lock = new ReentrantLock();
  10. static Condition condition = lock.newCondition();
  11. public static void main(String[] args) throws InterruptedException {
  12. Thread t1 = new Thread(() -> {
  13. try {
  14. TimeUnit.SECONDS.sleep(5);
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. }
  18. lock.lock();
  19. try {
  20. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  21. try {
  22. condition.await();
  23. } catch (InterruptedException e) {
  24. e.printStackTrace();
  25. }
  26. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  27. } finally {
  28. lock.unlock();
  29. }
  30. });
  31. t1.setName("t1");
  32. t1.start();
  33. //休眠5秒
  34. TimeUnit.SECONDS.sleep(1);
  35. lock.lock();
  36. try {
  37. condition.signal();
  38. } finally {
  39. lock.unlock();
  40. }
  41. System.out.println(System.currentTimeMillis() + ",condition.signal();执行完毕");
  42. }
  43. }

运行结果:

  1. 1563594886532,condition.signal();执行完毕
  2. 1563594890532,t1 start!

输出上面2行之后,程序无法结束,代码结合输出可以看出signal()方法在await()方法之前执行的,最终t1线程无法被唤醒,导致程序无法结束。

关于Condition中方法使用总结:

  1. 使用Condtion中的线程等待和唤醒方法之前,需要先获取锁。否者会报IllegalMonitorStateException异常
  2. signal()方法先于await()方法之前调用,线程无法被唤醒

Object和Condition的局限性

关于Object和Condtion中线程等待和唤醒的局限性,有以下几点:

  1. 2中方式中的让线程等待和唤醒的方法能够执行的先决条件是:线程需要先获取锁
  2. 唤醒方法需要在等待方法之后调用,线程才能够被唤醒

关于这2点,LockSupport都不需要,就能实现线程的等待和唤醒。下面我们来说一下LockSupport类。

LockSupport类介绍

LockSupport类可以阻塞当前线程以及唤醒指定被阻塞的线程。主要是通过park()unpark(thread)方法来实现阻塞和唤醒线程的操作的。

每个线程都有一个许可(permit),permit只有两个值1和0,默认是0。

  1. 当调用unpark(thread)方法,就会将thread线程的许可permit设置成1(注意多次调用unpark方法,不会累加,permit值还是1)。
  2. 当调用park()方法,如果当前线程的permit是1,那么将permit设置为0,并立即返回。如果当前线程的permit是0,那么当前线程就会阻塞,直到别的线程将当前线程的permit设置为1时,park方法会被唤醒,然后会将permit再次设置为0,并返回。

注意:因为permit默认是0,所以一开始调用park()方法,线程必定会被阻塞。调用unpark(thread)方法后,会自动唤醒thread线程,即park方法立即返回。

LockSupport中常用的方法

阻塞线程

  • void park():阻塞当前线程,如果调用unpark方法或者当前线程被中断,从能从park()方法中返回

  • void park(Object blocker):功能同方法1,入参增加一个Object对象,用来记录导致线程阻塞的阻塞对象,方便进行问题排查

  • void parkNanos(long nanos):阻塞当前线程,最长不超过nanos纳秒,增加了超时返回的特性

  • void parkNanos(Object blocker, long nanos):功能同方法3,入参增加一个Object对象,用来记录导致线程阻塞的阻塞对象,方便进行问题排查

  • void parkUntil(long deadline):阻塞当前线程,直到deadline,deadline是一个绝对时间,表示某个时间的毫秒格式

  • void parkUntil(Object blocker, long deadline):功能同方法5,入参增加一个Object对象,用来记录导致线程阻塞的阻塞对象,方便进行问题排查;

唤醒线程

  • void unpark(Thread thread):唤醒处于阻塞状态的指定线程

示例1

主线程线程等待5秒之后,唤醒t1线程,代码如下:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. import java.util.concurrent.locks.Condition;
  4. import java.util.concurrent.locks.LockSupport;
  5. import java.util.concurrent.locks.ReentrantLock;
  6. /**
  7. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  8. */
  9. public class Demo7 {
  10. public static void main(String[] args) throws InterruptedException {
  11. Thread t1 = new Thread(() -> {
  12. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  13. LockSupport.park();
  14. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  15. });
  16. t1.setName("t1");
  17. t1.start();
  18. //休眠5秒
  19. TimeUnit.SECONDS.sleep(5);
  20. LockSupport.unpark(t1);
  21. System.out.println(System.currentTimeMillis() + ",LockSupport.unpark();执行完毕");
  22. }
  23. }

输出:

  1. 1563597664321,t1 start!
  2. 1563597669323,LockSupport.unpark();执行完毕
  3. 1563597669323,t1 被唤醒!

t1中调用LockSupport.park();让当前线程t1等待,主线程休眠了5秒之后,调用LockSupport.unpark(t1);将t1线程唤醒,输出结果中1、3行结果相差5秒左右,说明t1线程等待5秒之后,被唤醒了。

LockSupport.park();无参数,内部直接会让当前线程处于等待中;unpark方法传递了一个线程对象作为参数,表示将对应的线程唤醒。

示例2

唤醒方法放在等待方法之前执行,看一下线程是否能够被唤醒呢?代码如下:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. import java.util.concurrent.locks.LockSupport;
  4. /**
  5. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  6. */
  7. public class Demo8 {
  8. public static void main(String[] args) throws InterruptedException {
  9. Thread t1 = new Thread(() -> {
  10. try {
  11. TimeUnit.SECONDS.sleep(5);
  12. } catch (InterruptedException e) {
  13. e.printStackTrace();
  14. }
  15. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  16. LockSupport.park();
  17. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  18. });
  19. t1.setName("t1");
  20. t1.start();
  21. //休眠1秒
  22. TimeUnit.SECONDS.sleep(1);
  23. LockSupport.unpark(t1);
  24. System.out.println(System.currentTimeMillis() + ",LockSupport.unpark();执行完毕");
  25. }
  26. }

输出:

  1. 1563597994295,LockSupport.unpark();执行完毕
  2. 1563597998296,t1 start!
  3. 1563597998296,t1 被唤醒!

代码中启动t1线程,t1线程内部休眠了5秒,然后主线程休眠1秒之后,调用了LockSupport.unpark(t1);唤醒线程t1,此时LockSupport.park();方法还未执行,说明唤醒方法在等待方法之前执行的;输出结果中2、3行结果时间一样,表示LockSupport.park();没有阻塞了,是立即返回的。

说明:唤醒方法在等待方法之前执行,线程也能够被唤醒,这点是另外2中方法无法做到的。Object和Condition中的唤醒必须在等待之后调用,线程才能被唤醒。而LockSupport中,唤醒的方法不管是在等待之前还是在等待之后调用,线程都能够被唤醒。

示例3

park()让线程等待之后,是否能够响应线程中断?代码如下:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. import java.util.concurrent.locks.LockSupport;
  4. /**
  5. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  6. */
  7. public class Demo9 {
  8. public static void main(String[] args) throws InterruptedException {
  9. Thread t1 = new Thread(() -> {
  10. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " start!");
  11. System.out.println(Thread.currentThread().getName() + ",park()之前中断标志:" + Thread.currentThread().isInterrupted());
  12. LockSupport.park();
  13. System.out.println(Thread.currentThread().getName() + ",park()之后中断标志:" + Thread.currentThread().isInterrupted());
  14. System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 被唤醒!");
  15. });
  16. t1.setName("t1");
  17. t1.start();
  18. //休眠5秒
  19. TimeUnit.SECONDS.sleep(5);
  20. t1.interrupt();
  21. }
  22. }

输出:

  1. 1563598536736,t1 start!
  2. t1,park()之前中断标志:false
  3. t1,park()之后中断标志:true
  4. 1563598541736,t1 被唤醒!

t1线程中调用了park()方法让线程等待,主线程休眠了5秒之后,调用t1.interrupt();给线程t1发送中断信号,然后线程t1从等待中被唤醒了,输出结果中的1、4行结果相差5秒左右,刚好是主线程休眠了5秒之后将t1唤醒了。结论:park方法可以相应线程中断。

LockSupport.park方法让线程等待之后,唤醒方式有2种:

  1. 调用LockSupport.unpark方法
  2. 调用等待线程的interrupt()方法,给等待的线程发送中断信号,可以唤醒线程

示例4

LockSupport有几个阻塞放有一个blocker参数,这个参数什么意思,上一个实例代码,大家一看就懂了:

  1. package com.itsoku.chat10;
  2. import java.util.concurrent.TimeUnit;
  3. import java.util.concurrent.locks.LockSupport;
  4. /**
  5. * 微信公众号:路人甲Java,专注于java技术分享(带你玩转 爬虫、分布式事务、异步消息服务、任务调度、分库分表、大数据等),喜欢请关注!
  6. */
  7. public class Demo10 {
  8. static class BlockerDemo {
  9. }
  10. public static void main(String[] args) throws InterruptedException {
  11. Thread t1 = new Thread(() -> {
  12. LockSupport.park();
  13. });
  14. t1.setName("t1");
  15. t1.start();
  16. Thread t2 = new Thread(() -> {
  17. LockSupport.park(new BlockerDemo());
  18. });
  19. t2.setName("t2");
  20. t2.start();
  21. }
  22. }

运行上面代码,然后用jstack查看一下线程的堆栈信息:

  1. "t2" #13 prio=5 os_prio=0 tid=0x00000000293ea800 nid=0x91e0 waiting on condition [0x0000000029c3f000]
  2. java.lang.Thread.State: WAITING (parking)
  3. at sun.misc.Unsafe.park(Native Method)
  4. - parking to wait for <0x00000007180bfeb0> (a com.itsoku.chat10.Demo10$BlockerDemo)
  5. at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
  6. at com.itsoku.chat10.Demo10.lambda$main$1(Demo10.java:22)
  7. at com.itsoku.chat10.Demo10$$Lambda$2/824909230.run(Unknown Source)
  8. at java.lang.Thread.run(Thread.java:745)
  9. "t1" #12 prio=5 os_prio=0 tid=0x00000000293ea000 nid=0x9d4 waiting on condition [0x0000000029b3f000]
  10. java.lang.Thread.State: WAITING (parking)
  11. at sun.misc.Unsafe.park(Native Method)
  12. at java.util.concurrent.locks.LockSupport.park(LockSupport.java:304)
  13. at com.itsoku.chat10.Demo10.lambda$main$0(Demo10.java:16)
  14. at com.itsoku.chat10.Demo10$$Lambda$1/1389133897.run(Unknown Source)
  15. at java.lang.Thread.run(Thread.java:745)

代码中,线程t1和t2的不同点是,t2中调用park方法传入了一个BlockerDemo对象,从上面的线程堆栈信息中,发现t2线程的堆栈信息中多了一行- parking to wait for <0x00000007180bfeb0> (a com.itsoku.chat10.Demo10$BlockerDemo),刚好是传入的BlockerDemo对象,park传入的这个参数可以让我们在线程堆栈信息中方便排查问题,其他暂无他用。

LockSupport的其他等待方法,包含有超时时间了,过了超时时间,等待方法会自动返回,让线程继续运行,这些方法在此就不提供示例了,有兴趣的朋友可以自己动动手,练一练。

线程等待和唤醒的3种方式做个对比

到目前为止,已经说了3种让线程等待和唤醒的方法了

  1. 方式1:Object中的wait、notify、notifyAll方法
  2. 方式2:juc中Condition接口提供的await、signal、signalAll方法
  3. 方式3:juc中的LockSupport提供的park、unpark方法

3种方式对比:

Object Condtion LockSupport
前置条件 需要在synchronized中运行 需要先获取Lock的锁
无限等待 支持 支持 支持
超时等待 支持 支持 支持
等待到将来某个时间返回 不支持 支持 支持
等待状态中释放锁 会释放 会释放 不会释放
唤醒方法先于等待方法执行,能否唤醒线程 可以
是否能响应线程中断
线程中断是否会清除中断标志
是否支持等待状态中不响应中断 不支持 支持 不支持
来源:http://itsoku.com/course/1/14
posted @ 2022-05-04 19:44  程序员小明1024  阅读(39)  评论(0编辑  收藏  举报