在现代软件开发中,多线程编程已经成为一项基础技能。无论是为了提升系统吞吐量,还是充分利用多核处理器的计算能力,我们几乎无法回避并发编程。然而,多线程环境带来的不仅仅是性能提升,更是一系列棘手的挑战——当多个线程同时访问共享资源时,数据不一致、竞态条件、可见性问题便随之而来。
Java 从诞生之初就内置了对多线程的支持,并在漫长的演进过程中不断丰富其并发工具集。从最基础的 synchronized 关键字,到 java.util.concurrent 包中琳琅满目的同步工具,Java 为开发者提供了一套完整而强大的线程同步解决方案。
本文将系统梳理 Java 线程同步的核心机制,聚焦于两大主题:一是保障互斥访问的锁机制,二是协调线程执行顺序的同步工具 CountDownLatch 与 CyclicBarrier。通过深入理解这些工具的设计思想与适用场景,读者将能够在实际项目中做出正确的技术选型,编写出既高效又安全的并发代码。
全文不贴代码,只讲原理与思路,力求帮助读者建立起清晰的知识框架。
二、线程同步的基础概念
2.1 为什么需要同步
在多线程环境中,线程的执行顺序是不可控的。操作系统的线程调度器会根据某种策略(如时间片轮转)来决定哪个线程获得 CPU 的执行权,这种调度对开发者来说几乎是透明的。
当多个线程同时对同一个共享变量进行读写操作时,问题就出现了。假设两个线程同时读取一个变量的值,分别进行修改后再写回——由于读、改、写这三个操作并非原子性的,最终的结果很可能与预期不符。这种因执行时序不确定而导致的结果异常,被称为“竞态条件”(Race Condition)。
线程同步的核心目标正是消除竞态条件,确保共享资源在并发环境下的操作是安全且可预测的。
2.2 同步要解决的三大问题
线程同步需要同时解决三个层面的问题:
原子性:确保一组操作要么全部执行,要么全部不执行,中间不会被其他线程打断。例如,银行转账操作中的“扣款”与“入账”必须作为一个不可分割的整体。
可见性:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。在 Java 内存模型中,每个线程都有自己的工作内存(类似于 CPU 缓存),变量的修改需要从工作内存刷新到主内存,其他线程才能读取到。如果缺乏适当的同步,一个线程的修改可能长时间对其他线程不可见。
有序性:编译器、处理器为了优化性能,可能会对指令进行重排序。在单线程环境下,这种重排序不会影响执行结果;但在多线程环境下,指令重排序可能导致意想不到的错误。同步机制需要在一定程度上限制这种重排序。
2.3 同步与互斥的关系
互斥是同步的一种特殊形式,也是最基础的形式。互斥确保同一时刻只有一个线程能够访问某段代码或某个资源,从而从根本上杜绝了并发修改的可能性。而同步的含义更为广泛,它不仅包括互斥,还包括线程之间的协作与协调——比如一个线程等待另一个线程完成某项任务后再继续执行。
可以这样理解:互斥解决的是“不能同时做”的问题,而同步解决的是“谁先谁后”的问题。
三、Java 锁机制
3.1 锁的核心原理
锁的本质是一种标记机制,用于控制多线程对共享资源的访问。在 Java 中,每个对象都关联着一个监视器(Monitor),线程进入同步代码块前需要获取该监视器的所有权,未获取到的线程将被阻塞,直到锁被释放。
从底层实现来看,锁的状态记录在 Java 对象的对象头(Mark Word)中。对象头中包含了锁状态标志位、指向 monitor 对象的指针等信息。当线程尝试获取锁时,JVM 会检查对象头中的锁状态,并根据当前竞争情况决定如何分配锁。
3.2 内置锁:synchronized 的演进
synchronized 是 Java 中最基础、最常用的同步机制,被称为“内置锁”或“隐式锁”。之所以说它是“隐式”的,是因为加锁和解锁的操作由 JVM 自动完成,开发者无需手动管理。
在早期的 Java 版本中,synchronized 的性能表现并不理想。它直接依赖于操作系统的互斥量(Mutex),每次加锁解锁都需要陷入内核态,带来较大的性能开销,因此被称为“重量级锁”。
从 JDK 1.6 开始,JVM 对 synchronized 进行了大规模的优化,引入了“锁升级”机制。锁的状态可以根据竞争激烈程度,从低到高依次升级:无锁状态 → 偏向锁 → 轻量级锁 → 重量级锁,且这个升级过程是不可逆的。
-
偏向锁:当只有一个线程访问同步代码块时,JVM 会将对象头设置为偏向锁,记录该线程的 ID。此后该线程再次进入同步块时,无需任何同步操作即可直接执行。偏向锁适用于绝大多数时间只有一个线程执行同步块的场景。
-
轻量级锁:当有另一个线程开始竞争锁时,偏向锁会升级为轻量级锁。此时线程通过 CAS(Compare-And-Swap)操作尝试获取锁,若未获取到,则自旋等待,而不是立即阻塞。自旋等待的目的是避免线程状态切换的开销。
-
重量级锁:当自旋超过一定次数,或有多个线程同时竞争时,轻量级锁会升级为重量级锁。此时未获取到锁的线程会进入阻塞状态,由操作系统进行线程调度。
这一系列优化使得 synchronized 的性能在大多数场景下已经不逊于显式锁,加之其使用简单、不易出错,synchronized 依然是 Java 并发编程的首选方案。
3.3 显式锁:Lock 接口及其实现
与内置锁相对的是 java.util.concurrent.locks.Lock 接口及其实现类,它们被称为“显式锁”。之所以称为“显式”,是因为开发者需要手动调用 lock() 和 unlock() 方法来获取和释放锁。
显式锁最典型的实现是 ReentrantLock,它在 synchronized 的基础上提供了更多灵活的特性:
-
可中断性:通过
lockInterruptibly()方法,等待锁的线程可以响应中断,避免无限期阻塞。 -
超时获取:
tryLock(timeout, unit)方法允许线程在指定时间内尝试获取锁,超时则放弃,提高了系统的弹性。 -
公平性选择:
ReentrantLock的构造方法允许传入fair参数。公平锁按照线程请求锁的顺序来分配,先到先得;非公平锁则允许插队,性能通常更高。synchronized默认是非公平的。
ReentrantLock 的“可重入”特性是指:同一个线程可以多次获取同一把锁,而不会造成死锁。例如,一个同步方法内部调用另一个同步方法,如果锁是可重入的,线程在进入第二个方法时能够成功获取锁;否则就会因第二次尝试获取同一把锁而阻塞,造成死锁。
3.4 读写锁的优化思路
在大多数并发场景中,读操作的频率远高于写操作。如果我们使用普通的互斥锁,多个读操作之间也会相互阻塞,这无疑会降低系统的并发能力。
ReentrantReadWriteLock 正是为了解决这个问题而设计的。它将锁拆分为“读锁”和“写锁”两部分:
-
读锁是共享锁,允许多个线程同时持有。当一个线程持有读锁时,其他线程也可以获取读锁,但不能获取写锁。
-
写锁是独占锁,只允许一个线程持有。当一个线程持有写锁时,其他线程既不能获取读锁,也不能获取写锁。
这种设计遵循一个核心规则:读-读不互斥,读-写互斥,写-写互斥。在“读多写少”的场景(如缓存系统、配置管理)中,读写锁可以显著提升系统的并发性能。
3.5 锁的选用原则
在实际开发中,如何选择合适的锁机制?以下是一些基本原则:
-
对于简单的同步需求,优先使用
synchronized。它使用简单,JVM 会持续优化,且无需担心忘记释放锁的问题。 -
当需要可中断、超时或公平性等高级特性时,选择
ReentrantLock。 -
在读多写少的场景下,考虑使用
ReentrantReadWriteLock来提升读并发能力。
四、CountDownLatch:等待所有任务完成
4.1 设计思想
CountDownLatch 是 java.util.concurrent 包中提供的一个同步辅助类,中文常译为“闭锁”或“倒计时门闩”。它的设计思想非常直观:维护一个计数器,初始值设定为正整数。线程可以通过 await() 方法进入等待状态,直到计数器的值减为 0;其他线程则通过 countDown() 方法使计数器递减。
CountDownLatch 的核心特点在于:它允许一个或多个线程等待其他线程完成各自的任务后再继续执行。这种“主-从”协作模式在并发编程中十分常见。
4.2 核心机制
从实现原理上看,CountDownLatch 基于 AQS(AbstractQueuedSynchronizer)构建,使用 AQS 中的 state 变量来充当计数器。当 state 不为 0 时,调用 await() 的线程会被封装成节点,放入 AQS 的同步队列中阻塞等待。每当一个线程调用 countDown() 方法,state 的值就减少 1。当 state 减至 0 时,AQS 会将同步队列中的所有等待节点按 FIFO 顺序依次唤醒,被阻塞的线程即可恢复运行。
这里有一个关键的设计细节:CountDownLatch 的计数器无法被重置。一旦计数器归零,await() 方法将不再阻塞任何线程,且 countDown() 方法的调用也不再产生任何效果。这意味着 CountDownLatch 是一次性的,不可重复使用。
4.3 典型应用场景
场景一:主线程等待多个子任务完成
这是最常见的用法。主线程创建并启动多个子线程执行任务,然后调用 await() 等待所有子任务完成,最后再执行后续的汇总或收尾工作。例如,批量导入数据时,可以将数据分片后由多个线程并行处理,主线程等待所有分片处理完毕后再提交事务。
场景二:模拟高并发压测
在性能测试中,我们经常需要模拟多个请求“同时”发起的场景。可以利用 CountDownLatch 实现这一效果:初始化一个计数器为 1 的 CountDownLatch,所有测试线程启动后立即调用 await() 进入阻塞状态;当所有线程都准备就绪后,主线程调用一次 countDown(),所有阻塞线程同时被唤醒,在极短的时间内发起请求,模拟并发访问。
场景三:依赖条件等待
当一个任务的执行依赖于多个前置条件时,可以使用 CountDownLatch。例如,系统启动时需要加载配置文件、初始化数据库连接池、建立外部服务连接等多个初始化步骤,主线程可以等待所有这些步骤完成后再对外提供服务。
五、CyclicBarrier:等待所有线程到位
5.1 设计思想
CyclicBarrier 同样是一个同步辅助类,中文常译为“循环屏障”或“同步屏障”。与 CountDownLatch 的“主-从”模式不同,CyclicBarrier 实现的是“多线程互相等待”的协作模式。
CyclicBarrier 的设计思想是:设置一个屏障点,指定需要等待的线程数量。每个线程完成任务后调用 await() 方法到达屏障,并进入阻塞状态。直到最后一个线程也到达屏障,屏障才会“打开”,所有被阻塞的线程同时被唤醒,继续执行后续任务。
5.2 核心机制
与 CountDownLatch 基于 AQS 的实现方式不同,CyclicBarrier 的内部实现依赖于 ReentrantLock 和 Condition。当线程调用 await() 方法时,首先获取锁,然后检查屏障是否已被破坏、计数器是否归零等状态。如果当前到达屏障的线程数尚未达到设定的阈值,当前线程会调用 Condition 的 await() 方法进入等待状态,并释放锁。
当最后一个线程到达屏障时,情况发生了变化:该线程会执行可选的“屏障动作”(一个通过构造方法传入的 Runnable 任务),然后调用 Condition 的 signalAll() 方法唤醒所有等待中的线程。最后,CyclicBarrier 还会重置其内部状态,为下一轮使用做好准备。
这正是 CyclicBarrier 名称中“Cyclic”(循环)的由来——它可以在屏障被打破后自动重置,从而被重复使用。
5.3 与 CountDownLatch 的核心区别
两者虽然都用于线程同步,但存在本质区别,理解这些区别对于正确选型至关重要:
| 差异维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 可重用性 | 一次性,计数器归零后无法重置 | 可循环使用,屏障打开后自动重置 |
| 等待对象 | 一个/多个线程等待其他线程 | 所有线程相互等待 |
| 计数器变化 | 递减(从 N 减到 0) | 递增(从 0 增加到 N) |
| 回调机制 | 不支持 | 支持,可在屏障打开时执行指定任务 |
| 实现基础 | AQS | ReentrantLock + Condition |
简单来说:CountDownLatch 是“等我做完”,CyclicBarrier 是“等大家都到齐”。
5.4 典型应用场景
场景一:多阶段并行计算
CyclicBarrier 非常适合分阶段执行的并行计算任务。例如,一个大型计算任务可以分成多个阶段,每个阶段需要所有子任务完成后才能进入下一阶段。由于 CyclicBarrier 可重复使用,它可以自然地表达这种多阶段的同步需求。
场景二:多线程同时开始执行
与 CountDownLatch 类似,CyclicBarrier 也可以用于让多个线程“同时”启动。各线程启动后立即调用 await(),待所有线程都到达屏障后,再同时开始执行实际任务。这种方式常用于公平地测试多线程并发场景。
六、两者的实战选型指南
6.1 判断标准
在实际项目中,如何选择 CountDownLatch 和 CyclicBarrier?可以从以下几个维度进行判断:
任务关系:如果存在明确的主从关系——一个线程需要等待其他线程的结果——选择 CountDownLatch。如果所有线程处于对等地位,需要互相等待到齐后再一起行动,选择 CyclicBarrier。
是否需要重复使用:如果同步逻辑只会执行一次(如启动检查、资源加载),两者皆可;但如果需要反复使用(如多轮并行计算),必须选择 CyclicBarrier。
是否需要回调:如果在所有线程就绪后需要执行某个特定的动作(如记录日志、触发下一阶段),CyclicBarrier 的构造方法直接支持传入屏障动作,非常便捷。
6.2 常见误区
误区一:CountDownLatch 的计数器可以重置
这是最常见的误解。CountDownLatch 的设计就是一次性的,计数器归零后即“失效”。如果需要重复使用,应当选用 CyclicBarrier。
误区二:CountDownLatch 只能阻塞一个线程
实际上,CountDownLatch 的 await() 方法可以被多个线程同时调用,所有这些线程都会等待计数器归零。它支持“多个等待者”的场景。
误区三:await() 的线程数量必须等于初始计数
await() 的调用次数没有限制,但只有计数器归零后才能唤醒等待线程。如果某个线程重复调用 await(),该线程会反复进入等待队列。
七、总结
Java 线程同步机制是一个层次丰富、设计精妙的体系。从最底层的锁机制到高层次的同步工具,每个组件都有其独特的定位和适用场景。
锁机制(synchronized 和 Lock 体系)解决的是互斥访问问题,保障共享资源在并发环境下的安全性。synchronized 以其简洁性和 JVM 层面的持续优化,成为日常开发的首选;而 Lock 体系则以其灵活性,在需要高级特性的场景中发挥作用。
CountDownLatch 和 CyclicBarrier 则从更高的抽象层次解决了线程协作问题。CountDownLatch 适用于“一个等待多个”的场景,是“分而治之”思想的典型体现;CyclicBarrier 则实现了“多个互相等待”的协作模式,其可循环使用的特性在多阶段并行计算中优势明显。
理解这些工具的设计意图和核心差异,远比死记硬背 API 用法更重要。在实际开发中,根据业务场景选择恰当的同步机制,既能保证线程安全,又能最大化系统性能。希望本文的梳理能够帮助读者在并发编程的道路上走得更稳、更远。
566

被折叠的 条评论
为什么被折叠?



