前言
有时仅仅为了读/写一个或者两个实例就使用同步的话,显得开销过大;而volatile关键字为实例域的同步访问提供了免锁的机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域可能是被另一个线程并非更新的。在说到volatile关键字之前,我们需要先了解下内存模型的相关概念以及兵法编程中的3个特性:原子性、可见性和有序性。
1. Java内存模型
Java中的堆内存用来存储对象实例,堆内存是被所有线程共享的运行时内存区域,因此它存在内存可见性的问题。而局部变量、方法定义的参数则不会再线程之间共享,它们不会有内存可见性问题,也不受内存模型影响。Java内存模型定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存中,每个线程都有一个私有的本地内存,本地内存中存储了该共享变量的副本。需要注意的是本地内存是Java内存模型的一个抽象概念,其实并不真实存在,它涵盖了缓存、写缓冲区、寄存器等区域。Java内存模型控制线程之间的通信,它决定一个线程对主存共享变量的写入何时对另一个线程可见。
2. 原子性、可见性和有序性
2.1. 原子性
对基本数据类型变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行完毕,要么就不执行。现在看看下面的代码:
x = 3;//语句1
y = x;//语句2
x++;//语句3
在上面3个语句中,只有语句1是原子性操作,其它两个都不是原子性操作。语句2虽说很短,但它包含了两个操作,它先读取x的值,再将x的值写入工作内存。读取x的值以及将x的值写入工作内存这两个操作单拿出来都是原子性操作,但是合起来就不是原子性操作了。语句3包括3个操作:读取x的值、对x的值进行加1、向工作内存写入新值。通过这3个语句我们得知,一个语句含有多个操作时,就不是原子性操作,只有简单的读取和赋值操作才是原子性操作。
2.2. 可见性
这里的可见性指的是线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上能看到。当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主存,所以对其它线程时可见的。当有其它线程需要读取该值时,其它线程会去主存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,并不会立即被写入主存,何时被写入主存也是不确定的。当其他线程去读取该值时,此时主存中可能还是原来的旧值,这样就无法保证可见性。
2.3. 有序性
Java内存模型中允许编译器和处理器对指令进行重排序,虽然重排序的过程不会影响线程执行的正确性,但是会影响到多线程并发执行的正确性。我们知道,synchronized和Lock保证每个时刻只有一个线程执行同步代码,这相当于让线程顺序执行同步代码,从而保证了有序性。
3. volatile关键字
3.1. 保证可见性,禁止进行指令重排序
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
先看一段代码,假如线程1先执行,线程2后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。 为何有可能导致无法中断线程?每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。 但是用volatile修饰之后就变得不一样了:
- 使用volatile关键字会强制将修改的值立即写入主存;
- 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效;
- 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
3.2. volatile不保证原子性
public class VolatileTest {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final VolatileTest test = new VolatileTest();
for (int i = 0; i < 10; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 100; j++) {
test.increase();
}
}
}.start();
}
//如果有子线程就让出资源,保证所有子线程都执行完
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(test.inc);
}
}
这段代码每次运行,结果都不一致。在前面已经提过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1、写入工作内存。也就是说,自增操作的这3个子操作可能会分割开执行。假如某个时刻变量inc的值为9,线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了。之后线程2对变量进行自增操作,也去读取变量inc的原始值,然后进行加1操作,并把10写入工作内存,最后写入主存。随后线程1接着进行加1操作,因为线程1在此前已经读取了inc的值为9,所以不会再去主存读取最新的数值,线程1对inc进行加1操作后inc的值为10,然后将10写入工作内存,最后写入主存。两个线程分别对inc进行了一次自增操作后,inc的值只增加了1,因此自增操作不是原子性操作,volatile也无法保证对变量的操作是原子性的。
4. 正确使用volatile关键字
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
第一个条件就是不能是自增自减等操作,上文已经提到volatile不保证原子性。 第二个条件我们来举个例子它包含了一个不变式 :下界总是小于或等于上界。
public class NumberRange {
private volatile int lower, upper;
public int getLower(){
return lower;
}
public int getUpper(){
return upper;
}
public void setLower(int value){
if (value > upper){
throw new IllegalArgumentException("...");
}
lower = value;
}
public void setUpper(int value){
if (value < lower){
throw new IllegalArgumentException("...");
}
upper = value;
}
}
这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全,从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3),这显然是不对的。 其实就是要保证操作的原子性就可以使用volatile,使用volatile主要有两个场景:
4.1. 状态标志
public class Shutdown {
volatile boolean shutdownRequested;
public void shutdown(){
shutdownRequested = true;
}
private void doWork(){
while (!shutdownRequested){
//do something
}
}
}
4.2. 双重检查模式
public class Singleton {
private volatile static Singleton instance = null;
public static Singleton getInstance(){
if (instance == null){
synchronized (Singleton.class){
if (instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
在这里使用volatile会或多或少的影响性能,但考虑到程序的正确性,牺牲这点性能还是值得的。 DCL优点是资源利用率高,第一次执行getInstance时单例对象才被实例化,效率高。缺点是第一次加载时反应稍慢一些,在高并发环境下也有一定的缺陷,虽然发生的概率很小。 DCL虽然在一定程度解决了资源的消耗和多余的同步,线程安全等问题,但是他还是在某些情况会出现失效的问题,也就是DCL失效,在《java并发编程实践》一书建议用以下的代码(静态内部类单例模式)来替代DCL:
public class Singleton {
private Singleton(){}
public static Singleton getInstance(){
return SingletonHolder.instance;
}
public static class SingletonHolder{
private static final Singleton instance = new Singleton();
}
}
总结
与锁相比,Volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循 volatile 的使用条件即变量真正独立于其他变量和自己以前的值 ,在某些情况下可以使用 volatile 代替 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加容易出错。本文介绍了可以使用 volatile 代替 synchronized 的最常见的两种用例,其他的情况我们最好还是去使用synchronized 。
网友评论