开场:第五辑了,并发还是那个最熟悉的陌生人
从第一辑整理《Java 并发编程的艺术》以来,我的顺手记录已经从简单的摘抄变成了一本小册子。翻到第四辑结尾时我原本想停一停,结果没过多久,群里又有同学甩过来一道并发编程的面试题,让我帮忙看看怎么答。我回了一句“这本书你还是得从头啃”,顺手把往期的目录发了过去。
这道题看着熟悉——线程池参数怎么配、AQS 的原理是什么、volatile 能不能保证原子性。坦白讲,这些基础问题我在网上见过无数个答案版本,但每次答得足够有底气时,靠的都不是背诵,而是真正翻过书、撸过代码、在线上环境踩过坑之后形成的理解。所以第五辑还是会继续写,而且会把重点放在那些书里讲透了、但日常容易被一带而过的底层细节上。这一辑不聊 API 怎么调,聊点“为什么要这么设计”的东西,当作给前四辑做一次底层逻辑的收口。
这篇文章适合已经把 Java 语法和基础并发 API 用得比较熟、但在读源码或者排查线上并发问题时会卡壳的同学。如果你是刚从 synchronized、volatile 关键字开始接触并发的小白,前四辑更合适,这一辑用来往后翻、给理解框架打通最后一公里。
1. 越聊越深:并发底层逻辑决定了上层实践的可靠性
1.1 并发编程到底在解决什么问题
很多人对“并发编程”的第一反应就是“多线程”,但《Java 并发编程的艺术》第一章其实就把这句话修正清楚了:并发编程的根本目的是——在保证线程安全的前提下,尽可能提升程序的执行性能。
这里有两个关键词,一是线程安全,二是性能提升。两者缺一不可。只讲线程安全,那就全部串行化执行好了,连多线程都不用开,何谈并发;只讲性能,极端情况下每个线程都自己搞一个数据副本、不分享任何公共状态,那也不会出现并发问题。真正的难点在于,我们既要共享数据(否则大多数业务根本跑不起来),又要在共享数据时保证正确性,同时还要尽可能地并行执行。
我用一个生活化的类比来解释这件事。假设你开了一家手工饼干店,前台的收银机是整个店里唯一能记账的地方。三个店员同时卖出三份饼干,要是每个人都在同一时间往账本上写自己的那笔收入,最后账本上的数字一定是乱掉的——轻则漏记,重则互相覆盖。你当然可以让三个人排队一个一个来记账,这样账本是绝对正确的,但收银台就变成了瓶颈,排队时间一长,顾客就全跑了。
Java 并发编程做的事情,本质上就是研究怎么把这三个店员、一台收银机的场景搞得更高效:比如给账本加一把锁、让大家错峰使用;比如把账本拆成三页分给每个人、最后再汇总;比如让每个人先记在自己的小本子上、空闲了再统一入账。对应到 Java 的世界里,就是锁、分区、异步缓冲这一套套方案的取舍。
书里为什么会把硬件和内存模型放在最前面讲?就是因为这些“下层的物理规律”决定了上层方案的天花板。你只有知道 CPU 有缓存、缓存会不一致、编译器和 CPU 会乱序执行指令,才能理解为什么在 Java 里要搞出 volatile、synchronized、AQS 这么一大堆东西。
1.2 一本讲并发的书,为什么先花大篇幅讲硬件
很多读者初看《Java 并发编程的艺术》前两章,会觉得硬件部分离 Java 太远,恨不得直接跳过去看锁的章节。这个想法在面试里特别容易翻车,因为并发领域几乎所有重要设计,都能追根溯源到硬件层面的事实。
书里以 JMM(Java 内存模型)为桥梁,把硬件和 Java 语言连了起来。核心链路是这样的:
- CPU 为了弥补与内存的速度差距,引入了多级缓存(L1、L2、L3)。
- 引入缓存之后,多个 CPU 核心各自持有同一份内存数据的副本。
- 当一个核心修改了自己缓存里的数据时,其他核心的缓存副本不会立刻同步。
- 为了最终保证一致性,硬件层面出现了缓存一致性协议(比如 MESI)。
- 即便有了缓存一致性协议,为了进一步提升执行效率,编译器和 CPU 还会对指令做重排序。
- 重排序在单线程内部不影响结果,但多线程交错执行时,就可能让另一个线程看到“不合理”的顺序。
JMM 的价值就在于,它把“缓存一致性协议怎样工作”“重排序什么时候允许、什么时候不允许”这些硬件层面的动态,抽象成一套对 Java 程序员可见的规则。程序员不需要关心哪条指令被重排了,只需要保证自己的程序符合 JMM 定义的几点规则,就能得到“最终一致且符合逻辑”的执行结果。
在这套规则里,最重要的就是 happen-before 原则。书里列出的那几条我背得滚瓜烂熟,但真正用起来的时候,只需要抓住一个核心思想:如果两个操作有 happen-before 关系,那么前一个操作的结果对后一个操作是可见的,且前一个操作的次序在后一个操作之前。比如“解锁操作”发生在“后续对同一把锁的加锁操作”之前,这就是为什么锁内修改的变量在另一个线程拿到锁后读到的值是新鲜的。
网上流传的各种并发面试题,什么“volatile 和 synchronized 的区别”“为什么要用 JMM”“重排序有什么危害”,归根到底都在考这一层的理解。书里之所以不厌其烦地从硬件讲起,就是希望读者不要死记规则,而是能自己推导出规则——当你理解 CPU 缓存和重排序之后,你会自然明白 volatile 为什么不能保证原子性,因为它的语义只管“可见”和“有序”,根本管不住“多条指令的复合操作”这种需要互斥的场景。
2. 内存模型、volatile 与 CAS:并发三基石逐个拆
2.1 volatile 的语义远比“可见性”三个字复杂
voaltile 大概是 Java 并发入门阶段被讨论最多的关键字,也是最容易被低估的关键字。书上给它的定位是轻量级的同步机制,强调的是可见性和有序性,但明确说明它不具备原子性。这三个性质放在一起,很多人其实是分裂着记的,并没有真正融合进自己的代码模型里。
我看过太多解释 volatile 的文章,大都停在“被 volatile 修饰的变量,一个线程修改后,另一个线程能立即看到”这个层面。但书里真正想表达的是两层意思:
第一层,写线程对 volatile 变量的写入,在写入完成后,会强制把当前处理器缓存中的数据写回主内存。说“强制”也不完全准确,更严谨地说,是这条写操作会让其他处理器核心上缓存的该变量失效,从而在下一次读取时必须重新到主内存中拿。这相当于 JMM 在缓存一致性协议之上做了一层“语义上”的保证。
第二层,读线程在读取 volatile 变量时,必须从主内存加载最新值,而不是直接用自己缓存里可能已经过期的副本。在 JSR-133 规范以后,volatile 的语义还被加强过:对一个 volatile 变量的写操作,相当于设置一个“释放屏障”;对它的读操作,相当于设置一个“获取屏障”。简单说,volatile 写之前的普通写入操作不能重排序到该写之后,volatile 读之后的普通读取操作不能重排序到该读之前。
但 volatile 无法把两个独立的操作合并成原子操作。经典的计数场景就是最好的反例:
public class VolatileCounter { private volatile int count = 0; public void increment() { count++; // 这不是原子操作! } }count++ 实际上包含三条底层动作:读取当前值、计算加一、写回新值。三个线程同时执行 count++,哪怕变量是 volatile,仍然会出现丢失更新的问题。因为三个线程可能同时读到旧值 0,各自加一后都写回 1,最终结果远小于 3。
这里有一个实操建议:在小并发量、写多读少的场景,如果只需要保证“读取的是最新写值”而不用做复合操作,volatile 是成本极低的方案。比如框架里的开关标志、应用配置的刷新标记、状态机的流转标识位,这种单写多读的场景用 volatile 几乎零开销。反之,多个线程同时写同一个 volatile 变量并且写前还要读旧值做判断,这种场景就该考虑 AtomicInteger 或者加锁。
2.2 CAS 的原子性与 ABA 问题
CAS(Compare And Swap,比较并交换)这一章是整本书的重头戏,直接围绕 java.util.concurrent.atomic 包展开。我当年第一次看 Unsafe 类的 compareAndSwapInt 源码时,真有“原来机智在这里”的感觉。
CAS 的核心操作其实就一句话:比较某个内存位置的值是否等于预期值,如果等于,则更新为新值;整个流程是原子操作。这个原子性由硬件指令保证(比如 x86 上的 LOCK CMPXCHG),所以在单条指令层面,CAS 不会被线程调度打断。
java.util.concurrent.atomic 包里的 AtomicInteger、AtomicLong、AtomicReference 都是基于这个原语实现的。以 AtomicInteger 为例:
public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) { return next; } } }get 读到当前值,计算新的 next,然后调用 compareAndSet 尝试提交。如果在这期间有其他线程抢先修改了值,current 和内存中的实际值不一致,compareAndSet 会失败,于是循环重试。这就是典型的乐观锁思路——不阻塞,靠重试来应对冲突。
这种设计的好处是高效,尤其是在竞争不激烈的场景下,比 synchronized 轻量得多;坏处也很明显:
ABA 问题:线程一读到 A,被切走,线程二将 A 改成 B 再改回 A,线程一恢复后 CAS 发现值还是 A,提交成功。但如果这个值代表的是一个指针、引用,中间过程可能已经“错位”过了。书里给的解决方案是使用 AtomicStampedReference,靠版本号来识别中间变化。
自旋开销:在高竞争场景下,CAS 会不断失败并重试,导致 CPU 空转。这时候不如直接用锁,让线程挂起等待。
只能保证单个变量的原子性:如果需要同时更新两个变量,要么把两个变量封装成一个对象,用 AtomicReference 统一替换;要么老老实实用锁。
有一说一,面试时问到 CAS,十个人里有九个能说出“比较并交换”,但真正能说出 ABA 问题的具体场景、并且摆出 AtomicStampedReference 示例的,不到两成。如果正在准备面试,建议专门为 ABA 问题写一个小 demo 跑一遍,理解的深度完全不一样。
2.3 原子类选型与 Java 9 之后的新选择
atomic 包里的类虽然思想统一,但选型上有一些容易被忽略的差异。书里作为基础版本介绍的是 AtomicInteger、AtomicLong、AtomicReference 这些,但随着 JDK 版本演进,实际上出现了明显的分化。
对于统计计数、ID 生成这类需要频繁自增/自减的场景,注意一下 AtomicLong 的使用。也顺带一提,如果对吞吐量有更高要求,在高并发场景下可以考虑 LongAdder。LongAdder 的设计思路是把一个热点计数变量拆成多个单元,各个线程分散到不同单元里去累加,需要总值时再把所有单元的值汇总。这正好对应我开场说的“把账本拆成三页”的思路,非常适合读少写多的场景。
如果需要原子地更新对象引用,用 AtomicReference;如果需要更新对象里的某个字段,而不是整体替换引用,可以直接用 AtomicIntegerFieldUpdater / AtomicLongFieldUpdater 这类字段更新器,也可以直接从 Java 9 开始用 VarHandle:
public class FieldUpdaterDemo { private static class Node { volatile int version; } static final VarHandle VERSION_HANDLE; static { try { VERSION_HANDLE = MethodHandles.lookup() .findVarHandle(Node.class, "version", int.class); } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); } } public static void main(String[] args) { Node node = new Node(); System.out.println(VERSION_HANDLE.compareAndSet(node, 0, 1)); System.out.println(node.version); } }VarHandle 提供了比 Unsafe 更规范、比 AtomicIntegerFieldUpdater 更灵活的访问句柄,语法也更接近 Java 9 之后的官方推荐路线。如果你目前项目还在 Java 8,那直接用 AtomicIntegerFieldUpdater 也完全没问题,只是注意字段必须声明为 volatile,否则更新器会直接抛异常。
实战中关于原子类还有一个经常被问到的点:AtomicInteger 能不能替代锁来保证某个业务方法的线程安全?答案是不能,因为 CAS 只保证单个变量的原子更新,不能保证“读-判断-写”这一整段业务逻辑的原子性。比如“余额大于等于 10 才扣减”这种业务判断,必须用锁或数据库行锁来保证复合操作的一致性,单纯靠 AtomicInteger 的 CAS 无法实现。
3. AQS 与锁:从源码角度读懂并发核心
3.1 AQS 的设计思想:CLH 队列与 state
如果说前面那些是基础器材,那 AQS(AbstractQueuedSynchronizer)就是并发包的地基。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 这些组件的同步逻辑,本质上都是建立在 AQS 之上的。
书里对 AQS 的剖析非常细致,核心就两个东西:一个 int 类型的 state 字段,和一个 CLH 队列的变体实现。
state 可以理解为资源的数量或状态。对于 ReentrantLock,state 表示当前线程获取锁的重入次数;对于 Semaphore 表示剩余许可数;对于 CountDownLatch 表示还需等待的计数。AQS 不关心子类用 state 表达什么,只提供基于 state 的原子操作接口(getState、setState、compareAndSetState),具体语义由子类覆盖 tryAcquire 和 tryRelease 来实现。
CLH 队列是一个虚拟的双向队列。说“虚拟”是因为没有实际的队列节点实例,而是通过每个线程节点里的 prev、next 指针把等待线程串起来。AQS 并没有采用原始 CLH 的自旋方式,而是做了一个变体:线程拿不到锁时会把自己封装成一个 Node 放到队列尾部,然后通过 LockSupport.park 挂起自己。当前面的线程释放锁时,会唤醒后一个线程。
这里有一个重要的工程技巧:park 挂起后可能因为中断等原因被“伪唤醒”,所以 AQS 里等待线程被唤醒后会重新检查自己是否真的可以去获取锁,而不是无脑执行下去。
3.2 acquire 流程逐行解读
AQS 的 acquire 方法虽然只有短短几行,但信息量极大:
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }拆开来看:
- tryAcquire(arg):由子类实现的非阻塞尝试获取。如果返回 true,说明拿锁成功,整个 acquire 直接结束;如果返回 false,进入下一步。
- addWaiter(Node.EXCLUSIVE):把当前线程封装成独占模式的 Node,加入等待队列尾部。
- acquireQueued(...):在队列里自旋检查前驱节点是否为首节点,如果前驱是头节点且 tryAcquire 再次成功,当前线程节点就晋升为新头节点,退出等待;否则调用 LockSupport.park 挂起。
- 如果 acquireQueued 期间线程被中断,方法返回 true,则调用 selfInterrupt 补上中断标记。
我刚接触这段代码时最不适应的是第三步的“自旋检查”。明明是挂起等待,为什么还用 for 循环?后来才明白,park 返回并不一定代表“被前驱唤醒了”,也可能是因为 interrupt。自旋检查一圈之后发现还是拿不到锁,就继续 park,这样才能保证逻辑的正确性。
同时,真正的掌控点其实在 tryAcquire 上。ReentrantLock 的公平锁和非公平锁的差别,在源码上就一步:
// 非公平锁: final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { // 直接抢,不看队列 ... } } ... } // 公平锁: protected final boolean tryAcquire(int acquires) { ... if (c == 0) { if (!hasQueuedPredecessors() && // 先看队列里有没有人排前面 compareAndSetState(0, acquires)) { ... } } ... }非公平锁一进来就尝试 CAS 抢锁,不管等待队列里有没有人排队;公平锁会先调用 hasQueuedPredecessors 确认队列里没有排在自己前面的线程,才会去尝试。公平锁的吞吐量通常低于非公平锁,但避免了线程饥饿问题。书里推荐默认用非公平锁,这个建议在绝大多数业务场景下是合理的——除非你想严格控制执行顺序,否则没必要为绝对的公平付出性能代价。
3.3 synchronized 锁升级与 ReentrantLock 选型
聊完 AQS 就不能不提 synchronized。在 JDK 6 之后,synchronized 经过锁升级机制的改造,性能已经不输 ReentrantLock,甚至大多数场景下因为自适应的自旋偏向策略,反而更好用。
锁升级的完整链路是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
偏向锁的含义是:锁会偏向于第一个获取它的线程,只要这个线程持续持有锁,后续获取时都无需额外的同步操作,直接检查 Mark Word 里的线程 ID 是否是自己即可。当有另一个线程来竞争时,偏向锁会撤销并升级。
轻量级锁的实现原理是:线程在自己栈帧里创建锁记录,通过 CAS 把对象头里的 Mark Word 替换为指向锁记录的指针。如果 CAS 成功,说明获取轻量级锁成功;如果失败,说明发生了竞争,锁会膨胀为重量级锁。重量级锁依赖操作系统的互斥量来实现,涉及用户态到内核态的切换,成本较高。
一个容易被忽略的细节是:偏向锁默认情况下是有延迟的,JVM 启动后最初 4 秒内,新创建对象的偏向锁是禁用状态。这是为了防止 JVM 内部自身的同步操作在启动阶段产生不必要的偏向锁撤销开销。了解到这一点,就不会在压测早期阶段对锁的耗时表现做出错误的判断。
关于 synchronized 和 ReentrantLock 的选择,我的经验是:
- 简单方法级别的同步、代码结构清晰,直接用 synchronized,可读性好,且 JVM 会做锁消除、锁粗化等优化。
- 需要尝试获取锁、设置超时时间、可中断锁等待、或实现公平锁时,用 ReentrantLock。
- 需要多个条件变量(Condition)来精细控制线程状态时,ReentrantLock 的 newCondition 更灵活。
- 读写场景明显读多写少,可以考虑 ReentrantReadWriteLock,但要注意读写锁的升级问题(读锁不能直接升级为写锁,否则可能死锁)。
书里在结尾用了一整节来强调锁优化的思路:减少锁持有时间、减小锁粒度、锁分离、锁粗化、锁消除。这些东西说起来简单,但每一项落地都需要对业务有足够的理解。举个例子:读多写少的场景用读写锁是缩小锁粒度,而如果锁内的代码本身执行极快,频繁加锁解锁的上下文切换开销反而更大,这时候或许更适合锁粗化或直接无锁化改造。
4. 并发容器与线程池:工程中的两个高频战场
4.1 ConcurrentHashMap 的演进与选择
书里对 ConcurrentHashMap 的解析覆盖了 JDK 7 和 JDK 8 两个版本的实现差异,这也是面试题里的常客。
JDK 7 的 ConcurrentHashMap 结构是 Segment + HashEntry。Segment 本身继承自 ReentrantLock,每个 Segment 保护一段桶(默认 16 个 Segment,即默认并发度 16)。put 操作只需要锁住目标 Segment,其他 Segment 仍可并发操作,从而提升并发吞吐。get 操作通过 volatile 读 HashEntry 的 value 字段来保证可见性,无需加锁。
JDK 8 之后结构大变,放弃了 Segment,改用 CAS + synchronized 来控制并发。put 时若桶为空,通过 CAS 直接放入新节点;若桶不为空,则对桶头节点加 synchronized 锁,再在链表或红黑树中进行更新。粒度从 Segment 级别细化到单个桶,锁竞争进一步降低。
为什么要用 synchronized 而不是 ReentrantLock?官方给出的解释很简单,JVM 在 synchronized 上做了大量优化,且锁粒度已经细到单个桶,重量级锁几乎不会出现。代码上更简洁,维护成本也更低。
工程上使用 ConcurrentHashMap 有几个容易出问题的细节:
- 正确使用 computeIfAbsent:这个方法是 Java 8 引入的原子操作,适合做“如果键不存在则计算并放入”的场景,能避免先 get 再 put 的竞态问题。但计算函数本身不能太重,否则会长时间持有桶锁。
- size() 不是实时精确值:为了并发性能,size() 在并发修改较多时可能返回近似值。如果你需要严格的精确计数,应该自己维护 Counter 或者使用 LongAdder 累加。
- 不要将 key 设计为可变对象:只要 hash 值的来源发生变化,就会导致定位出错,数据可能“丢失”。
4.2 CopyOnWriteArrayList 的适用与不适用场景
CopyOnWriteArrayList 的原理是写时复制:每次修改操作(add、set、remove)都会创建一个新数组并将原数组内容复制过去,然后在新的数组上执行修改,最后把内部 volatile 数组引用指向新数组。读操作不加锁,直接在快照上遍历。
这个设计带来的最大好处是读读无锁、读写也不互斥,非常适合读多写极少、且读操作可以容忍短暂的不一致快照的场景,比如监听器列表、配置项的只读缓存。
但它的“不适用”清单同样明显。每次写操作都完整复制底层数组,写操作的复杂度是 O(n),如果写频繁,复制开销非常大。而且数据量越大,单次写入的代价越高。搜索、排序、遍历等操作读到的都是某个时间点的快照,实时性不强,不适合需要强一致性的业务逻辑。
我见过一个典型案例:有人用 CopyOnWriteArrayList 维护一个可能包含几十万条记录的黑名单列表,并且每天要更新上千次。这种场景的结果就是频繁的大数组复制,内存和 CPU 都被拖垮。换成 ConcurrentHashMap 或带版本号的读写锁方案,效果反而好得多。
选择容器时先画一张表对候选方案进行权衡,别犯“看名字高级就直接用”的毛病。
| 容器 | 读性能 | 写性能 | 一致性 | 适用场景 |
|---|---|---|---|---|
| CopyOnWriteArrayList | 高 | 低(复制开销大) | 快照弱一致 | 读多写极少,列表小 |
| ConcurrentHashMap | 较高 | 较高(桶级锁) | 弱一致 | 高频读写,键值对结构 |
| Collections.synchronizedList | 一般 | 一般 | 强一致 | 低并发,结构简单 |
4.3 线程池参数设置与拒绝策略
线程池的青睐程度和踩坑程度在 Java 并发里几乎是并列第一。书里把 ThreadPoolExecutor 的几个核心参数讲得很透,这里我结合项目实践把参数设置的思路重新整理一遍。
核心参数速览:
| 参数 | 含义 | 影响 |
|---|---|---|
| corePoolSize | 核心线程数 | 常驻线程数量 |
| maximumPoolSize | 最大线程数 | 线程数量上限 |
| keepAliveTime | 非核心线程空闲存活时间 | 关停多余线程的等待时间 |
| workQueue | 任务队列 | 排队策略 |
| threadFactory | 线程工厂 | 线程命名、自定义属性 |
| rejectedExecutionHandler | 拒绝策略 | 队列满且线程达到最大值时的处理方式 |
任务提交的完整流程是:当提交一个新任务时,如果当前线程数小于 corePoolSize,即使有空闲线程也会优先创建新线程来执行;当线程数达到 corePoolSize 后,新任务进入 workQueue 排队;队列满后再创建非核心线程,直到线程数达到 maximumPoolSize;如果任务继续提交,就会触发拒绝策略。
这个流程的顺序有个很反直觉的设计:corePoolSize 没满时,即使有空闲核心线程,也会新建线程而不是复用空闲线程。这是为了优先吸收突发流量,避免让任务积压在队列里。
关于参数设置,没有万能公式,但有一个通用的思考框架:
- 如果是 CPU 密集型任务,核心线程数设置为 Ncpu + 1 左右比较合理;如果是 IO 密集型任务,核心线程数可以设置得更大,比如 2 * Ncpu 甚至更高,因为线程大部分时间在等待 IO。
- 队列选择要考虑任务特性。有界队列(如 ArrayBlockingQueue)能控制资源上限,防止任务无限堆积;无界队列(如 LinkedBlockingQueue 默认)的 maximumPoolSize 形同虚设,任务永远不会触发拒绝策略,但可能造成内存溢出。
- 拒绝策略中不推荐直接使用 DiscardPolicy 和 DiscardOldestPolicy,静默丢弃任务会导致业务数据丢失,至少要用 CallerRunsPolicy 或者自定义策略,配合告警。
特别提一个容易踩坑的点:用 Executors.newFixedThreadPool 和 Executors.newCachedThreadPool 的问题。newFixedThreadPool 默认使用无界队列,newCachedThreadPool 的最大线程数是 Integer.MAX_VALUE。在请求量突增时,前者可能堆出海量任务,后者可能创建出天文数字的线程,两边都有把 JVM 拖垮的风险。书里和众多生产实践都推荐直接使用 ThreadPoolExecutor 构造函数来创建线程池,明确自己的参数边界。
5. 并发场景下的数据一致性:从理论到落地的几个关键点
5.1 数据一致性的三个层次
数据一致性是并发编程的终极目标,也是搜索引擎里“java怎么保证数据一致性”这类问题反复被搜的原因。但“一致性”这个词在不同语境下含义差距很大,我梳理成三个层次:
第一层是线程内的一致性:单线程内,代码逻辑天然顺序执行,不存在一致性问题。编译器优化和指令重排不能改变单线程语义,这一点由 JMM 保证。
第二层是线程间的一致性:多个线程共享变量时,JMM 通过 volatile、锁、final 和 happen-before 规则来保证一个线程的写入对另一个线程可见。这里的核心问题是“你什么时候能看到我写的东西”。
第三层是跨组件的一致性:比如微服务调用之间的分布式数据一致性,涉及事务消息、分布式锁、最终一致性方案等。这一层已经超出 Java 语言本身的范围,需要借助数据库、消息队列、分布式协调组件等外部设施来解决。
前两层的技术手段,就是前面几节反复提到的 volatile、锁、AQS、并发容器;第三层的技术手段则更加系统化,但底层思想仍相通:要么通过强同步(锁、事务)保证实时一致,要么通过版本号、幂等重试等手段保证最终一致。
面试中如果被问到“Java 怎么保证数据一致性”,建议先明确语境,把这三个层次分别展开,显得逻辑清晰,而不是只简单抛出一个 synchronized 了事。
5.2 ThreadLocal 的使用与内存泄漏
ThreadLocal 在书里被归为线程封闭的一种实现方式,也是实际开发中被大量使用又容易被误用的工具。它的原理是每个线程内部维护一个 ThreadLocalMap,键是 ThreadLocal 对象的弱引用,值是线程持有的变量副本。这样每个线程操作的都是自己的私有副本,天然避免了线程间共享数据的竞争。
听起来很美好,但 ThreadLocal 的内存泄漏问题几乎是每个 Java 工程师都会遇到的坑。简单说:ThreadLocalMap 中 Entry 的 key 是弱引用,一旦外部强引用消失,key 会被 GC 回收,变成 null,但 value 仍然是强引用,归属于当前线程。如果这个线程是一个长期存活的核心线程(比如线程池里的线程),value 就永远无法被回收,导致老年代内存持续增长。
书里的处理建议非常明确:每次使用完 ThreadLocal 都要调用 remove() 方法清理。尤其在使用了线程池的场景下,线程复用的时间可能非常长,不清理就等于把数据“腌”在线程里,下一轮任务还能读到上一轮的残留值。
这里分享一个排查案例。有一次接收方系统上线后,老年代 GC 频率不断上升,从 dump 文件里看到大量相同业务对象堆积在线程对象内部。追根溯源,就是某段代码在拦截器里写了一个 ThreadLocal 存用户信息,但拦截器在 finally 里没有调用 remove,导致线程池里每个线程都绑定了一个用户对象,永不释放。加上线程池线程数量不小,对象数量很快顶了上来。
正确的使用模板是:
ThreadLocal<UserContext> contextHolder = new ThreadLocal<>(); try { contextHolder.set(userContext); doBiz(); } finally { contextHolder.remove(); }这个模板值得背下来,无论代码路径多复杂,都要保证 remove 一定执行。
5.3 实际业务中的并发控制实践
书里的理论再完备,最终都要落到业务里才能体现价值。我在项目中比较大的体会是:并发控制方案没有银弹,不同场景的侧重点完全不同。
以电商系统的库存扣减为例。最简单的做法是给库存行加锁(数据库行锁),确保同一时刻只有一个请求能扣减库存;但如果并发量非常大,行锁会成为瓶颈。更优的方案是使用 Redis 的原子操作(如 DECR 命令)来做预扣减,再异步落库对账;但这种方式在极端情况下可能出现超卖或者对账不平,需要有补偿机制。
以订单号生成场景为例,可以使用数据库自增主键、Redis INCR、或者基于雪花算法的分布式 ID 生成器。每种方案在性能、可用性、趋势递增等方面各有取舍。
以状态机流转场景为例,多个线程同时操作同一个订单的状态,比如“已支付”不能再次流转为“待支付”,就需要通过乐观锁版本号来控制,更新时带上版本号条件:
UPDATE order_table SET status = ?, version = version + 1 WHERE id = ? AND version = oldVersion如果更新影响行数为 0,说明有并发操作抢先修改了状态,程序就能感知到并做出相应处理。这种方式比悲观锁的阻塞等待性能更好,也更适合大多数互联网业务。
书里讨论这些问题时反复强调一个原则:先识别共享的到底是什么资源,竞争发生在哪个环节,再决定用哪种方案。同一份业务代码,在单机多线程下的并发控制和在多节点分布式下的并发控制,技术选型可能完全不同。
结尾:想真正学会并发,别停在“知道”这一步
第五辑写到这里,如果把这本书比作一张地图,我大概已经把最重要的几座城池标出来了:JMM 是外围的城墙,volatile 和 CAS 是城门,AQS 是城中心的宫殿,并发容器和线程池是迁往各处的街道。但要真正把这张地图用起来,靠的还是自己走一遍。
我在实际阅读和编码中的体会是,书里的每一段源码都值得亲手敲一遍,哪怕只是照着打,也比直接看解析有收获。敲的过程中你会注意到很多阅读时被忽略的细节,比如 AQS 里为什么用 unparkSuccessor 唤醒后继而不是当前节点的后继节点、ConcurrentHashMap 的扩容时为什么需要判断 ForwardingNode、ThreadLocalMap 的探测式清理如何维持哈希表的可用性。这些细节不是考试题,而是真正影响诊断问题的能力。
另外想建议的是,读完一章后,去找一个身边的真实业务场景做实验。比如给某个接口加上线程池、把某个计数改成 LongAdder、给状态流转加上乐观锁版本号,然后对比改造前后在压测和数据正确性上的表现。这种“读完即用”的循环,比单纯刷题更能建立对并发的直觉。
《Java 并发编程的艺术》这本书的定位是进阶读物,跳过基础直接啃确实会比较痛苦。但如果能沉下心来。慢慢把每一章读完、跑通示例代码,你对 Java 的掌控力会有一个明显的跃升。下一辑我打算专门拆一拆 CompletableFuture 和响应式编程里的并发模型,如果到时候有新的工程体验,再接着分享。