做Java开发这几年,凡是碰到高并发场景,最后的落脚点基本都跑不掉JUC(java.util.concurrent)这套工具包。它从JDK 1.5引入,一路演进到现在,几乎成了Java并发编程的事实标准。不管是多线程数据共享、任务协作、接口限流,还是线程池管理,JUC里都有现成的类可以直接拿过来用。这篇文章我想从实际开发的角度出发,把JUC里最常见的几类组件系统梳理一遍,包括锁、同步工具、原子类、并发容器和线程池,顺便聊一聊我在选型时踩过的坑、做过的取舍。
很多同学刚开始接触JUC,会被一堆类名绕晕:ReentrantLock、CountDownLatch、Semaphore、AtomicInteger、ConcurrentHashMap……单独看每个类都能看懂,但真到了项目里,面对一个具体需求,反而不知道选哪个。我的建议是别硬记类名,也别背面试题,先搞清楚每一类工具解决的是哪一类问题:锁解决的是"互斥与可见性",同步工具解决的是"线程之间的协作指挥",原子类解决的是"单变量高并发更新",并发容器解决的是"数据结构在多线程下的安全访问",线程池解决的是"线程生命周期和任务的调度管理"。把这五类问题装进脑子里,JUC就不再是一堆散乱的名字,而是一张坐标清晰的地图。
1. 先给JUC画张地图:每类组件到底解决什么问题
1.1 从一次并发事故说起
大概两年前,我接手过一个内部数据同步服务,业务逻辑不复杂,就是把上游推送的数据处理后写入本地存储。但一到高峰期,日志里就刷Full GC,接口延迟从几十毫秒涨到几秒。排查到最后,问题出在一个我根本没想到的地方:写日志用的计数器是普通的int,多个线程同时对它做++操作,数据互相覆盖,导致统计完全失真。更麻烦的是,某个核心状态位用了boolean标记,线程A写入之后线程B读取不到最新值,整个流程反复重复执行。
这个事故给我上了一课:并发问题不是"加了synchronized就万事大吉",而是一整套关于互斥、可见性、原子性、协作机制的体系问题。JUC之所以有价值,就是因为它把这套体系里的高频场景都抽象成了可以直接使用的组件,你不需要每次从零手写线程安全代码。
1.2 JUC的三块拼图与选型思路
如果让我给JUC画一张地图,我会把它分成三大块。
第一块是以Lock接口和AbstractQueuedSynchronizer(AQS)为核心的锁与同步器体系,包括ReentrantLock、ReadWriteLock、StampedLock,以及构建在AQS之上的CountDownLatch、CyclicBarrier、Semaphore。它们解决的是"线程之间如何安全地互相协调"。
第二块是原子类体系,从最常用的AtomicInteger、AtomicLong、AtomicReference,到JDK 8加入的LongAdder。它们解决的是"单个变量在高并发下如何无锁更新"。
第三块是并发容器与线程池,包括ConcurrentHashMap、CopyOnWriteArrayList、各种BlockingQueue实现,以及ThreadPoolExecutor、ForkJoinPool。它们解决的是"多线程环境下的数据结构和线程资源管理"。
选型时我有一个习惯:先问一下"这段逻辑真的需要多线程吗",再问"问题本质是互斥、协作还是计数",最后才是"哪个类最匹配"。这个顺序看起来很基础,但大多数选型错误都出在跳过了第一步和第二步。
2. 锁的军备竞赛:synchronized、ReentrantLock、读写锁、StampedLock
2.1 synchronized升级之后,为什么还要ReentrantLock
很多老Java程序员对synchronized的印象停留在"性能差"上,其实JDK 6引入了偏向锁、轻量级锁、重量级锁的锁升级机制之后,synchronized在大多数场景下性能已经不输ReentrantLock了。那问题来了:既然synchronized这么好用,为什么还要ReentrantLock?
我的答案是:ReentrantLock提供了几个synchronized给不了的能力。
第一个是可中断。lockInterruptibly()可以让一个正在等锁的线程响应中断,而不是傻傻地一直等下去。第二个是可超时。tryLock(timeout, unit)能在指定时间内获取不到锁就放弃,这在处理外部接口调用时特别有用。第三个是公平锁。new ReentrantLock(true)可以开启公平模式,按请求顺序分配锁,避免线程饥饿。第四个是多个Condition队列,能够精准唤醒某一类线程,而不是像synchronized那样只能配合单一的wait/notify。
举个实际例子,我在做某个消息推送模块时,需要同时控制消费者的"暂停"和"恢复"两种操作。用synchronized只能有一个等待队列,很难做到"只想唤醒等待恢复的那批线程"。换成ReentrantLock配两个Condition之后,代码一下就清晰了。不过话说回来,如果只是最简单的临界区保护,我还是会用synchronized,因为它写法简洁、不会忘记释放锁,而且JVM在锁释放方面有更成熟的优化。
ReentrantLock lock = new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); } } else { // 获取锁超时,走降级逻辑 }2.2 ReadWriteLock:读多写少场景的主力
ReentrantLock是"排他锁",无论读还是写,同一时刻只有一个线程能持有。但很多业务场景是典型的"读多写少",比如配置中心、热点数据缓存:大量线程在读,偶尔才有一次写。如果全部用排他锁,读线程之间就会互相阻塞,白白损失并发度。
ReadWriteLock把锁分成了读锁和写锁:读读不互斥、读写互斥、写写互斥。多个读线程可以同时持锁,只有当写线程进入时,读线程才需要等待。这个语义在上层的表现就是:读操作可以并行,写操作独占,整体吞吐量比一把大排他锁高不少。
但ReadWriteLock有个隐藏的坑——写锁饥饿。如果读线程一直持续不断,写线程可能长时间拿不到锁。JDK的ReentrantReadWriteLock默认是非公平模式,某些情况下写线程还是可能被后续的读线程插队。如果业务对写延迟敏感,可以开启公平模式,或者干脆换下面说的StampedLock。
2.3 StampedLock的乐观读与三个"不支持"
StampedLock是JDK 8新增的锁,也是读多写少场景下比ReadWriteLock更激进的选择。它最大的亮点是乐观读:读线程不做真正的加锁操作,只是拿一个stamp票据,然后直接读数据,读完之后再验证这个票据是否仍然有效。如果有效,说明读期间没有写操作,这次读就是安全的;如果无效,说明中间被写线程打断过,需要升级为悲观的读锁或写锁重新读一遍。
long stamp = lock.tryOptimisticRead(); // 直接读取共享数据,不加锁 if (!lock.validate(stamp)) { stamp = lock.readLock(); try { // 重新读取数据 } finally { lock.unlockRead(stamp); } }乐观读的思路很像版本号机制:先做无锁操作,最后用版本号校验数据是否被修改过。如果校验失败,再付出加锁的代价。在竞争不激烈的读多写少场景下,这种"先尝试、后补救"的策略能显著降低锁开销。
但StampedLock有三个"不支持"必须记住:不可重入,同一个线程不能重复获取;不支持Condition,不能调用await或signal;打断操作容易导致CPU飙高。我在某次压测里踩过第三个坑:持有写锁的线程被interrupt后,等待的读线程会陷入长时间自旋,CPU直接打满。所以官方也建议,不要把StampedLock用在可中断的锁等待流程里。
3. AQS是JUC的地基:看懂它,锁和同步器都不再神秘
3.1 state加队列:AQS的核心工作方式
前面提到的ReentrantLock、Semaphore、CountDownLatch,看起来功能差别很大,但它们的底层骨架是同一个——AbstractQueuedSynchronizer。AQS的核心设计可以概括成两句话:一个volatile int state变量记录资源状态,一个CLH变体双向队列记录等待线程。
线程想做事的时候,先用CAS尝试修改state。成功了,资源拿到手;失败了,就封装成一个Node节点挂到等待队列尾部,然后阻塞自己。当持有资源的线程释放时,AQS会唤醒队列头部的节点,让等待线程重新尝试获取。整个过程是典型的模板方法模式:AQS负责入队、出队、阻塞、唤醒这些通用流程,具体什么样的资源状态算"可获取",由子类通过tryAcquire、tryRelease决定。
不同的同步器只是对state语义的解释不同。我整理过一张小表,帮助记忆:
| 组件 | state的语义 | 获取资源方式 |
|---|---|---|
| ReentrantLock | 0表示无锁,大于0表示重入次数 | 独占 |
| Semaphore | 剩余的许可数量 | 共享 |
| CountDownLatch | 剩余待倒数的计数 | 共享 |
| ReentrantReadWriteLock | 高16位读锁计数,低16位写锁计数 | 独占+共享 |
3.2 公平与非公平锁的差异点
ReentrantLock默认是非公平锁。非公平锁在lock时,会直接尝试CAS去抢锁,如果抢不到再进入AQS队列;而公平锁会先检查队列里有没有比自己更早的等待线程,有就老老实实入队。这个差异只有几行代码,但影响很大。
非公平锁的优点是吞吐量更高,因为新进来的线程直接抢锁,可能正好赶上锁刚被释放的窗口期,省去了挂起和唤醒的开销。缺点是可能造成"插队",导致队列里等待时间很长的线程迟迟拿不到锁。公平锁则相反,谁先等谁先得,不会饿死,但因为线程频繁在阻塞和唤醒之间切换,整体吞吐量反而更低。
实际工作中,除非业务对等待顺序有严格要求,否则我基本默认用非公平锁。公平锁看起来"更公平",但在高并发下往往只是把性能问题换成了延迟问题。
3.3 手写一个基于AQS的独占锁
理解了AQS之后,最好的验证方式是自己写一个最简单的独占锁。只需要继承AbstractQueuedSynchronizer,实现tryAcquire和tryRelease:
class SimpleLock { private static class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire(int arg) { return compareAndSetState(0, 1); } @Override protected boolean tryRelease(int arg) { if (getState() == 0) { throw new IllegalMonitorStateException(); } setState(0); return true; } } private final Sync sync = new Sync(); public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } }这段代码里,我只需要关注"什么条件下能拿到锁",排队、阻塞、唤醒全部交给AQS。这也是AQS设计最漂亮的地方:它把并发编程中最复杂、最容易出错的队列管理沉淀到了底层,让上层开发者只关注业务语义。建议每个学JUC的人都亲手写一次类似的同步器,写完之后再看ReentrantLock源码,会觉得豁然开朗。
4. CountDownLatch、CyclicBarrier、Semaphore:三种协作语义全拆解
4.1 CountDownLatch:倒计时聚合
CountDownLatch就是个倒计时门闩。初始化时指定一个计数N,调用await()的线程会一直等待,直到其他线程调用countDown()把计数减到0,门闩打开,等待线程继续执行。这个机制最典型的应用是"主线程等子线程完成后汇总结果"。
我在做某个运营报表服务时就用过。报表数据分散在三个数据源,需要并行拉取,最后合并结果。顺序拉取太慢,直接new Thread又不好管理,于是用CountDownLatch:
CountDownLatch latch = new CountDownLatch(3); ExecutorService pool = Executors.newFixedThreadPool(3); for (String source : sources) { pool.submit(() -> { try { // 拉取数据 } finally { latch.countDown(); } }); } latch.await(5, TimeUnit.SECONDS); // 继续合并汇总这里有个非常关键的细节:countDown()必须放在finally里。否则子线程一旦抛出异常,计数永远减不完,主线程就会一直等着,直到我设置的await(5, TimeUnit.SECONDS)超时兜底。这个"finally+超时"的组合,是我处理CountDownLatch的固定姿势,能避免绝大多数线上卡死问题。
4.2 CyclicBarrier:多线程回合点
CyclicBarrier和CountDownLatch长得有点像,但语义完全不同。CountDownLatch是"主线程等子线程",CyclicBarrier是"N个线程互相等",而且CyclicBarrier可以循环使用,计数归零后自动重置,像一轮又一轮的关卡。
我记得它的最佳比喻是"团队赛跑的起点线":只有所有人都到达起跑线,裁判才发令枪响,大家同时起跑。CyclicBarrier还支持传入一个barrierAction,在所有线程到达屏障之后、放行之前,由最后一个到达的线程执行这个动作:
CyclicBarrier barrier = new CyclicBarrier(4, () -> { // 这轮所有人都到了,执行一轮汇总 }); for (int i = 0; i < 4; i++) { new Thread(() -> { // 阶段一 barrier.await(); // 阶段二 }).start(); }barrierAction的存在让CyclicBarrier特别适合"分阶段迭代"的计算。比如多线程跑算法迭代,每轮结束需要同步一次当前结果,下一轮再继续。这种场景用CountDownLatch会非常别扭,因为你还得手动重建新的实例。
4.3 Semaphore:信号量限流
Semaphore就是一个带计数的信号量,它维护一组许可(permits),线程获取许可才能执行,执行完要归还许可。它的核心价值是限流——限制同时访问某资源或某段代码的线程数量。
比如数据库连接池只有10个连接,想限制下游批量任务最多只有10个线程同时访问数据库,就可以:
Semaphore semaphore = new Semaphore(10); for (Runnable task : tasks) { executor.submit(() -> { try { semaphore.acquire(); // 访问受限资源 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }); }注意两点。一是release()必须在finally里,否则线程执行异常时许可不归还,几个异常之后资源就被"饿死"了。二是acquire()会响应中断,被中断时异常会冒出来,要正确处理中断状态。Semaphore也支持tryAcquire,可以设置等待时间,配合降级逻辑使用。
4.4 选型教训:这三个工具最容易用反的地方
这三个工具放在一起时,新人特别容易混淆,我的判断标准只有一句话:问自己"谁在等谁"。
- 如果是"一个线程等另外N个线程完成",用
CountDownLatch。 - 如果是"N个线程互相等在同一个回合点上,然后一起继续",用
CyclicBarrier。 - 如果是"控制同时执行的线程数量",用
Semaphore。
我曾经见过一段代码,本意是让10个任务都完成后统一入库,结果用了Semaphore,导致入库线程不断获取许可然后立刻释放,实际等待语义完全没实现。还有一些项目把CountDownLatch计数从1改成0,导致主线程根本没阻塞就继续往下跑了。这些坑都不是API不会用,而是"协作语义"没想清楚。
5. 原子类的性能密码:CAS、ABA与分段计数
5.1 CAS是怎么做到无锁更新的
AtomicInteger、AtomicLong这些原子类的底层支撑是一个叫做CAS(Compare-And-Swap)的CPU指令。它做的事情是:比较当前内存值是否等于期望值,如果等于,就替换成新值,返回true;如果不等于,说明有其他线程改过了,就返回false。整个过程由CPU硬件保证原子性,不需要加锁。
以AtomicInteger.incrementAndGet()为例,它内部就是一个自旋循环:不断读取当前值,尝试CAS设置为当前值加1,如果CAS失败就重读重试,直到成功。这种"乐观重试"的方式在低竞争场景下比加锁快得多,因为没有线程挂起和上下文切换的开销。
AtomicInteger count = new AtomicInteger(0); count.incrementAndGet(); // 线程安全的自增CAS虽然快,但有两个问题需要注意。一个是自旋再重试会让高竞争下的CPU空转,另一个就是著名的ABA问题:如果线程A读到值是1,此时线程B把1改成2再改回1,线程A执行CAS时会发现值还是1,于是判断"没人改过",但实际中间已经被改过两次了。
5.2 ABA问题:AtomicStampedReference
解决ABA问题,标准做法是给变量加上版本号。AtomicStampedReference内部同时维护对象引用和整数戳记,每次修改都需要同时满足"引用相等"和"戳记相等"两个条件,更新时戳记也跟着变。
AtomicStampedReference<BigDecimal> ref = new AtomicStampedReference<>(initialValue, 0); int[] stampHolder = new int[1]; BigDecimal current = ref.get(stampHolder); ref.compareAndSet(current, newValue, stampHolder[0], stampHolder[0] + 1);在真实业务中,ABA问题并不常见,但一旦出现就是那种"偶尔才发生、排起来要命"的隐蔽Bug。我个人的经验是:如果被修改的对象本身带有版本号或唯一ID,优先用版本号做CAS校验,不一定非要用AtomicStampedReference;只有状态本身没有天然版本信息时才引入戳记。
5.3 LongAdder为什么在高并发下更快
AtomicLong在高并发下有性能瓶颈,因为所有线程都去抢同一个内存地址的CAS更新,竞争激烈时大量线程自旋重试,效率直线下降。LongAdder的思路则是"拆":内部维护一个基础值base和一个Cell[]数组,每个线程更新时先分散到某个Cell上做CAS,相当于把压力分摊到多个独立的内存地址上;需要取值时,把base和所有Cell累加求和。
LongAdder qps = new LongAdder(); qps.increment(); // 请求进来 long total = qps.sum(); // 统计总和这种做法很像交通分流:AtomicLong是所有人挤一条收费车道,LongAdder则是多开几条道,车流量大时各走各的,只有统计总和时才汇总。所以LongAdder非常适合"高并发、写多读少、最终一致"的计数场景,比如接口调用次数、埋点统计、QPS指标。
但也要记住:LongAdder的sum()不是强一致的,计数过程中可能漏掉正在进行的更新,而且sum()本身也有开销。如果业务要求"每次读取都必须严格精确",那还是用AtomicLong更合适。简单总结就是:追求实时精确用AtomicLong,追求高并发吞吐用LongAdder。
6. 并发容器与线程池:生产环境每天见的两个主角
6.1 ConcurrentHashMap的锁粒度演进
ConcurrentHashMap应该是生产环境里出现频率最高的并发容器,它在不同JDK版本里的实现思路也很有意思。JDK 7时代它用的是分段锁:整个Map被拆成16个Segment,每个Segment是一把独立的锁,不同线程操作不同Segment时可以并行。JDK 8则彻底放弃了Segment,改成了"数组+链表/红黑树",锁粒度细化到单个桶头节点,同时引入CAS辅助初始化,锁粒度从"段级"降到了"桶级"。
桶级锁化的好处是:大多数读取操作连锁都不用加,直接volatile读就能拿到值;写入时只需要锁住当前桶的头节点,完全不干扰其他桶。这个设计让ConcurrentHashMap在绝大多数并发场景下都表现优秀。
不过要注意,ConcurrentHashMap虽然线程安全,但它的"安全"是单操作级别的。比如get之后再进行put,这个"先查后写"的复合操作并不是原子的,依然需要自己在外面加锁或使用computeIfAbsent等原子方法保证逻辑正确。
6.2 CopyOnWriteArrayList与BlockingQueue的适用边界
CopyOnWriteArrayList的设计非常极端:每次修改时,它把整个底层数组复制一份,在新副本上做修改,然后原子地替换引用;读线程永远访问的是旧数组,因此读取完全不加锁、不会发生并发修改异常。这套"写时复制"策略让它成为"读多写极少"场景的杀手锏,比如监听器列表、黑白名单配置。
但"写时复制"的代价是:每次写操作都是O(n)的内存拷贝,列表很大、写入频繁时,GC压力会非常夸张。我见过一个项目把订单日志塞进CopyOnWriteArrayList,结果写入频繁触发复制,内存碎片暴涨,最后只能换回普通加锁队列。用它的唯一标准就是:确认写入频率低得可以忽略拷贝成本。
BlockingQueue则是生产者消费者模式的核心组件。ArrayBlockingQueue是有界队列,容量固定;LinkedBlockingQueue默认无界,但也可以指定容量。阻塞队列的put和take会在队列满/空时自动阻塞,这让我不用手写wait/notify就能搭建完整的任务流水线。选型时我优先用有界队列,因为无界队列在任务高峰时会把压力全部转嫁给内存。
6.3 ThreadPoolExecutor参数与一次队列事故
ThreadPoolExecutor是线程池的核心实现,七个参数缺一不可:核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。它的执行逻辑可以归纳成三步:
- 当前线程数小于核心线程数,直接新建线程执行任务;
- 线程数达到核心数,任务优先入队;
- 队列满了,且当前线程数小于最大线程数,再新建临时线程;如果线程数已经到了最大值,触发拒绝策略。
我曾经因为忽略第2步,搞出过一次线上事故。当时我用默认的LinkedBlockingQueue(无界队列),以为"队列无限大"是最安全的。结果某天上游系统异常,任务短时间激增,线程数始终只维持在核心线程数大小,新任务全部堆进无界队列,内存逐渐被撑爆,然后就是频繁Full GC、接口雪崩。
那次之后我把工作队列换成了有界ArrayBlockingQueue,拒绝策略从默认的AbortPolicy改成了CallerRunsPolicy——任务被拒绝时,不直接丢弃,而是让提交任务的线程自己执行这个任务。这样压力没有消失,而是回传给了调用方,相当于让整个链路感知到"下游已经过载了",反而起到了自我保护的作用。
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue<>(1000), // 有界工作队列 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );线程池参数怎么调,业界有很多经验值,比如CPU密集型设置核心线程数 = CPU核数 + 1,IO密集型设置核心线程数 = CPU核数 * 2。但我的看法是,这些只是起点,真正要结合压测数据来调。线程池参数没有银弹,只有"任务耗时、并发量、队列容忍度"三个变量反复试出来的结果。
最后再分享一点个人体会:学JUC,千万不要停留在背类和背用法。我验证自己是否真正理解一个并发组件的方式,是问自己三个问题——它用到了什么底层机制?它在什么场景下会失效?如果我不用它,能不能用更简单的方案代替?能把这几个问题回答清楚,说明组件背后的协作模型是真的想明白了。这套工具包里没有银弹,但凡某个类被说得天花乱坠,往往都有人正在为它的适用边界交学费。希望这篇梳理能帮你少走一段弯路。