很多初学 Java 的人拿到《Java 程序设计》这门课,最容易卡住的就是并发编程这一章。你说语法吧,都认识;你说思路吧,好像也明白;真到自己写多线程代码,不是数据错乱就是死锁,跑起来完全不是那么回事。这篇文章我就把这门课里“并发编程基础”这部分彻底掰开揉碎,从线程怎么创建、锁到底锁的是什么,到线程池参数怎么定、线上问题怎么排查,一次性讲透。不管你是正在上课的学生,还是准备 Java 面试的求职者,这篇文章都值得你认真看完。
先说清楚这篇博文要解决什么问题:第一,帮你建立并发编程的完整知识框架;第二,把 synchronized、volatile、Lock、线程池这些核心知识点的底层原理讲明白;第三,结合我实际写代码踩过的坑,告诉你哪些地方容易出问题、怎么排查。全文不整虚的,全是能直接用在项目和面试里的东西。
1. 并发编程到底在解决什么问题
1.1 并发编程的本质是“压榨资源”
很多学生学并发编程时第一个疑问是:我单线程写得好好的,为什么要搞多线程?这个问题问得特别好,因为如果你不清楚并发编程要解决什么问题,后面学再多 API 都是空中楼阁。
大家看现在的主流服务器,动辄几十核 CPU、几百 GB 内存。你写一个程序只用单线程跑,就意味着同一时间只有一个 CPU 核在工作,其他核全部空转。这就好比你雇了十个小工搬砖,结果你只让一个人干活,剩下九个人在旁边站着看。多线程的核心目的,就是让多个 CPU 核同时干活,把硬件资源真正用起来。
但资源压榨是有代价的。当多个线程同时访问同一份数据时,就会出现竞争条件。比如两个线程同时往同一个账户里存钱,如果没有同步控制,最终余额可能只加了一次钱。这就是并发编程最核心的矛盾:为了性能引入多线程,同时又因为多线程引入数据安全问题。所有并发编程的知识点,本质上都是在解决“性能”和“安全”这对矛盾。
1.2 Java 并发编程的知识地图
我在带新人或者给学生讲这部分内容时,习惯先给一张知识地图,不然大家很容易迷失在细节里。Java 并发编程基础部分,其实就四块内容:线程的创建与管理、线程同步与互斥、线程间协作、并发容器与工具类。
线程的创建与管理,解决的是“怎么把任务拆给多个线程”的问题;线程同步与互斥,解决的是“多个线程同时改数据怎么保证正确”的问题;线程间协作,解决的是“线程之间怎么互相通知、怎么配合完成复杂任务”的问题;并发容器与工具类,是 JDK 帮我们封装好的一些线程安全的现成组件。
把这四块内容对应到《Java 程序设计》这门课上,前两章一般是线程基础和 synchronized,中间讲 Lock 和 volatile,后面讲线程池和并发工具类。你可以对照自己的教材看看,不管章节顺序怎么排,核心内容跑不出这个框架。
1.3 为什么说并发编程是区分程序员水平的分水岭
面试时有个特别有意思的现象:问 Java 基础语法,几乎人人都会;问到多线程,一半人开始含糊;再往深了问 volatile 的内存语义、线程池的拒绝策略、AQS 的实现原理,能答上来的人就很少了。
这倒不是因为并发编程有多高深,而是因为它涉及的知识点特别杂,而且每一个知识点背后都牵扯到操作系统、JVM 内存模型、CPU 缓存这些底层的概念。你光背 API 是不行的,必须理解数据在 CPU、内存、磁盘之间是怎么流转的,才可能真正写好并发代码。
我经常跟学生说,并发编程学得好不好,不看你背了多少八股文,就看你写出来的代码在高并发下跑不跑得稳。这篇文章后面讲的每一个知识点,我都会结合真实的场景来解释,而不是只给你概念定义。
2. 线程的基础:创建、生命周期与状态切换
2.1 线程到底是怎么创建出来的
Java 里创建线程有好几种方式,但归根结底,所有线程都是java.lang.Thread类的实例。初学者最容易困惑的是,网上资料一会儿说继承 Thread,一会儿说实现 Runnable,一会儿又说用 Callable,到底该用哪个?
我直接给结论:在实际开发中,几乎不用继承 Thread 的方式,因为 Java 是单继承,你继承了 Thread 就没法继承其他类了。最常用的是实现 Runnable 接口,这样你的任务逻辑和线程本身是解耦的。如果需要返回执行结果,就实现 Callable 接口,配合 FutureTask 使用。
给你看一个最简单的例子:
// 方式一:实现 Runnable 接口 public class DownloadTask implements Runnable { @Override public void run() { System.out.println(Thread.currentThread().getName() + " 开始下载任务"); // 模拟耗时操作 try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() + " 下载完成"); } } // 使用 Thread t1 = new Thread(new DownloadTask(), "download-thread-1"); t1.start();注意,调用的是start()方法,不是run()方法。这两个方法的区别是面试高频题:start()会创建一个新的线程并执行run()方法里的逻辑;如果直接调用run(),它只是在当前线程里执行一个普通方法,根本没有创建新线程。
2.2 线程的生命周期,不仅仅是五态
教科书上告诉你线程有新建、就绪、运行、阻塞、死亡五种状态,这个没错,但 Java 层面的线程状态和操作系统层面的线程状态是有映射关系的。
Java 的Thread.State枚举类定义了六种状态:NEW(新建)、RUNNABLE(可运行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WAITING(超时等待)、TERMINATED(终止)。
这里有个特别容易误解的地方:Java 的 RUNNABLE 状态其实涵盖了操作系统里的“就绪”和“运行”两个状态。因为 Java 线程的调度是由操作系统负责的,Java 虚拟机本身没法精确区分线程到底是正在 CPU 上跑,还是在等待被调度,所以干脆统一归为 RUNNABLE。
你可以用jstack命令查看运行中 Java 进程的线程状态,这在我们排查线上问题时会用到。比如看到大量线程处于 BLOCKED 状态,那大概率是锁竞争太激烈了;看到大量 TIMED_WAITING,可能是线程池里的空闲线程在等待新任务。
2.3 线程中断,是个协作机制
很多初学者以为Thread.stop()能强制终止线程,大错特错。stop()方法已经被废弃了,因为它会直接释放锁,导致数据不一致。正确的做法是使用中断机制。
中断机制本质上是一种协作机制:线程 A 调用线程 B 的interrupt()方法,并不是直接打断 B 的执行,而是给 B 打一个“中断标记”。B 需要在合适的时机自己检查这个标记,然后决定怎么处理。
public class InterruptExample { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // 模拟执行任务 System.out.println("worker 正在工作..."); } System.out.println("worker 收到中断信号,停止工作"); }); worker.start(); Thread.sleep(100); worker.interrupt(); } }这个机制的好处是,线程可以在自己认为安全的时机停止,避免强制终止导致的资源泄漏、数据不一致等问题。我在实际项目中处理任务的优雅关闭时,基本都是靠这个机制,让线程处理完当前任务后再退出。
3. 锁的核心:synchronized 和 volatile 的内存语义
3.1 synchronized 锁的到底是什么
要说并发编程里最重要的关键字,synchronized 当之无愧。但“锁的是什么”这个问题,很多工作两三年的开发都说不清楚。
synchronized 有三种用法,锁的目标各不相同:
第一种,修饰实例方法,锁的是当前实例对象。也就是说,同一个实例的多个线程访问这个方法需要竞争锁,不同实例之间互不干扰。
第二种,修饰静态方法,锁的是当前类的 Class 对象。注意,Class 对象在 JVM 中是全局唯一的,所以静态方法锁是全局锁,不同实例之间也会互相竞争。
第三种,修饰代码块,可以指定任意对象作为锁。
// 锁的是当前实例 public synchronized void methodA() { // 临界区 } // 锁的是类的 Class 对象 public static synchronized void methodB() { // 临界区 } // 锁的是指定对象 public void methodC() { synchronized (lockObject) { // 临界区 } }很多人问,锁对象和锁代码块有什么区别?锁对象只是承载锁状态的一个引用,真正关键的是临界区。同一把锁保护的临界区必须互斥执行,不同锁保护的临界区可以并行执行。
3.2 synchronized 的底层原理,从 monitorenter 说起
javap 反编译一个包含 synchronized 代码块的类,你会看到字节码里有monitorenter和monitorexit两条指令。每个 Java 对象在内存中都关联着一个 monitor(监视器)对象,线程进入临界区时要先获取 monitor 的所有权,退出时释放所有权。
如果两个线程同时尝试进入同一个 monitor 保护的临界区,只有一个能成功,另一个会被阻塞,直到锁被释放。
在 JDK 6 之后,synchronized 的性能大幅提升,原因是引入了锁升级机制:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁就是锁住之后没有其他线程竞争,后面同一个线程再进入就无需额外同步开销;轻量级锁是在短时间竞争时,通过 CAS 自旋的方式来避免线程阻塞;只有竞争激烈时才会膨胀为重量级锁,线程真正被阻塞挂起。
3.3 volatile 不保证原子性,但保证可见性
volatile 是 Java 并发编程里另一个核心关键字,也是面试里最容易踩坑的地方。
volatile 有两个语义:一是保证可见性,二是禁止指令重排序。但它不保证原子性。
什么叫可见性?如果不加 volatile,多个线程同时读一个变量,某个线程修改了变量值之后,其他线程可能看不到修改后的值。这是因为每个线程都有自己的工作内存,变量副本可能没有及时同步到主内存。加了 volatile 之后,对这个变量的读写都会直接操作主内存,所有线程看到的永远是最新的值。
但 volatile 不保证原子性,最经典的例子就是count++操作,即使 count 被 volatile 修饰,多个线程同时执行 count++ 仍然会丢数据。因为 count++ 在字节码层面是“读-改-写”三步操作,volatile 只保证了读取和写入的直接性,没法把这“三步”变成一个不可分割的整体。
所以 volatile 的使用场景一般有两个:一个是修饰状态标志位,比如线程的停止标记;另一个是在双重检查锁单例模式中,修饰单例对象,防止指令重排序导致返回了未完全初始化的对象。
4. 显式锁与协作工具:Lock、CountDownLatch、Future
4.1 Lock 接口为什么比 synchronized 更灵活
synchronized 用起来简单,但它也有局限性:不能中断等待锁的线程、不能设置超时时间、获取锁和释放锁必须写在一个方法里(没有死锁才怪了,其实是写法上比较容易出错)。
java.util.concurrent.locks.Lock接口提供了更多控制能力,它的经典实现是ReentrantLock。ReentrantLock 和 synchronized 一样是可重入锁,也就是同一个线程可以多次获取同一把锁而不会死锁。
Lock lock = new ReentrantLock(); public void doSomething() { lock.lock(); try { // 临界区逻辑 } finally { lock.unlock(); } }注意,ReentrantLock 的使用必须手动加锁和解锁,而且解锁一定要放在 finally 块里,否则一旦临界区抛异常,锁永远不会被释放,其他线程就会一直阻塞。
ReentrantLock 比 synchronized 多了一个非常实用的能力:可以尝试获取锁,获取不到就立即返回,或者等待一段时间后返回。
if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 获取锁成功 } finally { lock.unlock(); } } else { // 获取锁失败,处理其他逻辑 }4.2 多线程都完成了怎么通知:CountDownLatch 的正确用法
最近热搜里有个词叫“java线程等待都完成”,其实对应的正是 CountDownLatch 这个工具类。场景非常典型:主线程启动了若干个工作线程,需要等所有工作线程都完成之后,主线程再继续往下走。
CountDownLatch 的用法非常直观:初始化时指定一个计数器值,每个工作线程完成后调用一次countDown()方法,计数器减一;主线程调用await()方法,计数器不为零时一直等待。
int workerCount = 5; CountDownLatch latch = new CountDownLatch(workerCount); for (int i = 0; i < workerCount; i++) { new Thread(() -> { try { // 执行任务 System.out.println(Thread.currentThread().getName() + " 执行完成"); } finally { // 这里也很重要,即使任务抛异常,也要保证计数器减一 latch.countDown(); } }, "worker-" + i).start(); } // 主线程等待所有任务完成 latch.await(); System.out.println("所有任务都完成了,主线程继续执行");这里有个细节一定要记住:countDown()要放在 finally 里,否则线程执行时抛异常,计数器一直不减,主线程就会永久阻塞。我自己就在线上遇到过这种问题,排查了好久才发现是某个任务抛了异常导致 latch 没有减到底。
4.3 Future 和 FutureTask:获取线程执行结果
Runnable 接口的 run() 方法没有返回值,如果需要获取执行结果,就得用 Callable 接口和 FutureTask。
Callable<Integer> callable = () -> { Thread.sleep(2000); return 42; }; FutureTask<Integer> futureTask = new FutureTask<>(callable); new Thread(futureTask).start(); // 主线程可以干别的事,然后再获取结果 Integer result = futureTask.get(); // 阻塞等待结果 System.out.println("计算结果:" + result);get()方法是阻塞的,如果任务还没执行完成,调用 get() 的线程会一直等待。get()方法还能传入超时时间,比如futureTask.get(3, TimeUnit.SECONDS),超过 3 秒还没拿到结果就抛出 TimeoutException。这个在调第三方接口时特别有用,可以避免主线程无限制等待。
5. 线程池:不要 new Thread,用线程池
5.1 为什么禁止手动 new Thread
很多初学者写多线程代码喜欢直接 new Thread,这个习惯在生产环境必须改掉。手动创建线程有两个问题:
第一,线程创建和销毁的成本很高。一个线程从创建到销毁涉及系统调用、内存分配、资源清理,频繁创建销毁线程会浪费大量资源。第二,线程数量无法控制。如果并发请求特别多,每个请求都创建一个线程,系统资源很快会被耗尽,甚至导致 OOM。
线程池的作用就是复用线程、控制线程数量、管理线程的生命周期。好比一个公司,不可能每来一个客户就重新招聘一个人,而是有一个固定规模的员工团队,谁来服务都可以。
5.2 ThreadPoolExecutor 的七个核心参数
Java 最核心的线程池实现是ThreadPoolExecutor,它有七个参数,每一个都必须理解透彻。
new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 new LinkedBlockingQueue<>(100), // 任务队列 Executors.defaultThreadFactory(), // 线程工厂 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );七个参数分别是:核心线程数、最大线程数、空闲线程存活时间、存活时间单位、任务队列、线程工厂、拒绝策略。
当一个任务提交到线程池时,处理流程是这样的:先判断核心线程数是否已满,没满就直接创建核心线程执行任务;满了就把任务放入队列;队列也满了,就尝试创建新线程(但是总线程数不能超过最大线程数);如果线程数已经达到最大值,队列也满了,就触发拒绝策略。
核心线程数怎么定?有一个经验公式:CPU 密集型任务,设置成 CPU 核数 + 1;IO 密集型任务,设置成 CPU 核数 × 2 左右。因为 IO 密集型任务大部分时间在等待 IO,线程多一点也不太会增加 CPU 竞争。
5.3 四种拒绝策略,以及生产环境选哪种
JDK 提供了四种拒绝策略:
AbortPolicy:直接抛出 RejectedExecutionException 异常,这是默认策略。 CallerRunsPolicy:谁提交的任务谁去执行,不是丢弃,而是让提交任务的线程自己执行,起到一个天然限流的作用。 DiscardPolicy:直接丢弃任务,什么都不做。 DiscardOldestPolicy:丢弃队列中最老的任务,然后重新提交新任务。
实际生产中,最常用的是 CallerRunsPolicy,因为任务不会被无故丢弃,执行压力还能反馈到提交方,起到背压的效果。AbortPolicy 比较暴力,如果调用方没有做好异常处理,任务就悄悄丢了。
5.4 Executors 的快捷方法为什么不建议用
很多教材只教Executors.newFixedThreadPool()这种快捷方式,但阿里 Java 开发规范明确禁止在生产环境使用,原因是使用了无界队列。
newFixedThreadPool 和 newSingleThreadExecutor 的队列是LinkedBlockingQueue,容量是 Integer.MAX_VALUE,相当于没有上限。如果任务提交速度超过处理速度,队列会无限堆积,内存迟早被撑爆。
newCachedThreadPool 的线程数是 Integer.MAX_VALUE,如果任务特别多,会创建海量线程,同样容易 OOM。所以面试官问“Executors 和 ThreadPoolExecutor 选哪个”时,答案不是选哪个,而是直接用 ThreadPoolExecutor 手动指定参数,这样才能根据业务场景精确控制。
6. 并发容器与原子类:JDK 的现成方案
6.1 ConcurrentHashMap 和 Hashtable 有什么区别
讲到并发场景下的 Map,很多人马上想到 Hashtable 或者使用Collections.synchronizedMap()。这两种方式性能都很差,因为它们直接在方法级别加上了一把大锁,多个线程根本没法并发读。
ConcurrentHashMap 是 JDK 提供的专门用于并发场景的 Map,它的锁粒度要细得多。JDK 8 之后,ConcurrentHashMap 抛弃了分段锁,改用 CAS + synchronized 只锁住数组的每个桶节点,读操作不加锁,所以并发度非常高。
实际开发中,只要涉及多线程共享 Map,直接用 ConcurrentHashMap 就对了,不要再用 Hashtable 了。这是一条铁律。
6.2 原子类的核心:CAS 无锁编程
java.util.concurrent.atomic包下面提供了很多原子类,比如 AtomicInteger、AtomicLong、AtomicReference。它们的核心原理是 CAS(Compare And Swap),也就是比较并交换。
CAS 操作包含三个值:内存位置 V、预期原值 A、新值 B。执行时,如果内存位置 V 的值等于预期原值 A,就把 V 的值更新为 B,否则不做任何操作。这个操作是硬件层面支持的原子指令,所以效率很高。
但 CAS 有一个经典的 ABA 问题:线程一读到值 A,线程二把值改成 B 又改回 A,线程一再次执行 CAS 时发现值还是 A,就认为没被修改过,实际上已经被修改了两次。解决方法是使用AtomicStampedReference,通过版本号来感知中间的修改。
知道了 CAS 的原理,你就能理解为什么 AtomicInteger 比 synchronized 做计数要好得多:synchronized 会让线程阻塞,CAS 是自旋等待,在低竞争场景下 CPU 开销更小。
6.3 ThreadLocal:每个线程自己的变量副本
ThreadLocal 不是用来解决并发冲突的,恰恰相反,它是为了让每个线程独享一份数据,从而避免共享。
举个例子,一个 Web 请求从 Controller 到 Service 到 Dao,如果都需要访问当前请求的用户信息,每次调用都从参数传太麻烦。用 ThreadLocal 存一份,同一个线程内所有地方都能取到。
private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>(); public void processRequest(String userName) { CURRENT_USER.set(userName); try { // 同一线程内任何地方都能获取 String user = CURRENT_USER.get(); } finally { // 防止内存泄漏,必须清理 CURRENT_USER.remove(); } }ThreadLocal 底层是每个线程有一个 ThreadLocalMap,所以每个线程访问的是自己的变量副本,不存在竞争。但要注意,使用 ThreadLocal 后一定要调用remove()清理。尤其是使用线程池时,线程是复用的,如果不清理,下一次任务可能读到上一个任务遗留下来的数据。
7. 面试高频考点与实战排查中的关键经验
7.1 面试必背的并发编程八股文
我把 Java 并发编程面试最常考的题目整理成了一张表,你可以对着查漏补缺。
进程与线程的区别 创建线程的三种方式及对比 线程生命周期与状态切换 synchronized 底层原理与锁升级过程 volatile 关键字的内存语义 synchronized 与 ReentrantLock 的区别 CAS 原理与 ABA 问题 ThreadLocal 原理与内存泄漏 线程池七大参数与执行流程 ExecutorService 的四大拒绝策略 CountDownLatch、CyclicBarrier、Semaphore 的区别 ConcurrentHashMap 的底层实现这里有个常问点:CountDownLatch 和 CyclicBarrier 的区别。CountDownLatch 是一个线程等待多个线程完成,计数器只减不加,不能复用;CyclicBarrier 是多个线程互相等待,都到齐后再一起执行,计数器可以循环复用。
7.2 用 jstack 排查线程问题
真到了线上环境,你没法像本地那样直接调试多线程代码,必须借助工具。最常用的是jstack命令,它能输出 Java 进程当前所有线程的栈信息。
比如线程卡住不动了,用 jstack 看一眼输出,如果发现大量线程处于 WAITING(parking)状态,说明都在等待某个条件;如果看到很多线程 BLOCKED on <0x...>,说明在竞争同一把锁,而且锁竞争异常激烈,大概率是锁粒度设计有问题。
再比如 CPU 飙高,可以先top -Hp <pid>找到占用 CPU 最高的线程 id,转成十六进制后,再在 jstack 输出文件里搜索对应线程,定位到具体代码。
7.3 我踩过的最隐蔽的并发坑
分享三个我实际踩过的坑,每一个都很隐蔽,写代码时根本不会注意。
第一个坑:SimpleDateFormat 不是线程安全的。很多人用它格式化日期,多个线程共享同一个实例时,可能抛出 NumberFormatException 或者得到错误结果。解决办法是用 ThreadLocal 为每个线程存一份,或者用 JDK 8 的 DateTimeFormatter(这个是线程安全的)。
第二个坑:双重检查锁单例中使用 volatile。不加 volatile 可能拿到未初始化完成的对象。因为对象的创建在字节码层面是“分配内存、初始化、赋值给引用”三步,CPU 和编译器可能做指令重排,导致另一个线程看到引用非空,但里面的属性还没初始化。
第三个坑:线程池里的异常被吞掉。如果 Runnable 任务中抛出非 RuntimeException 异常,比如受检异常,由于 run() 方法没有声明 throws,异常会直接丢失。更有甚者,任务执行抛异常后,线程池的 Worker 线程会退出,然后线程池会创建一个新线程,从日志上看像一切正常,实际上任务已经失败了。可以用execute()方法提交任务并配合自定义的ThreadFactory设置未捕获异常处理器,或者使用submit()方法返回的 Future,调用future.get()时检查异常。
8. 一个完整的并发下载器案例
8.1 需求分析
最后用一个完整的案例把前面所有知识点串起来。需求很简单:模拟一个并发文件下载器,将一个文件分成 5 个分片,并发下载所有分片,全部完成后合并并提示“下载完成”。这个案例覆盖了线程创建、线程池、同步、协作、结果收集等几乎所有基础知识点。
8.2 代码实现
import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; public class ConcurrentDownloader { private static final int DOWNLOAD_THREAD_COUNT = 5; public static void main(String[] args) throws InterruptedException, ExecutionException { int totalPieces = 5; ExecutorService threadPool = new ThreadPoolExecutor( DOWNLOAD_THREAD_COUNT, DOWNLOAD_THREAD_COUNT, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(10), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy() ); CountDownLatch latch = new CountDownLatch(totalPieces); List<Future<String>> futures = new ArrayList<>(); for (int i = 1; i <= totalPieces; i++) { final int pieceNumber = i; Future<String> future = threadPool.submit(() -> { try { return downloadPiece(pieceNumber); } finally { latch.countDown(); } }); futures.add(future); } // 方式一:通过 CountDownLatch 等待所有分片完成 latch.await(); System.out.println("所有分片下载完成,开始合并..."); // 方式二:通过 Future.get() 获取各分片结果 for (Future<String> future : futures) { String result = future.get(); System.out.println("分片结果:" + result); } // 合并并输出结果 mergeFile(); threadPool.shutdown(); } private static String downloadPiece(int pieceNumber) throws InterruptedException { Thread.sleep(500); System.out.println("分片 " + pieceNumber + " 下载完成"); return "分片" + pieceNumber + "-OK"; } private static void mergeFile() { System.out.println("文件合并完成"); } }这段代码把本章所有核心知识点都用上了:线程池负责管理线程和任务队列,CountDownLatch 保证“所有分片都完成”这个条件,Future 负责收集每个分片的执行结果,finally里调countDown防止异常导致主线程卡死。
8.3 运行结果分析
运行这段代码,你会在控制台看到分片随机顺序完成打印,但“开始合并”一定在所有分片完成之后,这就验证了 CountDownLatch 的等待语义。
你再试着把 latch.countDown() 从 finally 里挪到 try 块最后,然后让某个分片抛个异常,你会发现主线程永远在 await,这就是我前面强调“countDown 必须放 finally”的原因。
把这个案例做完,并发编程基础就算真正入门了,后面再去学 AQS、并发包源码、JMM 底层模型,就会顺畅很多。我在带学生时发现,能把线程池 + CountDownLatch + Future 组合起来写出一个完整功能的人,再去理解什么锁升级、什么内存屏障,只是时间早晚的事。