☰
Java并发编程核心:从JMM可见性到线程池实战排查
2026/9/30 4:08:09 网站建设 项目流程

最近重新翻《Java并发编程的艺术》第二遍,还是在几个老地方卡住了。并发这块东西,看一遍以为懂了,实际上手写代码又是另一回事。趁着整理第二辑书摘,我把这几章的核心内容重新捋了一遍,结合自己项目里踩过的坑,把真正用得上的东西沉淀下来。

这篇书摘不打算面面俱到,而是挑几个高频考点和实战高频场景来聊:JMM与可见性、synchronized与AQS、并发容器选型、线程池参数搭配、线上排查手法。适合刚看完并发基础理论、准备写实际代码的Java工程师,也适合面试前想系统串一遍知识点的朋友。每一部分我都尽量带上代码、参数和真实场景,方便直接对照着用。

1. 并发编程的底牌:从JMM看可见性与有序性

1.1 我为什么会先啃Java内存模型这一章

很多新手学并发,上来就背synchronized和lock的用法,写了几个demo就以为会并发编程了。但真正到了线上,问题往往不是你锁没锁,而是没有锁的地方出了诡异问题——比如一个变量在两个线程里各自改了,另一个线程读到的还是旧值;或者单例双重检查锁写出来,线上偶尔出现半初始化对象。这些问题光靠背API是找不到答案的,必须回到Java内存模型(JMM)这个源头。

JMM的核心定义很朴素:它规定了在多线程环境下,一个线程对共享变量的写入,什么时候对另一个线程可见。它的实现基础是主内存和工作内存的分离——每个线程有自己的工作内存(可以类比成CPU高速缓存),共享变量要先加载到工作内存才能被线程操作,操作完再刷回主内存。问题就出在这个“加载”和“刷回”的时机上,时机不对,另一个线程看到的就是陈旧数据。

《Java并发编程的艺术》在这一章给了一个很到位的比喻印象:线程之间的通信就像两个人通过一块白板(主内存)传话,每个人手里有一份备忘录(工作内存),如果备忘录不更新,你看到的就是老版本。要解决这个更新问题,就要靠volatile、synchronized和final等机制的介入。

我建议所有打算深入并发的人,第一次看书时哪怕只理解了“主内存和工作内存存在差异”这个点,后面再看锁和并发容器都会顺很多。

1.2 volatile与happens-before规则的实际映射

volatile是JMM最直接的体现。它有两个语义:保证变量可见性、禁止指令重排序(准确说,是禁止它前后的读写指令越过这个变量操作)。但很多人对它的理解停留在“线程读到最新值”,忽略了“禁止重排序”在生产代码里往往更关键。

典型场景就是双重检查锁(DCL)的单例。我之前写过这样一段代码:

public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

如果instance不声明为volatile,new Singleton()的指令可能被重排序成“先赋值引用,再执行构造方法”,另一个线程就会拿到一个构造未完成的对象。这个问题的根源就是指令重排序,volatile在这里是必需品,不是锦上添花。

再往下走,volatile的正确性其实建立在happens-before规则上。书中把happens-before总结为几条规则,我用Project在实际开发中最常碰到的三条:

  • 程序次序规则:同一个线程内,按代码顺序,前面的操作happens-before后面的操作。
  • volatile规则:对一个volatile变量的写操作,happens-before后续对它的读操作。
  • 传递性:如果A happens-before B,B happens-before C,那么A happens-before C。

这三条串起来,就能推导出很多看似隐晦的可见性结论。比如线程A往某个普通变量写入数据,然后写一个volatile标志位,线程B读这个标志位为true后,再读那个普通变量——因为A的普通写happens-before A的volatile写,A的volatile写happens-before B的volatile读,B的volatile读happens-before B的普通读,传递下来,B一定能看到A写入的最新值。这种“串门式”的推导,比死记“volatile保证可见性”要高级得多,面试时能讲清楚更是加分项。

1.3 内存屏障到底是干什么的

读到JMM底层时,会出现“内存屏障”这个概念。volatile的可见性和禁止重排序,本质上就是通过插入内存屏障实现的。

书上提到两种核心屏障:

  • 读屏障(Load Barrier):让后续的读操作从主内存读取,而不是从工作内存(缓存)读取。
  • 写屏障(Store Barrier):让当前线程工作内存中的已写数据强制刷新到主内存。

volatile写的后面会插入一个Store屏障,强制把修改刷入主内存;volatile读的前面会插入Load屏障,强制从主内存重新加载。这样一读一写,两个线程之间就建立了可见性的桥梁。

实际工作中,我用到内存屏障概念最多的地方就是写无锁代码。比如一个多生产者、单消费者的环形队列,消费者靠一个volatile的写索引判断是否有新数据,如果漏掉屏障层面的理解,很容易在CPU缓存一致性上踩坑——在本地跑一百万次没问题,放到线上多核环境下就开始偶发“消费不到数据”。

当然,现在日常开发99%的场景不需要你自己去设计无锁结构,但理解了屏障机制,你至少能明白:为什么volatile不能保证原子性。因为它只是让读写可见、禁止重排,并没有阻止两个线程同时执行count++这种读改写操作。这也是为什么volatile必须搭配CAS或者锁才能实现线程安全的计数器。

2. Synchronized与AQS:锁的世界里没有银弹

2.1 synchronized的锁升级到底升了几级

`Java并发编程的艺术》在锁这一章花了大量篇幅讲synchronized的锁升级机制,这也是现在Java面试的必考热点。早期JDK里synchronized是重量级锁,依赖操作系统mutex,线程一旦竞争就要从用户态切到内核态,性能很难看。JDK 6之后引入了偏向锁、轻量级锁、重量级锁的升级路径,把性能大幅拉高。

我用大白话描述下这个升级过程:

  • 大部分情况下,一个锁在同一时刻只有一个线程访问,没有竞争——偏向锁就是给这个线程贴一个专属标签,下次再来直接进,不用重复加锁。
  • 一旦出现另一个线程来争抢,偏向锁撤销,升级为轻量级锁。轻量级锁靠自旋尝试获取,不立刻阻塞线程。
  • 如果自旋超过一定次数(JDK 6之后是自适应自旋,按历史状态动态调整),或者竞争者实在太多,就升级为重量级锁,线程进入阻塞等待。

书里有一张升级状态图非常经典,建议直接照着画一遍。我在实际项目里感受最深的是:锁升级是不可逆的。一旦从轻量级锁升级到重量级锁,短时间内不会降级回来(虽然在特定GC场景下可能存在短暂的锁擦除/撤销,但整体趋势就是单向升级)。所以锁竞争一旦激烈,性能就会断崖式下降,这也是为什么高并发场景下不能过度依赖synchronized。

一个很常见的实践误区是:认为synchronized性能已经优化得很好了,就到处都用。我在一个内部中间件里见过这种用法——高频接口的入口方法上直接加synchronized,导致所有请求串行化,TPS直接从几千掉到几百。所以锁的正确做法是:能缩小锁粒度就缩小锁粒度,能用读写分离就用读写分离,实在不行再考虑乐观锁和CAS方案。

2.2 AQS的设计精髓:state + CLH队列

ReentrantLock、Semaphore、CountDownLatch这些工具类的底层实现,几乎都建立在AbstractQueuedSynchronizer(AQS)之上。AQS的结构可以拆成两个关键部分:一个int类型的state字段和一个CLH变体队列。

state代表共享资源的占用状态。拿ReentrantLock举例,state = 0表示锁没人占用;线程每获取一次锁,state + 1;每释放一次,state - 1。因为锁是可重入的,所以同一线程可以反复累加。这个设计既简洁又灵活——只要改改state的语义,同一套队列机制就能复用到各种同步工具上。

CLH队列则是存放等待线程的FIFO双向链表。竞争失败的线程会被包装成Node节点,通过CAS的方式安全地插入队尾,然后挂起等待前驱节点的唤醒。AQS的关键方法acquire长这个样(我简化过):

public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

先把眼前的机会抢一下(tryAcquire),抢不到就排队;排队过程会不断尝试,直到自己成为队头节点,或者被中断。

理解AQS最大的价值,在于你看懂之后,再去翻ReentrantLock和Semaphore的源码,会有一种豁然开朗的感觉——它们本质上是同一套骨架,只是“资源获取条件”不同。面试如果问到“AQS的设计思想”,拿state加CLH队列讲清楚,比背一堆API细节有用得多。

2.3 用ReentrantLock源码回看AQS的公平与非公平

ReentrantLock最典型的两个实现是FairSync和NonfairSync,它们在tryAcquire的写法上有一行关键的区别。

非公平锁的tryAcquire逻辑,上来先看一下锁是否空闲,空闲就直接CAS抢占:

final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { // 不检查队列,直接抢 setExclusiveOwnerThread(current); return true; } } ... }

而公平锁的版本多了一个判断:

if (c == 0) { if (!hasQueuedPredecessors() && // 前面还有人排队就不抢 compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } }

这行hasQueuedPredecessors()就是公平和非公平的分水岭。非公平锁允许“插队”,性能通常更好,因为新到的线程可能正好持有CPU时间片,减少了线程切换;但代价是队列里的老线程可能被饿很久。公平锁按先来后到,吞吐量相对低,但不会饿死线程。

我实际使用中的经验是:默认用非公平锁,别轻易换公平锁。只有在明确要求所有线程按申请顺序获得锁时,才选择FairSync,比如某些与订单状态机强相关的场景。

书里在讲这块时,还提醒了一个小细节:tryLock()即使底层用的是非公平锁,它依然带超时和中断响应能力。这个在线上服务里很有用——加锁等待不要无限期,可以设置一个超时,拿不到锁就快速失败,避免线程全部堆积。

3. 并发容器的选型与避坑:从Hashtable到ConcurrentHashMap

3.1 ConcurrentHashMap 1.7与1.8的分水岭

并发容器这块,ConcurrentHashMap一直是面试高发区。JDK 7和JDK 8的实现变化非常大,很多人光记得“分段锁”,却没有意识到1.8已经完全换了一套玩法。

JDK 7的ConcurrentHashMap采用分段锁结构,底层是Segment数组,每个Segment继承自ReentrantLock。数据操作时,只要锁住对应的Segment,其他Segment依然可以并行读写。理论上最多支持Segment数组长度的并发度(默认16)。这个设计在当时已经比Hashtable的全局锁好太多,但分段粒度还是太粗——如果两个key的哈希恰好落在同一个Segment内,还是会串行。

JDK 8直接抛弃了Segment,改为使用Node数组 +synchronized锁链头节点 + CAS的组合策略。写入时,如果对应桶位为空,直接CAS插入,连锁都不用;如果桶位非空,就锁住这个桶位的头节点(链表头或者红黑树根节点),再进行插入或更新。锁粒度从“段”降到了“桶”——并发度随着数组长度增长,理论上可以做到非常高。

扩容也是1.8变化很大的地方,采用多线程协助扩容。每个线程领取一段旧数组的index范围,迁完自己的部分后,再去领取下一段,这就是fwd节点(ForwardingNode)的用途——标记某个桶已经迁移完成,方便其他线程快速知道去向,同时可以在迁移过程中支持新的读写请求。

这两代实现的差异,不仅是面试考点,对你的代码运行影响也很大。如果公司项目还在跑JDK 7(现在很少见了),你写并发代码时要注意分段锁的并发上限问题;如果已经切换到JDK 8及以上(绝大多数公司都已经),那么ConcurrentHashMap基本上就是并发Map的首选,没有之一。

3.2 CopyOnWriteArrayList:读多写少场景的银弹与陷阱

CopyOnWriteArrayList是JUC包里的一个“另类”——它用“每次写都复制一份底层数组”的方式来保证线程安全。读操作永远不用加锁,直接读老数组;写操作则是在新数组上修改,改完再让volatile的引用指向新数组。

这种设计让它在读多写少、并且数据量不大的场景下非常好用。比如配置中心里的一份本地缓存,每天只更新几次,但每次更新后会被几十个线程高频读取。用CopyOnWriteArrayList或者CopyOnWriteArraySet都能收获很低的读延迟。

但CopyOnWriteArrayList有两个坑必须注意:

  • 内存占用大。每次写都复制一整份数组,如果集合里装了1万个对象,每来一次写操作就复制1万个引用的数组。集合越大,写浪费越严重。
  • 弱一致性。读写分离导致“读到旧数据”是正常的。比如线程A正在迭代集合,线程B同时修改了集合,A拿到的还是修改前的快照。这在某些业务中是不能接受的,比如实时库存、实时价格这类强一致场景。

以我个人的经验,CopyOnWriteArrayList只适合做配置类数据、监听器列表、事件通知器这种低频变更的高频读场景。一旦写频率超过每秒几十次,且集合规模稍大,它就不行了——GC压力会非常明显。

3.3 队列家族:BlockingQueue的四个成员怎么挑

`Java并发编程的艺术》对BlockingQueue的讲解也很有价值。很多项目里线程池或者生产者消费者模式都用到了队列,但选型经常是“看着哪个顺眼用哪个”。其实不同队列的语义差异挺大:

队列数据结构有界线适用场景
ArrayBlockingQueue数组有界固定长度、公平性可控,适合线程池
LinkedBlockingQueue链表默认有界可指定生产消费解耦,吞吐量高
SynchronousQueue无缓冲不存储元素直接交接,适合需要即时处理的任务
PriorityBlockingQueue优先堆无界需要按优先级处理的任务

ArrayBlockingQueue和LinkedBlockingQueue最常被拿来对比。前者底层数组,天然有界,内存分配效率高;后者链表节点的方式,理论上容量可以做大,但节点多了会影响GC。线程池默认使用LinkedBlockingQueue,需要特别注意:如果不传容量,LinkedBlockingQueue默认是无界的,任务可以无限堆积,会把内存打爆。这是线上事故的重灾区。

SynchronousQueue则比较特殊,它自己根本不存数据,生产者put任务后必须等消费者来take,直接交接。这种模式把缓冲彻底去掉,让每个任务都立即被处理,有些实时性极高的场景会用到,但配对不到消费者时生产者会被阻塞。

选队列的保守做法是:优先用有界队列。有界意味着你有背压能力——队列满后,可以选择拒绝或降级,而不是让系统默默堆内存。无界队列看着“不会丢任务”,实际是拿内存换安全,压力一大就是OOM的导火索。

4. 线程池:参数搭配比背八股更重要

4.1 corePoolSize、maximumPoolSize与队列的三角关系

线程池这块,ThreadPoolExecutor是核心中的核心。它有三个关键参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、workQueue(任务队列),三者的关系是:

  1. 线程数小于corePoolSize,来一个任务就创建一个线程,直到达到核心线程数。
  2. 核心线程都忙着,任务进workQueue排队等待。
  3. 队列排满(有界队列时才会出现满的情况),继续创建新线程,直到maximumPoolSize。
  4. 线程数到了maximumPoolSize且队列也满了,触发拒绝策略。

这个顺序很多人背得滚瓜烂熟,但实际配置时经常拍脑袋。我在项目里一般按照任务类型分三类:

  • CPU密集型任务:线程数约等于CPU核心数+1或+2,太多线程只会增加上下文切换开销。
  • IO密集型任务:线程数可以调大,一般是CPU核心数 * 2,或者参考公式“CPU核心数 / (1 - 阻塞系数)”。因为IO等待比例高,线程阻塞时CPU可以切去执行别的任务。
  • 混合型任务:尽量拆开,不同类型用不同的线程池,避免互相挤占。

举个例子,一台4核机器上跑一个定时扫描文件并上传的任务,如果IO占比很高,阻塞系数可能达到0.8,估算线程数就是4 / (1 - 0.8) = 20左右。当然这个公式是参考值,实际还要看IO吞吐、内存等,但比“随便猜一个”科学得多。

4.2 四种拒绝策略和应用场景

ThreadPoolExecutor提供了四种拒绝策略:

策略行为适用场景
AbortPolicy抛出RejectedExecutionException默认策略,适合不想静默吞任务的场景
CallerRunsPolicy调用者线程自己执行适合需要降低任务提交速度、保证不丢任务的场景
DiscardPolicy直接丢弃适合可容忍丢失的非核心任务(比如日志)
DiscardOldestPolicy丢弃队列里最老的任务适合追求最新数据的场景,比如实时刷新缓存

我最常用的是CallerRunsPolicy,尤其在后台批处理任务上。任务丢不起,又不能把线程无限拉高。任务提交线程被所有线程都占满时,它自己也会执行一次任务,这样相当于一种天然限流——提交变慢,系统压力反过来传导给上游调用方,而不是把任务硬塞进队列。

DiscardOldestPolicy用得比较少,但它有一个很有意思的变体场景:本地消息推送队列里,队列里积压的是上一次下发的配置,新配置一到,旧配置直接作废,丢旧保新正合适。

很多人写线程池时不管拒绝策略,用默认的AbortPolicy,这在某些场景下会导致任务静默丢失(捕获异常后不做补偿),所以我建议在初始化线程池时就显式写明拒绝策略,别依赖默认值。

4.3 线程池故障排查实录

分享一个上个月真实踩过的坑。一个内部数据同步服务,突然在高峰期出现接口RT剧增,日志里看到一堆RejectedExecutionException。当时线程池参数是这样的:核心线程数配置了8,最大线程数配置了12,队列用的是LinkedBlockingQueue,但没传初始容量。

问题就在这个没传容量上——LinkedBlockingQueue默认构造器生成的是无界队列,理论上不会触发拒绝策略;但因为没有设定容量,任务会无限堆积。当任务堆积到一定程度,每个任务都在等数据库连接池释放(数据库连接池只有20个连接),8个核心线程全部阻塞在DB调用上,新任务全部排队。队列越排越长,RT自然就上去了。

排查思路比较传统:

  1. 先用jstack看线程堆栈,确认线程都卡在哪些调用上。
  2. 再用jstat -gcutil看GC情况,排除GC导致的停顿。
  3. 最后看线程池的活跃线程数监控,发现核心线程始终是8个,但队列深度已经几千。

解决方法是:把LinkedBlockingQueue改成有界队列,容量设置为200,拒绝策略换成CallerRunsPolicy,同时让核心线程数可以适当释放空闲连接压力。改完后,任务堆积不再无限扩大,系统保持在一种“快速失败、调用方自己重试”的状态,整体RT反而平稳了。

这个案例让我深刻意识到:线程池的参数不是配好就一劳永逸的,必须配合监控指标来调整。至少要监控核心线程活跃数、队列深度、拒绝次数这几个指标。

5. 并发问题排查:从理论到一线的最后一公里

5.1 死锁定位:jstack的正确打开方式

理论与落地之间,最让人头疼的是线上问题排查。并发问题天然难复现,而且很多时候不能随便重启(现场就没了)。这时候jstack是最有力的工具。

死锁的排查流程很简单。先用jps找到Java进程ID,再用jstack <pid> > threaddump.txt把线程堆栈抓下来,然后搜索"deadlock"或者Found one Java-level deadlock关键字。

一次典型的死锁日志长这样(简化版):

Found one Java-level deadlock: "Thread-1": waiting to lock <0x000000076b7e4a28> (a java.lang.Object), which is held by "Thread-0" "Thread-0": waiting to lock <0x000000076b7e4a30> (a java.lang.Object), which is held by "Thread-1"

看到这个输出基本就能确定是死锁。修复方向上,优先用ReentrantLock的tryLock(long timeout, TimeUnit unit)替代synchronized的大括号锁,给锁操作加上超时时间,避免无限期等待。

线程堆栈还有一个更深入的用途:通过看线程状态(RUNNABLE、BLOCKED、WAITING)来评估线程池健康度。如果大量线程处于BLOCKED状态,说明存在锁竞争;如果大量线程处于WAITING,说明都在队列或条件变量上排队;如果RUNNABLE的线程比例高,但RT仍然很高,可能是GC压力或者外部IO慢。

我在做压测时,通常会每10秒采集一次线程堆栈,连续采集几份,对比线程状态的变化趋势。这样能快速定位到“锁”“IO”“GC”三类问题的大方向,再配合visualvm、arthas做定点分析。

5.2 并发性能评估与调整

并发调优是个反复修正的过程。书里讲的锁优化策略,我总结成几条可落地的实践:

  • 减少锁的持有时间。尽量把不需要锁保护的代码移出synchronized块,比如组装参数、日志打印这些耗时不等的操作。
  • 减小锁粒度。用一个全局锁保护一个大的列表,不如用分片锁或者ConcurrentHashMap的桶级粒度。
  • 用读写锁替代互斥锁。读多写少的场景,ReentrantReadWriteLock比synchronized的上限高很多。JDK 8之后还能换StampedLock,乐观读在无竞争场景下几乎是零开销。
  • 锁分离。比如LinkedBlockingQueue里用两把锁分别保护队列头和尾,入队和出队互不干扰。

性能评估上,最简单的做法是压测时对比加锁前后的吞吐量变化,观察是否出现锁竞争导致的线程上下文切换暴增。Linux下可以用vmstat看cs字段,数值突然飙升往往说明锁竞争严重。

我自己调并发代码的习惯参考是:先跑一遍基线压测,记录TP99和吞吐量;然后逐步放大并发数,观察性能拐点;再用线程堆栈定位拐点原因;最后调整策略后再压一遍,形成闭环。别一上来就堆线程数,很多时候问题不在线不够,而在锁和IO资源上。

5.3 一个小小的实践总结:先有监控,再谈优化

最后写一段我个人在并发项目里最深的心得。并发优化的前提是可观测,没有监控指标的优化都是盲人摸象。

在中间件项目里,我曾经花了一整天去“优化”一段锁代码,凭感觉认为synchronized太重,换成了ReentrantLock,结果TP99不降反升。后来加了监控才发现,真正的问题是DB连接池不够,两个线程卡在连接获取上,根本不关锁的事。

所以在动手调优之前,我建议先给线程池加好这几个指标:核心线程活跃数、最大线程数是否撑满、队列深度、任务执行耗时分布、拒绝次数。然后才是代码层面的锁优化。顺序反了,大概率白忙活。

《Java并发编程的艺术》这本书的价值,不在于告诉你某个API怎么用,而在于把“为什么这样设计”讲透了。这个第二辑的书摘,本质上是我把书里的理论重新翻译成自己在项目里验证过的东西,很多参数和结论都带上了真实场景的味道。如果你也想深入并发这一块,建议照着这本书的目录啃,每一章看完都去找个实际场景写点代码,比光刷题有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询