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 要读取相邻变量 ab

false-sharing-exclusive

  • 核心 A 读取 a,连带加载包含 a 的整个缓存行到本地缓存。
  • 此时只有 A 访问该缓存行,状态标记为 Exclusive(E)

稍后,核心 B 读取 b

false-sharing-shared

  • 由于 ab 在同一缓存行,B 也加载了该行。
  • 现在两个核心都持有该行,状态变为 Shared(S)

接着,核心 A 修改 a

false-sharing-invalid

  • A 将新值写入 store buffer,缓存行状态变为 Modified(M)
  • 同时通知其他核心:你们的缓存行已过期!
  • B 收到通知,将其缓存行标记为 Invalid(I)

这就是 MESI 如何保证多核间缓存一致性的核心机制。


3. 什么是 False Sharing(伪共享)?

问题来了:当核心 B 再次读取 b 时会发生什么?

你可能觉得 b 没变,应该能从缓存快速读取。但现实是残酷的:

false-sharing-flush

  • B 发现自己的缓存行是 Invalid,必须重新从主存加载。
  • 更糟的是,这次加载会强制核心 A 刷出 store buffer,把修改后的 a 写回缓存行,确保 B 能读到最新值。
  • 最终,两个核心又回到 Shared 状态:

false-sharing-shared-again

⚠️ 关键点: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 源码中复制 Striped64LongAdder 的全部实现(包括 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)

false-sharing-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,防止偷任务时的伪共享
  • ConcurrentHashMapCounterCell,统计用的分段计数器
  • Exchanger:内部的 dual data structure

8. 总结

  • False Sharing 是多线程性能的隐形杀手,源于缓存行级别的数据共享。
  • @Contended 是 HotSpot 提供的“银弹”,通过内存填充隔离热点字段。
  • ⚠️ 默认只对 JDK 内部类生效,自定义类需加 -XX:-RestrictContended
  • 使用 perf 等工具可深入分析底层性能瓶颈,是高级调优的必备技能。

所有示例代码已上传至 GitHub:https://github.com/yourname/tutorials/tree/master/jmh


原始标题:A Guide to False Sharing and @Contended