
给所有写并发代码的程序员提个醒真正让你夜里三点爬起来调线上问题的往往不是复杂的锁算法而是对 JMMJava 内存模型这几个字的理解不到位。JMM 不是 JVM 的某一个区域也不是某个 API它是一套描述了线程之间如何通过内存通信、什么情况下一个线程的写入对另一个线程可见的抽象规范。很多线上偶发问题比如标志位一直读不到最新值、双重检查锁拿到半成品对象、高并发计数器比预期少了一大截根子都在这套模型上。我见过不少经验挺丰富的开发者聊起并发工具头头是道但一问到“为什么这里必须加 volatile”、“为什么 i 在多线程下不是安全的”就说不清楚。这篇不是教科书式搬运概念我会从 CPU 缓存架构这条线铺开讲清楚 JMM 到底在解决什么问题再回到 Java 的同步原语和真实代码复盘那些 99% 的团队都会踩的并发坑。适合正在啃 Java 并发、准备面试、或者排查线上并发问题找不到头绪的读者看完能建立一套自己的排查心智模型。1. 为什么CPU缓存直接决定了JMM的走向1.1 从“中央仓库与工位抽屉”说起理解 JMM 的第一件事是抛掉“变量存在内存里线程直接读内存”的想法。真实的计算机存储结构是金字塔式的从寄存器、多级 CPU 缓存、到主内存每一层都比上一层大、但更慢。打个比方。主内存像公司的中央仓库所有货品都有唯一的账目记录。每个 CPU 核心就像一位员工工位上有个小抽屉L1 缓存走廊里还有几个人共用的中转柜L2/L3 缓存。员工干活时不会每次取货都跑一趟中央仓库而是优先从抽屉里拿。问题马上就来了员工 A 把抽屉里的某份文件改了员工 B 的抽屉里还放着一份旧拷贝他对“最新状态”的感知完全取决于什么时候被强制同步。Java 的多线程正是这样运行的。每个线程都有一份“工作内存”对应 CPU 缓存和寄存器线程对变量的所有操作都必须先在工作内存中完成再择机刷回主内存。如果这个“择机”没有规则约束读到的就是旧值。JMM 要做的事就是定义线程工作内存与主内存之间的交互规则规定什么时候必须刷回、什么时候必须重新读取从而保证“一个线程的写入在什么条件下对另一个线程可见”。这也就是为什么很多并发 Bug 看起来像“玄学”。你加不加锁、加不加 volatile影响的不只是代码还会通过编译器和 CPU 的优化行为改变实际执行路径。我在刚接触这块时犯过一次特别蠢的错用一个普通 boolean 变量作为线程停止标志位主线程置成 true 后子线程还在一路狂奔。后来才知道线程可能长期读到的都是工作内存里的旧值主内存里变了它看不见。1.2 MESI缓存一致性协议CPU自己先解决“改不改得到”既然多核 CPU 各搞各的缓存那就得有个规则不然所有多核程序都会乱套。这个规则就是缓存一致性协议最典型的是 MESI 协议。它给每个缓存行定义了四种状态状态含义典型场景M (Modified)本核心已修改和主内存不一致核心写入缓存行后且尚未写回主内存E (Exclusive)本核心独占和主内存一致其他核心都不缓存这一行该核心可自由写S (Shared)多个核心共享一致多个核心都只读同一缓存行I (Invalid)缓存行无效必须重新读取其他核心修改后本核心的副本失效当一个核心要修改某个共享变量时会先向其他核心广播“这个地址我要独占写”收到响应的核心把自己的缓存行标记为 Invalid之后读这个变量时就必须重新去主内存拿。这套监听-响应机制保证了缓存与主内存之间最终一致。但这远远没到万事大吉的程度。现代 CPU 为了流水线性能又引入了写缓冲区和乱序执行。那感觉就像一个员工虽然知道仓库账目变了但为了把手头活干完先把改动记在自己小本本上晚点再上交。CPU 为了提高吞吐把写操作放入 store buffer 后不会立刻让其他核心看到编译器也会把不改变单线程语义的代码顺序打乱重排。结果就是即使同一地址的数据A 线程写的顺序和 B 线程观察到的顺序也可能不一致。为了让开发者有能力约束这种“乱来”CPU 提供了内存屏障指令你在用 Java 时可能没直接见过它但 volatile 和 synchronized 的底层实现里都藏着它。JMM 正是站在这个位置上向上给 Java 程序员提供 happens-before 规则向下映射到具体平台的内存屏障指令。这也是理解 JMM 的关键视角它不是脱离实际的纸上标准而是对硬件做了一系列相当务实的妥协和抽象。1.3 伪共享缓存行断裂带来的隐形成本聊到缓存行必须提一个在实际性能压测里经常冒头的坑伪共享。CPU 缓存和主内存之间交换的单位不是单个字节而是一个缓存行常见大小是 64 字节。也就是说即使你只改一个 long 型变量CPU 也会把周围 64 字节一起拉进缓存行。设想两个不同的变量被放到同一个缓存行线程 A 只改变量 X线程 B 只改变量 Y。按 MESI 协议A 修改缓存行后会把共享的缓存行置脏B 的缓存副本立刻 Invalid。B 下次要读 Y发现缓存行失效只好重新从主内存捞数据。这样一来两个线程明明碰的是不同内存地址却因为住在同一条“船”上互相拖累需要反复地互相发无效通告。缓存行在两个核心之间来回跳性能就崩了。我处理过一次高并发统计场景一段代码里多个线程各自更新独立的 Long 字段按理说毫无竞争但压测发现吞吐量低得离谱后来排查才发现这些字段被定义在同一个对象里地址挨着全部落在同一条缓存行上。解决办法也不复杂要么把字段用固定长度的数组分隔开让它们错开缓存行边界要么用 JDK 8 之后提供的Contended注解让 JVM 自动做填充。需要注意Contended默认只在 JDK 内部类里生效自己代码要用得在启动参数上加-XX:-RestrictContended。伪共享这个坑对普通业务开发可能不常见但只要你写并发计数器、并发队列、或者任何以“多线程高频写共享内存”为核心的组件它就大概率在某个角落等着你。2. JMM的三大核心特性可见性、原子性、有序性2.1 可见性工作内存与主内存之间的“终极同步”JMM 规定所有变量都存储在主内存中每个线程还有自己的工作内存。线程对变量的读写不能直接操作主内存必须先把变量拷贝到工作内存再读写的副本。这听起来很绕但正是这个抽象模型解释了“读不到最新值”的问题。看一个我当年写过的经典死循环示例public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { while (!flag) { // 忙等 } System.out.println(子线程看到 flag 变化了); }, worker); t.start(); Thread.sleep(1000); System.out.println(主线程修改 flag); flag true; } }这个代码在加了volatile和没加volatile时表现可能完全不同。不加 volatile主线程对 flag 的写入可能一直留在自己的工作内存里被编译器优化后的子线程循环也可能已经把!flag优化成常量判断于是子线程永远跑不到打印那行。加上 volatile 后写 flag 时会强制先把工作内存中的新值刷回主内存读 flag 时会强制从主内存重新加载子线程就能及时退出了。可见性是并发编程的第一道坎但很多人把它等同于 volatile 一个修饰符这有点窄。synchronized 的加锁与解锁也天然携带了可见性加锁时清空工作内存解锁时把工作内存中的修改刷回主内存。事实上《Java 并发编程实战》里有一句被反复引用“写锁保护的共享变量读也必须用锁保护因为锁的可见性保障是双向的。”这句话背后就是这个机制。2.2 原子性i不是一个操作JMM 分析一个变量时把操作拆得很细有 read、load、use、assign、store、write 这些动作。对初级开发者来说知道循环里一个i并不是一步完成就够了。它至少可以拆成“读取 i 的当前值—加 1—写回新值”三个阶段。多线程同时执行这段代码时完全可能同时读到同一个旧值。比如线程 A 读到 100还没写回线程 B 也读到 100两边各自算出 101 并写回最终结果只是 101而不是 102。两个线程各自多跑一遍白白少加了一次。我简单复现过private static int count 0; // 多个线程各自执行 10000 次 count // 最终 count 往往小于线程数 * 10000要解决这个方向就三条第一用 synchronized 或 Lock 把读改写包成临界区第二用AtomicInteger这类基于 CAS 的类直接利用 CPU 提供的cmpxchg指令保证“比较再交换”这一整个动作是原子的第三直接用LongAdder这类分段计数器。很多人看到 AtomicInteger 以为万事大吉其实它只保证了单个方法的原子性如果你在代码里做“先 get 再比较再 put”那还是要考虑组合操作的原子性。这里有个特别重要的区分volatile 并不提供原子性。它只保证读写两个独立操作是可见的、有序的但“读—改—写”这个组合没人替你保证。所以我见过有人把共享计数器声明成 volatile int 然后到处 压测时数字照样不对原因就在这里。给 volatile 变量的每个独立读写加“最新可见”的保证容易但把两个动作没有缝隙地拼接起来它做不到。2.3 有序性指令重排与as-if-serial、happens-before第三个坑被聊得最少但造成的幻觉最多指令重排。为了让流水线更紧凑编译器和 CPU 都可能在不改变单线程执行结果的前提下调整指令顺序。注意它真的可能改变多线程下的可见顺序。JMM 的应对是引入了 happens-before 规则。只要操作 A happens-before 操作 B那么 A 的执行结果对 B 是可见的并且编译器、CPU 都不能把 A 重排到 B 之后。这条规则有具体列表我整理成表格面试和日常排查都能用到规则说明程序顺序规则同一个线程中按代码书写顺序前一个操作 happens-before 后一个操作volatile 变量规则对一个 volatile 字段的写操作 happens-before 后续对同一字段的读操作锁规则对一个锁的解锁 happens-before 后续对同一把锁的加锁传递性规则若 A happens-before BB happens-before C则 A happens-before C线程启动规则Thread.start() happens-before 该线程中的任何动作线程终止规则线程中所有动作 happens-before 其他线程对该线程的 join() 返回理解 happens-before 的姿势不是说“只要 A 在时间上先执行了B 就能看到 A 的结果”而是说这两个操作之间存在一种“可见性契约”。只要没有这条契约时间上的先后并不能保证读取到新值。举个例子经典模型里 volatile 写之后的普通变量读取顺序线程 A 先写普通变量 x再写 volatile 变量 v线程 B 读 v再读 x。因为 v 的写 happens-before v 的读而程序顺序又保证了 x 写 happens-before v 写、v 读 happens-before x 读通过传递性就能得出结论B 读到的 x 一定是 A 写的最新值。这就是很多并发框架里用 volatile 作为“发布”信号的理论依据。3. 常用同步原语的底层逻辑与选型策略3.1 volatile被严重低估的双向屏障volatile 可能是 Java 关键字里最被误解的一个。它不做互斥却能在“可见性”和“有序性”两个维度同时生效。底层实现依赖内存屏障对一个 volatile 变量写入时JMM 会在它前面插入 StoreStore 屏障禁止编译器把前面普通的写重排到它后面同时在它后面插入 StoreLoad 屏障保证这个写操作对其他核心来说是“刺眼”的读取 volatile 变量时插入 LoadLoad 和 LoadStore 屏障禁止把后面的普通读写重排到它前面。这个“屏障”到底有什么用我把它理解成一条排水渠修在代码指令流中间把水流里的脏东西挡住。平时写业务时不会直接碰内存屏障指令但你在判断“要不要加 volatile”时就要意识到它承担的是“单向/双向排序”的职责。在实际选型里volatile 最典型的应用场景是状态标志位和发布不可变快照。比如一个volatile boolean running线程 A 把它设成 false线程 B 在下一个读操作立刻感知再比如服务启动时加载一份不可变配置引用变量声明成 volatile读取线程拿到最新引用后读到的对象内部字段不需要再加锁因为对象发布之后没有任何修改。这里有个额外注意事项volatile 引用的对象如果内部状态一直在变那 volatile 只是保证“引用本身是最新的”不保证“对象内部字段可见性”别拿它包治百病。3.2 synchronized从偏向锁到重量级锁的升级之路在 JDK 1.6 之前synchronized 留给人的印象是“重”因为它的实现直接依赖操作系统的互斥原语可能把线程挂起涉及用户态到内核态的切换。这确实是并发性能的噩梦。但 JDK 1.6 引入了一整套锁升级机制让 synchronized 在大多数场景下性能非常可观。锁状态记录在对象头的 Mark Word 里整体路径是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁是给“这个锁始终只有一个线程访问”的场景准备的。第一次获取时通过 CAS 把线程 ID 写进 Mark Word之后同一个线程再来基本无开销。一旦出现第二个线程竞争偏向锁撤销升级为轻量级锁。轻量级锁的做法是当前线程在自己的栈帧里创建锁记录然后用 CAS 尝试把对象头里的 Mark Word 换成指向锁记录的指针如果成功就表示拿到锁失败则进入自旋——反复重试而不是马上挂起。自旋会消耗 CPUJVM 有自适应自旋会根据历史情况调整自旋次数。如果自旋持续失败锁会膨胀成重量级锁这就要靠操作系统来管理等待队列了。有一点很多人不知道锁升级是单向的不可能从重量级锁降回轻量级锁。所以在极高竞争场景下大量线程抢一把重量级锁性能依然可能很糟。另外编译器还可以做锁消除和锁粗化锁消除是说 JVM 发现对象根本不会逃逸出当前线程就会把加锁直接优化掉锁粗化是把相邻的几个加锁解锁合并成一个减少反复加锁的损耗。这些优化在高版本 JDK 里都是自动发生的但别因此觉得 synchronized 就不用管竞争粒度了好的并发设计永远是先减少共享再谈同步。3.3 final与不可变对象并发里的隐形安全层聊 JMM 时final 经常被忽略。但 JMM 对 final 字段有一项特殊承诺只要 final 字段在构造器里正确赋值并且构造器没有把 this 引用“逃逸”到其他线程那么任意线程在拿到该对象后看到 final 字段时一定是构造器赋值后的最终值不需要额外同步。这条承诺非常有用。它意味着不可变对象天然适合并发共享你可以把一个对象安全的发布给多个线程大家只读不写根本不需要加锁。比如 java.time 里那些日期类、String、所有值类型包装类都是这么设计的。但这里有个专属坑构造器逃逸。如果在构造器中把 this 传给某个监听器、或者启动一个线程并把 this 传进去那这些“旁观者”可能在字段赋值完成前就看到半初始化的对象。测试时偶尔出现字段为空或默认值排查半天不下十次了你要记住“不要从构造器泄漏 this”这条纪律。4. 实战复盘99%开发者踩过的并发坑4.1 DCL单例volatile到底补的是什么双重检查锁Double-Checked Locking是面试里出现频率最高的模式也是理解 volatile 价值的入门案例。常见写法是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 new Singleton()这句。它并不是一个原子操作大致要经历三步分配内存调用构造器初始化对象把引用赋值给 instance。JIT 和 CPU 有可能把第二步和第三步重排即先给 instance 赋一个地址再执行构造器逻辑。此时另一个线程过来发现 instance 不可能是 null都返回这个地址去用了可里面的字段还没初始化完拿到的就是个半成品。加了 volatile 之后写 instance 会触发内存屏障禁止把“初始化”重排到“引用赋值”之前读 instance 时也能保证看到的是一个完成构造的对象。这就是 volatile 在这个模式里不可替代的原因。如果你不想跟这段历史较劲也有更干净的替代方案用静态内部类或者枚举实现单例。静态内部类靠 JVM 的类加载机制保证线程安全枚举更是从语言层面防止了反射和序列化破坏。我是真心建议新代码直接用后者DCL 留给学习场景和旧代码维护就够了。4.2 用普通HashMap搞“并发缓存”引发的连环事故另一个高发坑是拿 HashMap 当并发缓存用。JDK 1.7 的 HashMap 在并发插入时可能让链表形成环多线程在 get 时陷入死循环表现出来就是 CPU 飙升 100%线程栈全部卡在 HashMap 的 get 方法附近。虽然这个问题的根源更多是数据结构缺陷而不是纯粹的 JMM 问题但它反映的并发认知是一致的使用非线程安全容器却不做任何同步约束就是在赌运行时的运气。JMM 的规则告诉你什么情况下能安全地共享数据而你违反规则编译器、CPU、数据结构实现共同制造故障只是时间问题。现在 Java 并发包里已经有非常靠谱的替代ConcurrentHashMap 的实现从 JDK 1.7 的分段锁演进到 JDK 1.8 的 CAS synchronized 粒度的桶锁并发读性能很好写竞争也只锁住单个桶。CopyOnWriteArrayList、BlockingQueue 系列也都自带并发保障。把共享容器一律换成并发容器等于在源头避开一大批隐患。要注意的是“并发容器安全”不等于“复合操作安全”两个线程同时“先检查再放入”这种操作仍然要自己加锁。4.3 共享计数器与伪共享从AtomicLong到LongAdder如果只是简单的计数器AtomicLong 是最自然的方案。但高并发下所有线程都往同一个内存地址上发起 CAS竞争激烈时会导致大量重试性能上限不高。JDK 8 引入的 LongAdder 把单一热点拆成了一个 Cell 数组每个线程尽量在属于自己的 Cell 上做累加需要取值时再把所有 Cell 加起来。这相当于把“一个账户大家抢着记账”变成“每个人有自己的账本最后汇总”竞争一下就被分摊了。我做过一次简单压测在 16 线程高频累加场景下LongAdder 的吞吐比 AtomicLong 能高出几倍竞争越激烈优势越明显。但低并发时 LongAdder 反而因为维护数组有额外开销用 AtomicLong 更直接。选型逻辑就这么简单竞争低或对内存敏感用 AtomicLong高竞争、允许一定延迟汇总的用 LongAdder。另外LongAdder 内部对 Cell 的每个元素都做了缓存行填充目的就是规避本文开头说的伪共享。这其实是个高级优化思路如果自己也写底层并发组件在记录型字段旁边用数组留出空白把热点数据分散到不同缓存行就能大幅降低 MESI 协议的无效通知开销。4.4 ThreadLocal的内存泄漏陷阱ThreadLocal 和 JMM 的关系乍看不如锁那么直接但理解“每个线程持有自己的变量副本”时会产生一个常见的错误联想把 ThreadLocal 当成解决可见性的万能钥匙。实际上 ThreadLocal 解决的是“线程隔离”它根本没有提供跨线程可见性。不同线程的 ThreadLocal 就是两份独立数据不存在同步问题也不需要同步。真正容易踩的坑是内存泄漏。每个线程内部有一个 ThreadLocalMap作为键的 ThreadLocal 实例是弱引用。弱引用意味着如果外部只通过 ThreadLocal 引用它GC 时就会把它回收但 map 里的 value 仍然被一个强引用链坚持着等到这个线程自己销毁。如果在使用 ThreadLocal 后不主动 remove尤其是线程池里线程被复用的时候value 就会一直积在某个线程的 map 里慢慢演变成内存增长甚至 OOM。我在业务系统里排查过一个“奇怪”的堆外增长老年代在缓慢爬坡dump 文件里能看见大量历史请求的上下文对象所有线索都指向 ThreadLocal 的 value。解决办法也很朴素在 finally 里显式调用 remove别依赖弱引用机制自动清理。JDK 的 WeakReference 设计是为了缓解泄漏不是让你把清理责任交给 GC。4.5 happens-before不能被“逻辑推断”替代最后提醒一个习惯性错误不要用执行时间先后判断 happens-before 关系。很多人喜欢对着日志时间戳说“线程 A 明明先执行完线程 B 后打印为什么拿不到 A 的值”时间只能说明真实时钟的先后顺序不能保证 JMM 层面的可见性契约。日志时间戳是打印日志那一刻的状态可能 B 在早于 A 刷主内存之前就把旧值读进工作内存了。所以排查并发问题时正确的姿势永远是盯着同步机制而不是时间。确认这条数据通道上有没有锁、volatile、ConcurrentHashMap 内部的同步点、或者通过线程 start/join 建立的边界。如果没有那无论日志显示多少次“先执行后看到”结论都应当是不安全。5. 线上并发问题定位与排查实录5.1 CPU飙高的通用排查五步法Java 服务 CPU 突然冲高是最常见的线上故障。我的常规排查路径是固定的五步分享出来供你直接抄。第一步top找到 CPU 占用最高的 Java 进程记录 PID。第二步执行top -Hp pid看这个进程里哪个线程最耗 CPU。第三步把该线程的十进制线程 ID 转成十六进制命令是printf %x\n tid。第四步用jstack pid jstack.txt导出线程栈然后在 dump 文件里搜第一步得到的十六进制定位线程名和当前堆栈。第五步结合代码看它到底卡在哪个方法。top top -Hp java_pid printf %x\n thread_id jstack java_pid /tmp/jstack.txt # 找到 hex比如 0x4d2然后 grep -A 50 0x4d2 /tmp/jstack.txt这个流程对绝大多数 CPU 飙高都有效。比如线程卡在一个无界循环里栈上会显示VM AT NATIVE或某业务类的while(true)如果是频繁 GC那要配合jstat -gcutil pid 1000看 GC 间隔和回收率如果大量线程在自适应自旋抢锁dump 出来会看到一堆线程 Blocked 或 Runnable 且全都聚集在同一个同步代码块附近。有一次线上加急单我怀疑是某个线程在做无意义的String.intern()导致 CPU 飙高。用这套流程不到三分钟就找到了。很多新人不理解为什么要用 printf 转十六进制因为 jstack 里线程显示的标准形式是nid0x...你不转就没有对应关系。5.2 从jstack结果看并发问题信号拿到 jstack dump 之后关键不是看有没有异常而是看线程状态和栈的组合信号。大量线程WAITING而且堆栈里指向LockSupport.park说明它们都在等某个锁或条件变量可能存在锁竞争激烈或死锁。大量线程BLOCKED集中在同一个synchronized或者ReentrantLock入口说明有热点锁。一个线程RUNNABLE且 CPU 占用高堆栈反复指向同一个循环方法大概率是死循环或空转。发现Found one Java-level deadlock说明 JVM 已经帮你识别出了死锁顺着后续信息就能找到两个线程互相等待的 monitor 和条件对象。线上排查我强烈推荐在需要跑一遍排查流程的时候顺手用一次 Arhtas。它的dashboard能实时显示各线程 CPU 占用thread -n 3直接打印最忙的线程thread --state BLOCKED能把阻塞线程摊开看。比纯命令行直观不少尤其适合那种不想反复导 jstack 的场合。还要记一个经验jstack 是瞬间快照如果你完全没抓到异常高峰的现场dump 再多次也可能看不到问题线程。要让监控系统在 CPU 达到阈值时自动触发采集再结合日志上下文一起看。一次性的手工 dump 往往只能抓到“问题已经过去了”的场景。5.3 JMM与JVM内存模型别混为一谈最后必须把概念掰清楚。网上搜“Java 内存模型”会看到两类截然不同的材料。一类说的是 JMM即并发编程里抽象出来的主内存与工作内存另一类说的是 JVM 运行时数据区也就是堆、栈、方法区、程序计数器、本地方法栈这些内存区域。这两个其实是不同维度的东西但因为名字相近被混在一起讲太久了。JVM 内存区域的划分回答的是“对象存在哪”JMM 回答的是“多线程共享数据时写入何时可见、操作能否保证原子和有序”。你调 GC 参数、分析堆 dump、做 JVM 内存优化那是第一类你分析 volatile、synchronized、happens-before、DCL那是第二类。对面试而言如果你先解释一段“JMM 是 Java 并发的基础抽象模型不是堆内存也不是方法区”再往下分可见性、原子性、有序性最后补两行 happens-before 例子基本能cover掉 90% 并发问题。对日常开发而言一句话GC 优化的对象是堆内存布局JMM 约束的是代码的同步语义两者别在排查时互相误导。个人体会并发靠的不是API是心智模型我在实际支持和 review 过的代码里见过太多“工具用得很溜但并发特性全凭记忆”的开发。加锁只是因为别人加了用 volatile 只是因为搜索引擎说这样能修 bug。当你被线上并发问题折磨过一次、又见识过 MESI 和内存屏障之后心态会完全不同。你真正建立起来的是一套“这个变量在线程之间如何流动、哪个动作重新建立了可见性边界”的判断习惯。所有 API 和关键字都服务于同一种底层逻辑要么在操作之间插入屏障要么建立互斥要么让共享变成隔离。把 JMM 当成一套可推导的行为契约而不是几个关键字的说明书写并发代码时你会少很多“玄学”线上排查时也能少几根白头发。