Volatile与伪共享问题
今天上班坐地铁路上刷到一篇文章,内容是使用Volatile
遇到伪共享问题导致性能大幅下降。感觉讲的不是很清楚,决定自己研究一下为什么会出现性能大幅下降的问题。
Volatile
首先简单介绍一下什么是Volatile
?Volatile
就是Java的一个关键字,够简单吧!
Volatile
主要用于解决两个关键问题:
- 内存可见性问题:一个线程修改了共享变量的值,其他线程能够立即看到最新的值。
- 指令重排序问题: 编译器和处理器为了优化性能,可能会对指令的执行顺序进行重新排序(只要不影响单线程执行结果)。
volatile
可以限制这种重排序,保证特定顺序。
那么为什么要解决这两个问题呢?
这是因为现代的计算机架构为了提高性能,采用多级缓存机制也就是CPU Cache。每个 CPU 核心通常有自己的缓存。当一个线程读取一个共享变量时,它可能会从自己的缓存中读取(如果缓存中存在该数据),而不是每次都从主内存(RAM)读取。同样,写入操作也可能先写入缓存,稍后才刷新到主内存。当面临多线程情况下就会出现内存可见性问题:
- 线程 A 修改了共享变量
flag
的值(比如在自己的缓存中将其设为true
)。 - 线程 B 随后读取
flag
的值(可能从自己的缓存中读取,而该缓存里还是旧的false
值)。 - 结果:线程 B 没有看到线程 A 的最新修改,导致程序逻辑错误。
volatile
如何解决内存可见性?
当一个变量被声明为 volatile
:
- 写操作: 任何线程对该变量的写操作都会立即刷新到主内存中。写操作完成后,该变量在其他 CPU 核心缓存中的副本会 失效。
- 读操作: 任何线程对该变量的读操作都会直接从主内存中读取(或者从最新更新的缓存中读取),确保获取的是最新值。
简单来说,volatile
强制所有读写都直接作用于主内存(或者保证缓存一致性协议被严格遵守如 MESI ),绕过了线程工作内存(缓存)可能带来的延迟和过期问题,从而保证了修改对所有线程的立即可见性。
volatile
如何解决指令重排序?
编译器和处理器会在不改变 单线程程序语义 的前提下对指令进行重排序优化。但在多线程环境下,这种重排序可能导致意想不到的结果。
volatile
通过插入 **内存屏障 **来限制编译器和处理器的重排序:
- 写屏障: 在
volatile
写操作之后插入。确保该写操作之前的所有写操作(无论是否volatile
)都已经刷新到主内存,并且该写操作本身不能被重排序到这些操作之前。 - 读屏障: 在
volatile
读操作之前插入。确保该读操作之后的所有读/写操作都不能被重排序到这个读操作之前。同时,它会强制处理器从主内存(或有效缓存)加载该volatile
变量的最新值。
这些屏障保证了以下顺序:
- 当线程 A 写入一个
volatile
变量时,所有在写入操作之前对其他变量(无论是否volatile
)的修改,对于随后读取这个volatile
变量的线程 B 来说,都是可见的。 - 当线程 B 读取一个
volatile
变量时,它能看到线程 A 在写入该volatile
变量之前所做的所有修改(得益于写屏障保证了之前的修改都刷新了,读屏障保证了 B 能看到这些刷新)。
volatile
的特性总结:
- 可见性: 保证一个线程对
volatile
变量的修改能 立即 被其他线程看到。这是它最主要的作用。 - 原子性:仅限于对
volatile
变量本身的单次读/写操作是原子的。例如,读取一个volatile long
或volatile double
在 32 位机器上是原子的(标准要求)。但volatile
不能 保证复合操作的原子性,比如i++
(它包含读、改、写三步操作)。 - 禁止指令重排序(部分):通过内存屏障限制编译器和处理器的优化,保证
volatile
变量操作相对于其他操作的有序性。
volatile
的局限性 (什么情况下不能用 volatile
)
- 非原子复合操作:
volatile
无法保证像i++
、count += 5
这类读-改-写操作的原子性。这类操作在多线程环境下仍然需要同步(如synchronized
)或使用java.util.concurrent.atomic
包中的原子类(如AtomicInteger
)。 - 多个变量的约束: 如果一个操作的正确性依赖于多个共享变量之间的逻辑关系(例如
low < up
),仅仅将这些变量声明为volatile
是不够的。因为volatile
保证了单个变量的可见性和单个操作的原子性,但不能保证涉及多个变量的操作的原子性或一致性。这种情况下通常需要锁 (synchronized
) 或其他的并发控制机制。
volatile
vs synchronized
特性 | volatile | synchronized |
---|---|---|
作用域 | 变量 | 代码块或方法 |
可见性 | 保证该变量的可见性 | 保证进入同步块/方法前能看到之前释放锁时的所有修改;退出时保证修改刷新到主存 |
原子性 | 仅保证对该变量单次读写的原子性 | 保证同步块内所有语句的原子性执行 |
互斥性 | 无 | 有 (同一时间只有一个线程能持有锁) |
阻塞/上下文切换 | 不会引起线程阻塞 | 获取锁失败会引起线程阻塞,涉及上下文切换 |
性能 | 通常开销很小(读接近普通变量,写稍慢) | 开销相对较大(涉及锁获取、释放、阻塞唤醒) |
适用场景 | 状态标志、安全发布、独立观察 | 需要互斥访问共享资源、需要保证复合操作原子性 |
伪共享
在了解伪共享之前,我们先了解一下几点概念:
- 多核处理器:现代 CPU 通常包含多个独立的处理核心,它们可以并行执行指令。
- 缓存: 由于访问主内存的速度远慢于 CPU 核心的速度,每个核心(或一组核心)都拥有自己较小但速度极快的缓存(L1、L2、L3)。缓存用于存储最近访问过的数据和指令的副本,以减少访问主内存的次数。
- 缓存行: 缓存并不是以单个字节为单位进行管理的。数据在缓存和主内存之间传输的最小单位称为 缓存行。常见的缓存行大小是 64 字节。这意味着当你从内存中读取一个字节时,CPU 实际上会把包含该字节在内的连续 64 个字节(一个缓存行)都加载到缓存中。
- 缓存一致性协议:为了确保多个核心看到的内存视图是一致的(即一个核心写入的数据,其他核心能读取到最新值),CPU 使用复杂的缓存一致性协议(如 MESI)。当一个核心修改了其缓存行中的数据时,它必须通知其他核心该缓存行已无效。其他核心后续如果需要读取该缓存行中的数据,就必须从主存或其他核心的缓存中重新获取最新的副本。这个过程涉及核心间的通信(通常通过总线或片上网络),是有开销的。
伪共享就是在多核处理器环境下,一种严重影响程序性能的隐藏瓶颈。它发生在多个处理器核心看似在访问不同的内存地址,但实际上这些地址位于同一个 缓存行 中,从而导致不必要的缓存一致性协议开销,显著降低程序的并行效率。
Volatile与伪共享
先明确一点:volatile
本身并不会直接导致伪共享。伪共享的核心在于内存布局(变量是否位于同一缓存行)。然而,volatile
放大了伪共享的性能影响。这使得伪共享造成的性能下降在 volatile
变量上表现得尤为明显。
那么为什么Volatile
会涉及到伪共享呢?我们先通过一个案例来看一下具体的现象,假设我们有一个类 Data
,包含两个 volatile
变量 value1
和 value2
,它们被设计为分别由不同的线程独立且频繁地修改。
private static class VolatileData {
volatile long value1; // 可能与value2在同一缓存行
volatile long value2; // 可能与value1在同一缓存行
}
//共享数据实例
private static VolatileData data = new VolatileData();
public static void main(String[] args) throws InterruptedException {
// 创建第一个线程,持续修改value1
Thread t1 = new Thread(() -> {
for (long i = 0; i < 10000000L; i++) {
data.value1 = i;
}
});
// 创建第二个线程,持续修改value2
Thread t2 = new Thread(() -> {
for (long i = 0; i < 10000000L; i++) {
data.value2 = i;
}
});
// 记录开始时间 单位为纳秒
long start = System.nanoTime();
// 启动两个并发线程
t1.start();
t2.start();
// 等待线程执行完成
t1.join();
t2.join();
// 记录结束时间
long end = System.nanoTime();
System.out.println("代码执行耗时: " + (end - start) / 1000000 + " ms");
}
我们运行看看代码执行耗时为多少:
代码执行耗时: 245 ms
然后我们在两个Volatile
变量中间添加无用的填充字段
private static class VolatileData {
volatile long value1; // 可能与value2在同一缓存行
public long p1, p2, p3, p4, p5, p6, p7; // 填充字段,避免伪共享
volatile long value2; // 可能与value1在同一缓存行
}
再次执行,查看代码耗时为多少:
代码执行耗时: 78 ms
从最开始的245ms
减少为 78ms
这是为什么呢?
问题的关键就在于内存布局, Java 对象在内存中有一个对象头(Mark Word + Class Pointer,通常 12 字节或 16 字节)。紧接着对象头之后,对象的实例字段会按照一定的规则(如类型、声明顺序等)连续排列在内存中。而long
类型在 Java 中占 8 个字节假设对象头是 12 字节。 value1
的起始地址可能是:对象起始地址 + 12,而value2
的起始地址可能是:对象起始地址 + 12 + 8 = 对象起始地址 + 20。我们之前提到常见的缓存行大小是 64 字节,那么它很可能从某个64字节对齐的地址开始,覆盖连续的64字节内存区域,极有可能就同时覆盖了这两个变量,也就是value1
与 value2
在同一个缓存行内!(例如,缓存行覆盖地址 [base, base+63],而 value1
在 base+12, value2
在 base+20)。
落在同一个缓存行又会怎么样呢?
问题的关键就在于前面提到的volatile
它的作用之一就是解决内存可见性!它修饰的变量每一次写入都会强制刷新缓存。
同一个缓存行导致的灾难性后果:
-
Thread t1 修改
value1
:value1
被声明为volatile
,Thread t1 所在的 CPU 核心必须将包含value1
的整个缓存行标记为 Modified (M),并将该修改写回主内存。- 同时,它会通过缓存一致性协议,使所有其他 CPU 核心(包括 Thread t2 所在的 CPU 核心)缓存中该缓存行的副本失效
-
Thread t2 修改
value2
:- Thread t2 准备修改
value2
。 - 它发现自己的缓存行副本已失效(Invalid)。
- 它必须重新加载整个缓存行(包含
value1
和value2
)!这需要从主内存或其他核心的缓存中读取,速度慢得多。 - 然后才能修改
value2
,并同样因为value2
也是volatile
,将整个修改后的缓存行(包含value1
和value2
)写回主内存,并再次使其他核心的缓存行失效。
- Thread t2 准备修改
-
恶性循环:
-
性能暴跌: 虽然 Thread t1 和 Thread t2 在逻辑上操作的是完全独立的变量 (
value1
和value2
),但由于它们物理上位于同一个缓存行,并且都是volatile
(强制要求缓存一致性),导致了:- 大量的缓存行无效化消息在核心间传递。
- 大量的缓存未命中(Cache Misses)。
- 频繁的、缓慢的主内存访问。
- CPU 核心花费大量时间在等待缓存同步,而不是执行有用的计算。
- 程序的并行性能急剧下降,甚至可能比单线程执行还要慢很多倍。
我们再看看之前提到的代码
private static class VolatileData {
volatile long value1; // 可能与value2在同一缓存行
public long p1, p2, p3, p4, p5, p6, p7; // 填充字段,避免伪共享
volatile long value2; // 可能与value1在同一缓存行
}
这个方案为什么避免了伪共享问题呢?这是因为在 value1
和 value2
之间添加了7个 long
类型的填充字段(p1
到 p7
,每个占8字节)。假设对象头为12字节,value1
占据接下来的8字节(偏移量12-19),那么填充字段 p1
到 p7
将占据偏移量20到75(共56字节)。这样 value2
将从偏移量76开始,确保它与 value1
(位于偏移量12)相隔超过64字节(76 - 12 = 64),这样让两个变量不再位于同一个缓存行,避免伪共享问题。