读完《Java 并发编程的艺术》第二遍,正好赶上要做并发相关的技术分享,于是把这一遍的笔记整理成了第二辑书摘。为什么要单独拿第二辑出来写?因为这书的章节结构很有意思:前四章在讲“为什么会有并发问题”——对象为什么没有安全发布、编译器为什么要重排指令、内存模型里的 happens-before 到底想约束什么。到了第五到第十章,书才开始真正动刀:锁是怎么工作的,ConcurrentHashMap 为什么可以做到既安全又飞快,线程池的参数到底应该怎么定,Executor 框架又是如何把线程管理变成一种资源调度。如果你已经动手写过一阵多线程代码,但总觉得思路是散的,这一辑非常适合你;如果你正打算系统性梳理 Java 并发面试题,那这一辑同样是高频考点的集中区。
1. 第二辑的主线:从锁机制一路走到线程池
1.1 为什么前四章是地基,第五到第十章才是真正的战场
这本书的结构安排其实是刻意为之。前四章解决的是“你为什么会写出有问题的并发代码”,第二辑则直接切换到“这些同步机制到底是怎么实现的”。第五章讲锁,从 synchronized 的膨胀过程一路讲到 AQS、ReentrantLock 的源码级拆解;第六章铺开并发容器和阻塞队列;第七章讲原子操作类;第八章讲 CountDownLatch、CyclicBarrier、Semaphore、Exchanger 这组并发工具;第九章和第十章进入线程池与 Executor 框架。
这里有个阅读上的关键点:第二辑的内容是强依赖的。你不看第五章的 AQS,就没法真正理解第八章的信号量凭什么能控制许可数;不懂线程池的核心参数,Executor 框架里的那些工厂方法对你来说就只是黑盒。我给团队的建议一直是按 锁 -> 容器 -> 原子类 -> 工具类 -> 线程池 的顺序读。我见过太多人跳过锁直接抢着用 ConcurrentHashMap,遇到并发问题去翻源码时,连 ReentrantLock 的 lock 方法为什么不直接 synchronized 都要停下来想半天,这就是基础顺序没理顺。
1.2 一条隐形的贯通线:state 与队列
第二辑里有一条贯穿始终的设计主线:状态位加等待队列。AQS 是这种结构,Condition 是这种结构,Semaphore 也是这种结构,甚至 ConcurrentHashMap 的扩容迁移控制字段 sizeCtl 也能看到同样的影子。另一个反复出现的设计原则是“能无锁就无锁,非不得已再上锁”。作者不断提醒读者,所有同步机制都有成本,这也是为什么 ConcurrentHashMap 在 JDK8 里会从分段锁演进成 CAS 配合局部 synchronized。
单看某一章的结论其实不难,难的是把这些结论串起来。同样是基于 AQS,ReentrantLock 的 state 记录的是可重入次数,Semaphore 的 state 记录的是剩余许可,CountDownLatch 的 state 记录的是剩余计数。同一个字段在不同类里代表完全不同的业务含义,这种抽象能力才是这本书真正值得反复琢磨的地方。读到这里我自己的感受是:别把 AQS 当成一个只能被 JUC 使用的黑盒,把它看成一套“排队与唤醒”的通用框架,你的并发知识体系会顺畅很多。
1.3 最优阅读姿势:边读边写迷你版 AQS
这本书不适合当小说一口气读完,也不适合当字典遇到再翻。我的笨办法是:每读完第五章的一个小节,就把对应源码的断点打上,看一眼线程入队前后状态怎么变化,再盯着 state 从 0 到 1 再到 0 的过程。第二遍读的时候,我顺手写了一个 100 行不到的 MiniAQS,只支持独占锁,但足够让我看清 acquireQueued 里的那层 for 循环到底在做什么。等这个迷你版本跑通,后面再读 Semaphore、CountDownLatch 基本是顺水推舟。
如果时间紧,第十章 Executor 框架可以先通读,把 FixedThreadPool 和 CachedThreadPool 在队列选择上的差异弄明白,后面再针对具体参数做实验。第二辑不是一本读得快的书,但只要你在第五章多花点时间,后续章节的阅读速度会明显提上来,因为你会发现自己读的已经不是陌生概念,而是同一套思想在不同场景下的变体。
2. AQS 与锁的底层实现:别再说“AQS 就是一个队列”
2.1 state、CLH 变体队列和模板方法:AQS 的三层骨架
AQS 是 JUC 包最核心的类,没有之一。它的整体结构可以拆成三层。第一层是 volatile int state,记录同步状态;第二层是双向的 CLH 变体队列,每个节点包装一个正在等待的线程;第三层是模板方法:你选择重写 tryAcquire 和 tryRelease 来实现独占锁,或者重写 tryAcquireShared 和 tryReleaseShared 来实现共享锁,剩下的入队、阻塞、唤醒、中断处理都由 AQS 的公共逻辑接管。
特别容易误解的一点是:CLH 队列本身并不持有锁,它只存放那些“暂时没抢到锁的线程”。线程调用 lock 时,会先尝试用 CAS 修改 state,改成功就直接拿到锁;改不成功才把自己包装成节点挂到队尾,接着在 acquireQueued 里自旋或者 park。书里用“用 state 记录资源,用队列记录等待者”这句话把结构说明白了,但很多人写代码时容易把锁对象、等待队列、state 混成一个概念,结果一到 Condition 就彻底绕晕。
2.2 ReentrantLock 加锁解锁的完整过程
把 ReentrantLock 的非公平锁走一遍是理解 AQS 的捷径。以非公平锁为例:线程来抢锁时先不看队列里有没有人排着,直接对 state 做 CAS,从 0 改成 1,成功就把 exclusiveOwnerThread 设为当前线程。如果 CAS 失败,会进入 AQS 的 acquire,然后走到 tryAcquire。此时如果发现锁已经被当前线程持有,state 会加 1,实现重入;如果锁被别的线程持有,就返回 false,线程随即被封装成节点入队。
入队之后是关键中的关键。acquireQueued 里有个 for 循环,先检查当前节点的前驱是不是头节点,如果是,就再尝试抢一次锁,抢不到就判断前驱节点的 waitStatus 是否为 SIGNAL,是就把自己 park 住。解锁时,tryRelease 会把 state 减 1,只有减到 0 才清除 exclusiveOwnerThread,再交给 AQS 去唤醒头节点的后继线程。整个过程解释了为什么 ReentrantLock 的 unlock 可以在完全不同的方法里执行,也解释了节点状态里为什么是 -1 的节点才需要被唤醒。
2.3 公平锁与非公平锁到底差在哪
读过源码之后,你很难再说“公平锁就是更公平”。公平锁在 tryAcquire 之前会先调 hasQueuedPredecessors,队列里一旦有排队的节点,新线程就老实去排队;非公平锁则先抢一次,抢不中才入队。这个差异带来的性能变化非常有意思:非公平锁的吞吐量通常更好,因为唤醒排队线程需要时间,新来的线程可以利用这个窗口快速拿到锁,减少线程切换;但它确实可能让队列里某些线程等得更久。
工程上的取舍很清楚:如果业务对响应时间稳定性要求高,可以选公平锁,但要接受吞吐下降的代价;如果系统本身没有强公平性要求,用默认的非公平锁就好。书里特意点了一句,ReentrantLock 的默认值不是拍脑袋定的,多数并发场景下非公平锁能换来更好的整体吞吐,公平锁用来防止极端饥饿。
2.4 共享锁如何复用同一副骨架
看完独占锁再看共享锁,你会感受到 AQS 的抽象能力。CountDownLatch 把计数存进 state,await 方法走 acquireSharedInterruptibly,当 state 不为 0 时线程入队等待;countDown 走 releaseShared,对 state 做 CAS 减 1,减到 0 就唤醒队列里的共享节点。Semaphore 的逻辑几乎一样,只是 acquire 对应 state 减 1,release 对应 state 加 1,许可耗尽时线程入队。
这里最值得琢磨的是 tryAcquireShared 的返回值约定:小于 0 表示失败且需要入队,等于 0 表示成功但已经没有剩余资源,大于 0 表示成功且后续共享节点可以继续唤醒。读懂这套约定,就会明白 CountDownLatch 和 Semaphore 虽然业务含义完全不同,但底层走的却是同一套排队和唤醒机制。CyclicBarrier 有点特殊,它没有完全依赖 AQS,而是结合 ReentrantLock 和 Condition 实现了可复用的栅栏语义,这部分还得多看几眼。
3. 并发容器与数据一致性:高频热点但最容易被用歪
3.1 ConcurrentHashMap 从分段锁到 CAS 的演进
JDK7 的 ConcurrentHashMap 用分段锁,一个 Segment 锁住一段哈希桶,最多可以并发写 16 个分段。JDK8 直接把分段锁去掉了,改成对桶数组的头节点做 synchronized,同时用 CAS 协助初始化、计数等操作。为什么要走这一步?因为分段锁的粒度还是太粗:扩容时要锁住整个 Segment,代价不比全局锁小多少;而 JDK8 的桶在冲突严重时会升级成红黑树,锁住单个头节点就能控制一整条冲突链,锁粒度细了很多。
实际使用里,ConcurrentHashMap 的并发安全只限于单次操作。如果有人告诉你“用了 ConcurrentHashMap 就一定安全”,你要多留个心眼:先用 containsKey 判断再 put,这两个动作之间完全可能有别的线程插入元素;先 get 再根据结果做 put,同样会丢中间状态。书里没有把这句话说得特别直白,但这是我在生产环境里看到最多的问题。任何“依赖旧值再做更新”的复合操作,都不能因为容器本身是并发安全的就放弃外部锁。
3.2 CopyOnWriteArrayList:快照读的取舍
CopyOnWriteArrayList 的原理很容易懂:每次写都拿一把 ReentrantLock,复制一份新数组,在新数组上完成修改,再用 volatile 引用把数组指针替换掉;读的时候不加锁,直接读当前引用指向的数组。因为读操作永远在读一个完整快照,所以迭代时不会抛 ConcurrentModificationException,也不会读到半修改状态。
但这种便利要付出代价。每次写都是 O(n) 的数组复制,内存占用和 GC 压力都会上升。我一般不建议把大集合塞进 CopyOnWriteArrayList 里做高频写,它更适合监听器列表、配置项缓存这类“写极少、读极多、集合还不大”的场景。学这一节的重点不是记住某个类名,而是理解并发容器的设计出发点:每种容器都有一套适合自己的并发策略,没有哪个容器是万能药。
3.3 JMM 三要素:原子性、可见性、有序性
数据一致性是并发编程的核心话题,但很多讨论一开始就把问题问歪了。Java 内存模型关注的是三件独立的事:原子性,一个操作要么整体生效要么整体不生效;可见性,一个线程的修改能不能被另一个线程及时看到;有序性,指令重排会不会导致逻辑结果和预期不一致。这三件事不是一个层面的东西,所以也没法用同一种工具解决。
synchronized 和 Lock 可以同时覆盖三者;volatile 只保证可见性,以及 volatile 变量自身读写的原子性;原子类能保证单个复合操作的原子性,但救不了“先读再写”这种业务场景。最经典的例子是多个线程对同一个 volatile 变量做 i++,结果总是小于等于线程数,因为 i++ 根本不是原子操作,volatile 只保证读到的值新不新,解决不了两个线程同时读到旧值再写回去的问题。
3.4 一张表理清 volatile、原子类、锁分别守住了什么
给新人讲这块时,我习惯用下面这张表,快速定位不同同步手段的能力边界:
| 手段 | 原子性 | 可见性 | 有序性 | 典型使用场景 |
|---|---|---|---|---|
| volatile | 否(自身读除外) | 是 | 部分保证 | 状态开关、安全发布不可变对象 |
| 原子类(AtomicInteger 等) | 是(单操作) | 是 | 是 | 计数器、累加器、统计数据 |
| synchronized / ReentrantLock | 是 | 是 | 是 | 复合操作、整体保护共享资源 |
这张表不是绝对真理,因为 synchronized 内部还有锁升级和偏向锁之类的前置过程,但用它来定位“为什么我用 volatile 修计数还是不准”已经够用了。书里对这块的表述很严谨,核心建议是:先想明白你要保护的是什么资源、操作是不是复合的、对数据新鲜度要求有多高,再决定用哪种手段。这一节是整个第二辑的理论地基,后面的并发工具类全都建立在这三要素的理解之上。
4. 线程池参数不是背出来的:从源码到真实业务
4.1 三个核心参数如何协同工作
ThreadPoolExecutor 的可配参数有五个加两个可选项,但真正决定线程池行为的是 corePoolSize、maximumPoolSize 和 workQueue 之间的三角关系。任务刚提交时,如果工作线程数小于 corePoolSize,线程池会新建线程执行;达到 corePoolSize 后,新任务会放进队列;队列满了才会继续创建线程,直到 maximumPoolSize;再超出的任务才会走拒绝策略。
这套流程里最反直觉的是:只要队列还没满,线程池就不会把线程数扩到 corePoolSize 以上。哪怕你把 maximumPoolSize 配成 200,只要 workQueue 是一个无界队列,200 就只是摆设,线程数永远贴着 corePoolSize 走。Executors.newFixedThreadPool 用的正是无界队列,这是它在高并发下能把内存打满的根本原因。书里明确提过这一点,但工程里还是经常能看到裸用工厂方法的代码。
4.2 拒绝策略的正确打开方式
四种内置拒绝策略各有脾气。AbortPolicy 是默认策略,直接抛 RejectedExecutionException,适合那些“任务失败要立刻暴露”的场景;DiscardPolicy 和 DiscardOldestPolicy 都会静默丢任务,使用之前必须确认业务允许丢;CallerRunsPolicy 最温和,它会让提交任务的线程自己去执行,相当于一种变相限流。
我自己踩过一个大坑:为了不丢任务,把所有线程池的拒绝策略都设成 CallerRunsPolicy。结果高峰期调用外部接口的线程全跑去执行内部通知任务,接口响应时间瞬间拉高,最后还是靠监控发现线程池指标异常才定位到。正确做法是组合使用:先根据业务设定合理的队列容量,再设置线程数上限,拒绝时至少要打日志,并考虑把任务落盘或者投递到消息队列兜底。
4.3 线程数到底怎么算:两个真实案例的计算过程
线程数设置是个老生常谈的话题,书里给了一个通用公式:线程数 = CPU 核数 / (1 - 阻塞系数),阻塞系数取值在 0 到 1 之间,IO 密集型的阻塞系数很高,CPU 密集型的接近 0。公式不是银弹,但至少比“感觉”靠谱。我拿两个实际案例演示。
案例一:一套纯本地计算任务,单核执行需要 1 秒,机器是 8 核,阻塞系数几乎为 0,那么线程数 = 8 / (1 - 0) = 8,再加一个兜底线程,取 9。这时候把线程池配到 64 反而会更慢,线程切换和缓存失效的开销会吞掉并发带来的收益。
案例二:一个业务接口要查 MySQL 还要调外部 HTTP 服务,单次总耗时约 200ms,其中有 180ms 在等 IO。阻塞系数大致是 0.9,机器 8 核,线程数 = 8 / (1 - 0.9) = 80。80 是理论启动值,上线前一定要做压测校正,但至少你已经有一个合理的基线,而不是凭感觉填一个数字。
4.4 给线程池加上名字和监控
线程池调参不是一锤子买卖,它需要数据和反馈。第一件事是自定义 ThreadFactory,给每条线程起一个有业务含义的名字,否则线上出问题的时候,你看到的全是 pool-1-thread-x,根本不知道是哪一个业务线程池在拖后腿。第二件事是持续记录核心指标:taskCount、completedTaskCount、activeCount、queueSize。我一般会写一个定时任务每 30 秒采集一次,按线程池名称分维度打印,再对接已有的监控系统。
书里没有花太大篇幅讲监控,但这恰恰是把线程池知识落地的关键。你可以基于 ThreadPoolExecutor 写一个 Reporter 子类,重写 beforeExecute 和 afterExecute 把任务耗时和执行结果埋点上报。从 Java 并发编程的实际体感来说,Executor 框架的真正价值是把线程管理变成资源调度问题,而不是让你每次想跑异步任务就 new 一个 Thread。
5. 实战里的坑与排查:记一次被并发问题支配的下午
5.1 用 park 和 unpark,而不是 while 循环自旋
我早期手写同步逻辑时,差点就用 while(true) 搭配 Thread.sleep(10) 去实现“等待某个状态”。这种写法在低并发时看不出问题,一旦线程数上来,CPU 会被空转耗尽。书里介绍的 LockSupport.park/unpark 才是正解,AQS 内部也是靠它在管理阻塞线程。park 会让线程进入 WAITING 状态,不消耗 CPU,unpark 能精确唤醒指定线程。
不过等待方式还是要看场景:如果等待时间极短,比如几个时钟周期的锁冲突,自旋反而有优势;如果等待时间可能达到毫秒级以上,自旋就是事故。线上排查时我见过很多 CPU 飙到百分百的案例,最后定位到 synchronized 获取失败的线程在反复自旋重试,或者自定义循环在空转。遇到这种情况,拿 jstack 一看线程状态是 RUNNABLE 还是 WAITING,基本就能判断是自旋问题还是阻塞问题。
5.2 Future.get 一定要加超时,并且想清楚拒绝策略
Future 是并发编程里最容易“卡住”的一环。拿到 Future 后直接调用 get(),如果任务所在线程恰好被无界队列长期阻塞,或者任务本身陷入死锁,你的调用线程就会无限期等下去。书里介绍了 FutureTask 的实现,但很少强调调用侧的防御。我的习惯是 Future.get(3, TimeUnit.SECONDS),超时抛 TimeoutException,再根据业务决定是重试、降级还是记录日志。
这个坑和线程池参数直接相关。无界队列意味着任务会不断堆积,线程池只按 corePoolSize 速度消费,Future.get 的等待时间会被无限拉长。我会把队列容量放进响应时间模型里估算:队列长度乘上单任务平均耗时,就是最坏情况下的等待时间。如果这个值超过了调用方的超时设置,就必须重新设计队列长度或者切换拒绝策略。
5.3 用 CompletableFuture 时别忽略公共线程池的干扰
Lambda 和函数式接口让并发代码写起来流畅很多,但 CompletableFuture 默认使用的 ForkJoinPool.commonPool 有隐藏的共享陷阱。这个公共线程池的并行度是 CPU 核数减一,如果你在一个 4 核机器上无脑提交几百个阻塞型任务,公共池会被占满,所有依赖 commonPool 的其他异步逻辑全部跟着遭殃。书中讲解 Executor 框架时没专门提醒这个坑,但我在实际项目里见过太多次。
处理方式可以拆成两层。第一层:所有耗时任务都显式传入自定义线程池,不依赖默认的 commonPool。第二层:lambda 捕获外部变量时,确认变量是 effectively final,否则编译器直接报错。我现在的做法是把 CompletableFuture 和自定义线程池组合成一套异步编排服务,每个任务的超时时间、线程池、兜底结果都统一配置,出问题时能从监控里直接按业务维度检索。
5.4 常见并发问题速查表
把我在项目和团队代码评审里反复遇到的并发问题整理成一张表,方便对照排查:
| 现象 | 可能原因 | 先查什么 |
|---|---|---|
| 多线程计数结果偏小 | i++ 非原子,volatile 救不了 | 是否使用原子类或加锁 |
| 线程池线程数长期不变 | 无界队列把新任务全吞了 | workQueue 类型和容量设置 |
| 接口偶发超时,CPU 不高 | 队列堆积,Future.get 无超时 | 队列长度、超时时间配置 |
| 线程名全是 pool-1-thread-x | 没自定义 ThreadFactory | ThreadFactory 实现与命名规范 |
| 同一用户数据读到旧值 | 可见性问题或对象未安全发布 | volatile、final、加锁策略 |
| ThreadLocal 出现脏数据 | 线程池复用线程导致残留 | 任务结束后是否执行 remove |
这张表不能替代排查工具,但能帮你快速建立怀疑方向。每次定位完线上并发问题,我都会把结论补回团队文档,书摘这种东西本来就适合做成可持续积累的知识库,读完就扔是最可惜的用法。
把这一辑读完后,我做的第一件事是把团队里所有裸用的 Executors.newFixedThreadPool 改成显式构造的 ThreadPoolExecutor,再配上自定义线程名和监控。之后又花了一个周末,基于 AQS 的思路把 ReentrantLock 和 Semaphore 的迷你版重新实现了一遍。这些动作短期内看不到业务收益,但下一次再遇到并发问题,我就不再是“上网搜一个解法”的状态,而是能顺着 state、队列、调度这几条线自己往下推。Java 并发编程的知识体系很容易被当成一堆零碎的面试题,但这本书第二辑的价值就在于帮你把这堆碎片粘成一个整体。读的时候遇到卡壳,别急着跳,把源码断点打上,一遍一遍走,比问任何人都有用。