Volatile的隐藏陷阱:伪共享如何拖垮你的并发性能

Volatile与伪共享问题

今天上班坐地铁路上刷到一篇文章,内容是使用Volatile 遇到伪共享问题导致性能大幅下降。感觉讲的不是很清楚,决定自己研究一下为什么会出现性能大幅下降的问题。

Volatile

首先简单介绍一下什么是VolatileVolatile 就是Java的一个关键字,够简单吧!

Volatile 主要用于解决两个关键问题:

  1. 内存可见性问题:一个线程修改了共享变量的值,其他线程能够立即看到最新的值。
  2. 指令重排序问题: 编译器和处理器为了优化性能,可能会对指令的执行顺序进行重新排序(只要不影响单线程执行结果)。volatile 可以限制这种重排序,保证特定顺序。

那么为什么要解决这两个问题呢?

这是因为现代的计算机架构为了提高性能,采用多级缓存机制也就是CPU Cache。每个 CPU 核心通常有自己的缓存。当一个线程读取一个共享变量时,它可能会从自己的缓存中读取(如果缓存中存在该数据),而不是每次都从主内存(RAM)读取。同样,写入操作也可能先写入缓存,稍后才刷新到主内存。当面临多线程情况下就会出现内存可见性问题:

  • 线程 A 修改了共享变量 flag 的值(比如在自己的缓存中将其设为 true)。
  • 线程 B 随后读取 flag 的值(可能从自己的缓存中读取,而该缓存里还是旧的 false 值)。
  • 结果:线程 B 没有看到线程 A 的最新修改,导致程序逻辑错误。

volatile 如何解决内存可见性?

当一个变量被声明为 volatile

  1. 写操作: 任何线程对该变量的写操作都会立即刷新到主内存中。写操作完成后,该变量在其他 CPU 核心缓存中的副本会 失效
  2. 读操作: 任何线程对该变量的读操作都会直接从主内存中读取(或者从最新更新的缓存中读取),确保获取的是最新值。

简单来说,volatile 强制所有读写都直接作用于主内存(或者保证缓存一致性协议被严格遵守如 MESI ),绕过了线程工作内存(缓存)可能带来的延迟和过期问题,从而保证了修改对所有线程的立即可见性。

volatile 如何解决指令重排序?

编译器和处理器会在不改变 单线程程序语义 的前提下对指令进行重排序优化。但在多线程环境下,这种重排序可能导致意想不到的结果。

volatile 通过插入 **内存屏障 **来限制编译器和处理器的重排序:

  • 写屏障:volatile 写操作之后插入。确保该写操作之前的所有写操作(无论是否 volatile)都已经刷新到主内存,并且该写操作本身不能被重排序到这些操作之前。
  • 读屏障:volatile 读操作之前插入。确保该读操作之后的所有读/写操作都不能被重排序到这个读操作之前。同时,它会强制处理器从主内存(或有效缓存)加载该 volatile 变量的最新值。

这些屏障保证了以下顺序:

  1. 当线程 A 写入一个 volatile 变量时,所有在写入操作之前对其他变量(无论是否 volatile)的修改,对于随后读取这个 volatile 变量的线程 B 来说,都是可见的。
  2. 当线程 B 读取一个 volatile 变量时,它能看到线程 A 在写入该 volatile 变量之前所做的所有修改(得益于写屏障保证了之前的修改都刷新了,读屏障保证了 B 能看到这些刷新)。

volatile 的特性总结:

  1. 可见性: 保证一个线程对 volatile 变量的修改能 立即 被其他线程看到。这是它最主要的作用。
  2. 原子性:仅限于对 volatile 变量本身的单次读/写操作是原子的。例如,读取一个 volatile longvolatile double 在 32 位机器上是原子的(标准要求)。但 volatile 不能 保证复合操作的原子性,比如 i++(它包含读、改、写三步操作)。
  3. 禁止指令重排序(部分):通过内存屏障限制编译器和处理器的优化,保证 volatile 变量操作相对于其他操作的有序性。

volatile 的局限性 (什么情况下不能用 volatile)

  • 非原子复合操作: volatile 无法保证像 i++count += 5 这类读-改-写操作的原子性。这类操作在多线程环境下仍然需要同步(如 synchronized)或使用 java.util.concurrent.atomic 包中的原子类(如 AtomicInteger)。
  • 多个变量的约束: 如果一个操作的正确性依赖于多个共享变量之间的逻辑关系(例如 low < up),仅仅将这些变量声明为 volatile 是不够的。因为 volatile 保证了单个变量的可见性和单个操作的原子性,但不能保证涉及多个变量的操作的原子性或一致性。这种情况下通常需要锁 (synchronized) 或其他的并发控制机制。

volatile vs synchronized

特性volatilesynchronized
作用域变量代码块或方法
可见性保证该变量的可见性保证进入同步块/方法前能看到之前释放锁时的所有修改;退出时保证修改刷新到主存
原子性仅保证对该变量单次读写的原子性保证同步块内所有语句的原子性执行
互斥性有 (同一时间只有一个线程能持有锁)
阻塞/上下文切换不会引起线程阻塞获取锁失败会引起线程阻塞,涉及上下文切换
性能通常开销很小(读接近普通变量,写稍慢)开销相对较大(涉及锁获取、释放、阻塞唤醒)
适用场景状态标志、安全发布、独立观察需要互斥访问共享资源、需要保证复合操作原子性

伪共享

在了解伪共享之前,我们先了解一下几点概念:

  1. 多核处理器:现代 CPU 通常包含多个独立的处理核心,它们可以并行执行指令。
  2. 缓存: 由于访问主内存的速度远慢于 CPU 核心的速度,每个核心(或一组核心)都拥有自己较小但速度极快的缓存(L1、L2、L3)。缓存用于存储最近访问过的数据和指令的副本,以减少访问主内存的次数。
  3. 缓存行: 缓存并不是以单个字节为单位进行管理的。数据在缓存和主内存之间传输的最小单位称为 缓存行。常见的缓存行大小是 64 字节。这意味着当你从内存中读取一个字节时,CPU 实际上会把包含该字节在内的连续 64 个字节(一个缓存行)都加载到缓存中。
  4. 缓存一致性协议:为了确保多个核心看到的内存视图是一致的(即一个核心写入的数据,其他核心能读取到最新值),CPU 使用复杂的缓存一致性协议(如 MESI)。当一个核心修改了其缓存行中的数据时,它必须通知其他核心该缓存行已无效。其他核心后续如果需要读取该缓存行中的数据,就必须从主存或其他核心的缓存中重新获取最新的副本。这个过程涉及核心间的通信(通常通过总线或片上网络),是有开销的。

伪共享就是在多核处理器环境下,一种严重影响程序性能的隐藏瓶颈。它发生在多个处理器核心看似在访问不同的内存地址,但实际上这些地址位于同一个 缓存行 中,从而导致不必要的缓存一致性协议开销,显著降低程序的并行效率。

Volatile与伪共享

先明确一点:volatile 本身并不会直接导致伪共享。伪共享的核心在于内存布局(变量是否位于同一缓存行)。然而,volatile 放大了伪共享的性能影响。这使得伪共享造成的性能下降在 volatile 变量上表现得尤为明显。

那么为什么Volatile会涉及到伪共享呢?我们先通过一个案例来看一下具体的现象,假设我们有一个类 Data,包含两个 volatile 变量 value1value2,它们被设计为分别由不同的线程独立且频繁地修改。

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字节内存区域,极有可能就同时覆盖了这两个变量,也就是value1value2 在同一个缓存行内!(例如,缓存行覆盖地址 [base, base+63],而 value1 在 base+12, value2 在 base+20)。

落在同一个缓存行又会怎么样呢?

问题的关键就在于前面提到的volatile 它的作用之一就是解决内存可见性!它修饰的变量每一次写入都会强制刷新缓存。

同一个缓存行导致的灾难性后果:

  1. Thread t1 修改 value1

    • value1 被声明为 volatile,Thread t1 所在的 CPU 核心必须将包含 value1整个缓存行标记为 Modified (M),并将该修改写回主内存。
    • 同时,它会通过缓存一致性协议,使所有其他 CPU 核心(包括 Thread t2 所在的 CPU 核心)缓存中该缓存行的副本失效
  2. Thread t2 修改 value2

    • Thread t2 准备修改 value2
    • 它发现自己的缓存行副本已失效(Invalid)。
    • 它必须重新加载整个缓存行(包含 value1value2)!这需要从主内存或其他核心的缓存中读取,速度慢得多。
    • 然后才能修改 value2,并同样因为 value2 也是 volatile,将整个修改后的缓存行(包含 value1value2)写回主内存,并再次使其他核心的缓存行失效。
  3. 恶性循环:

在这里插入图片描述

  1. 性能暴跌: 虽然 Thread t1 和 Thread t2 在逻辑上操作的是完全独立的变量 ( value1value2),但由于它们物理上位于同一个缓存行,并且都是 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在同一缓存行
}

这个方案为什么避免了伪共享问题呢?这是因为在 value1value2 之间添加了7个 long 类型的填充字段(p1p7,每个占8字节)。假设对象头为12字节,value1 占据接下来的8字节(偏移量12-19),那么填充字段 p1p7 将占据偏移量20到75(共56字节)。这样 value2 将从偏移量76开始,确保它与 value1(位于偏移量12)相隔超过64字节(76 - 12 = 64),这样让两个变量不再位于同一个缓存行,避免伪共享问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值