模块三 · 并发与 JVM
并发全景:从线程池到锁到无锁
并发题的核心不是背 API,而是理解"共享、可见、有序、互斥"四件事。这一节按线程池、锁、JMM、CAS、并发工具的顺序讲。
读完这一节,你能做到
- 说清线程池七个参数与四种拒绝策略的取舍。
- 解释 synchronized 的锁升级与 ReentrantLock 的差异。
- 用 happens-before 解释 volatile 的可见性与有序性。
- 判断 CAS 的 ABA 问题与自旋开销。
一、线程池:七个参数
new ThreadPoolExecutor(
corePoolSize, maximumPoolSize, keepAliveTime, unit,
workQueue, threadFactory, handler
);
执行顺序是:核心线程 → 队列 → 非核心线程 → 拒绝策略。很多人以为"线程满了才排队",实际是"核心满了先排队,队列满了才扩线程"。
| 拒绝策略 | 行为 | 适用 |
|---|---|---|
| AbortPolicy | 抛 RejectedExecutionException | 默认,快速失败 |
| CallerRunsPolicy | 调用方线程执行 | 降速,避免丢任务 |
| DiscardPolicy | 静默丢弃 | 不重要任务 |
| DiscardOldestPolicy | 丢最老的,再尝试提交 | 实时性优先 |
线程数估算:
- CPU 密集:约
CPU 核数 + 1。 - IO 密集:约
CPU 核数 × (1 + 等待时间/计算时间)。 - 最终要以压测为准,公式只是起点。
队列不要用无界队列:任务堆积会 OOM,且 maximumPoolSize 永远不会生效。
二、锁:synchronized 与 ReentrantLock
synchronized 是 JVM 层面的内置锁,自动加锁/释放,异常也会释放。锁升级路径:
无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(操作系统互斥量)
注意:偏向锁在 JDK 15 被废弃、JDK 17 默认关闭,面试时要说明版本差异。
ReentrantLock 是 AQS 实现的显式锁,能力更强:
| 能力 | synchronized | ReentrantLock |
|---|---|---|
| 自动释放 | 是 | 否,必须 finally unlock |
| 公平锁 | 否 | 可选 |
| 可中断 | 否 | lockInterruptibly |
| 超时尝试 | 否 | tryLock(timeout) |
| 条件队列 | 单个 wait/notify | 多个 Condition |
AQS 的核心是 state + CLH 双向队列:获取失败入队,释放时唤醒后继;公平与非公平的差别在于是否允许插队。
三、JMM 与 volatile
Java 内存模型规定:每个线程有工作内存,共享变量在主内存。没有同步时,一个线程的修改对另一个线程不一定可见。
volatile 保证两件事:
- 可见性:写立即刷新到主内存,读从主内存读。
- 有序性:禁止指令重排(通过内存屏障)。
它不保证原子性:i++ 仍然是读-改-写三步,多线程下会丢更新。
happens-before 规则是判断可见性的依据,常用的几条:
- 程序顺序:同一线程内,前面的操作先于后面的。
- 监视器锁:解锁先于后续加锁。
- volatile:写先于后续读。
- 线程启动/终止:start 先于线程内操作,线程内操作先于 join 返回。
- 传递性:A 先于 B、B 先于 C,则 A 先于 C。
四、CAS 与原子类
CAS 是"比较并交换":只有当前值等于期望值时才更新。AtomicInteger 用 Unsafe 的 CAS 实现无锁自增。
三个问题:
- ABA:值从 A 变 B 又变 A,CAS 察觉不到。用
AtomicStampedReference加版本号。 - 自旋开销:竞争激烈时 CPU 空转,必要时退化为锁。
- 只能保证一个变量:多变量用
AtomicReference或锁。
LongAdder 用分段累加降低热点,读的时候再汇总,适合高并发计数;AtomicLong 适合低竞争。
五、并发工具
| 工具 | 作用 |
|---|---|
| CountDownLatch | 等待 N 个任务完成,一次性 |
| CyclicBarrier | 等齐再一起走,可循环 |
| Semaphore | 控制并发数量 |
| CompletableFuture | 异步编排与组合 |
CompletableFuture 的三个常用组合:thenCompose(串行依赖)、thenCombine(并行合并)、allOf(等待全部)。异常用 exceptionally 或 handle 处理。
六、常见坑
- 用无界队列,导致任务堆积与 OOM。
- 线程池用完不 shutdown,JVM 无法退出。
- 在 synchronized 块里做远程调用,锁持有时间过长。
- 用 volatile 修饰计数器,误以为原子。
- 线程池里抛出的异常被吞掉,任务静默失败。
七、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个固定大小线程池,对比 CallerRunsPolicy 与 AbortPolicy 的行为。
- 用两个线程演示 volatile 可见性,再去掉 volatile 复现死循环。
- 用 CompletableFuture 并行调用三个接口并合并结果。
② 让 AI 写同一题
把第 2 题(线程池配置)交给 AI,补一句 说明队列容量、拒绝策略与为什么这么选。
③ 对比要盯的点
- 它用了无界队列吗?那等于放弃背压,任务会堆到 OOM。
ThreadLocal用完它remove()了吗?线程池复用会导致串数据。CompletableFuture默认用哪个线程池?它有没有说清为什么不建议默认。
④ 一句话取舍
它这套配置在流量翻三倍时,第一个撑不住的是哪一环?
八、自测
- 线程池的执行顺序是什么?
- 四种拒绝策略分别适合什么场景?
- synchronized 的锁升级路径?
- volatile 保证什么、不保证什么?
- CAS 的 ABA 问题怎么解决?
参考答案要点:核心→队列→非核心→拒绝;快速失败/降速/丢弃/丢老;无锁→偏向→轻量→重量;可见性与有序性,不保证原子;版本号。
附录:高频追问
问:为什么阿里规约禁止用 Executors 创建线程池? 固定/缓存线程池用无界队列,可能堆积 OOM;应显式指定队列与拒绝策略。
问:线程池核心线程会回收吗? 默认不回收;allowCoreThreadTimeOut(true) 可以让核心线程也超时回收。
问:synchronized 和 Lock 怎么选? 简单同步用 synchronized;需要公平、超时、可中断、多条件队列时用 Lock。
问:什么是锁消除与锁粗化? JIT 发现锁对象不会逃逸就消除;相邻的多次加锁合并成一次,减少开销。
问:ThreadLocal 会内存泄漏吗? 会。key 是弱引用,value 是强引用;线程池里用完必须 remove。
问:ConcurrentHashMap 的 size 准确吗? JDK 8 用 CounterCell 分段计数,size 是近似值,并发下不保证精确。
问:AQS 的 state 一定是 1 吗? 不一定。ReentrantLock 用它表示重入次数,Semaphore 表示许可数,CountDownLatch 表示剩余计数。
问:死锁的四个必要条件? 互斥、占有且等待、不可抢占、循环等待;破坏任一即可。
问:如何排查死锁? jstack 会直接给出死锁报告,或用 Arthas 的 thread 命令。
问:乐观锁与悲观锁的区别? 乐观锁用版本号/CAS,冲突少时性能好;悲观锁先加锁,冲突多时更稳。
线程池监控
生产环境至少监控四个数:活跃线程数、队列长度、拒绝次数、任务耗时。队列持续增长说明消费能力不足;拒绝次数上升说明需要扩容或限流。
Spring 的 ThreadPoolTaskExecutor 可以暴露指标,接入 Micrometer 后在 Prometheus 里看趋势。
锁的选择清单
- 简单互斥:synchronized。
- 需要超时/可中断/公平:ReentrantLock。
- 读多写少:ReentrantReadWriteLock 或 StampedLock。
- 高并发计数:LongAdder。
- 跨进程:Redis/ZooKeeper 分布式锁。
先问“临界区多大”,再选锁;临界区越小,锁竞争越少。
线程池的关闭
shutdown 不再接新任务,等已提交的跑完;shutdownNow 中断工作线程并返回未执行的任务。关闭后要 awaitTermination 等待,否则 JVM 可能提前退出或任务被丢。临界区越小,锁越轻;先缩临界区。先缩。
补遗:ThreadLocal 与内存泄漏
ThreadLocal 为每个线程维护独立副本,底层是 Thread 的 ThreadLocalMap,key 是弱引用、value 是强引用。线程池里线程长期存活,用完不 remove() 就会泄漏,还会串数据。InheritableThreadLocal 可传给子线程,但线程池下不可靠;异步场景用 TransmittableThreadLocal 或显式传参。
补遗:阻塞队列与 CompletableFuture
常用队列:ArrayBlockingQueue(有界数组)、LinkedBlockingQueue(链表,默认无界要当心)、SynchronousQueue(不存储)、PriorityBlockingQueue(优先级)、DelayQueue(延迟)。
CompletableFuture 的常用组合:thenApply(转换)、thenCompose(串行依赖)、thenCombine(并行合并)、allOf/anyOf、exceptionally/handle。默认用 ForkJoinPool.commonPool,IO 密集要传自定义线程池,否则会拖垮公共池。
补遗:虚拟线程
JDK 21 的虚拟线程由 JVM 调度,阻塞时自动让出载体线程,适合高并发 IO。它不适合 CPU 密集,也不需要池化(创建很便宜)。注意 synchronized 阻塞可能钉住载体线程,必要时换 ReentrantLock。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解