理解Java内存模型对提升代码性能的帮助
一场线上故障让我重新认识了JMM。一个服务里三个线程分别修改三个 boolean 标记,为了不让主线程读到过期值,三个标记全加了 volatile。结果并发一上来,CPU 使用率飙到 85%,业务吞吐反而塌了一半。查到最后,问题既不在算法,也不在 IO,而是这三个 volatile 刚好落在同一个缓存行里,互相拖垮了对方。
很多人把Java内存模型当成面试题,背完 happens-before 就束之高阁。真正的性能调优,恰恰是从看清内存模型开始的。Java内存模型不是一道面试题,而是一张性能地图。它不负责告诉你堆和栈长什么样,它只负责定义一件更底层的事:线程之间究竟如何观察彼此的行为。
JMM 的管辖范围
JMM 由 JSR-133 规范定义,核心围绕三个词展开:可见性、有序性、原子性。它不规定每条 Java 语句在 CPU 里具体怎么执行,而是建立一套抽象规则:一个线程对共享变量的写入,在什么条件下、以什么顺序,对另一个线程可见。它管的是线程之间“什么时候能看到”,以及“以什么顺序看到”。理解到这个层面,才会明白加一个修饰符对性能究竟意味着什么。
现代 CPU 和 JIT 编译器会在语义不变的前提下执行大量优化:指令重排序、寄存器缓存、写缓冲合并。单线程里这些优化又快又正确,多线程里却有可能把代码顺序撕成碎片。重排序不是bug,没有同步的并发才是bug。JMM 用 happens-before 把重排序约束在安全边界内。
happens-before 不是什么玄学:如果 A happens-before B,那么 A 的结果对 B 可见,A 也不得被重排到 B 之后。锁的 unlock 规则、volatile 的写读规则、线程启动和传递性规则,都在给并发操作排座次。理解 happens-before 就是在给多线程排座次。你越清楚谁先谁后,就越知道该在哪里放内存屏障、在哪里省掉内存屏障。
volatile 是放大器,不是保险箱
volatile 的底层机制是内存屏障。每次 volatile 写,JIT 通常会插入 StoreLoad 屏障,强制让之前的值刷出核心,并禁止周围指令越界。volatile不是用来保证线程安全的,而是用来发布安全的可见性的。它适合做状态标记、发布引用,但不适合做复合操作,更不能替代锁。
普通写操作几乎零成本,volatile 写可能贵一个数量级;而 synchronized 的成本更多取决于竞争,而不是语法本身。把volatile当锁用,等于拿狙击枪扫马路。这个比喻有点狠,但很多代码就是这么干的:为了省一点锁开销,把 volatile 用在一堆需要原子判断的场景,结果照样出错,性能也不见得好。
伪共享:缓存行上的无声内战
JMM 在抽象层面把变量当成独立单元,真实 CPU 却按缓存行读写。一个 64 字节的缓存行往往同时装着多个独立变量。线程 A 只改 x,线程 B 只改 y,但 x 和 y 住在同一间宿舍,缓存一致性协议就得让 A 和 B 不停传纸条。伪共享是并发代码里最贵的不经意。
刚才说的三个 volatile boolean 就是这种惨案。它们逻辑上毫无关系,物理上却共享一个缓存行,导致每次写都触发一次缓存行所有权转移。两个线程修改无关变量,却被迫轮流持有同一块缓存行所有权。解决方式要么用 @Contended 注解隔离,要么做字段填充,把热点变量分到不同缓存行。这不是技巧,这是内存模型落到硬件后必然给出的答案。
锁的粒度就是性能的粒度
synchronized 进入时要拿到锁对象的最新状态,退出时要保证修改可见。这个语义落到 CPU 上,就是一系列 load/store 屏障。锁块内部代码越多,持锁时间越长;锁竞争越激烈,屏障成本被放大得越狠。synchronized块的大小,往往就是性能的生死线。
但别因此把 synchronized 当成洪水猛兽。无竞争时它可能是偏向锁或轻量级锁,JIT 甚至能做锁消除和锁粗化。真正摧毁性能的不是锁本身,而是把一个原本不需要共享的操作硬放进共享临界区。锁的粒度,决定了性能的粒度。缩小临界区,比换任何并发工具都更直接。
final 是低调的可见性捷径
JMM 给 final 字段开了绿色通道。只要一个对象被正确构造,没有把 this 泄漏出去,另一个线程拿到这个对象后,final 字段就能读到最终值,不需要额外同步。final字段的可见性问题,在JMM里几乎被免费解决了。这直接鼓励我们多用不可变对象:读线程不需要为可见性付出屏障成本,也少了很多共享状态的心智负担。
不可变对象不会因为多线程就变得不安全,它本身就是并发设计的默认答案。final字段让不可变对象成为并发世界的默认答案。性能调优时,优先把可变共享数据改成不可变数据,比任何锁优化都更优雅。
用 JMM 给性能算账
理解 JMM 不是为了背诵规则,而是为了算账。每一次跨线程共享,都在支付内存屏障的账单。大多数性能问题不是慢在CPU,而是慢在内存屏障。优化之前,先问:这个变量真的需要被多个线程看到吗?它必须在临界区里吗?能不能用局部变量或不可变对象绕开?
AtomicLong 在高并发下会引发大量 CAS 竞争,LongAdder 的做法是分散到多个计数单元,读取时才合并,本质就是减少同一个缓存行上的争抢。没有内存模型意识的锁和原子类,只能让你在错误方向跑得更快。方向错了,越快越危险。
JMH 是这里最好的裁判。同一段逻辑,在单线程、多线程、不同 CPU 上的表现可能天差地别,只用肉眼观察和压测工具结论经常互相打脸。性能调优的终点不是更快的代码,而是更合理的内存契约。契约清楚了,线程之间不需要互相猜忌,CPU 也能放开手脚做优化。
回到开头那场事故。把三个 volatile 换成无锁状态机,再配合 @Contended 隔离缓存行,CPU 占用从 85% 降到 20%。这不是魔法,而是终于读懂了 JMM 在说些什么。Java内存模型不是一张考卷,而是一张地图。看懂了它,性能优化就不再是堆参数和试配置的盲人摸象。
