在多核处理器上写并发程序时,我们往往会把注意力放在锁、原子变量和线程池上,却容易忽略一个藏在硬件底层的性能杀手:虚假共享。它不会让程序算错结果,却能让本该线性扩展的多核算力大打折扣。理解它的成因并学会用缓存行填充化解,是进阶并发编程的必修课。

什么是虚假共享
现代CPU为了弥补内存延迟,会给每个核心配备多级缓存,其中离核心最近的一级缓存以缓存行(cache line)为单位管理,常见大小为64字节。当某个核心修改了缓存行里的某一个字节,其他核心如果也持有这行数据的副本,就必须将该行标记为失效,重新从更高级缓存或内存加载。这个机制由缓存一致性协议(如MESI)保证。
问题出在:如果两个逻辑上毫无关系的变量,比如线程A专用的计数器a和线程B专用的计数器b,在内存里紧挨着,恰好落在同一个64字节缓存行中,那么A改a会导致B的缓存行失效,B改b又会让A的失效。尽管它们没有共享数据,却因为共用缓存行而被迫互相通知,这就是虚假共享。它带来的不是正确性问题,而是无谓的缓存同步流量和停顿。
如何确认缓存行大小
不同架构的缓存行大小可能不同,但在x86、ARMv8等主流平台基本都是64字节。在Linux下可以通过sysfs直接读取,在代码里也可以静态假设。明确大小是做填充的前提,否则填少了仍然同处一行,填多了则浪费内存。
下面是一段C++程序,用来打印当前系统的缓存行大小(利用gcc内置宏):
#include <iostream>
int main() {
// GCC提供了缓存行大小的内置宏
std::cout << "cache line size: "
<< __GCC_DESTRUCTIVE_SIZE
<< " bytes" << std::endl;
return 0;
}
如果编译器不支持该宏,也可在终端执行 getconf LEVEL1_DCACHE_LINESIZE 查看。确认是64字节后,我们填充时就以64为对齐目标。
Java中的缓存行填充实践
在Java里,对象字段在内存中的排列受JVM和字段顺序影响,并不总是紧凑,但相邻字段仍可能共用缓存行。JDK 8之前常用long类型数组手动填充,JDK 8引入了@Contended注解,JDK 9之后需配合JVM参数-XX:-RestrictContended开启。
先看一个存在虚假共享风险的计数器类,两个volatile long被不同线程狂写:
public class BadCounters {
// 线程A写counterA,线程B写counterB
public volatile long counterA;
public volatile long counterB;
}
由于counterA和counterB可能同处一个缓存行,并发写入会互相失效。改进方式是手动在两者之间插入7个long(共56字节)再加自身8字节凑满64字节,或者采用JDK注解方式:
// 手动填充方案
public class PaddedCounters {
public volatile long counterA;
// 填充7个long避免与counterB同缓存行
private long p1, p2, p3, p4, p5, p6, p7;
public volatile long counterB;
}
// 注解方案(需开启JVM参数)
import sun.misc.Contended;
public class AnnotatedCounters {
@Contended
public volatile long counterA;
@Contended
public volatile long counterB;
}
手动填充简单直观,不依赖特定JVM版本,但代码稍显丑陋;@Contended由JVM在对象布局时自动插入填充,更干净,却要求运行环境支持且开启对应开关。在高频交易、统计埋点等场景下,填充后的吞吐量通常能回升三到五成。
C++中的对齐与填充
C++11起提供了alignas说明符,可以把变量或结构体按指定字节对齐,从而让每个热点变量独占缓存行。相比手动塞哑变量,这种方式语义更明确。
下面定义一个按64字节对齐的线程本地计数器结构:
#include <atomic>
struct alignas(64) PaddedAtomic {
std::atomic<long> value;
// 编译器会补齐到64字节,避免相邻实例同缓存行
char padding[64 - sizeof(std::atomic<long>)];
};
// 每个线程拥有独立实例,互不干扰
PaddedAtomic counters[4];
使用alignas(64)后,数组中每个元素起始地址都是64的倍数,天然不在同一缓存行。若不用对齐,直接定义普通结构体数组,就很容易触发虚假共享。需要注意的是,过度对齐会增加内存占用,在海量对象场景下要权衡。
填充方案的代价与取舍
缓存行填充不是银弹。它用空间换时间:每个被填充的对象都会膨胀到缓存行整数倍,当对象数量极大时,缓存能容纳的对象变少,反而可能提升缓存miss率。因此只建议对真正高并发写入的少量热点变量做填充。
另外,现代编译器和运行时可能重排字段,手动填充有时会被优化掉,所以关键路径上最好用语言级对齐属性或官方注解,而非单纯相信字段声明顺序。下表总结了常见方案的适用情况:
| 方案 | 语言/环境 | 优点 | 缺点 |
|---|---|---|---|
| 手动long填充 | Java通用 | 无版本依赖 | 代码冗长,可能被重排 |
| @Contended | Java 8+ | 语义清晰 | 需JVM参数,旧版不支持 |
| alignas | C++11+ | 标准支持,对齐可靠 | 内存膨胀明显 |
实践中,先通过性能剖析工具(如perf cache-misses)确认是否存在大量缓存行争用,再决定是否填充,避免盲目优化。
小结
虚假共享是隐藏在多核并发下的静默性能漏洞。通过识别热点独立写变量、测算并对齐缓存行大小,再利用手动填充或语言特性将其隔离,可以显著降低缓存一致性流量。记住,任何优化都应以测量为前提,缓存行填充只用在刀刃上,才能让多核程序既正确又飞快。
false_sharingcache_line_paddingvolatile修改时间:2026-07-31 12:03:35