在多线程并发编程中,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。即一个线程在修改一个共享变量时,另一个线程可以读到这个修改的值。

volatile的使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

Java语言提供了volatile,在某种情况下比加锁更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的,Java语言保证使用volatile关键字修饰的变量在读取时,必须从主内存中获取最新的值,写入时一定实时写入到主内存(工作内存和主内存是JMM中的抽象概念,不一定实际存在)。

使用volatile

class demo {
    volatile int i = 1;
    public void set(int num) {
        i = num;
    }
    public void inc() {
        i++;
    }
    public void get() {
        return i;
    }
}

使用锁

class demo {
    int i = 1;
    public synchronized void set(int num) {
        i = num;
    }
    public void inc() {
        int tmp = get();
        tmp = tmp + 1;
        set(tmp);
    }
    public synchronized void get() {
        return i;
    }
}

上面的两段代码是等价的,对于get和set操作,使用volatile变量和使用锁的效果是一致的,但是对于inc操作,多线程环境下这样的操作不具有原子性。

volatile底层原理

未优化的缓存一致性协议能够保证并发编程的可见性,但加入了写缓冲器和无效队列之后,必须由编译器在生成的机器码中插入内存屏障才能保证可见性。

内存屏障(memory barriers):一组处理器指令,用于实现对内存操作的顺序限制。

缓存行(cache line):CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行。

缓存一致性协议

为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。

对于单核CPU,通常有两种方式:

  • 通写法(Write Through):每次cache中的内容被修改后立即写入到内存。
  • 写回法(Write Back):cache 中内容被修改后,延迟写入内存。当cache和内存数据不一致时以cache中的数据为准。

对于多核CPU来说,cache与主存的内容同步可能会存在多线程竞争问题,又引入了以下操作:

  • 写失效:当一个CPU修改了数据,其他CPU中的该数据失效。
  • 写更新:当一个CPU修改了数据,通知其他CPU对该数据进行更新。

在CPU层面,提供了两种解决方案:

  • 总线锁:在多CPU情况下,某个CPU对共享变量进行操作时对总线加锁,其他CPU不能对该变量进行读写。
  • 缓存锁:降低了锁的粒度,基于缓存一致性协议。

缓存一致性协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

缓存一致性协议需要满足两种特性:

  1. 写序列化:缓存一致性协议要求总线上任意时间只能出现一个CPU写事件,多核并发的写事件会通过总线仲裁机制转换成串行化的写事件序列。
  2. 写传播:一个处理器的写操作对其他处理器可见。
  • 方式一:嗅探,CPU监听总线上的所有活动。
  • 方式二:基于目录,总线事件仅发送给需要接受的CPU。

通常使用的缓存一致性协议为MESI(Modified Exclusive Shared Invalid),实现了写回法,写失效,缓存行锁,写传播,写序列化和嗅探机制。

四种状态:

  • M: 被修改(Modified)
    当前 CPU 缓存有最新数据, 其他 CPU 拥有失效数据,当前 CPU 数据与内存不一致,但以当前 CPU 数据为准。
  • E: 独享的(Exclusive)
    只有当前 CPU 有数据,其他 CPU 没有该数据,当前 CPU 数据与内存数据一致。
  • S: 共享的(Shared)
    当前 CPU 与其他 CPU 拥有相同数据,并与内存中数据一致。
  • I: 无效的(Invalid)
    当前 CPU 数据失效,其他 CPU 数据可能有可能无,数据应从内存中读取,且当前 CPU 与 内存数据不一致。

处理器对缓存的请求:

  • PrRd:CPU 读操作
  • PrWr:CPU 写操作

总线对缓存的请求:

  • BusRd: 窥探器请求指出其他处理器请求一个缓存块
  • BusRdX: 窥探器请求指出其他处理器请求一个该处理器不拥有的缓存块
  • BusUpgr: 窥探器请求指出其他处理器请求一个该处理器拥有的缓存块
  • Flush: 窥探器请求指出请求回写整个缓存到主存
  • FlushOpt: 窥探器请求指出整个缓存块被发到总线以发送给另外一个处理器(缓存到缓存的复制)

不同状态时,执行不同操作,会产生不同的状态转移。

当前CPU状态为Modified

  • PrRd:直接从缓存中读取数据,无总线事务生成,状态不变。
  • PrWr:直接修改当前 CPU 缓存数据,无总线事务生成,状态不变。

当前状态为Exclusive

  • PrRd:无总线事务生成,状态不变。
  • PrWr:修改当前 CPU 缓存值,无总线事务生成,状态改为 M。

当前状态为Shared

  • PrRd:状态不变,无总线事务生成。
  • PrWr:发出总线事务BusUpgr信号,状态改为M,其他缓存看到BusUpgr信号时标记缓存行为Invalid。

当前CPU状态为Invalid

  • PrRd: CPU 缓存不可用,需要读内存。给总线发出BusRd信号,其他处理器看到BusRd,检查自己是否有失效的数据副本,向发送者回复Response。
  • 如果其他缓存有有效的副本,则状态转换为Shared。
  • 如果其他缓存都没有有效的副本,则从主存读取数据,状态转换为Exclusive。
  • PrWr:当前 CPU 缓存不可用,需要写内存。给总线发出BusRdX信号,状态转换为Modified。
  • 如果其他缓存有有效的副本,则从其中一个缓存中获取数据,并向缓存块中写入修改后的值。
  • 否则,从主存中获取数据。

总线对缓存的请求:

  • BusRd: 窥探器请求指出其他处理器请求一个缓存块
  • BusRdX: 窥探器请求指出其他处理器请求一个该处理器不拥有的缓存块
  • BusUpgr: 窥探器请求指出其他处理器请求一个该处理器拥有的缓存块
  • Flush: 窥探器请求指出请求回写整个缓存到主存
  • FlushOpt: 窥探器请求指出整个缓存块被发到总线以发送给另外一个处理器(缓存到缓存的复制)

不同状态时,执行不同操作,会产生不同的状态转移。

当前CPU状态为Modified

  • PrRd:直接从缓存中读取数据,无总线事务生成,状态不变。
  • PrWr:直接修改当前 CPU 缓存数据,无总线事务生成,状态不变。

当前状态为Exclusive

  • PrRd:无总线事务生成,状态不变。
  • PrWr:修改当前 CPU 缓存值,无总线事务生成,状态改为 M。

当前状态为Shared

  • PrRd:状态不变,无总线事务生成。
  • PrWr:发出总线事务BusUpgr信号,状态改为M,其他缓存看到BusUpgr信号时标记缓存行为Invalid。

当前CPU状态为Invalid

  • PrRd: CPU 缓存不可用,需要读内存。给总线发出BusRd信号,其他处理器看到BusRd,检查自己是否有失效的数据副本,向发送者回复Response。
  • 如果其他缓存有有效的副本,则状态转换为Shared。
  • 如果其他缓存都没有有效的副本,则从主存读取数据,状态转换为Exclusive。
  • PrWr:当前 CPU 缓存不可用,需要写内存。给总线发出BusRdX信号,状态转换为Modified。
  • 如果其他缓存有有效的副本,则从其中一个缓存中获取数据,并向缓存块中写入修改后的值。
  • 否则,从主存中获取数据。

总线操作的状态转化:

初始状态

操作

响应

Invalid

BusRd

状态保持不变,信号忽略

Invalid

BusRdX/BusUpgr

状态保持不变,信号忽略

Exclusive

BusRd

状态变为共享 发出总线FlushOpt信号并发出块的内容

Exclusive

BusRdX

状态变为无效 发出总线FlushOpt信号并发出块的内容

Shared

BusRd

状态变为共享 可能发出总线FlushOpt信号并发出块的内容

Shared

BusRdX

状态变为无效 可能发出总线FlushOpt信号并发出块的内容

Modified

BusRd

状态变为共享 发出总线FlushOpt信号并发出块的内容

Modified

BusRdX

状态变为无效 发出总线FlushOpt信号并发出块的内容

写操作仅在缓存行是 Modified 或 Excluded 状态时可自由执行。如果在共享状态或无效状态,其他缓存都要先把该缓存行置为无效,这种广播操作称作Request For Ownership (RFO)

MESI的问题:

在 MESI 中,依赖总线嗅探机制,整个过程是串行的,可能会发生阻塞。

  1. 若 CPU 发生RFO(让其他 CPU 将缓存修改为 Invalid 状态),首先需要发送一个 Invalidate 消息给到其他缓存了该数据的 CPU,随后阻塞并等待其他 CPU 的 ACK。
  2. 对于 CPU 收到总线的读信号,需要失效缓存。当其高速缓存压力很大时,要求实时的处理失效事件也存在一定的困难,会有一定的延迟。

为了解决MESI中的处理器等待问题,引入了写缓冲区和失效队列。

写缓冲区与失效队列

写缓冲区Store Buffer

写缓冲区是每个 CPU 私有的一块比高速缓存还小的存储部件,当使用了写缓冲区后,每当发生CPU的写操作(需要其他 CPU 将缓存无效化时),当前 CPU 不再阻塞地等待其他 CPU 的确认回执,而是直接将更新的值写入写缓冲区,然后继续执行后续指令,随后在某个时刻异步将数据写入到 cache 中,并将状态更新为 M 。

存储转发Store Forwarding

在进行 LR 时,CPU 会先在写缓冲区中查询记录是否存在,如果存在则会从写缓冲区中直接获取。

写缓冲区帮助处理器实现了异步写数据的能力,使得处理器处理指令的能力大大提升。

失效队列

失效队列也是每个 CPU 私有的,使用失效队列后,发生总线读事务时对应的 CPU 缓存不再同步地失效缓存并发送确认回执,而是将失效消息放入失效队列,立即发送ACK,随后在 CPU 空闲时异步将 cache 行置为 Invalid 状态。

失效队列解决了删除数据等待的问题。

写缓冲区和失效队列虽然解决了缓冲一致性协议执行时,由于总线事务导致的CPU等待问题,但又导致了内存系统重排序(伪重排序)和可见性问题。

由于写缓冲器和无效化队列的出现,处理器对cache的写入都变成了异步操作,且写缓冲和失效队列中的数据可能以任意顺序刷新到主存中。

例如:

可见性问题

CPU1 更新变量到写缓冲器中,而 CPU2 在收到 Invalidate 消息后,回复ACK,并向无效化队列写入一条无效化缓存的消息,当 CPU2 还未消费无效化队列的信息,并读取变量时,读到的依然是旧值;或者 CPU1 在未收到全部 ACK,修改的数据仍位于写缓冲中时,CPU3 进行读操作,读到的仍然是旧值。

伪重排序问题

Store-Load重排序,对于代码:

int a = 10;
int b = 2;
void f() {
    a = 20;
    int c = b;
}

当 CPU 将对变量 a 和 c 的写入记录到写缓冲中,且 c 变量先于 a 变量从写缓冲中刷新到 cache ,则会导致代码的执行顺序看起来变为了 2 -> 1,而这是由于写缓冲区写入 cache 顺序的随机性导致的,有的观点也将这种由于可见性导致的重排序称为内存系统重排序。

处理器在写缓冲器满、I/O指令被执行时会将写缓冲器中的内容写入高速缓存中。但从变量更新角度来看,处理器本身无法保障这种更新的”及时“性。为了保证处理器对共享变量的更新可被其他处理器同步,编译器等底层系统借助一类称为内存屏障的特殊指令来实现。

内存屏障

内存屏障(英语:Memory barrier),也称内存栅栏内存栅障屏障指令等,是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在memory barrier 之前的指令和memory barrier之后的指令不会由于系统优化等原因而导致乱序。

大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。

内存屏障 (Memory Barrier)分为写屏障(Store Barrier)读屏障(Load Barrier)全屏障(Full Barrier),其作用有两个:

  1. 防止指令之间的重排序:写屏障会禁止屏障前后的写指令的重排序,读屏障会禁止屏障前后的读指令的重排序,全屏障会禁止屏障前后的读写指令之间的重排序。
  2. 保证数据的可见性:写屏障会阻塞直到 Store Buffer 中的数据刷新到主存中读屏障会阻塞直到 Invalid Queue 中的消息执行完毕。

内存屏障的一种实现:

  • 当CPU收到屏障指令时,不将屏障指令放入序列缓冲区,而将屏障指令及后续所有指令放入一个FIFO队列中(指令是按批发送的,不然没有乱序的必要)
  • 允许乱序执行完序列缓冲区中的所有指令
  • 从FIFO队列中取出屏障指令,执行(并刷新缓存等,实现内存可见性的语义)
  • 将FIFO队列中的剩余指令放入序列缓冲区
  • 恢复正常的乱序执行

Java中的内存屏障

屏障类型

指令示例

说明

LoadLoad Barriers

Load1;LoadLoad;Load2

该屏障确保Load1先于Load2及其后所有 load 指令完成,且屏障前的 Load 指令不会排序到屏障后。

StoreStore Barriers

Store1;StoreStore;Store2

该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作,且屏障前的 Store 指令不会被排序到屏障后。

LoadStore Barriers

Load1;LoadStore;Store2

Ensures that Load1 completes before Store2 and any subsequent store operations. Loads before Load1 may not float below Store2 and any subsequent store operations.

StoreLoad Barriers

Store1;StoreLoad;Load2

Ensures that Store1 completes before Load2 and any subsequent load operations. Stores before Store1 may not float below Load2 and any subsequent load operations.

StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障mfence,memory fence or full barrier),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。

不同的CPU架构对内存屏障的实现也不同。

如果硬件架构本身已经保证了内存可见性(如单核处理器、一致性足够的内存模型等),或者硬件架构本身不进行处理器重排序、有更强的重排序语义(能够分析多核间的数据依赖)、或在单核处理器上重排序,那么volatile就是一个空标记,不会插入相关语义的内存屏障。

在具体实现上,x86架构下,LoadLoadLoadStoreStoreStore三种屏障是空操作(由架构本身保证相关指令不进行重排序),StoreLoad通过lock前缀指令实现的,在x86架构下HotSpot虚拟机中 volatile 的底层实现是在变量赋值指令前加上 lock 前缀。

volatile内存语义的增强

为了让volatile的读写具有和锁的获取与释放相同的效果,JSR对volatile的语义进行了增强,在编译器层面,对volatile变量的读写与普通变量的读写重排序规则做出了一定的限制,与volatile在内存屏障上的优化结合,实现了对对某个未被锁保护的变量的访问操作进行排序。

volatile变量需要维护的特性:

  • 可见性,每次读 volatile 变量总能读到它最新值,即最后一个对它的写入操作,不管这个写入是不是当前线程完成的。
  • 禁止指令重排,也即维护 happens-before 关系,对 volatile 变量的写入不能重排到写入之前的操作之前,从而保证别的线程看到写入值后就能知道写入之前的操作都已经发生过;对 volatile 的读取操作一定不能被重排到后续操作之后,比如我需要读 volatile变量后根据读到的值做一些事情,做这些事情如果重排到了读 volatile 之前,则相当于没有满足读 volatile 需要读到最新值的要求,因为后续这些事情是根据一个旧 volatile 值做的。

是否能重排序

第二个操作

第一个操作

普通读/写

volatile 读

volatile 写

普通读/写

NO

volatile 读

NO

NO

NO

volatile 写

NO

NO

volatile变量的内存屏障:

  • 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
  • 在每个volatile读操作后插入LoadLoadLoadStore屏障;

volatile的内存屏障策略非常严格保守,保证了线程可见性。

例如,对于双重检查锁定与延迟初始化模型:

public class Singleton1_3 {
  private static volatile Singleton1_3 singleton = null;
  public int f1 = 1;   // 触发部分初始化问题
  public int f2 = 2;
  private Singleton1_3() {
  }
  public static Singleton1_3 getInstance() {
    if (singleton == null) {
      synchronized (Singleton1_3.class) {
        // must be a complete instance
        if (singleton == null) {
          singleton = new Singleton1_3();
        }
      }
    }
    return singleton;
  }
}

通过增强的volatile语义,new关键字,禁止对象初始化与实例引用赋值两个操作之间的重排序,能够防止其他线程在某个线程已经进入临界区时,获取到值为null的引用变量。

volatile不保证原子性

volatile可以保证单个变量的读/写具有原子性,但不保证一个代码块的原子性。

volatile方式的i++,总共是四个步骤:load、Increment、store、Memory Barriers。

内存屏障是线程安全的,但是内存屏障之前的指令并不是。在某一时刻线程1将 i 的值load取出来,放置到CPU缓存中,然后再将此值放置到寄存器A中,然后A中的值自增1(寄存器A中保存的是中间值,没有直接修改i,因此其他线程并不会获取到这个自增1的值)。如果在此时线程2也执行同样的操作,获取值i=10,自增1变为11,然后马上刷入主内存。此时由于线程2修改了i的值,实时的线程1中的i=10的值缓存失效,重新从主内存中读取,变为11。接下来线程1恢复。将自增过后的A寄存器值11赋值给CPU缓存i。这样就出现了线程安全问题。

总结

  1. 为了解决CPU与主存速度的不匹配,引入了cache,但导致了缓存一致性问题。
  2. 为了解决缓存一致性问题,引入了MESI(缓存一致性协议),但总线事务等待其他CPU的响应导致了CPU等待的问题
  3. 引入了写缓冲队列和无效化队列,但导致了重排序和一致性问题
  4. 引入内存屏障解决重排序与一致性问题。

在逻辑层面上,Java内存模型规定了所有的变量都存储在主内存中,每条线程有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况,Java中的volatile关键字提供了一个功能,被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用前都从主内存刷新。

工作内存和主内存都是抽象的概念,具体实现由JVM决定。

JVM在处理volatile关键字时,首先对Java编译器重排序做出一定限制,再使用内存屏障对CPU重排序做出一定限制,最后通过MESI协议的支持,实现volatile的功能。

参考维基百科及部分博客。