关于并发关键字Volatile

volatile通常被比喻成"轻量级的synchronized",也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。

Javavolatile关键字用于标记一个变量 “应当存储在主存” 。更确切地说,每次读取volatile变量,都应该从主存读取,而不是从CPU缓存读取。每次写入一个volatile变量,应该写到主存中,而不是仅仅写到CPU缓存。

实际上,从Java 5开始,volatile关键字除了保证volatile变量从主存读写外,还提供了更多的保障。

变量可见性问题

Javavolatile关键字能保证变量修改后,对各个线程是可见的。这个听起来有些抽象,下面就详细说明。

在一个多线程的应用中,线程在操作非volatile变量时,出于性能考虑,每个线程可能会将变量从主存拷贝到CPU缓存中。如果你的计算机有多个CPU,每个线程可能会在不同的CPU中运行。这意味着,每个线程都有可能会把变量拷贝到各自CPU的缓存中,如下图所示:

对于非volatile变量,JVM并不保证会从主存中读取数据到CPU缓存,或者将CPU缓存中的数据写到主存中。这会引起一些问题。试想一下,如果有两个以上的线程访问一个共享对象,这个共享对象包含一个counter变量,下面是代码示例:

1
2
3
public class SharedObject {
public int counter = 0;
}

如果只有线程1修改了(自增)counter变量,而线程1和线程2两个线程都会在某些时刻读取counter变量。

如果counter变量没有声明成volatile,则counter的值不保证会从CPU缓存写回到主存中。也就是说,CPU缓存和主存中的counter变量值并不一致,如下图所示:

这就是“可见性”问题,线程看不到变量最新的值,因为其他线程还没有将变量值从CPU缓存写回到主存。一个线程中的修改对另外的线程是不可见的。这其实是非常常见的一类问题。

volatile可见性保证

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

Javavolatile关键字就是设计用来解决变量可见性问题。将counter变量声明为volatile,则在写入counter变量时,也会同时将变量值写入到主存中。同样的,在读取counter变量值时,也会直接从主存中读取。

下面的代码演示了如果将counter声明为volatile

1
2
3
public class SharedObject {
public volatile int counter = 0;
}

将一个变量声明为volatile,可以保证变量写入时对其他线程的可见。

在上面的场景中,一个线程(T1)修改了counter,另一个线程(T2)读取了counter(但没有修改它),将counter变量声明为volatile,就能保证写入counter变量后,对T2是可见的。

然而,如果T1和T2都修改了counter的值,只是将counter声明为volatile还远远不够,后面会有更多的说明。

完整的volatile可见性保证

实际上,volatile的可见性保证并不是只对于volatile变量本身那么简单。可见性保证遵循以下规则:

  • 如果线程A写入一个volatile变量,线程B随后读取了同样的volatile变量,则线程A在写入volatile变量之前的所有可见的变量值,在线程B读取volatile变量后也同样是可见的。
  • 如果线程A读取一个volatile变量,那么线程A中所有可见的变量也会同样从主存重新读取。

下面用一段代码来示例说明:

1
2
3
4
5
6
7
8
9
10
11
public class MyClass {
private int years;
private int months
private volatile int days;

public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}

update()方法写入3个变量,其中只有days变量是volatile

完整的volatile可见性保证意味着,在写入days变量时,线程中所有可见变量也会写入到主存。也就是说,写入days变量时,yearsmonths也会同时被写入到主存。

下面的代码读取了yearsmonthsdays变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MyClass {
private int years;
private int months
private volatile int days;

public int totalDays() {
int total = this.days;
total += months * 30;
total += years * 365;
return total;
}

public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}

请注意totalDays()方法开始读取days变量值到total变量。在读取days变量值时,monthsyears的值也会同时从主存读取。因此,按上面所示的顺序读取时,可以保证读取到daysmonthsyears变量的最新值。

注:可以将对volatile变量的读写理解为一个触发刷新的操作写入volatile变量时,线程中的所有变量也都会触发写入主存。而读取volatile变量时,也同样会触发线程中所有变量从主存中重新读取。因此**,应当尽量将volatile的写入操作放在最后,而将volatile的读取放在最前,这样就能连带将其他变量也进行刷新**。上面的例子中,update()方法对days的赋值就是放在years、months之后,就是保证years、months也能将最新的值写入到主存,如果是放在两个变量之前,则days会写入主存,而years、months则不会。反过来,totalDays()方法则将days的读取放在最前面,就是为了能同时触发刷新years、months变量值,如果是放后面,则years、months就可能还是从CPU缓存中读取值,而不是从主存中获取最新值。

更深入地

其实,volatile对于可见性的实现,内存屏障起着至关重要的作用。因为内存屏障相当于一个数据同步点,他要保证在这个同步点之后的读写操作必须在这个点之前的读写操作都执行完之后才可以执行。并且在遇到内存屏障的时候,缓存数据会和主存进行同步,或者把缓存数据写入主存、或者从主存把数据读取到缓存。

我们知道内存一致性模型的实现可以通过缓存一致性协议来实现。同时,留了一个问题:已经有了缓存一致性协议,为什么还需要volatile

这个问题的答案可以从多个方面来回答:

1、并不是所有的硬件架构都提供了相同的一致性保证,Java作为一门跨平台语言,JVM需要提供一个统一的语义。

2、操作系统中的缓存和JVM中线程的本地内存并不是一回事,通常我们可以认为:MESI可以解决缓存层面的可见性问题。使用volatile关键字,可以解决JVM层面的可见性问题。

3、缓存可见性问题的延伸:由于传统的MESI协议的执行成本比较大。所以CPU通过Store Buffer和Invalidate Queue组件来解决,但是由于这两个组件的引入,也导致缓存和主存之间的通信并不是实时的。也就是说,缓存一致性模型只能保证缓存变更可以保证其他缓存也跟着改变,但是不能保证立刻、马上执行。

  • 其实,在计算机内存模型中,也是使用内存屏障来解决缓存的可见性问题的(再次强调:缓存可见性和并发编程中的可见性可以互相类比,但是他们并不是一回事儿)。写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列)。进而避免由于Store Buffer和Invalidate Queue的非实时性带来的问题。

所以,内存屏障也是保证可见性的重要手段,操作系统通过内存屏障保证缓存间的可见性,JVM通过给volatile变量加入内存屏障保证线程之间的可见性。

内存模型是怎么解决缓存一致性问题的?

volatile的原理

为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。

但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议

缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

指令重排问题

出于性能考虑,JVM和CPU是允许对程序中的指令进行重排的,只要保证(重排后的)指令语义一致即可。如下代码为例:

1
2
3
4
5
int a = 1;
int b = 2;

a++;
b++;

这些指令可以按以下顺序重排,而不改变程序的语义:

1
2
3
4
5
int a = 1;
a++;

int b = 2;
b++;

然而,指令重排面临的一个问题就是对volatile变量的处理。还是以前面提到的MyClass类来说明:

1
2
3
4
5
6
7
8
9
10
11
12
public class MyClass {
private int years;
private int months
private volatile int days;


public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}

一旦update()变量写了days值,则years、months的最新值也会写入到主存。但是,如果JVM重排了指令,比如按以下方式重排:

1
2
3
4
5
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}

days被修改时,months、years的值也会写入到主存,但这时进行写入,months、years并不是新的值(注:即在months、years被赋新值之前,就触发了这两个变量值写入主存的操作,自然这两个变量在主存中的值就不是新值)。新的值自然对其他线程是不可见的。指令重排导致了程序语义的改变。

Java volatile Happens-Before保证

为了解决指令重排的问题,Javavolatile关键字在可见性之外,又提供了happends-before保证。happens-before原则如下:

  • **如果有读写操作发生在写入volatile变量之前,读写其他变量的指令不能重排到写入volatile变量之后。**写入一个volatile变量之前的读写操作,对volatile变量是有happens-before保证的。注意,**如果是写入volatile之后,有读写其他变量的操作,那么这些操作指令是有可能被重排到写入volatile操作指令之前的。但反之则不成立。**即可以把位于写入volatile操作指令之后的其他指令移到写入volatile操作指令之前,而不能把位于写入volatile操作指令之前的其他指令移到写入volatile操作指令之后。
  • **如果有读写操作发生在读取volatile变量之后,读写其他变量的指令不能重排到读取volatile变量之前。**注意,如果是读取volatile之前,有读取其他变量的操作,那么这些操作指令是有可能被重排到读取volatile操作指令之后的。但反之则不成立。即可以把位于读取volatile操作指令之前的指令移到读取volatile操作指令之后,而不能把位于读取volatile操作指令之后的指令移到读取volatile操作指令之前。
  • 挺绕的,反正会者不难,难者不会。

以上的happens-before原则为volatile关键字的可见性提供了强制保证。

注:这两个原则读起来有些拗口(当然翻译也不足够好),其实就是不管JVM怎么去禁止/允许某些情况下的指令重排,最终就是保证“完整的volatile可见性保证”的那种效果,所以,只要理解了“完整的volatile可见性保证”的效果就足够了。

具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
    • 对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
    • 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
    • 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。
    • 对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

volatile原子性问题

虽然volatile关键字能保证volatile变量的所有读取都是直接从主存读取,所有写入都是直接写入到主存中,但在一些情形下,仅仅是将变量声明为volatile还是远远不够的。

就像前面示例所说的,线程1写入共享变量counter的值,将counter声明为volatile已经足够保证线程2总是能获取到最新的值。

事实上,多个线程都能写入共享的volatile变量,主存中也能存储正确的变量值,然而这有一个前提,变量新值的写入不能依赖于变量的旧值。换句话说,就是一个线程写入一个共享volatile变量值时,不需要先读取变量值,然后以此来计算出新的值。

如果线程需要先读取一个volatile变量的值,以此来计算出一个新的值,那么volatile变量就不足够保证正确的可见性。(线程间)读写volatile变量的时间间隔很短,这将导致一个竞态条件,多个线程同时读取了volatile变量相同的值,然后以此计算出了新的值,这时各个线程往主存中写回值,则会互相覆盖。

多个线程对counter变量进行自增操作就是这样的情形。

设想一下,如果线程1将共享变量counter的值0读取到它的CPU缓存,然后自增为1,而还没有将新值写回到主存。线程2这时从主存中读取的counter值依然是0,依然放到它自身的CPU缓存中,然后同样将counter值自增为1,同样也还没有将新值写回到主存。如下图所示:

img

从实际的情况来看,线程1和线程2现在就是不同步的。共享变量counter正确的值应该是2,但各个线程中CPU缓存的值都是1,而主存中的值依然是0。这是很混乱的。即使线程最终将共享变量counter的值写回到主存,那值也明显是错的。

所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。为了保证原子性,需要通过字节码指令monitorentermonitorexit,但是volatile和这两个指令之间是没有任何关系的。

所以,volatile是不能保证原子性的。

在以下两个场景中可以使用volatile来代替synchronized

1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。

2、变量不需要与其他状态变量共同参与不变约束。

除以上场景外,都需要使用其他方式来保证原子性,如synchronized或者concurrent包

volatile的性能考量

读写volatile变量会导致变量从主存读写。从主存读写比从CPU缓存读写更加“昂贵”。访问一个volatile变量同样会禁止指令重排,而指令重排是一种提升性能的技术。因此,你应当只在需要保证变量可见性的情况下,才使用volatile变量。

参考

https://ifeve.com/java-volatile关键字/

https://juejin.cn/post/6844903656274264078

http://www.hollischuang.com/archives/2673