1. 概述
多线程编程中,我们常以为并发越多性能越好,但有些底层机制可能让并发“适得其反”——False Sharing(伪共享) 就是典型例子。
本文将从 CPU 缓存机制讲起,结合 @Contended
注解,深入剖析伪共享如何拖慢并发性能。我们会“手搓”一个 LongAdder
实现,与 JDK 原生版本对比,通过 JMH 基准测试和 perf
工具,一步步揭示性能差异背后的真相。
⚠️ 注意:本文讨论的内存布局细节依赖于 HotSpot JVM 实现,并非 JVM 规范强制要求。不同 JVM 或版本行为可能不同,以下分析以 HotSpot 为准。
2. 缓存行与缓存一致性
现代 CPU 为提升性能,使用多级缓存(L1/L2/L3)。当 CPU 读取内存数据时,并非只加载单个变量,而是以“缓存行(Cache Line)”为单位加载一段连续内存。这是基于“空间局部性”原则的优化——程序访问某个地址后,很可能紧接着访问其附近地址。
✅ 缓存行大小通常是 64 字节(部分 CPU 为 128 字节),这意味着一次加载会包含目标变量及其周边数据。
当多个 CPU 核心同时操作同一或相邻内存地址时,它们可能共享同一个缓存行。此时,必须保证各核心缓存中的数据一致,这就是缓存一致性(Cache Coherency)。
维护缓存一致性的协议有很多,本文聚焦最主流的 MESI 协议。
2.1 MESI 协议详解
MESI 是四种缓存行状态的首字母缩写:
- Modified(修改)
- Exclusive(独占)
- Shared(共享)
- Invalid(无效)
我们通过一个例子理解其工作流程。
假设两个核心 A 和 B 要读取相邻变量 a
和 b
:
- 核心 A 读取
a
,连带加载包含a
的整个缓存行到本地缓存。 - 此时只有 A 访问该缓存行,状态标记为 Exclusive(E)。
稍后,核心 B 读取 b
:
- 由于
a
和b
在同一缓存行,B 也加载了该行。 - 现在两个核心都持有该行,状态变为 Shared(S)。
接着,核心 A 修改 a
:
- A 将新值写入 store buffer,缓存行状态变为 Modified(M)。
- 同时通知其他核心:你们的缓存行已过期!
- B 收到通知,将其缓存行标记为 Invalid(I)。
这就是 MESI 如何保证多核间缓存一致性的核心机制。
3. 什么是 False Sharing(伪共享)?
问题来了:当核心 B 再次读取 b
时会发生什么?
你可能觉得 b
没变,应该能从缓存快速读取。但现实是残酷的:
- B 发现自己的缓存行是 Invalid,必须重新从主存加载。
- 更糟的是,这次加载会强制核心 A 刷出 store buffer,把修改后的
a
写回缓存行,确保 B 能读到最新值。 - 最终,两个核心又回到 Shared 状态:
⚠️ 关键点:A 和 B 操作的是不同变量(a
vs b
),但因为它们在同一个缓存行,一个核心的写操作会强制另一个核心的缓存失效。
这种“无辜被牵连”的现象就是 False Sharing。它会导致:
- 频繁的缓存未命中(Cache Miss)
- 额外的内存总线流量
- 核心间不必要的同步开销
在高并发场景下,性能损耗可能非常显著。
4. 实战案例:动态分段(Dynamic Striping)
为了直观感受伪共享的影响,我们来“复刻” java.util.concurrent.atomic.LongAdder
。
LongAdder
是高并发计数器,核心思想是“动态分段(Dynamic Striping)”:用一个 Cell[]
数组代替单一计数器,让不同线程更新不同数组元素,降低竞争。
我们先定义两个空类:
abstract class Striped64 extends Number {}
public class LongAdder extends Striped64 implements Serializable {}
然后从 OpenJDK 源码中复制 Striped64
和 LongAdder
的全部实现(包括 import)。
⚠️ 注意 Unsafe
的使用:
- Java 8 中需替换
sun.misc.Unsafe.getUnsafe()
为反射获取:private static Unsafe getUnsafe() { try { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { throw new RuntimeException(e); } }
- Java 9+ 使用
VarHandle
,无需特殊处理。
4.1 基准测试对比
使用 JMH 对比我们“复刻”的 LongAdder
和 JDK 原生版本:
@State(Scope.Benchmark)
public class FalseSharing {
private java.util.concurrent.atomic.LongAdder builtin = new java.util.concurrent.atomic.LongAdder();
private LongAdder custom = new LongAdder();
@Benchmark
public void builtin() {
builtin.increment();
}
@Benchmark
public void custom() {
custom.increment();
}
}
运行命令:
# 2 次 fork,16 线程,吞吐量模式
java -jar benchmarks.jar -f 2 -t 16 --bm thrpt
结果令人震惊:
Benchmark Mode Cnt Score Error Units
FalseSharing.builtin thrpt 40 523964013.730 ± 10617539.010 ops/s
FalseSharing.custom thrpt 40 112940117.197 ± 9921707.098 ops/s
❌ 我们的实现吞吐量不到原生版本的 1/4!
延迟对比也印证了这一点:
Benchmark Mode Cnt Score Error Units
FalseSharing.builtin avgt 40 28.396 ± 0.357 ns/op
FalseSharing.custom avgt 40 51.595 ± 0.663 ns/op
原生版本延迟更低。差异在哪?看底层性能计数器。
5. 使用 perf 分析性能事件
Linux 的 perf
工具可监控 CPU 硬件性能计数器(PMC),如缓存命中/未命中、指令周期等。
分别对两个版本运行:
perf stat -d java -jar benchmarks.jar -f 2 -t 16 --bm thrpt custom
perf stat -d java -jar benchmarks.jar -f 2 -t 16 --bm thrpt builtin
关键指标:L1 数据缓存加载未命中数(L1-dcache-load-misses)
- 我们的实现:约 10.36 亿次
- 原生实现:约 1.20 亿次
✅ 原生版本缓存未命中数少了近 90%!这正是性能差距的根源。
接下来,我们深入 LongAdder
内部找答案。
6. 再看动态分段:@Contended 的作用
LongAdder
的核心是 Striped64
类中的 Cell
内部类:
@jdk.internal.vm.annotation.Contended
static final class Cell {
volatile long value;
// 其他字段...
}
transient volatile Cell[] cells;
每个 Cell
包含一个 volatile long value
,代表一个分段计数器。
理想情况下,每个 Cell
应独占一个缓存行,避免伪共享。但如果 JVM 将多个 Cell
实例连续分配在堆上,它们可能落在同一缓存行,导致伪共享。
解决方案:内存填充(Padding)
通过在 Cell
前后添加“无用”字段(填充),确保每个 Cell
实例跨越整个缓存行,从而隔离。
而 @Contended
注解正是干这个的!
但为什么我们的“复刻”版本没生效?因为:
⚠️ @Contended
默认只对 JDK 内部类生效!我们自定义的类即使加了注解,HotSpot 也不会添加填充。
7. 深入 @Contended 注解
Java 8 引入 sun.misc.Contended
(Java 9+ 移至 jdk.internal.vm.annotation.Contended
),专为解决伪共享。
7.1 如何工作?
- 字段级别:在字段上加
@Contended
,JVM 会在该字段前后添加填充。 - 类级别:在类上加
@Contended
,JVM 会在所有字段前添加填充。
7.2 解除限制:-XX:-RestrictContended
要让 @Contended
对自定义类生效,需启动时加 JVM 参数:
-XX:-RestrictContended
重新运行基准测试:
Benchmark Mode Cnt Score Error Units
FalseSharing.builtin thrpt 40 541148225.959 ± 18336783.899 ops/s
FalseSharing.custom thrpt 40 546022431.969 ± 16406252.364 ops/s
✅ 现在两者性能几乎持平!差异在误差范围内。
7.3 配置项
- **
-XX:ContendedPaddingWidth
**:设置填充宽度,默认 128 字节(覆盖 2 个 64 字节缓存行,更安全)。 - **
-XX:-EnableContended
**:完全禁用@Contended
,节省内存(但牺牲性能)。
7.4 JDK 中的应用场景
@Contended
在 JDK 内部广泛用于关键并发结构:
- ✅
Striped64
/LongAdder
:高吞吐计数器 - ✅
Thread
类:threadLocalRandomProbe
等字段,避免线程局部随机数生成器的伪共享 - ✅
ForkJoinPool
:工作队列的WorkQueue
,防止偷任务时的伪共享 - ✅
ConcurrentHashMap
:CounterCell
,统计用的分段计数器 - ✅
Exchanger
:内部的 dual data structure
8. 总结
- False Sharing 是多线程性能的隐形杀手,源于缓存行级别的数据共享。
@Contended
是 HotSpot 提供的“银弹”,通过内存填充隔离热点字段。- ⚠️ 默认只对 JDK 内部类生效,自定义类需加
-XX:-RestrictContended
。 - 使用
perf
等工具可深入分析底层性能瓶颈,是高级调优的必备技能。
所有示例代码已上传至 GitHub:https://github.com/yourname/tutorials/tree/master/jmh