十几年前我刚接触并发编程的时候,觉得《Java并发编程的艺术》这种书就是给面试前突击用的,背几个结论、记几个关键字,面的差不多就完事了。后来真正在线上环境里被各种诡异问题折磨过几轮,再回头翻这本书,才发现当初很多“背过”的东西根本没往脑子里去。这个第三辑书摘,我从全书里挑了几个这些年反复用到的核心硬骨头——AQS、内存模型与数据一致性、线程池、JUC工具类,外加线上排查套路。这一辑不是科普向的入门导读,更偏向“你已经写过一阵子并发代码,但总觉得哪里差点意思”的那种查漏补缺。我会把书中原理和实际踩坑揉在一起写,代码能跑、思路能用,方便你对照自己的项目重新审视一遍。
1. 本辑书摘选了什么:并发编程里最值钱的四个硬骨头
1.1 为什么是AQS、内存模型、线程池和并发工具
先说说这辑为什么选这几个主题。网上聊Java并发的文章浩如烟海,但绝大多数停留在“怎么用”的层面:synchronized怎么加、ThreadPoolExecutor怎么new、CountDownLatch怎么await。可一旦你进入生产环境,遇到的基本都是“为什么”的问题:为什么这个变量明明赋值了别的线程看不到?为什么加了锁性能还是上不去?为什么线程池偶尔会拒绝任务,偶尔又积压一堆?
这四个主题恰好回答了这些“为什么”。AQS是ReentrantLock、Semaphore、CountDownLatch这些工具的底层骨架,搞懂它等于给整个JUC并发工具包开了天眼;内存模型和volatile、CAS、锁升级直接相关,是理解数据一致性的根;线程池是实际项目里用得最多、也最容易埋雷的地方;JUC工具类则是解决特定业务场景的现成零件。这四个东西串起来,差不多就是并发编程从“背结论”到“能排查”的进阶路径。
1.2 这本书该怎么读才不白读
这本书的开篇章节其实有点劝退,上来就是Java内存模型,抽象程度很高。我的建议是不要按书页顺序生啃。第一遍先翻目录,把第二章锁相关的部分通读一遍,然后跳到AQS那一节,配合源码看,再回头读内存模型。为什么这么跳?因为你得先知道“锁到底在锁什么”,才会理解内存模型里的可见性问题有多重要。就像学开车先得知道刹车油门在哪,再去研究发动机原理才有动力。
这也是这个第三辑书摘的定位:书里已经帮你把原理讲清楚了,我这篇尽量把原理翻译成“工作中真正会遇到的场景”,让每一条都能落地。
2. 从state到CLH队列:AQS到底在管什么
2.1 模板方法模式:JUC工具的统一骨架
AQS这名字很唬人,AbstractQueuedSynchronizer,翻译过来就是“抽象队列同步器”。它干的事情其实相当朴素:管理一个volatile int state状态字段,外加一个线程等待队列。队列里的节点代表“想拿锁但没拿到的线程”,按先进先出的顺序排队,前一个节点释放了,才通知后一个节点继续尝试。
AQS之所以能成为ReentrantLock、Semaphore、CountDownLatch共同的底座,核心在于它用了模板方法模式。框架固定了获取锁、释放锁的流程骨架,把具体“怎么算拿到锁”留给子类实现。比如:
public abstract class AbstractQueuedSynchronizer { // 子类必须实现的方法 protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); } }ReentrantLock里的FairSync和NonfairSync就是分别实现tryAcquire,决定是“先看看队列里有没有人排在我前面”,还是“直接跟新来的线程抢一把”。这就是公平锁和不公平锁的本质差异所在。
2.2 公平锁与非公平锁:加锁入队的完整流程
看源码的时候,最容易被绕晕的是acquire的调用链。其实把流程拆成四步就非常清晰:
- 线程调用
lock(),实际进入acquire(1)。 acquire先调tryAcquire(1),如果返回true,锁到手,流程结束。- 如果获取失败,AQS把当前线程包装成
Node节点,用CAS方式挂到CLH队列尾部,然后LockSupport.park自己进入等待状态。 - 前一个线程释放锁时,会唤醒队列头部的下一个节点,被唤醒的线程再次走一遍
tryAcquire。
非公平锁的关键在第二步:新的线程根本不看队列里有没有人在排队,上来就CAS尝试抢锁。这就是为什么“非公平”吞吐量反而更高——它减少了线程挂起和唤醒的开销。代价是有可能造成排队线程饥饿等待,虽然实际场景里极端饥饿很少出现,因为刚释放锁的瞬间,持锁线程和唤醒线程之间存在竞争窗口,新线程抢到了也就马上结束,抢不到继续返回队尾等待,通常不会无限饿死。
我在项目里一般默认用非公平锁,包括ReentrantLock的默认构造函数。真需要公平场景(比如任务执行时间极短、希望严格先来后到),再手动改成公平模式。
2.3 用AQS手写一个可重入共享锁
光看原理解释多少有点虚,自己动手实现一个AQS子类才是真的吃透。这里分享一个简单的可重入共享锁例子,我当年就是靠类似的小练习搞懂AQS的:
public class SimpleReentrantSharedLock { private final Sync sync = new Sync(); private static final class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquireShared(int acquires) { for (;;) { int state = getState(); // 当前线程已持有锁,则重入计数累加 if (state != 0 && currentThreadIsOwner()) { if (compareAndSetState(state, state + acquires)) { return 1; } } // 无锁状态才尝试获取 if (state == 0 && compareSetState(0, acquires)) { setExclusiveOwnerThread(Thread.currentThread()); return 1; } return -1; } } @Override protected boolean tryReleaseShared(int releases) { int state = getState() - releases; boolean free = false; if (state == 0) { free = true; setExclusiveOwnerThread(null); } setState(state); return free; } private boolean currentThreadIsOwner() { return Thread.currentThread() == getExclusiveOwnerThread(); } } public void lock() { sync.acquireShared(1); } public void unlock() { sync.releaseShared(1); } }这段代码的调试过程比代码本身更有价值。tryAcquireShared返回正数代表获取成功,返回负数代表失败,返回0代表成功但后续共享获取不能再成功——这个返回值约定刚接触AQS时极容易搞混。我建议你把书翻到AQS节点状态那节,对照着写一遍,遇到线程一直卡在park就检查是不是tryReleaseShared返回值写错了。
3. 可见性、指令重排与CAS:数据一致性的三根柱子
3.1 volatile的两层语义与典型误用
书里对volatile的定义可以浓缩成两句话:保证字段在多个线程之间的可见性;禁止指令重排优化。很多文章喜欢用“内存可见性”的说辞带过,但真正要理解,得从缓存一致性协议入手。现代CPU有L1、L2、L3缓存,每个CPU核心操作自己的缓存,在数据被写回主存之前,其他核心看到的都是旧值。volatile修饰的变量在写入时,会强制把缓存行写回主存,并让其他核心中对应的缓存行失效,这样其他线程读取时只能重新从主存加载。
volatile最常见的误用场景是“以为它能保证原子性”。比如:
private volatile int count = 0; public void increment() { count++; // 不是原子操作 }count++底层是“读取-修改-写回”三步,两个线程可能同时读到0,各自加1,最后写回1,真实计数变成1而不是2。这个例子我见过很多人踩进去,包括我自己刚学多线程时也写过。书里给出的正确解法,在JDK 8以后通常是AtomicInteger或LongAdder,后面再细说。
3.2 synchronized的锁升级与偏向锁
volatile解决的是可见性和有序性问题,但原子性还是得靠锁。JVM对synchronized做了大量的优化,这部分的演变速度其实比很多教科书写的要快。现在运行在HotSpot上的sychronized,已经不是一个重量级互斥锁那么简单,它有一个升级路径:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
偏向锁针对的场景是“锁大多数时候只被同一个线程获取”。当线程第一次获取锁时,JVM会在对象头中记录这个线程的ID,之后该线程再进入同步块,不需要任何CAS操作,直接进入。如果另一个线程尝试获取,偏向锁升级为轻量级锁,轻量级锁通过自旋(CAS)在用户态尝试获取锁,避免线程立即陷入内核态。自旋到一定次数还没成功,就会升级成重量级锁,此时未获取锁的线程会进入阻塞状态,涉及到用户态到内核态的切换,开销陡增。
从实际开发的角度看,这些升级过程让synchronized在低竞争场景下表现很好,并不比ReentrantLock差太多。所以项目里只要不需要可中断、超时、公平这类高级特性,优先用synchronized没有问题,简洁且不易出错。书里给的那个对比表格我觉得时代稍微有点老了,现在的性能结论要结合JDK版本来看,不能照搬。
3.3 CAS的ABA问题与LongAdder设计思路
讲完synchronized,紧接着就会讲到CAS(Compare And Swap)。它本身是硬件层面的一条原子指令,Java里的原子类底层就是靠它实现的。CAS的核心是“先比较再交换”,但这里埋着一个著名的坑:ABA问题。
假设有一个栈顶是A,线程1准备把A换成B,但在它比较完之后、交换之前,线程2把A改成B再改回A。线程1继续执行CAS时发现期望值仍然是A,CAS成功,但实际上栈的中间状态已经变了。对于单纯计数器场景,ABA没影响,但对于链表、栈这类数据结构,ABA会造成数据结构破坏。解决方式是加上版本号,JDK里的AtomicStampedReference就是干这个的。
如果说CAS是并发编程的安全底座,那LongAdder就是高性能计数的优化典范。它内部维护了一个基础计数器base和一个Cell[]数组,线程操作时先分散到不同的Cell槽位,各加各的,最后汇总时把base和所有Cell加一遍。这样把单一竞争热点分散成多个热点,在高并发场景下比AtomicLong的单一CAS自旋有本质性的吞吐提升。我有一台8核机器上做过压测,同等工作量下LongAdder的QPS大约是AtomicLong的三到四倍,代价是占用更多内存,且最终一致性有一定延迟,适合统计类、指标类计数。
4. 线程池漫谈:从参数配置到线上事故
4.1 七个参数与四种拒绝策略
线程池应该是并发编程里出镜率最高的组件,但很多项目里的线程池配置完全靠抄模板。写之前建议把ThreadPoolExecutor的构造参数从头到尾捋一遍:
corePoolSize:核心线程数,即使空闲也会保留的线程数量。maximumPoolSize:最大线程数,线程池允许创建的最大线程数量。keepAliveTime:非核心线程空闲存活时间,超过这个时间如果没有新任务,就会被回收。unit:存活时间的单位。workQueue:任务队列,当线程数已达核心线程数但任务还在增加时,任务会先进入队列。threadFactory:线程工厂,统一设置线程名、优先级、是否守护线程等。handler:拒绝策略,当队列饱和且线程数已达最大值时触发。
任务处理流程是:先判断线程数是否小于核心线程数,是则新建线程处理任务;否则尝试将任务入队;队列满时判断线程数是否小于最大线程数,是则新建非核心线程;否则执行拒绝策略。这个顺序很有迷惑性,很多人以为“队列满之后就直接拒绝了”,实际上会先尝试扩容到maximumPoolSize,最后才会拒绝。
四种拒绝策略里,CallerRunsPolicy(让提交任务的线程自己执行)一般用在不能丢弃任务、但允许降低吞吐的场景;AbortPolicy(默认,直接抛异常)容易在业务高峰期埋雷;DiscardPolicy和DiscardOldestPolicy会丢任务,除非明确接受丢任务的结果,否则不建议在生产环境用。我见过一个服务因为默认拒绝策略不了解,高峰期直接抛RejectedExecutionException,触发了大批任务重试,导致雪崩,这种事故其实一个自定义RejectedExecutionHandler就能避免。
4.2 线程数计算公式的局限与手动估算
网上流传的“CPU密集线程数=CPU核数+1,IO密集线程数=CPU核数*2”这类公式,在书里并没有这么粗糙的定论。书里强调的是根据任务类型来推导:
对于CPU密集型任务,线程数理论上等于可用核心数即可,多出来的线程只会增加上下文切换开销。对于IO密集型任务,比如网络请求、磁盘读写,大部分时间线程在等待IO,这时线程数可以更大,经验上可以设置为“核心数 * (1 + IO等待时间 / CPU计算时间)”。真要我给一个相对靠谱的参考值,IO密集型大致是核心数的1.5到2倍起步,再结合压测调整。
但这类公式只适用于任务性质相对统一的场景。真实业务里线程池往往接收多种任务,有的纯计算、有的带远程调用、有的限时等待,混在一起根本没法精确套公式。我个人处理方式是:先根据任务耗时和QPS预估一个量级,用ThreadPoolExecutor的动态参数(corePoolSize、maximumPoolSize、keepAliveTime都支持运行时修改)慢慢调。线上压测时观察线程池的getActiveCount()增长曲线、队列堆积趋势、任务平均等待时间,再做微调。比固执套公式靠谱得多。
4.3 百分之九十的人没用对的线程池细节
书里没有单独辟一节讲的几个细节,我觉得比主流程更值钱:
第一个是Executors工厂方法的坑。Executors.newFixedThreadPool()底层用的是LinkedBlockingQueue,队列容量是Integer.MAX_VALUE,相当于无限队列。如果任务一直增加,线程数永远只会停在核心线程数,不会触发扩容,更不会拒绝。任务都在队列里堆积,内存被撑爆,排查时你看到线程数很少,很容易误判。我排查过好几个内存持续上涨的服务,最后都指向这个隐藏的无限队列。
第二个是线程池的异常处理。execute()提交的任务如果内部抛出异常,线程池会捕获并打印到标准错误,但并不影响其他任务,也不会传播给提交方。问题在于你很难感知这只“沉默的异常”。要避免这种静默失败,建议提交的任务内部自己包装try/catch,或者使用submit()获取Future,主动在Future.get()时捕获ExecutionException。这属于“代码里多写三行,线上少排查三天”的典型案例。
第三个是线程名。用threadFactory统一设置线程名前缀,比如order-pool-thread-1,排查问题时用jstack看一眼线程名就能知道是哪个业务池子的线程。很多线上事故排查效率低,就是线程名乱七八糟,线程dump拿下来一堆pool-3-thread-4这种怎么看都一样的名字,根本没法区分。
5. JUC工具类:真实业务里的对号入座
5.1 CountDownLatch与CyclicBarrier:一个倒计时,一个凑齐人
书里把CountDownLatch和CyclicBarrier放在一起讲是有道理的,这俩容易被搞混,但适用场景完全不同。
CountDownLatch像一个倒计时门闩:初始设置一个计数值N,主线程调用await()等待,其他N个线程各调用一次countDown(),倒计时归零,门闩打开,主线程继续执行。适合“等待多个子任务完成后再汇总”的场景。比如批量处理订单后做统一步骤:
CountDownLatch latch = new CountDownLatch(10); for (int i = 0; i < 10; i++) { executor.submit(() -> { try { doProcess(); } finally { latch.countDown(); } }); } latch.await(30, TimeUnit.SECONDS); // 最多等30秒注意两点:countDown()最好放在finally里,防止任务抛异常导致倒计时卡死;await尽量增加超时参数,很多线上卡死事故就是这里漏了超时,主线程一直挂到天荒地老。
CyclicBarrier则像几个人约好集合点:N个线程互相等待,大家都到齐了才同时继续。和CountDownLatch的区别在于它可循环使用,而且构造时还能传一个barrierAction,在所有线程到达屏障后、放行之前执行一次特定动作。适合“分阶段计算、每阶段结束后统一同步结果”的场景,比如并行计算里多轮迭代,每轮结束都要汇总一次再进入下一轮。
5.2 Semaphore限流与Exchanger数据交换
Semaphore是信号量,控制同时访问某资源的线程数,本质是一个计数器维护的许可机制。最常见的用途是简单限流:比如数据库连接池的并发限制,或者限制同一个接口的并发调用数。它的tryAcquire还支持带超时获取许可,这对优雅降级特别友好:
if (semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)) { try { // 业务处理 } finally { semaphore.release(); } } else { // 快速返回或降级 }tryAcquire失败不要做重试风暴,否则限流反而制造更大的流量冲击,这是我踩过的坑。
Exchanger则是冷门一些的同步工具,两个线程在同一个屏障点交换数据,谁先到谁先等。看名字好像很高深,实际应用场景比较窄,我只在两个线程对称交换结果的流水线任务里用过一次,比如一个线程往缓冲区写入,一个线程消费后需要把处理结果传回给对方。大多数项目里完全可以用CompletableFuture替代,不需要特意去用它。
5.3 CompletableFuture:异步编排的正确打开方式
CompletableFuture虽然不算最早JUC包里就出现的工具类,但书里如果讲异步编排就绕不开它。它相比前端Future的最大优势是可组合。thenApply做同步转换、thenAccept做消费、thenCompose做依赖异步任务串联、allOf和anyOf做多任务聚合、exceptionally做异常恢复。
实际项目里一个比较典型的用法是:从多个远程服务拉取数据,再合并结果:
CompletableFuture<OrderInfo> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrder(orderId), pool); CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(userId), pool); CompletableFuture<String> result = orderFuture .thenCombine(userFuture, (order, user) -> merge(order, user)) .exceptionally(ex -> { log.error("merge failed", ex); return "fallback"; });这里有个隐藏细节:supplyAsync如果没传线程池参数,默认走ForkJoinPool.commonPool()。这个公共池在多个业务模块共用时,很容易被某一个阻塞任务耗尽线程。所以在业务代码里基本都会显式传入独立线程池,各管各的。另一个注意点是thenApply如果内部抛异常,会传播到最终结果上,必须配合exceptionally或handle兜底,否则异步链上某个环节失败,后面所有依赖它的环节都会静默返回异常对象。
6. 线上并发Bug排查:一张图看懂的排查流程
6.1 死锁现场:jstack怎么念“锁的经文”
死锁是并发编程里最经典的故障形态。两个线程各自持有一把锁,还互相等待对方手里的锁,双双卡死。Java应用遇到死锁不会崩溃,表现就是线程一直不退出、接口超时、线程池线程数不断增长。这时候最有效的工具是jstack,把线程栈dump下来诊断。
死锁在dump里长这样:
Found one Java-level deadlock: "Thread-A": waiting to lock monitor 0x00007f4b5800e800 (object 0x00000007f5c8d280, a java.lang.Object), which is held by "Thread-B" "Thread-B": waiting to lock monitor 0x00007f4b5800e800 (object 0x00000007f5c8d260, a java.lang.Object), which is held by "Thread-A"看到waiting to lock ... which is held by ...成对出现,基本就是死锁实锤。排查思路要从代码层面找锁的获取顺序:是不是A线程先拿lock1再拿lock2,B线程先拿lock2再拿lock1。解决方式最稳妥的是保证全局锁顺序一致,或使用tryLock带超时避免无限等待。在代码评审阶段,多问一句“这个锁会不会反向获取别的锁”,就能避免大部分死锁。
6.2 CPU打满与线程卡顿的三板斧
并发问题不一定都是死锁,更常见的是CPU飙升、响应变慢。
第一步用top -Hp pid查看Java进程里哪个线程占据高CPU,记下线程的十六进制nid。第二步用jstack pid导出线程栈,找到nid对应的线程,看它的栈顶在干什么。如果栈顶是java.util.concurrent.locks相关代码,说明是锁竞争激烈;如果栈顶是GC方法,说明是内存问题导致的GC频繁;如果栈顶是纯业务计算方法,那要考虑是不是做了大量无用计算或者陷入自旋循环。
线程卡顿就麻烦一些。看dump时重点看所有线程的执行状态:如果大量业务线程都卡在LockSupport.park上,且都是同一个锁,那大概率是某个持有锁的线程出了问题;如果所有线程都在TIMED_WAITING等待队列,可能是线程池队列积压太多,任务拿不到执行机会。这两者处理策略完全不同,前者要定位持锁线程卡住的根因,后者要调整线程池参数或提高消费速度。
6.3 从书摘到实战:我的三条体会
这本书我从头到尾翻过不止一遍,结合这些年的实际项目,有三条体会想特别说出来。
第一条,并发编程的难点从来不是语法,而是推理。你写volatile int的时候,心里要清楚它保证的是什么、不保证的是什么;你调Semaphore.tryAcquire的时候,要清楚超时没拿到许可之后的每一种分支行为。真的能做到每一行并发的代码都能完整说出“它为什么安全”,才算入门。
第二条,线上Bug是最好的老师,但代价最高。千万别指望靠读一遍书就能避开所有坑。系统性看一遍《Java并发编程的艺术》建立知识框架,然后带着真实场景去怀疑、去验证,遇到问题再回来翻对应章节,效果远好于从头到尾逐页精读。哪怕多花点时间,把你自己的业务代码里涉及的每一个并发组件源码翻一遍,收益都比狂刷面试题大得多。
第三条,只要并发编程相关的内容,面试也好、交流也好,别急着堆砌术语。先想清楚场景:这里有没有真正的共享状态?有没有竞态条件?这个锁到底是保护不变式还是保护复合操作?想明白了,哪怕术语朴素一点,也远比背完一套完整的“八股”却对应不上实际代码强。这也是我一直坚持写书摘和笔记的原因——把知识打碎揉进真实的工程判断里,才真正属于自己。