Java Volatile Keyword
Java volatitle
关键字用来标记变量”被存储在主存里”,更精确的说,volatitle
变量的每一次读取都需要从计算机的主存读取而不是CPU Cache
,而每一次写入都是直接写入主存,而不是CPU Cache
。从Java 5开始volatitle
关键字不仅仅是保证写入主存和从主存读取。
变量可见性问题
Java volatitle
保证了变量跨线程的可见性,这么说可能有点抽象,so let me elaborate。
在线程操作non-volatile
变量的多线程应用中,由于性能原因每个线程会从主存中拷贝变量到CPU Cache(each thread may copy variables from main memory into a CPU cache while working on them)。如果你的计算机是多CPU的,每个线程可能跑在不同的CPU上,这意味着每个线程会拷贝变量到不同CPU的Cache中,就像这样:
对于JVM来说,从主存读取值到CPU Cache和从CPU Cache写入数据到主存,non-volatile variables
没有上述的保证,会引出一些问题。
设想一种情况,两个或更多线程可以访问包含一个计数器变量的共享对象,声明如下:1
2
3
4
5public class SharedObject {
public int counter = 0;
}
再想象一下,只有Thread 1 increments the counter
variable,但是Thread 1 和Thread 2都会一遍一遍的读取counter
变量。如果counter
变量没有volatitle
修饰,那么就不保证counter
变量的值何时从CPU Cache写入主存。这就意味着在CPU Cache中counter
的值与主存里的值可能不一样,就像这样:
这个问题就是线程不能看到变量的最新值,因为在另一个线程中没有将变量最新值写入到主存中,这个问题叫做可见性
问题。一个线程的更新对另一个线程不可见。
Java volatitle可见性保证
Java关键字volatitle
旨在解决变量可见性问题。定义了counter
变量volatitle
后线程的写入操作会立刻写入到主存,同时所有的读取都是直接从主存读取。下面就是volatitle
定义counter
变量:1
2
3
4
5public class SharedObject {
public volatile int counter = 0;
}
定义了volatitle
后就保证了变量的可见性,在上述的情况下,线程1修改counter
,线程2读取counter
,对于线程2来说它能够见到线程1的最新修改。但是当两个线程都要修改counter
的时候,定义volatitle
显然不够了。
Full volatile Visibility Guarantee
Full保证如下:
- 如果线程A写入一个
volatitle
变量,并且线程B随后读取相同的volatitle
变量,则在写入volatitle
变量之前,线程A可见的所有变量在线程B读取volatitle
变量后也将可见。 - 如果线程A读取一个
volatitle
变量,则读取volatitle
变量时线程A可见的所有变量也将从主内存中重新读取。可以看到,这个开销是比较恐怖的,首先直接读主存和写主存原本就很慢,慢到几十倍甚至几百倍,同时一个volatitle变量读取会带动其他所有volatitle重新读取,大大降低了性能。
来看一个例子:1
2
3
4
5
6
7
8
9
10
11
12public 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
是volatitle
,Full volatitle保证意味着,当写入days
时,所有的变量都会写入主存。在这个例子中就是,当我们要写入days
时,years
和months
也要写入主存。当要读取years
、months
和days
时,你可以像这样做:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public 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
值时,years
和months
也会从主存中读取,因此你可以通过上面的读取步骤读到所有变量的最新值。
指令重排
为了性能,Java VM和CPU是允许重排程序中的指令的,只要语义含义保持不变。举个例子,看看下面的指令:1
2
3
4
5int a = 1;
int b = 2;
a++;
b++;
在不丢失语义含义的情况下,上述指令可能被重排成下列指令序列:1
2
3
4
5int a = 1;
a++;
int b = 2;
b++;
但对于volatitle
变量来说,指令重排是一个挑战,让我们来看一下上述MyClass的例子1
2
3
4
5
6
7
8
9
10
11
12public 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;
}
}
当写入days
时,也会同时写入years
和months
到主存。但是如果Java VM重排指令如下:1
2
3
4
5public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
当days
修改时,months
和years
也会写入主存,但是这次days
写入发生在years
和months
之前,因此这个新值对其他线程不可见,这个语义含义已经被改变了。
特别注意,在写入时,把
volatitle
变量写入放在最后,这样当运行到volatitle
变量写入时,会同时把其他非volatitle
写入主存,但当volatitle
写在最开始时,后面的非volatitle
便不会写入最新值。读取相反,将volatitle
读取放在最前,线程会读取所有值最新的主存值,如果放在最后面,那么前面读取的变量不是从主存直接读取的。
Happens-Before
为了解决指令重组问题,Java volatitle
关键字除了可见性保证,还给了一个happens-before
保证,happens-before
保证如下:
- 如果读写发生在写入
volatitle
变量之前,那么读写其他变量的指令不能被重组到写入volatitle
变量之后。写入volatitle
变量之前的读写被保证happens before
写volatitle
变量。需要注意,写入volatitle
变量之后的读写其他变量仍然有可能被重组到写入volatitle
变量之前。因此From after to before is allowed, but from before to after is not allowed(volatitle
写操作之后的指令被重组到写之前是允许的,而写操作之前的指令不允许被重组到写之后) - 读
volatitle
变量之后的读写操作不能被重组到volatitle
之前,但同样注意发生在读volatitle
之前的指令可以重组到读volatitle
之后。因此From before to after is allowed, but from after to before is not allowed(volatitle
读之前的指令被重组到读之后值允许的,而读操作之后的指令不允许被重组到读值前)
Reads from and writes to other variables cannot be reordered to occur after a write to a volatile variable, if the reads / writes originally occurred before the write to the volatile variable.
The reads / writes before a write to a volatile variable are guaranteed to “happen before” the write to the volatile variable. Notice that it is still possible for e.g. reads / writes of other variables located after a write to a volatile to be reordered to occur before that write to the volatile. Just not the other way around. From after to before is allowed, but from before to after is not allowedReads from and writes to other variables cannot be reordered to occur before a read of a volatile variable, if the reads / writes originally occurred after the read of the volatile variable. Notice that it is possible for reads of other variables that occur before the read of a volatile variable can be reordered to occur after the read of the volatile. Just not the other way around. From before to after is allowed, but from after to before is not allowed
重点解释一下这两个保证:
对于volatitle
写来说,如果写之前的指令被重组到写之后,那么当执行到volatitle
时,最新值就不能被立刻写入主存;但写之后的指令被重组到写之前是没有影响的,因为写之后的指令最终还是要被写到主存,只是重组之后提前将新值写入主存。
对于volatitle
读来说,如果读之后的指令被重组到读之前,那么指令没有获取最新值,因为只有到volatitle
读执行时才重读所有变量的主存值,比如当years
读取发生volatitle days
之后,那么执行到volatitle days
会刷新years
的值,但years
被重组到volatitle days
之前,years
的值没有刷新;相反,如果读取years
在volatitle days
之前,被重组到之后,它会读到最新值,这正是我们希望的结果。
volatile is Not Always Enough
当多个线程都需要对一个volatitle
变量进行写入,且写入之前需要依赖volatitle
之前的值,这时候就会出现竞争条件:
想象一下,如果线程1读取一个shared counter
值到自己CPU Cache中,开始值为0,然后increment
该值为1,但没有把改变的值写入主存;然后线程2也从主存中读取counter
,由于线程1没有将改变的值写入主存,此时线程2读到的值为0,它也对值进行increment
后值变为1,此时线程2也没有将值写入主存,就像这样:
线程1和线程2几乎不同步,而我们希望的是进行两次加操作后值为2,但线程1和线程2的CPU Cache中counter
都是1,即使我们将它们的值写入到主存,结果仍然不对,这都是volatile is Not Always Enough
When is volatile Enough?
既然出现了上诉情况,那么如果解决这个问题?之前volatitle
已经明显不够,那么这时候可以使用Java另外一个关键字synchronized
去保证读取变量和写入变量是原子性的操作。上诉的volatitle
读写操作并不会产生线程阻塞,使用synchronized
保证临界区读写不出现上述问题,但这样会导致线程阻塞。另外也可以使用java.util.concurrent package
里面的AtomicLong
or AtomicReference
或者其他。
在这样的情况下,只有一个线程允许对volatitle
变量进行读写操作,其他线程只允许读取,这样即保证读取最新的值,也保证直接写入主存。但是光有synchronized
并不能保证可见性。
性能问题
之前已经说过,volatitle
的读写都要直接接触主存,那么与读写CPU Cache相比,开销增大许多倍,放一张计算机各级缓存以及主存的读取速度比较: