谈谈伪共享

168 阅读3分钟

一、定义与本质

伪共享是指多个线程同时读写不同变量,但这些变量恰好位于同一个缓存行(Cache Line)中,

导致缓存失效的现象。本质是缓存行层面的资源竞争,虽不直接冲突,但会间接影响性能。

二、CPU 缓存架构与缓存行

  1. 多级缓存结构

    • L1(最快,每个核心独享)→ L2(核心独享)→ L3(多核共享)→ 主存(最慢)。
    • 缓存与主存的数据交换以 缓存行(通常 64 字节)为单位。
  2. 缓存行机制

    • 当 CPU 读取一个变量时,会将整个缓存行载入缓存。
    • 若缓存行中其他变量被修改,整个缓存行需失效(Invalidate)并重新从主存加载。

三、伪共享的危害

示例场景

class Counter {
    long countA;  // 线程A修改
    long countB;  // 线程B修改
}
  • 若 countA 和 countB 位于同一缓存行:

    1. 线程 A 修改 countA,导致缓存行失效。
    2. 线程 B 读取 countB 时需重新加载整个缓存行,即使 countB 未被修改。
    3. 性能下降:频繁的缓存行失效和重新加载(称为 缓存颠簸)。

四、解决方法:缓存行填充(Padding)

通过填充使变量独占缓存行,避免被其他变量影响。

1. 传统手动填充(JDK 8 前)
class Counter {
    long p0, p1, p2, p3, p4, p5, p6;  // 前填充
    long countA;                      // 独占缓存行
    long q0, q1, q2, q3, q4, q5, q6;  // 后填充
    long countB;                      // 另一个缓存行
}
  • 原理:每个 long 占 8 字节,7 个填充变量 + 1 个数据变量 = 64 字节(刚好 1 个缓存行)。
2. JDK 8 的 @sun.misc.Contended 注解
@sun.misc.Contended
class CounterCell {
    volatile long value;
}
  • 原理:JVM 自动在变量前后添加填充,确保独占缓存行。
  • 启用参数:需添加 JVM 参数 -XX:-RestrictContended

五、典型应用案例

  1. JDK 中的实现

    • ConcurrentHashMap 的 CounterCell 使用 @Contended 避免伪共享。
    • LongAdderStriped64 等并发组件同样依赖此优化。
  2. 高性能框架

    • Netty 的 HashedWheelTimer 使用手动填充确保时间轮的槽位独占缓存行。

六、性能对比测试

测试代码(简化版)

// 无填充
class Data {
    long value1;
    long value2;
}

// 有填充
@Contended
class PaddedData {
    long value1;
    long value2;
}

// 测试逻辑:双线程分别修改 value1 和 value2

测试结果示例

场景操作耗时
无填充1200 ns
手动填充150 ns
@Contended120 ns

七、注意事项

  1. 过度使用的代价

    • 填充增加内存消耗(每个变量独占 64 字节)。
    • 可能导致缓存利用率下降(如缓存行浪费)。
  2. 现代处理器的优化

    • 部分处理器(如 Intel)支持 MESI 协议 的增强版(如 MESIF),可减少伪共享影响。
    • 但在高并发场景下,手动优化仍有必要。

八、总结

伪共享是多线程编程中隐蔽的性能杀手,通过合理的缓存行填充(手动或注解)可显著提升性能。理解伪共享需掌握:

  1. 缓存行机制:CPU 以行为单位读写缓存。

  2. 竞争模式:不同变量在同一缓存行时的间接竞争。

  3. 优化手段:填充技术与 @Contended 注解的应用。

在设计高性能并发组件(如计数器、时间轮、队列)时,伪共享是必须考虑的优化点。