写点什么

40 个问题让你快速掌握 Java 多线程的精髓

发布于: 2021 年 04 月 25 日

​​摘要:多线程可以理解为在同一个程序中能够同时运行多个不同的线程来执行不同的任务,这些线程可以同时利用 CPU 的多个核心运行。


本文分享自华为云社区《对Java多线程的用法感到一头乱麻?40个问题让你快速掌握多线程的精髓》,原文作者:breakDraw。


多线程可以理解为在同一个程序中能够同时运行多个不同的线程来执行不同的任务,这些线程可以同时利用 CPU 的多个核心运行。多线程编程能够最大限度的利用 CPU 的资源。本文将通过以下几个方向为大家讲解多线程的用法。


  • 1.Thread 类基础

  • 2.synchronized 关键字

  • 3.其他的同步工具

  1. CountDownLatch

  2. FutureTask

  3. Semaphore

  4. CyclicBarrier

  5. Exchanger

  6. 原子类 AtomicXXX

  • 4.线程池

  • 5.Thread 状态转换

  • 6.Volatile

  • 7.线程群组


一、Thread 类基础


Q: Thread 的 deprecated 过期方法是哪 3 个?作用是啥


A:

  • stop(), 终止线程的执行。

  • suspend(), 暂停线程执行。

  • resume(), 恢复线程执行。


Q: 废弃 stop 的原因是啥?


A:调用 stop 时,会直接终止线程并释放线程上已锁定的锁,线程内部无法感知,并且不会做线程内的 catch 操作!即线程内部不会处理 stop 后的烂摊子。如果其他线程等在等着上面的锁去取数据, 那么拿到的可能是 1 个半成品。


变成题目的话应该是下面这样,问会输出什么?


public class Test {
public static void main(String[] args) throws InterruptedException {
System.out.println("start"); Thread thread = new MyThread(); thread.start(); Thread.sleep(1000); thread.stop(); // thread.interrupt();
}}
class MyThread extends Thread { public void run() { try { System.out.println("run"); Thread.sleep(5000); } catch (Exception e) { //处理烂摊子,清理资源 System.out.println("clear resource!"); } }}
复制代码


答案是输出 start 和 run,但是不会输出 clear resource


Q: stop 的替代方法是什么?


A: interrupt()。调用 thread.interrupt()终止时, 不会直接释放锁,可通过调用 interrupt()或者捕捉 sleep 产生的中断异常,来判断是否被终止,并处理烂摊子。


上题把 thread.stop()改成 thread.interrupt(),在 Thread.sleep()过程中就会抛出 interrupException(注意,InterrupExcetpion 是 sleep 抛出的)因此就会输出 clear resource。如果没有做 sleep 操作, 可以用 isInterrupted()来判断自己这个线程是否被终止了,来做清理。


另外注意一下 interrupt 和 isInterrupted 的区别:


Q: suspend/resume 的废弃原因是什么?


A: :调用 suspend 不会释放锁。如果线程 A 暂停后,他的 resume 是由线程 B 来调用的,但是线程 B 又依赖 A 里的某个锁,那么就死锁了。例如下面这个例子,就要知道会引发死锁:


    public class Test {    public static Object lockObject = new Object();    public static void main(String[] args) throws InterruptedException {
System.out.println("start"); Thread thread = new MyThread(); thread.start(); Thread.sleep(1000);
System.out.println("主线程试图占用lockObject锁资源"); synchronized (Test.lockObject) { // 用Test.lockObject做一些事 System.out.println("做一些事"); } System.out.println("恢复"); thread.resume();
}}
class MyThread extends Thread { public void run() { try { synchronized (Test.lockObject) { System.out.println("占用Test.lockObject"); suspend(); } System.out.println("MyThread释放TestlockObject锁资源"); } catch (Exception e){} }}
复制代码


答案输出


MyThread 内部暂停后,外部的 main 因为没法拿到锁,所以无法执行后面的 resume 操作。


Q: 上题的 suspend 和 resume 可以怎么替换,来解决死锁问题?


A: 可以用 wait 和 noitfy 来处理(不过尽量不要这样设计,一般都是用 run 内部带 1 个 while 循环的)


   public class Test {    public static Object lockObject = new Object(); //拿来做临时锁对象    public static void main(String[] args) throws InterruptedException {
Thread thread = new MyThread(); thread.start(); Thread.sleep(1000);
System.out.println("主线程试图占用lockObject锁资源"); synchronized (Test.lockObject) { // 用Test.lockObject做一些事 System.out.println("做一些事"); } System.out.println("恢复");
synchronized (Test.lockObject) { Test.lockObject.notify(); }
}}
class MyThread extends Thread { public void run() { try { synchronized (Test.lockObject) { System.out.println("占用Test.lockObject"); Test.lockObject.wait(); } System.out.println("MyThread释放TestlockObject锁资源"); } catch (Exception e){} }}
复制代码


如此执行,结果正常:


Q: 下面这例子为什么会运行异常,抛出 IllegalMonitorStateException 错误?


public static void main(String[] args) throws InterruptedException {    Thread thread = new MyThread();    thread.start();    thread.notify();}
复制代码


A: notify 和 wait 的使用前提是必须持有这个对象的锁, 即 main 代码块 需要先持有 thread 对象的锁,才能使用 notify 去唤醒(wait 同理)。


改成下面就行了:


    Thread thread = new MyThread();    thread.start();    synchronized (thread) {        thread.notify();    }
复制代码


Q: Thread.sleep()和 Object.wait()的区别


A:sleep 不会释放对象锁, 而 wait 会释放对象锁。


Q:Runnable 接口和 Callable 的区别。


A: Callable 可以和 Futrue 配合,并且启动线程时用的时 call,能够拿到线程结束后的返回值,call 方法还能抛出异常。


Q:thread.alive()表示线程当前是否处于活跃/可用状态。活跃状态: 线程已经启动且尚未终止。线程处于正在运行或准备开始运行的状态,就认为线程是“存活的


thread.start()后,是否 alive()一定返回 true?


public class Main {    public static void main(String[] args) {        TestThread tt = new TestThread();        System.out.println("Begin == " + tt.isAlive());        tt.start();        System.out.println("end == " + tt.isAlive());    }}
复制代码


A:不一定,有可能在打印时,线程已经运行结束了,或者 start 后,还未真正启动起来(就是还没进入到 run 中)


Q: 线程 A 如下:


public class A extends Thread {    @Override    public void run() {        System.out.println("this.isAlive()=" + this.isAlive());    }}
复制代码


把线程 A 作为构造参数,传给线程 B


A a = new A();Thread b = new Thread(a);b.start()
复制代码


此时会打印什么?


A:此时会打印 false!



因为把 a 作为构造参数传入 b 中, b 执行 start 时, 实际上是在 B 线程中去调用了 A 对象的 run 方法,而不是启用了 A 线程。


如果改成


A a = new A();a.start()
复制代码


那么就会打印 true 了


Q:把 FutureTask 放进 Thread 中,并 start 后,会正常执行 callable 里的内容吗?


public static void main(String[] args) throws Exception {    Callable<Integer> callable = () -> {    System.out.println("call 100");    return 100;    };     FutureTask<Integer> task = new FutureTask<>(callable);    Thread thread = new Thread(task);    thread.start();}
复制代码


A:能正常打印

二、synchronized 关键字


  • 即可作为方法的修饰符,也可以作为代码块的修饰符

  • 注意修饰方法时,并不是这个方法上有锁, 而是调用该方法时,需要取该方法所在对象上的锁。

class A{     synchroized f(){     }   }
复制代码


即调用这个 f(), 并不是说 f 同一时刻只能进入一次,而是说进入 f 时,需要取到 A 上的锁。


Q: 调用下面的 f()时,会出现死锁吗?


class A{     synchroized f(){        t()     }      synchroized t(){     }}
复制代码


A:不会。1 个线程内, 可以重复进入 1 个对象的 synchroized 块。


  • 原理:

当线程请求自己的锁时。JVM 会记下锁的持有者,并且给这个锁计数为 1。如果该线程再次请求自己的锁,则可以再次进入,计数为 2。退出时计数-1,直到全部退出时才会释放锁。


Q:2 个线程同时调用 f1 和 f2 会产生同步吗?


class A{	private static synchronized void f1(){};	private synchronized void f2(){};}
复制代码


A:不会产生同步。二者不是 1 个锁。

f1 是类锁,等同于 synchronized(A.class)

f2 是对象锁。

三、其他的同步工具

CountDownLatch


final CountDownLatch latch = new CountDownLatch(2);
复制代码


2 是计数器初始值。


然后执行 latch.await()时, 就会阻塞,直到其他线程中把这个 latch 进行 latch.countDown(),并且计数器降低至 0。

  • 和 join 的区别:


join 阻塞时,是只等待单个线程的完成

而 CountDownLatch 可能是为了等待多个线程

Q: countDownLatch 的内部计数值能被重置吗?

A:不能重置了。如果要重新计数必须重新 new 一个。毕竟他的类名就叫 DownLatch

FutureTask

可以理解为一个支持有返回值的线程

FutureTask<Integer> task = new FutureTask<>(runable);

当调用 task.get()时,就能能达到线程里的返回值


Q:调用 futrueTask.get()时,这个是阻塞方法吗?如果是阻塞,什么时候会结束?

A:是阻塞方法。


  1. 线程跑完并返回结果

  2. 阻塞时间达到 futrueTask.get(xxx)里设定的 xxx 时间

  3. 线程出现异常 InterruptedException 或者 ExecutionException

  4. 线程被取消,抛出 CancellationException

Semaphore


信号量:就是操作系统里常见的那个概念,java 实现,用于各线程间进行资源协调。用 Semaphore(permits)构造一个包含 permits 个资源的信号量,然后某线程做了消费动作, 则执行 semaphore.acquire(),则会消费一个资源,如果某线程做了生产动作,则执行 semaphore.release(),则会释放一个资源(即新增一个资源)


更详细的信号量方法说明:https://blog.csdn.net/hanchao5272/article/details/79780045


Q: 信号量中,公平模式和非公平模式的区别?下面设成 true 就是公平模式


//new Semaphore(permits,fair):初始化许可证数量和是否公平模式的构造函数semaphore = new Semaphore(5, true);
复制代码

A:其实就是使用哪种公平锁还是非公平锁。

Java 并发中的 fairSync 和 NonfairSync 主要区别为:

  • 如果当前线程不是锁的占有者,则 NonfairSync 并不判断是否有等待队列,直接使用 compareAndSwap 去进行锁的占用,即谁正好抢到,就给谁用!

  • 如果当前线程不是锁的占有者,则 FairSync 则会判断当前是否有等待队列,如果有则将自己加到等待队列尾,即严格的先到先得!

CyclicBarrier

栅栏,一般是在线程中去调用的。它的构造需要指定 1 个线程数量,和栅栏被破坏前要执行的操作,每当有 1 个线程调用 barrier.await(),就会进入阻塞,同时 barrier 里的线程计数-1。当线程计数为 0 时, 调用栅栏里指定的那个操作后,然后破坏栅栏, 所有被阻塞在 await 上的线程继续往下走。

Exchanger

我理解为两方栅栏,用于交换数据。简单说就是一个线程在完成一定的事务后,想与另一个线程交换数据,则第一个先拿出数据的线程会一直等待第二个线程,直到第二个线程拿着数据到来时才能彼此交换对应数据。


原子类 AtomicXXX

就是内部已实现了原子同步机制 Q:下面输出什么?(考察 getAndAdd 的用法)

AtomicInteger num = new AtomicInteger(1);System.out.println(num.getAndAdd(1));System.out.println(num.get());
复制代码

A:输出 1、2 顾名思义, getAndAdd(),那么就是先 get,再加, 类似于 num++。如果是 addAndGet(),那么就是++num

Q:AtomicReference 和 AtomicInteger 的区别?A:AtomicInteger 是对整数的封装,而 AtomicReference 则对应普通的对象引用。也就是它可以保证你在修改对象引用时的线程安全性。即可能会有多个线程修改 atomicReference 里包含的引用。

  • 经典用法:

boolean exchanged = atomicStringReference.compareAndSet(initialReference, newReference)就是经典的 CAS 同步法

compreAndSet 它会将将引用与预期值(引用)进行比较,如果它们相等,则在 AtomicReference 对象内设置一个新的引用。类似于一个非负责的自旋锁。

  • AtomicReferenceArray 是原子数组, 可以进行一些原子的数组操作例如 set(index, value),

java 中已实现的全部原子类:

注意,没有 float,没有 short 和 byte。

四、线程池

Q: ThreadPoolExecutor 线程池构造参数中,corePoolSize 和 maximumPoolSize 有什么区别?A:当提交新线程到池中时

  • 如果当前线程数 < corePoolSize,则会创建新线程

  • 如果当前线程数=corePoolSize,则新线程被塞进一个队列中等待。

  • 如果队列也被塞满了,那么又会开始新建线程来运行任务,避免任务阻塞或者丢弃

  • 如果队列满了的情况下, 线程总数超过了 maxinumPoolSize,那么就抛异常或者阻塞(取决于队列性质)。

  • 调用 prestartCoreThread()可提前开启一个空闲的核心线程

  • 调用 prestartAllCoreThreads(),可提前创建 corePoolSize 个核心线程。


Q: 线程池的 keepalive 参数是干嘛的?

A:当线程数量在 corePoolSize 到 maxinumPoolSize 之间时, 如果有线程已跑完,且空闲时间超过 keepalive 时,则会被清除(注意只限于 corePoolSize 到 maxinumPoolsize 之间的线程)


Q: 线程池有哪三种队列策略?

A:

  1. 握手队列

相当于不排队的队列。可能造成线程数量无限增长直到超过 maxinumPoolSize(相当于 corePoolSize 没什么用了,只以 maxinumPoolSize 做上限)

2.无界队列

队列队长无限,即线程数量达到 corePoolSize 时,后面的线程只会在队列中等待。(相当于 maxinumPoolSize 没什么用了)

缺陷: 可能造成队列无限增长以至于 OOM

3.有界队列

Q: 线程池队列已满且 maxinumPoolSize 已满时,有哪些拒绝策略?

A:

  • AbortPolicy 默认策略:直接抛出 RejectedExecutionException 异常

  • DiscardPolicy 丢弃策略: 直接丢了,什么错误也不报

  • DiscardOldestPolicy 丢弃队头策略: 即把最先入队的人从队头扔出去,再尝试让该任务进入队尾(队头任务内心:不公平。。。。)

  • CallerRunsPolicy 调用者处理策略: 交给调用者所在线程自己去跑任务(即谁调用的 submit 或者 execute,他就自己去跑)

  • 也可以用实现自定义新的 RejectedExecutionHandler

Q:有以下五种 Executor 提供的线程池,注意记忆一下他们的用途,就能理解内部的原理了。

  • newCachedThreadPool: 缓存线程池

  • corePoolSize=0, maxinumPoolSize=+∞,队列长度=0 ,因此线程数量会在 corePoolSize 到 maxinumPoolSize 之间一直灵活缓存和变动, 且不存在队列等待的情况,一来任务我就创建,用完了会释放。

  • newFixedThreadPool :定长线程池

  • corePoolSize= maxinumPoolSize=构造参数值, 队列长度=+∞。因此不存在线程不够时扩充的情况

  • newScheduledThreadPool :定时器线程池

  • 提交定时任务用的,构造参数里会带定时器的间隔和单位。 其他和 FixedThreadPool 相同,属于定长线程池。

  • newSingleThreadExecutor : 单线程池

  • corePoolSize=maxinumPoolSize=1, 队列长度=+∞,只会跑一个任务, 所以其他的任务都会在队列中等待,因此会严格按照 FIFO 执行

  • newWorkStealingPool(继承自 ForkJoinPool ): 并行线程池

  • 如果你的任务执行时间很长,并且里面的任务运行并行跑的,那么他会把你的线程任务再细分到其他的线程来分治。ForkJoinPool 介绍:https://blog.csdn.net/m0_375428


Q: submit 和 execute 的区别是什么?

A:

  • execute 只能接收 Runnable 类型的任务,而 submit 除了 Runnable,还能接收 Callable(Callable 类型任务支持返回值)

  • execute 方法返回 void, submit 方法返回 FutureTask。

  • 异常方面, submit 方法因为返回了 futureTask 对象,而当进行 future.get()时,会把线程中的异常抛出,因此调用者可以方便地处理异常。(如果是 execute,只能用内部捕捉或者设置 catchHandler)


Q:线程池中, shutdown、 shutdownNow、awaitTermination 的区别?

A:

  • shutdown: 停止接收新任务,等待所有池中已存在任务完成( 包括等待队列中的线程 )。异步方法,即调用后马上返回。

  • shutdownNow: 停止接收新任务,并 停止所有正执行的 task,返回还在队列中的 task 列表 。

  • awaitTermination: 仅仅是一个判断方法,判断当前线程池任务是否全部结束。一般用在 shutdown 后面,因为 shutdown 是异步方法,你需要知道什么时候才真正结束。

五、Thread 状态转换

Q: 线程的 6 种状态是:

A:

  • New: 新建了线程,但是还没调用 start

  • RUNNABLE: 运行, 就绪状态包括在运行态中

  • BLOCKED: 阻塞,一般是因为想拿锁拿不到

  • WAITING: 等待,一般是 wait 或者 join 之后

  • TIMED_WAITING: 定时等待,即固定时间后可返回,一般是调用 sleep 或者 wait(时间)的。

  • TERMINATED: 终止状态。

欣赏一幅好图,能了解调用哪些方法会进入哪些状态。

原图链接

Q: java 线程什么时候会进入阻塞(可能按多选题考):

A:

  • sleep

  • wati()挂起, 等待获得别的线程发送的 Notify()消息

  • 等待 IO

  • 等待锁

六、Volatile


用 volatile 修饰成员变量时, 一旦有线程修改了变量,其他线程可立即看到改变。


Q: 不用 volatile 修饰成员变量时, 为什么其他线程会无法立即看到改变?

A:线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值。


Q: 用了 volatile 是不是就可以不用加锁啦?

A: 不行。

  • 锁并不是只保证 1 个变量的互斥, 有时候是要保证几个成员在连续变化时,让其他线程无法干扰、读取。

  • 而 volatile 保证 1 个变量可变, 保证不了几个变量同时变化时的原子性。


Q:展示一段《Java 并发编程实战》书里的一个经典例子,在科目二考试里也出现了,只是例子换了个皮。为什么下面这个例子可能会死循环,或者输出 0?

A:首先理解一下 java 重排序,可以看一下这篇博文:https://www.cnblogs.com/coshaho/p/8093944.html

然后分析后面那 2 个奇怪的情况是怎么发生的。

  • 永远不输出:

经过程序的指令排序,出现了这种情况:

  1. ReaderThread 在 while 里读取 ready 值, 此时是 false, 于是存入了 ReaderThread 的寄存器。

  2. 主线程修改 ready 和 number。

  3. ReaderThread 没有感知到 ready 的修改(对于 ReaderThread 线程,感知不到相关的指令,来让他更新 ready 寄存器的值),因此进入死循环。

  • 输出 0

经过程序的指令排序,出现了这种情况:

1)主线程设置 ready 为 true

2)ReaderThread 在 while 里读取 ready 值,是 true,于是退出 while 循环


  1. ReaderThread 读取到 number 值, 此时 number 还是初始化的值为 0,于是输出 0

  2. 主线程这时候才修改 number=42,此时 ReaderThread 已经结束了!


上面这个问题,可以用 volatile 或者加锁。当你加了锁时, 如果变量被写了,会有指令去更新另一个寄存器的值,因此就可见了。

七、线程群组

为了方便管理一批线程,我们使用 ThreadGroup 来表示线程组,通过它对一批线程进行分类管理

使用方法:

Thread group = new ThreadGroup("group");Thread thread = new Thread(gourp, ()->{..});
复制代码

即 thread 除了 Thread(Runable)这个构造方法外,还有个 Thread(ThreadGroup, Runnable)构造方法


Q:在线程 A 中创建线程 B, 他们属于同一个线程组吗

A:是的

线程组的一大作用是对同一个组线程进行统一的异常捕捉处理,避免每次新建线程时都要重新去 setUncaghtExceptionHandler。即线程组自身可以实现一个 uncaughtException 方法。

ThreadGroup group = new ThreadGroup("group") {	@Override	public void uncaughtException(Thread thread, Throwable throwable) {		System.out.println(thread.getName() + throwable.getMessage());		}	};}
复制代码

线程如果抛出异常,且没有在线程内部被捕捉,那么此时线程异常的处理顺序是什么?相信很多人都看过下面这段话,好多讲线程组的博客里都这样写:

(1)首先看看当前线程组(ThreadGroup)有没有父类的线程组,如果有,则使用父类的 UncaughtException()方法。

(2)如果没有,就看线程是不是调用 setUncaughtExceptionHandler()方法建立 Thread.setUncaughtExceptionHandler 实例。如果建立,直接使用它的 UncaughtException()方法处理异常。

(3)如果上述都不成立就看这个异常是不是 ThreadDead 实例,如果是,什么都不做,如果不是,输出堆栈追踪信息(printStackTrace)。

来源:

https://blog.csdn.net/qq_43073128/article/details/90597006

https://blog.csdn.net/qq_43073128/article/details/88280469

好,别急着记,先看一下下面的题目,问输出什么:


Q:

// 父类线程组static class GroupFather extends ThreadGroup {    public GroupFather(String name) {        super(name);    }    @Override    public void uncaughtException(Thread thread, Throwable throwable) {        System.out.println("groupFather=" + throwable.getMessage());    }}
public static void main(String[] args) { // 子类线程组 GroupFather groupSon = new GroupFather("groupSon") { @Override public void uncaughtException(Thread thread, Throwable throwable) { System.out.println("groupSon=" + throwable.getMessage()); } }; Thread thread1 = new Thread(groupSon, ()->{ throw new RuntimeException("我异常了"); }); thread1.start();}
复制代码

A:一看(1),那是不是应该输出 groupFather?

错错错,输出的是 groupSon 这句话在很多地方能看到,但没有去实践过看过源码的人就会这句话被误导。实际上父线程组不是指类继承关系上的线程组,而是指下面这样的:

即指的是构造关系的有父子关系。如果子类的 threadGroup 没有去实现 uncaughtException 方法,那么就会去构造参数里指定的父线程组去调用方法。


Q: 那我改成构造关系上的父子关系,下面输出什么?

public static void main(String[] args) {    // 父线程组    ThreadGroup group = new ThreadGroup("group") {        @Override        public void uncaughtException(Thread thread, Throwable throwable) {            System.out.println("group=" + throwable.getMessage());        }         };
// 建一个线程,在线程组内 Thread thread1 = new Thread(group, () -> { throw new RuntimeException("我异常了"); }); // 自己设置setUncaughtExceptionHandler方法 thread1.setUncaughtExceptionHandler((t, e) -> { System.out.println("no gourp:" + e.getMessage()); });
thread1.start();}
复制代码

A:看之前的结论里,似乎是应该输出线程组的异常?但是结果却输出的是:

也就是说,如果线程对自己特地执行过 setUncaughtExceptionHandler,那么有优先对自己设置过的 UncaughtExceptionHandler 做处理。


那难道第(2)点这个是错的吗?确实错了,实际上第二点应该指的是全局 Thread 的默认捕捉器,注意是全局的。实际上那段话出自 ThreadGroup 里 uncaughtException 的源码:

这里就解释了之前的那三点,但是该代码中没考虑线程自身设置了捕捉器


所以修改一下之前的总结一下线程的实际异常抛出判断逻辑:

  1. 如果线程自身有进行过 setUncaughtExceptionHandler,则使用自己设置的按个。

  2. 如果没设置过,则看一下没有线程组。并按照以下逻辑判断:

  3. 如果线程组有覆写过 uncaughtException,则用覆写过的 uncaughtException

  4. 如果线程组没有覆写过,则去找父线程组(注意是构造体上的概念)的 uncaughtException 方法。

  5. 如果线程组以及父类都没覆写过 uncaughtException, 则判断是否用 Thread.setDefaultUncaughtExceptionHandler(xxx)去设置全局的默认捕捉器,有的话则用全局默认

  6. 如果不是 ThreadDeath 线程, 则只打印堆栈。

  7. 如果是 ThreadDeath 线程,那么就什么也不处理。


点击关注,第一时间了解华为云新鲜技术~


发布于: 2021 年 04 月 25 日阅读数: 46
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
40个问题让你快速掌握Java多线程的精髓