fenlan

Everything gonna be fine in the end, if it's not fine, it's not the end.

0%

Java volatitle

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
5
public 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
5
public 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
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()方法会写入三个变量,其中只有daysvolatitle,Full volatitle保证意味着,当写入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()方法在开始时读取daystotal变量。当读取days值时,yearsmonths也会从主存中读取,因此你可以通过上面的读取步骤读到所有变量的最新值。

指令重排

为了性能,Java VM和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++;

但对于volatitle变量来说,指令重排是一个挑战,让我们来看一下上述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;
}
}

当写入days时,也会同时写入yearsmonths到主存。但是如果Java VM重排指令如下:
1
2
3
4
5
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}

days修改时,monthsyears也会写入主存,但是这次days写入发生在yearsmonths之前,因此这个新值对其他线程不可见,这个语义含义已经被改变了。

特别注意,在写入时,把volatitle变量写入放在最后,这样当运行到volatitle变量写入时,会同时把其他非volatitle写入主存,但当volatitle写在最开始时,后面的非volatitle便不会写入最新值。读取相反,将volatitle读取放在最前,线程会读取所有值最新的主存值,如果放在最后面,那么前面读取的变量不是从主存直接读取的。

Happens-Before

为了解决指令重组问题,Java volatitle关键字除了可见性保证,还给了一个happens-before保证,happens-before保证如下:

  • 如果读写发生在写入volatitle变量之前,那么读写其他变量的指令不能被重组到写入volatitle变量之后。写入volatitle变量之前的读写被保证happens beforevolatitle变量。需要注意,写入volatitle变量之后的读写其他变量仍然有可能被重组到写入volatitle变量之前。因此From after to before is allowed, but from before to after is not allowedvolatitle写操作之后的指令被重组到写之前是允许的,而写操作之前的指令不允许被重组到写之后)
  • 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 allowed

Reads 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的值没有刷新;相反,如果读取yearsvolatitle 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相比,开销增大许多倍,放一张计算机各级缓存以及主存的读取速度比较:

参考链接