Java中的volatile关键词被用来将变量标记为“存储在内存中”。准确地的讲每次volatile变量的读取和写入都是直接操作内存,而不是cpu cache。
实际上自从java 5之后,volatile关键词保证除了volatile变量直接读写内存外,它也被赋予了更多的含义,文章后续会解释。
变量可见性问题
java volatile 关键词保证变量在多线程间变化的可见性。听起来有点抽闲,让我详细说明下。
在多线程应用中,当线程操作非volatile变量时,因为性能上的考虑,每个线程会把变量从内存中拷贝一份到cpu cache里(译者注:读写一次磁盘需要100ns,level1 cache只需要1ns)。如果你的电脑有多个cpu,每个线程运行在不同的cpu上,这就意味着每个线程会将变量拷贝到不同的cpu cache上,如下图。
对于非volatile变量,JVM不会保证每次都写都是从内存中读写,这可能会导致一系列的问题。
试想下这样一个场景,多个线程操作一个包含计数器的变量,如下。
public class SharedObject {
public int counter = 0;
}
如果只有线程1会增加counter,但线程1和线程2会时不时读counter。
如果counter没有被声明成volatile,jvm不会保证每次counter写cpu cache都会被同步到主存。这就意味着cpu cache里的数据和主存中的有可能不一致,如下图所示。
另一个线程线程没法读到最新的变量值,因为数据还没有从cpu cache里同步到主存中,这就是 可见性问题,数据的更新对其他线程不可见。
Java volatile可见性保证
Java volatile的诞生就是为了解决可见性问题。把counter变量声明为volatile,所有counter的写入都会被立刻写会到主存里,所有的读都会从主存里直接读。
volatile的用法如下:
public class SharedObject {
public volatile int counter = 0;
}
把变量声明为volatile保证了其他线程对这个变量更新的可见性。
在上面的例子中,线程1修改counter变量,线程2只读不修改,把counter声明为volatile就可以保证线程2读取数据的正确性了。
当时,如果线程1线程2都会修改这个变量,那volatile也无法保证数据的准确性了,后续会详解。
volatile 完全可见性保证
实际上,Java volatile可见性保证超出了volatile变量本身。可见性保证如下。
- 如果线程A修改一个volatile变量,并且线程B随后读取了同一个变量,你们线程A在写volatile变量前的所有变量操作在线程B读取volatile变化后对线程B都可见。
- 如果线程A读取了volatile变量,那么在它之后线程A读取都所有变量都将从主存中重新读取。
测试代码如下:
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被声明为volatile。
volatile完全可见性保证的含义是:当线程修改了days,所有的变量都会被同步到主存中,在这里years和months也会被同步到主存中。
在读取years、months、days的时候,你可以这么做。
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变量后,months和years也会从主存中同步。如果按上面的顺序,可以保证你一定读到days,months和years的最新值。
译者注:在上面这个特定读写顺序下,虽然只有days是volatile变量,但days和months也实现了volatile。我猜测原因和cpu硬件有关,volatile变量读取前将要读取的地址在cpu cache中置为失效,这样就保证了每次读取前必须从内存中做数据更新。同样写入后会强制同步cache数据到主存中,这样就实现了volatile语义。但实际上cpu cache在管理cache数据的时候并不是以单个地址为单位,而是以一个block为单位,所以一个block中只要有一个volatile变量,那么读写这个变量都会导致整个block和主存同步。
综上所述,我认为原作者博客中这部分内容不具备参考性,java没有承诺过类似的保证,而且这种可见性估计和具体的cpu实现有关,可能不具备可迁移性,不建议大家这么用。所以如果有多个变量需要可见性保证,还是得都加volatile标识。
指令重排序挑战
Jvm和cpu为性能考虑都可能会最大指令进行重排序,但都会保证语义的一致性。例如:
int a = 1;
int b = 2;
a++;
b++;
这些指令在保证语义正确性下可以被重排为下面的次序。
int a = 1;
a++;
int b = 2;
b++;
但当一个变量是volatile的时候,指令重排序会面临一个挑战。 让我们再来看下上面提到的MyClass()的例子。
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像下面一样重新排列了指令:
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
虽然months和years最终也会被写入到主存中,但却不是实时的,无法保证对其他线程的立即可见。实际语义也会因为指令重排序而改变。
Java 实际上已经解决了这个问题,让我们接着看下去。
Java volatile和有序性(Happens-Before)保证
为了解决重排序的挑战,java volatile关键词可见性之上也保证了"有序性(happens-before)",有序性的保证含义如下。
- 对其他变量的读和写如果原来就在volatile变量写之前,就不能重排到volatile变量的写之后。 一个volatile变量写之前的的读/写保证在写之前。请注意有特殊情况,例如,对volatile的写操作之后的其他变量的读/写操作会在对volatile的写操作之前重新排序。而不是反过来。从后到前是允许的,但从前到后是不允许的。
- 如果对其他变量的读/写如果最初就在对volatile变量的读/写之后,则不能将其重排序到volatile读之前。请注意,在读取volatile变量之前发生的其他变量的读取可以在读取volatile变量之后重新排序。而不是反过来。从前到后是允许的,但从后到前是不允许的。
上面的happens-before保证确保了volatile关键字强可见性。
volatile还不够
尽管volatile保证数据的读写都是从主存中直接操作的,但还有好多情况下volatile语义还是不够的。在前面的例子中,线程1写counter变量,如果将counter声明为volatile,线程2总能看到最新的值。
但事实上,如果多个线程都可以写共享的volatile变量且每次写入的新值不依赖于旧值,依旧可以保证变量值的准确性,换句话说就是有个线程写之前不需要先读一次再在读入的数据上计算出下一个值。
在读出-计算-写入的模式下就无法再保证数值的正确性了,因为在计算的过程中,这个时间差下数据可能已经被其他线程更新过了,多个线程可能竞争写入数据,就会产生数据覆盖的情况。
所以在上面例子中,多个线程共同更新counter的情况下,volatile就无法保证counter数值的准确性了。下面会详细解释这种情况。
想象下线程1读到的counter变量是0,然后它加了1,但没有写回到主存里,而是写在cpu cache里。这个时候线程2同样也看到的counter是0,它也同样加了1并只写到自己的cpu cache中,就像下图这样。
这个时候线程1和线程2的数据实际上是不同步的。我们预期的counter实际值应该是2,但在主存中是0,在某个cpu cache中是1。最终某个线程cpu cache中的数据会同步会主存,但数据是错的。
什么时候volatile就足够了?
像上文中提到的一样,如果有多个线程都读写volatile变量,只用volatile远远不够,你需要用synchronized来保证读和写是一个原子操作。 读和写一个volatile变量不会阻塞其他的线程,为了避免这种情况发生,你必须使用synchronized关键词。
除了synchronized之外,你还可以使用java.util.concurrent包中提供的原子数据类型,比如AtomicLong或者AtomicReferences。
如果只有一个线程写入,其他线程都是只读,那么volatile就足够了,但不使用volatile的话是不能保证数据可见性的。
注意:volatile只保证32位和64位变量的可见性。
volatile的性能考量
volatile会导致数据的读写都直接操作主存,读写主存要不读写cpu cache慢的多。volatile也禁止了指令重排序,指令重排序是常见的性能优化手段,所以你应该只在真正需要强制变量可见性时才使用volatile。