如果你用 Java 写过并发程序,那你一定听过 JUC 这个名字。JUC 的全称是 java.util.concurrent,它是 JDK 5 开始引入的一套并发工具包,也是国内 Java 面试绕不开的“重镇之地”。很多人在面试的时候被问“JUC 了解吗”,于是开始背 ReentrantLock、ConcurrentHashMap、CountDownLatch 这些名词,但真到了生产环境,线上线程卡死、任务堆积、数据错乱,翻开线程栈却不知道从哪里下手,才发现自己只是“听过”而不是“会用”。这篇内容我想换个角度,把 JUC 背后真正解决的问题讲透:AQS 是怎么当地基的,显式锁和 synchronized 的区别在哪里,并发容器为什么不能乱用,线程池参数到底在配什么,还有我实际项目中踩过的几个坑。适合已经写过一些多线程代码、想系统理解 JUC、或者准备面试想要深层理解的 Java 开发者。全文不念文档,直接讲人话。
1. JUC到底在解决什么问题——从synchronized的痛点说起
很多教程一上来就堆概念,反而容易让人忘掉 JUC 本来要干什么。回到最开始,Java 1.0 就有 synchronized 关键字,按理说它已经能解决“多线程互斥”的问题,那为什么还要造一个 java.util.concurrent 出来?原因很简单:光靠 synchronized 这一个大锤,很多现实场景根本敲不动。
1.1 synchronized 的三大硬伤
第一个硬伤是“只能独占”。synchronized 天然是互斥锁,同一时刻只允许一个线程进入临界区。可现实里相当多的场景是“读多写少”,比如一个配置缓存,十个线程同时在读,只有一个线程偶尔更新。用 synchronized 硬锁,等于把“同时只读”这件事也禁掉了,十个读线程强行排队,吞吐量直接变成原来的十分之一。读操作明明不产生竞争,却因为“锁的粒度只有一把”而付出串行化的代价。
第二个硬伤是“等不到、收不了”。synchronized 的阻塞等待是不能被中断的。也就是说,线程 A 拿到锁之后卡住了(比如在临界区内发起了一个永久阻塞的网络调用),线程 B 在锁上等着,外部想去打断 B、让它赶紧去做别的事,是做不到的。另一个问题是等待没有超时机制。如果两个线程互相持有对方想要的锁,形成死锁,synchronized 会让大家永远抱着等待,服务只能重启。对线上系统来说,“无限等待”比“报错”可怕得多。
第三个硬伤是“锁的边界不够灵活”。synchronized 的加锁、解锁必须框在一个代码块或者方法结构里,锁的获得和释放天生绑定语法。很多场景需要“在一个方法里加锁,在另一个方法里释放”,比如从队列里取一批任务,先锁住队列,扫描完再解锁,中间可能跨过多个方法调用,这种代码用 synchronized 写起来非常别扭,强行拆成 synchronized 块又会破坏操作的原子性。
1.2 JUC 的解题思路:从“强制互斥”走向“精细协调”
synchronized 的问题不是“互斥”错了,而是它把问题想得太简单。JUC 的设计思路,是把“同步”从一个语法级关键字,抽象成一组更精细的原语:锁只是其中的一种,队列、信号量、栅栏、阶段器、原子变量、并发容器,每一件都解决特定场景下的协作问题。
我自己的理解是,JUC 做了三件 synchronized 做不了的事:
- 锁可以排队:通过 AQS 维护等待队列,支持公平与非公平策略。
- 锁可以商量:支持超时等待、可中断等待,线程在等待时能主动退出。
- 锁可以分工:读写锁让读读并行,CountDownLatch 让多个线程一起等一个信号,CyclicBarrier 让一组线程彼此等待,Semaphore 直接控制同时执行的线程数量。
所以 JUC 不是“synchronized 的替代品”,而是对“什么时候需要互斥、什么时候需要协作”这个问题的重新梳理。理解这一点之后,下面讲 AQS 时你就不会觉得它只是一个“处理锁的底层工具”了,它其实是整个并发协作逻辑的中枢神经。
2. AQS:一块地基如何撑起整个并发工具包
AQS 全称 AbstractQueuedSynchronizer,如果你去看 ReentrantLock、Semaphore、CountDownLatch 这些类的源码,会发现它们底层都委托给一个继承 AQS 的内部类。AQS 之于 JUC,就像地基之于整栋楼——楼里每一间房的功能各不相同,但承重结构是同一套。
2.1 状态位与等待队列:AQS 的两个核心零件
AQS 内部维护了两种东西:一个 volatile int 类型的 state,和一个双向等待队列。这两样东西几乎解释了 JUC 所有并发工具的行为。
state 可以理解为一个“状态计数器”。ReentrantLock 用它记录锁被获取了几次(重入多少次);Semaphore 用它记录剩余许可证数量;CountDownLatch 用它记录还有多少事件没完成。AQS 本身不定义 state 的具体含义,它只提供 getState、setState、compareAndSetState 这三个方法,至于这个状态代表什么,完全由子类决定。这正是 AQS 能支撑多种并发工具的原因——它提供了“状态 + 队列”的框架,却不绑架你的业务语义。
等待队列是一个基于 Node 的双向链表。每当一个线程尝试获取“某种同步状态”失败,它会被包装成一个 Node,挂到队列尾部,然后通过 LockSupport.park 挂起自己。当拥有状态的线程释放时,它会从队列头部唤醒后继节点。这套机制解决了一个很关键的问题:如果一个线程获取不到锁就原地死循环自旋,CPU 会被浪费在无意义的忙等上。AQS 的做法是让失败的线程真正“睡过去”,等条件可能满足时再被唤醒,省下的 CPU 时间可以用来做别的事。
2.2 独占模式与共享模式
AQS 支持两种获取方式:独占模式(exclusive)和共享模式(shared)。最直观的理解是:独占模式是“只有一个人能拿到”,共享模式是“大家可以一起拿,但不能拿得太多”。
ReentrantLock 就是独占模式的代表。同一时刻只有一个线程能成功把 state 从 0 改成 1,其他线程都进队列。Semaphore 是共享模式:每个线程来 acquire 时把 state 减一,只要 state 还大于 0,多个线程可以同时成功。CountDownLatch 也属于共享模式:所有等待线程可以同时被唤醒,不需要排队“一个一个出去”。
这个区分在实际使用中很有价值。比如说你要写一个限流组件,希望允许 10 个线程同时执行某个任务。如果你错误地用了独占锁,那效果就不是限流而是串行化;用 Semaphore 这种共享语义的实现,才能同时放过 10 个线程,第 11 个才等待。
2.3 一个零基础的实验:用 AQS 手写一把锁
理解 AQS 最好的方式,是自己实现一把简单的锁。下面这段代码是我早期学习时写的,非常简陋但足够说明问题。
import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SimpleLock { private final Sync sync = new Sync(); // 继承 AQS,只实现独占模式的获取和释放 private static final class Sync extends AbstractQueuedSynchronizer { // 尝试获取锁:把 state 从 0 改成 1,成功就返回 true @Override protected boolean tryAcquire(int acquires) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } // 尝试释放锁:直接把 state 改回 0 @Override protected boolean tryRelease(int releases) { if (getState() == 0) { throw new IllegalMonitorStateException(); } setExclusiveOwnerThread(null); setState(0); return true; } } public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } }这段代码能跑,但有一个明显的问题:它不是可重入的。同一个线程调用 lock 两次,第二次的 compareAndSetState 会失败,线程会把自己挂起,然后死锁。ReentrantLock 之所以支持重入,是因为它在 tryAcquire 里加了“如果当前线程已经是持有者,就把 state 加一”的逻辑。看到没有?AQS 只是提供了框架,具体的同步规则完全由子类写。
从这个小实验里你可以直观感受到 AQS 的强项:获取和释放的模板流程(失败入队、挂起唤醒、队列维护)已经被抽象好了,你只需要关心“怎么改变 state 的值”这一个问题。这也是 JUC 这么多并发工具代码量看起来不算大,却能覆盖大量场景的根本原因。
3. 显式锁:ReentrantLock和它的兄弟们
synchronized 在 JDK 6 之后做了大量优化,锁膨胀、偏向锁、轻量级锁、自旋,性能已经不像早年那么差。那还要 ReentrantLock 干嘛?核心因素不是性能,而是功能:可中断、可超时、公平策略、条件变量。ReentrantLock 是 JUC 里面最常用、也最容易出细节问题的显式锁。
3.1 重入机制:锁是怎么“记住”自己的主人的
ReentrantLock 的“重入”指同一个线程持有锁后可以再次获取,而不会把自己锁死。实现方式不神秘:AQS 的 state 每次 acquire 加 1,每次 release 减 1,只有当 state 减到 0 时锁才真正释放。同时 AQS 里记录了一个 exclusiveOwnerThread 字段,指向当前持有锁的线程。当线程发现自己就是持有者,就直接 state+1,不再走入队挂起的路。
这个设计解决了一个被很多人忽略的真实痛点:一个方法如果调用了另一个同样需要加锁的方法,比如一个事务方法内部调用了另一个同样被锁保护的方法,如果没有重入能力,第二次加锁就会死锁。synchronized 天然支持重入,ReentrantLock 也支持,但如果你自己用 AQS 写锁,不小心就会把重入逻辑写丢——我见过有人手写锁把系统锁出死锁的,排查半天发现是重入计数被忽略了。
3.2 公平锁与非公平锁:别被“公平”二字骗了
ReentrantLock 的构造函数可以传一个 fair 参数,true 为公平锁,false 为非公平锁。默认是非公平。很多人一看到“公平”就觉得更好,但在并发场景里,公平不一定对应“更正确”。
非公平锁的优点是吞吐量通常更高。原因在于,当一个线程释放锁时,队列尾部恰好来了一个新线程,这个线程如果直接尝试抢锁,有可能在队首等待线程被唤醒之前就已经拿到了锁,省掉了一次线程上下文切换。代价是队首线程可能被“插队”,极端情况下存在饥饿风险,但实际中概率很低。
公平锁则严格按队列顺序来,等待越久的线程越先拿到锁。它更适合“处理器时间要尽量均匀分配”的批处理场景,比如定时任务调度、事件处理系统。我在实践中建议:没有明确公平性要求时,用默认的非公平锁就可以了,不要为了“名字好听”而选公平锁,那可能白付性能代价。
有一个细节值得记一下:tryLock() 方法默认不会遵守公平策略,它就像插队通道,只要有锁空闲就尝试抢占,哪怕你创建的是公平锁。如果你用 tryLock 来避免死锁,就要知道这种“插队”行为是预期内的,不要觉得是 bug。
3.3 ReentrantReadWriteLock:读多写少场景的正解
ReentrantReadWriteLock 维护了两把锁:读锁和写锁。读锁之间可以共享,写锁是独占的;读锁和写锁互斥,写锁和写锁也互斥。这正好解决了本文开头提到的问题:读多写少场景下,多个读线程不必互相排队。
它的核心价值是提升读多写少系统的并发度。比如一个缓存 Map,绝大多数请求是读,只有定时任务偶尔写。如果用 ReentrantLock 整体加锁,读线程全部串行,QPS 会非常难看;换成读写锁,读读并行,QPS 能提高好几倍。
使用读写锁有一个容易搞错的地方:写锁可以降级为读锁,但读锁不能升级为写锁。降级的意思是,在拥有写锁时先获取读锁,再释放写锁,这样当前线程仍然有读保护,但其他写线程可以进来了。读锁升级为写锁则是死锁的经典来源:两个读线程都持有读锁,然后都尝试获取写锁,互相等对方释放读锁,永远等不到。记住:同一把锁,写可以降级,读不能升级。
4. 并发容器:HashMap到ConcurrentHashMap的进化和BlockingQueue
JUC 不只是提供锁,还提供了一整套并发容器。如果你还在用 HashMap 加锁去模拟并发访问,或者还在用 Vector、Hashtable 这种老旧方案,那 JUC 的很多工作都被白费了。并发容器的核心价值在于:容器内部的并发控制已经做完了,你不需要在外面再加锁,也不需要接受整表锁死带来的低性能。
4.1 从 HashMap 到 ConcurrentHashMap:一次安全改造
HashMap 线程不安全,最著名的问题是在 JDK 7 头插法扩容时可能形成环形链表,导致下一次查询死循环,CPU 飙到 100%。JDK 8 改成尾插法,环的问题缓解了,但并发 put 仍然可能丢数据,因为多个线程同时写同一个桶位时,前面的写覆盖了后面的写。
最简单的解决方案是 Collections.synchronizedMap,但它本质上是给每个方法套了一层全表锁,读和读之间都互斥,并发度极低。ConcurrentHashMap 则是把锁的粒度精细到“桶位级别”。JDK 7 的实现用了分段锁,把整个 Map 分成若干段,每段一把锁;JDK 8 改成了 CAS 加 synchronized 锁单个桶的头节点。为什么改?分段锁需要两次 hash 定位段和桶,内存开销也更大,而锁单个桶的做法在大多数场景下已经足够精细,而且实现更轻。
关于 ConcurrentHashMap,不少人有个误解:觉得它是“绝对线程安全的 Map”。其实它只是保证单个操作的原子性和内部结构的完整性。比如先判断 containsKey 再 put,这两步之间可能有别的线程插进来;用 size() 获取元素个数时,在高并发下也只是一个尽力而为的估计值,不能拿来做精确的业务判断。如果你需要“先检查再插入”这个复合操作原子化,应该用 putIfAbsent 或者 computeIfAbsent,而不是在外面套锁。
4.2 BlockingQueue:搭起生产者-消费者桥梁
BlockingQueue 是 JUC 并发容器里的另一个核心家族。它不只是“线程安全的队列”,更重要的是提供了阻塞能力:当队列满时,put 操作会阻塞;当队列空时,take 操作会阻塞。这种天然的生产者-消费者语义,让很多协作代码变得非常简洁。
常用的实现有这么几个:
| 队列类型 | 有界性 | 特点 | 适用场景 |
|---|---|---|---|
| ArrayBlockingQueue | 有界 | 底层循环数组,容量固定,效率高 | 生产者消费者数量比较确定的系统 |
| LinkedBlockingQueue | 默认无界,可指定有界 | 链表结构,吞吐较高 | 请求缓冲、线程池任务队列 |
| SynchronousQueue | 不存储元素 | 每个 put 必须等待一个 take | 直接交接、无中间缓冲 |
| PriorityBlockingQueue | 无界 | 按优先级出队 | 任务带优先级处理的场景 |
使用阻塞队列时,put 和 take 是“会等”的操作,offer 和 poll 是“不等”的操作,offer 还可以传超时时间。实际业务里,我一般建议生产环境使用 offer 带超时来放入任务,避免线程被永久阻塞。如果队列满到一直放不进去,那说明系统已经积压严重,应该走拒绝策略或者告警,而不是让生产者线程无限期等下去。
4.3 CopyOnWriteArrayList:读多写极少时的选择
CopyOnWriteArrayList 是并发 List 里的一个特殊选手。它的策略是:每次写操作,都把底层数组复制一份,在副本上修改,然后把引用指向新数组;读操作完全不加锁,直接在旧数组上读。那如果读发生在写的过程中呢?读到的还是旧数组,它不会被修改,所以不会读到“写到一半”的数据。
这个策略的本质是用“复制”换取“读无锁”。适合读非常多、写非常少的场景,比如缓存黑白名单、系统配置项。代价也非常明显:每次写都要复制整个数组,如果数组很大,写操作会非常昂贵;而且读操作虽然不加锁,但可能读不到最新的写结果。注意 CopyOnWriteArrayList 的迭代器是“弱一致性”的,不支持迭代过程中 remove,如果你指望用它来做实时统计那种要不停看最新数据的场景,那思路就错了。
5. 线程池:省心背后的心机
线程池是 JUC 里最容易被“用”却最难被“用对”的组件。很多团队直接 Executors.newFixedThreadPool 一把梭,线上出问题时才发现线程池比想象中复杂得多。线程池的核心并不只是“复用线程”,而是一套完整的资源管理和状态控制机制。
5.1 七个参数和一个状态机:ThreadPoolExecutor 的骨架
ThreadPoolExecutor 的构造参数有七个:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。理解它们的关系,是设置线程池的第一步。
线程池的工作逻辑简单说就是:当提交任务时,如果当前线程数小于 corePoolSize,就新建线程处理;如果大于 corePoolSize 但队列未满,就放入队列;如果队列满了且线程数小于 maximumPoolSize,就继续新建线程;如果队列满了且线程数已到 maximumPoolSize,就触发拒绝策略。
这里有一个常见的误解:maximumPoolSize 不是“一开始就建这么多线程”,而是“队列塞满之后才允许额外增加的线程数”。所以队列的长度,决定了你什么时候才会开始扩容线程。很多人配了 maximumPoolSize=200,但用了无界队列,结果线程数永远只停在 corePoolSize,任务全堆在队列里,内存被消耗殆尽。
线程池内部还有一个状态机,表示生命周期:RUNNING 正常运行,SHUTDOWN 不接受新任务但继续处理队列任务,STOP 立刻停掉所有任务,TIDYING 整理中,TERMINATED 终结。调用 shutdown 和 shutdownNow 分别对应这两个不同的停止策略:shutdown 是温柔停止,shutdownNow 是强制打断。
5.2 队列选型:哪些坑是 Executors 帮你埋好的
Executors 提供了若干快捷方法,但我强烈建议生产环境不要直接使用。最大的坑是 newFixedThreadPool 和 newSingleThreadExecutor 使用了无界队列 LinkedBlockingQueue。无界队列意味着队列永远装不满,线程数永远不需要扩容到 maximumPoolSize,任务无限堆积,最终导致 OOM。
另一种容易踩的坑是 newCachedThreadPool,它的 corePoolSize 为 0,maximumPoolSize 为非常大,队列是 SynchronousQueue。只要提交速度快于处理速度,它就会不断创建新线程,线程数量直接打到系统上限,造成频繁上下文切换甚至崩溃。在我的实践中,哪怕是一些短任务场景,也要注意给这种池加一个速度上限,或者干脆用有界队列加拒绝策略。
推荐的做法是手动创建 ThreadPoolExecutor,显式指定有界队列和拒绝策略。比如下面这份配置,是一个比较稳妥的出发点:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new NamedThreadFactory("order-process"), new ThreadPoolExecutor.CallerRunsPolicy() );有界队列 1000,可以让任务等待一定时间,又不至于无限堆积。拒绝策略 CallerRunsPolicy 会让提交任务的线程自己执行该任务,等于把压力回传给了生产者,不会无声无息丢任务。
5.3 关于线程工厂、预热和超时的三个细节
线程池的默认线程工厂叫 DefaultThreadFactory,它创建的线程名是 pool-1-thread-1 这种无意义的名字。线上排查问题时,你打开线程栈看到一堆 pool-1-thread-1,根本不知道是哪个业务在运行。自定义一个 ThreadFactory 给线程起业务名,是排查效率利器。稍微讲究一点的做法是给线程设置 UncaughtExceptionHandler,以及设置 daemon 属性。
另外两个细节分别是 prestartAllCoreThreads 和 allowCoreThreadTimeOut。前者会在线程池创建后立即把 corePoolSize 个线程全部拉起来,避免第一个任务到达时才慢吞吞创建线程,适合对延迟敏感的系统。后者允许核心线程在空闲超过 keepAliveTime 后销毁,适合流量有明显波谷的系统,能减少空闲线程占用。
6. 同步工具:三个老搭档的选型经
CountDownLatch、CyclicBarrier、Semaphore 这三个类,表面功能有些相似,实际上语义和使用场景差得很远。经常有同学在面试的时候被问到“三者区别”,然后开始背:Latch 是倒数门闩,Barrier 是栅栏,Semaphore 是信号量。但真要写代码时,却不知道该用哪个。
6.1 CountDownLatch:一次性的闸门
CountDownLatch 用来实现“一组线程等待若干个事件完成”。比如一个主线程要等待三个子线程分别从三个上游接口拉数据,都拉完了才继续汇总。此时可以初始化 new CountDownLatch(3),三个子线程完成后各调用一次 countDown,主线程调用 await 被挂起,直到计数器归零。
它的两个特性要注意:第一,不可复用,计数器归零后,这个实例就废了,想再等下一批任务就要 new 一个新的;第二,countDown 和 await 的线程可以不是同一批。比如一个主线程 await,另外多个工作线程 countDown,这完全没问题。底层同样是 AQS,countDown 对应共享状态减一,await 对应共享模式获取。
如果担心 await 被无限期挂起,可以用 await(long, TimeUnit) 的版本,设置超时时间。实际项目中我几乎总是用带超时的版本,因为“等待无上限”对线上服务来说是一个风险点。
6.2 CyclicBarrier:可循环的栅栏
CyclicBarrier 的语义是“所有线程互相等待,等大家都到达了,再一起继续”。和 CountDownLatch 不一样,它等待的主角是一组线程本身,而且要齐头并进。举个例子:三个线程各自处理一批数据,每处理完一个批次,都要等另外两个也处理完,再同时进入下一批次。这种场景用 CyclicBarrier 就非常自然。
它和 CountDownLatch 最大的区别有两个:一是可循环使用,batch 结束后可以 reset 接着用下一轮;二是支持一个可选的 barrierAction,也就是当所有线程都到达屏障时,由最后一个到达的线程执行这个动作,常用于“每次批次完成后汇总一下分片结果”。
使用 CyclicBarrier 时注意:如果其中某个线程发生异常或超时,barrier 会进入 broken 状态,其他等待线程会抛出 BrokenBarrierException。实际写代码时,要给这个异常留下处理逻辑,否则等待线程可能永久挂起或异常满天飞。我在项目里见过因为没处理 BrokenBarrierException 导致整个任务组卡死的案例,定位的时候才发现是这个隐藏的依赖关系。
6.3 Semaphore:独立的限流器
Semaphore 管理一组许可证。线程执行前 acquire 拿一个许可证,执行完 release 归还。拿不到许可的线程就等待。它和线程池不同,它不限制“有几个线程在跑”,只限制“有几个线程同时通过许可”,所以可以被用在很多不需要池化的限流场景。
比如你要控制某个上游接口的全局并发度,不希望同时超过 5 个请求打上去,就可以在调用前 Semaphore.acquire(),调用结束后 release()。它可以跨线程使用,也就是说一个线程 acquire,另一个线程 release,这在线程池任务传递的场景里很有用。
它同样支持公平和非公平模式,默认非公平。信号量还有一个 acquireUninterruptibly,如果等不到许可就不响应中断,这是一个容易被忽略的阻塞形态,使用时要评估清楚。
6.4 一张表看清三者的差异
| 工具 | 核心语义 | 复用性 | 典型用法 | 底层类型 |
|---|---|---|---|---|
| CountDownLatch | 等 N 个事件完成后放行 | 不可复用 | 主线程等待多任务完成 | AQS 共享 |
| CyclicBarrier | 等 N 个线程互相到齐 | 可 reset 复用 | 分片数据批量处理 | 基于 ReentrantLock 和 Condition |
| Semaphore | 控制同时通过的线程数 | 可复用 | 限流、连接池模拟 | AQS 共享 |
7. 我踩过的JUC深坑:问题排查实录
理论说再多,不如实战踩坑。最后这一节分享我在真实项目里遇到过的几个并发问题,每一个都是可以复现的、值得警惕的。
7.1 循因溯源:一个锁顺序死锁的排查过程
那一次我负责的某个服务在高峰期突然大面积请求超时。打开线程栈,发现大量线程卡在 ReentrantLock.lock() 的调用上,互相等待,明显是死锁。但代码里用了 ReentrantLock,为什么还会死锁?
顺着线程栈往下看,线程 A 持有锁 L1,等待锁 L2;线程 B 持有锁 L2,等待锁 L1。原因是两个业务方法加锁顺序不一致:方法甲先锁 L1 再锁 L2,方法乙先锁 L2 再锁 L1。锁顺序不一致是死锁的经典配方。修复方式不复杂:强制统一所有场景下加锁的顺序,全部先 L1 后 L2。上线后问题消失。
这个排查过程给我几个经验:第一,用 ReentrantLock 也要考虑锁顺序,它并不免疫死锁;第二,排查死锁最快的方式还是 jstack 看线程栈,栈里会把“waiting to lock”和“held lock”清清楚楚列出来;第三,代码审查时如果发现多处加锁,一定要盯住加锁顺序,这是最隐蔽的死锁来源之一。我后来会在跨方法加锁的地方专门写注释标明锁的层级顺序。
7.2 共享变量没加 volatile 的可见性事故
另一个案例更隐蔽。当时的代码有一个 while 标志位控制后台任务循环:一个线程检查标志位,另一个线程负责在收到停止信号后把标志位改成 false。看起来逻辑没问题,但实测中,线程 A 一直不退出。原因是标志位没有用 volatile 修饰,线程 A 在自己线程的缓存里一直看到“旧值”,永远读不到线程 B 的修改。
Java 内存模型里,volatile 保证的是可见性和禁止重排序,不保证复合操作的原子性。如果一个标志位只是被单线程修改、被另一线程读取,用 volatile 就够;如果多个线程同时修改同一个计数,应该用 AtomicInteger 或者加锁。这个例子让我记住了一条经验:在 JUC 的世界里,不只是锁要管,变量本身的可见性也要管,ATOMIC 系列和 volatile 是 JUC 里最基础但最容易被忽视的零件。
7.3 Future.get 阻塞:等不到的调用
使用线程池时,很多人习惯用 Future 获取任务结果。我也踩过坑:某个任务里调用了远程接口,没有设置超时时间,线程池中一个线程在 Future.get() 上永久阻塞;调用方也调用了 get(),同样被永久阻塞。一个超时,传染成整条调用链阻塞。
后来我给所有 Future.get 都加了超时参数:get(5, TimeUnit.SECONDS)。如果超时,就取消任务并记录告警。另外要记住,FutureTask 超时后线程并不会自动中断任务本身,它只是让调用者不再等待。如果任务是 CPU 密集型且不响应中断,线程其实还在后台跑。所以超时取消后,还要检查任务本身是否真的终止了,必要时给任务提供取消逻辑。
7.4 线程池参数拍脑袋:压测才是唯一标准
最后想说的经验是,线程池参数绝对不要拍脑袋定。我见过太多团队把 corePoolSize 设为 8、maximumPoolSize 设为 16,但没人说得清为什么是这两个数字。核心线程数、队列长度、拒绝策略,必须结合你的业务类型——CPU 密集型、IO 密集型还是纯异步任务——进行估算和压测验证。
CPU 密集型的经验值一般是 CPU 核数 + 1,目的是让每个线程都跑满一个核,不引入太多上下文切换。IO 密集型的线程数要适当放大,因为线程大部分时间阻塞在等待 IO 上,可以多开一些线程来掩藏延迟。但这只是一个出发点,真正的参数要靠压测定。线上系统改线程池参数后,一定要观察任务堆积量、线程空闲率、拒绝次数这三个指标,任何一个指标异常都要回去重新调。
JUC 是一个工具包,更是一套并发编程的思维方式。如果你能理解 AQS 是骨架、同步工具是语义、容器是优化、线程池是资源管理,那么再遇到并发问题,就不会只想“加个锁”或者“换个大一点的线程池”了,你会有条件反射般的排查思路和选型直觉。以后再看源码或者做系统设计的时候,建议回过头来想一个问题:这个类解决的,到底是哪个 synchronized 解决不了的问题?想清楚了,才算真正入门 JUC。