Java中volatile关键字用于标记Java变量“始终存储在主存中”.这意味着每次都是从主存中读取volatile修饰的变量,且每次对volatile修饰的变量的更改都会写回到主存中,而不是cpu缓存.
事实上,自java5之后,volitle关键字保证的不只是始终在主存中读取和修改volatile修饰的变量.
变量可见性问题
volatile保证了线程间对共享变量的修改是可见的.
在多线程应用中,对于没有volatile修饰的变量,每个线程在执行过程中都会从主存中拷贝一份变量的副本到cpu缓存中.假设计算机中拥有多个cpu,每个线程可能在不同的cpu上执行,即每个线程很可能将变量加载到不同的cpu缓存中.如图所示:
没有volatile修饰将不能保证JVM何时从主存中读取变量或何时将变量写回主存.这将导致若干问题的发生.
假设两个或更多的线程访问一个包含有counter变量的共享对象,声明如下:
public class SharedObject{
public int counter = 0;
}
想象一下,当只有线程1对counter进行累加计算,但线程1和线程2在往后的时间会不定时的从主存中加载变量counter.
如果没有将变量counter修饰为volatile将不能保证对变量counter的修改会在何时写回主存.这意味着不同cpu缓存中counter变量的值可能与主存中不一样.如图所示:
问题在于线程1对counter变量的修改对于线程2不可能见.这种一个线程对共享变量的修改对于另一个线程不可见的问题,我们称之为"可见性"问题.
volatile对于可见性的保障
Java中 volatile关键字用于解决可见性问题.若将counter修饰为volatile,那么所有对于counter的修改会被立即写回到主存中,且限制counter只能从主存中读取.
public class SharedObject{
public volatile int counter = 0;
}
将变量修饰为volatile保证了变量修改对其他线程的可见性.
上文中提及线程1对counter的修改,线程2对counter的读取能够通过volatile来保证线程1对counter变量的修改对于线程2可见.
然而,如果线程1和2同时累加counter变量,此时仅仅将变量修饰为volatile是不够的.详情在下文会提及.
volatile对可见性的充分保障
事实上,volatile对于可见性保障不仅仅局限于volatile修饰的变量本身.可见性保障内容如下所示:
- 如果线程1修改
volatile修饰的变量,紧接着线程2读取同样volatile修饰的变量,那么线程1对修改volatile变量之前其他变量的修改都会对线程2可见. - 如果线程1读取一个
volatile修饰的变量,那么volatile修饰变量之后用到的其他变量都会强制从主存中读取以保证所有变量对于线程1可见.
代码实例:
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的值时,会连同days之前的yeas 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的读取都是最新的.
指令重排序带来的挑战
JVM和CPU能够在语义相同的情况下对程序中的指令进行重排序以达到更好的执行效率.如下所示:
int a = 1;
int b = 2;
a++;
b++;
这些指令能够在语义一致的情况下重新调整顺序:
int a = 1;
a++;
int b = 2;
b++;
然而,当重排序中有volatile修饰的变量时,将会带来一些挑战.
再来看看之前提及的实例:
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;
}
}
一旦update()方法更新days变量,那么对于years和months的更新也会被写回到主存中,若JVM进行重排序:
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
当对days变量进行修改时,months和years的修改也会被写回主存,但这次对days的修改是在months和years之前,因此对于months和years的最新修改不会对其他线程可见.重排序后的语义已经发生改变.
volatile关于Happens-Before的保障
针对指令重排序的挑战,volatile给出了"happens-before"保障,用于补充可见性保障.happens-before保障的内容如下所示:
- 若对于其他变量的读写原顺序是在写volatile修饰变量之前进行的,不能被重排序为之后进行.保证了写volatile变量之前对其他变量的读写操作正常的发生.相反,允许对于其他变量的读写原顺序是在写volatile修饰变量之后的,被重排序为之前进行.
- 若对于其他变量的读写原顺序是在读volatile变量之后的,不能被重排序为之前进行.保证了读volatile变量之后对其他变量的读写操作正常的发生.相反,允许对于其他变量的读写原顺序是在读volatile修饰变量之前的,被重排序为之后进行.
happens-before保证了volatile可见性保障的强制执行.
volatile并不总是足够的
尽管volatile保障了volatile修饰的变量总是从主存中读取和写回主存,但还是有些情况即使将变量修饰为volatile也不能满足.
之前的情况是线程1对于volatile变量的修改总是对于线程2可见.
在多线程下,如果产生的新值并不依赖主存中的旧值(不需要使用旧值来推导出新值),那么即使两个线程同时更新主存中volatile修饰的变量值也不会有问题.
当一个线程产生的新值需要依赖旧值时,那么仅仅用volatile修饰共享变量来保障可见性是不够的.当一个线程在读取主存中volatile修饰的共享变量之前,此时有两个线程同时从主存中加载相同的volatile修饰的变量,同时进行更新且写回主存时会产生竞态条件,此时两个线程对旧值的更新会互相覆盖.那么之后线程从主存中读取的数值可能是错误的.
这种情况下,当两个线程同时累加相同的counter变量时,用volatile修饰变量已经不能满足了.如下所示:
当线程1从主存加载counter到cpu缓存中,此时counter为0,对counter进行累加之后counter变为1,此时线程1还没有将counter写回主存,线程2同样将主存中counter加载到cpu缓存中进行累加操作.此时线程2也还没有将counter写回主存.
实际上线程1和线程2是同时进行的.而主存中counter变量的预期结果应该为2,但如图所示两个线程在各自缓存中的值为1,而在主存中的值为0.即使两个线程将各自缓存中的值写回主存也是错误的.
volatile什么情况才是足够的?
两个线程同时对一个共享变量进行读写操作时,使用volatile修饰已经不能满足情况.你需要使用synchronized来保障变量读写操作的原子性.使用volatile并不能同步线程的读写操作.这种情况下只能使用synchronized关键字来修饰临界区代码.
除了synchronized,你还可以选择java.util.concurrent包中提供的原子数据类型.如AtomicLong和AtomicRefrerence等.
在其他情况下,如果只有一个线程对volatile修饰的变量进行读写操作,其他线程只进行读操作,那么volatile是足够保障可见性的,若没有volatile修饰,那就不能保障了.
volatile关键字在32bit和64上的变量可用.
volatile实践建议
对于volatile修饰变量的读写能够被强制在主存中进行(从主存中读取,写回主存).直接在主存中读写的性能消耗远大于在cpu缓存中读写.volatile能够在特定情况有效防止指令重排序.所以应该谨慎使用volatile,只有在真正需要保障变量可见性的情况下使用.
该系列博文为笔者复习基础所著译文或理解后的产物,复习原文来自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial