并发编程中的伪共享(False Sharing):分析 @Contended 注解如何通过内存对齐隔离变量缓存行
伪共享因多线程修改同一缓存行不同变量,引发MESI协议频繁失效,导致性能骤降。@Contended注解通过填充字节隔离字段独占缓存行,需配合JVM参数启用。手动填充long变量是兼容性更佳的替代方案。
先说几个核心判断:伪共享这玩意儿,还真不是你代码写错了,而是多线程在底层被硬件“坑”了一把。当多个 CPU 核心上的线程,各自修改同一缓存行里不同变量时,MESI 协议就得反复做一致性协调——你改一下,我失效一次;我再改一下,你又失效一次。一来二去,性能直接崩掉。而 @Contended 注解干的事很简单:告诉 JVM 在字段前后塞一堆填充字节,把它跟旁边字段彻底隔开,独占整个缓存行。

伪共享为什么发生:缓存行 + MESI 是关键
CPU 读写内存,不是按字节来的,而是以缓存行为单位——主流是 64 字节。当两个线程分别更新同一缓存行上的 valueA 和 valueB 时,好戏就上演了:
- 线程1 改 valueA → Core1 缓存行标记为 Modified
- MESI 协议一看,不行,得让 Core2 里那行失效,于是标记为 Invalid
- 线程2 想改 valueB → 发现自己的缓存行无效了,只能重新从内存加载整个 64 字节
- 接着线程2 修改 valueB → 反过来又把 Core1 那行打失效……
这么一来一回,就成了高频“拉锯战”。结果很统一:吞吐量掉到原来的 1/5 到 1/2,CPU 跑满、但 jstack 抓不到锁、日志里也看不到任何异常。这才是最让人头疼的地方——没有直接错误,只有性能烂。
@Contended 怎么实现内存隔离
它的思路很简单:不动逻辑,只动手脚。在被标记字段的前后各插一段填充(padding),把字段“包”成一个孤岛,保证它前后至少空出一个缓存行的距离。
- 默认填充宽度是 128 字节——覆盖 64 字节缓存行,还留了安全余量
- 用
-XX:ContendedPaddingWidth=64可以显式设为标准缓存行大小 - 支持分组:
@Contended("counter")能让同组字段打包后再整体填充。比如 head 和 tail 这两个本就需要共处一组的变量,可以放在一起隔离,但又不会和外部字段互相干扰
这就有意思了——你不只可以隔离单个字段,还能按业务逻辑把热点字段分到同一组里,内部共享、外部隔离。
必须配齐的 JVM 启动参数
需要特别警惕的是,@Contended 是“有条件启用”的特性,缺一个参数它就不干活:
-XX:+UnlockExperimentalVMOptions(JDK 8/9 必须开启,否则直接忽略)-XX:+UseContended(JDK 8) 或-XX:-RestrictContended(JDK 9+)
另外还有几个隐性规则:静态字段加了也白加;public 字段会被 JVM 直接无视;必须是实例字段,且访问权限为 private/protected 或包级。这几个坑踩的人不少,记得检查到位。
不用 @Contended 的替代方案
如果环境不支持注解,或者你更想掌控一切,手动填充也是个靠谱的选择,兼容性反而更好:
- 在热点字段前后各塞 7 个 long(7×8=56 字节),加上字段本身凑够 64 字节隔离带
写法就是long p1, p2, p3, p4, p5, p6, p7; - 借助 JOL(Ja va Object Layout)来验证布局:
执行ClassLayout.parseClass(YourClass.class).toPrintable(),可以直观看到字段真实偏移 - 还有一个容易忽略的点:ja vac 不保证字段声明顺序等于内存顺序,最终布局由 JVM 对齐策略决定。所以就算你代码里把缓存行隔离字段写在前面,实际运行时可能并不按你想象的位置排。用 JOL 看一遍,一切就清楚了。
说到底,理解伪共享不是为了炫技,而是让你在排查性能问题时多一个方向——下次遇到 CPU 高、锁不明显的场景,不妨先看看缓存行上是不是正打着一场“看不见的战争”。


































