(2.1.27.5)Java并发编程:Volatile

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。

  • 当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。
  • volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

当一个变量定义为 volatile 之后,将具备两种特性:

  1. 保证此变量对所有的线程的可见性

    • 当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。
    • volatile之所以具有可见性,是因为底层中的Lock指令,该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。
  2. 禁止指令重排序优化。

    • 有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;
    • 指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理
    • volatile之所以能防止指令重排序,是因为Java编译器对于volatile修饰的变量,会插入内存屏障。内存屏障会防止CPU处理指令的时候重排序的问题

一、volatile可见性

1.1 线程的可见性

当一个变量定义为volatile后,那么该变量对所有线程都是“可见的”,其中“可见的”是指当一条线程修改了这个变量的值,那么新值对于其他线程来说是可以立即知道的

在这里插入图片描述
【volatile可见性】

  • 在上图中,线程A与线程B分别从主内存中获取变量a(用volatile修饰)到自己的工作内存中,也就是现在线程A与线程B中工作内存中的a现在的变量为12
  • 当线程A修改a的值为8时,会将修改后的值(a=8)同步到主内存中
  • 同时,那么会导致线程B中的缓存a变量的值(a=12)无效,会让线程B重新从主内存中获取新的值(a=8)

1.2 volatile可见性的原理

物理计算机为了处理缓存不一致的问题。提出了缓存一致性的协议,其中缓存一致性的核心思想是:

  1. 当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态
  2. 因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

既然volatile修饰的变量能具有“可见性”,那么volatile内部肯定是走的底层,同时也肯定满足缓存一致性原则。

因为涉及到底层汇编,这里我们不要去了解汇编语言,我们只要知道当用volatile修饰变量时,生成的汇编指令会比普通的变量声明会多一个Lock指令。那么Lock指令会在多核处理器下会做两件事情:

  1. 将当前处理器缓存的数据直接写会到系统内存中
    • 从Java内存模型来理解,就是将线程中的工作内存的数据直接写入到主内存中
  2. 这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效
    • 从Java内存模型理解,当线程A将工作内存的数据修改后(新值),同步到主内存中,那么线程B从主内存中初始的值(旧值)就无效了

从某种意义上,它就相当于:声明变量是 volatile 的,JVM 保证了每次读变量都从主内存中读,跳过 CPU cache 这一步。

二、volatile防止重排序

在《(2.1.27.2)Java并发编程:JAVA的内存模型》我们已经提及“CPU(处理器)会对没有数据依赖性的指令进行重排序”在多线程环境中引发的问题,Java内存模型规定了使用volatile来修饰相应变量时,可以防止CPU(处理器)在处理指令的时候禁止重排序。具体如下图所示

public class Demo {
    private int a = 0;
    private volatile boolean isInit = false;
    private Config config;
	 public void init() {
        config = readConfig();//1
        isInit = true;//2
    }
    public void doSomething() {
        if (isInit) {//3
            doSomethingWithconfig();//4
        }
    }
}

2.1 volatile防止重排序规则

那么为了处理CPU重排序的问题。Java定义了以下规则防止CPU的重排序。

在这里插入图片描述
【volatile防止重排序规则】

从上表我们可以看出

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序,这个规则确保voatile写之前的操作不会被编译器排序到volatile之后。
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  3. 当第一个操作是volatile写,第二个操作如果是volatile读时,不能进行重排序。

2.2 volatile防止重排序原理

为了具体实现上诉我们提到的重排序规则,在Java中对于volatile修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序问题

在了解内存屏障之前,我们先复习之前的主内存与工作内存交互的8种原子操作,因为内存屏障主要是对Java内存模型的几种原子操作进行限制的。

在这里插入图片描述
【volatile防止重排序原理】

这里对内存屏障所涉及到的两种操作进行解释:

  1. load:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入到工作内存变量副本中。
  2. store:作用于工作内存的变量,它把工作内存中一个变量值传送到主内存中。以便随后的write操作。

下面是基于volatile修饰的变量,编译器在指令序列插入的内存屏障保守插入策略如下:

  • 在每个volatile写操作的前面插入一个storestore屏障。
  • 在每个volatile写操作的后面插入一个storeload屏障。
  • 在每个volatile读操作的后面插入一个loadload屏障。
  • 在每个volatile读操作的后面插入一个loadstore屏障。

2.2.1 volatile写内存屏障

在这里插入图片描述
【volatile写内存屏障】

  • storestore屏障:
    • 对于这样的语句store1; storestore; store2,在store2及后续写入操作执行前,保证store1的写入操作对其它处理器可见。
    • (也就是说如果出现storestore屏障,那么store1指令一定会在store2之前执行,CPU不会store1与store2进行重排序)
  • storeload屏障:
    • 对于这样的语句store1; storeload; load2,在load2及后续所有读取操作执行前,保证store1的写入对所有处理器可见。
    • (也就是说如果出现storeload屏障,那么store1指令一定会在load2之前执行,CPU不会对store1与load2进行重排序)

2.3 volatile读内存屏障

在这里插入图片描述
【volatile读内存屏障】

  • loadload屏障:
    • 对于这样的语句load1; loadload; load2,在load2及后续读取操作要读取的数据被访问前,保证load1要读取的数据被读取完毕。
    • (也就是说,如果出现loadload屏障,那么load1指令一定会在load2之前执行,CPU不会对load1与load2进行重排序)
  • loadstore屏障:
    • 对于这样的语句load1; loadstore; store2,在store2及后续写入操作被刷出前,保证load1要读取的数据被读取完毕。
    • (也就是说,如果出现loadstore屏障,那么load1指令一定会在load2之前执行,CPU不会对load1与store2进行重排序)

2.4 编译器内存屏障的优化

上面我们讲到了在插入内存屏障时,编译器如果采用保守策略的情况下,分别会在volatile写与volatile读插入不同的内存屏障,那现在我们来看一下,在实际开发中,编译器在使用内存屏障时的优化。

public class VolatileBarrierDemo {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    public void readAndWrite() {
        int i = v1;//第一个volatile读
        int j = v2;//第二个volatile读
        a = i + j;//普通写
        v1 = i + 1;//第一个volatile写
        v2 = j * 2;//第二个volatile写
    }
}

在这里插入图片描述
【编译器内存屏障的优化】

观察上图,我们发现,在编译器生成屏障时,省略了第一个volatile读下的loadstore屏障,省略了第二个volatile读下的loadload屏障,省略了第一个volatile写下的storeload屏障。结合上诉我们所讲的loadstore屏障、loadload屏障、storeload屏障下的语义,我们能得到省略以下屏障的原因。

  • 省略第一个volatile读下的loadstore屏障:因为第一个volatile读下的下一个操作是第二个volatile的读,并不涉及到写的操作(也就是store)。所以可以省略。
  • 省略第二个volatile读下的loadload屏障:因为第二个volatile读的下一个操作是普通写,并不涉及到读的操作(也就是load)。所以可以省略
  • 省略第一个volatile写下的storeload屏障:因为第一个volatile写的下一个操作是第二个volatile的写,并不涉及到读的操作(也就是load)。所以可以省略。

其中大家要注意的是,优化结束后的storeload屏障时不能省略的,因为在第二个volatile写之后,方法理解return,此时编译器可能无法确定后面是否会有读写操作,为了安全起见,编译器通常会在这里加入一个storeload屏障。

2.5 处理器内存屏障的优化

上面我们讲了编译器在生成屏障的时候,会根据程序的逻辑操作省略不必要的内存屏障。

但是由于不同的处理器有不同的“松耦度”的内存模型,内存屏障的优化根据不同的处理器有着不同的优化方式。

以x86处理器为例。针对我们上面所描述的编译器内存屏障优化图。在x86处理器中,除最后的storeload屏障外,其他的屏障都会省略。

在这里插入图片描述
【处理器内存屏障的优化】

三、volatile的使用条件

现在我们已经了解了volatile的相关特性,那么就来说说,volatile的具体使用场景,因为volatie变量只能保证可见性,并不能保证原子性,这就是说线程能够自动发现 volatile 变量的最新值。所以在轻量级线程同步中我们可以使用volatile关键字。

但是有两个前提条件:

  1. 第一个条件:运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  2. 第二个条件:变量不需要与其他的状态变量共同参与不变约束。

3.1 针对第一个条件


volatile int a  = 0;
  
//在多线程情况下错误,在单线程情况下正确的方式
public void doSomeThingA() {
//在单线程情况下,不会出现线程安全的问题,正确
//在多线程情况下,a最终的值依赖于当前a的值,错误
	 a++;     
}

//正确的使用方式
public void doSomeThingB() {
	//不管是在单线程还是多线程的情况下,都不会出现线程安全的问题
	if(a==0){
	 a = 1;
	}
}

上述伪代码中,我们能明确的看出:

  1. 只要volatile修饰的变量不涉及与运算结果的依赖,那么不管是在多线程,还是单线程的情况下,都是正确的。
  2. 否则,只在单线程中有效

3.2 针对第二个条件

其实理解第二个条件,大家可以反过来理解,即使用volatile的变量不能包含在其他变量的不变式中,下面伪代码将会通过反例说明:

	private volatile int lower;
    private volatile int 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)。
那么在多线程的情况下,两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。

  1. 如果初始状态是(0, 5),同一时间内,线程 A 调用setLower(4) 并且线程 B 调用setUpper(3),显然这两个操作交叉存入的值是不符合条件的
  2. 然而实际上,两个线程都会通过用于保护不变式的检查,使得最后的范围值是(4, 3)。很显然这个结果是错误的。

四、volatile的适用场景

Volatile 变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。因此,单独使用 volatile 还不足以实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类(例如 “start <=end”)。

模式1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

volatile boolean shutdownRequested;  
  
...  
  
public void shutdown() {   
    shutdownRequested = true;   
}  
  
public void doWork() {   
    while (!shutdownRequested) {   
        // do stuff  
    }  
}  

线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。
而如果使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

这种类型的状态标记的一个公共特性是:通常只有一种状态转换;shutdownRequested 标志从false 转换为true,然后程序停止。
这种模式可以扩展到来回转换的状态标志,但是只有在转换周期不被察觉的情况下才能扩展(从false 到true,再转换到false)。此外,还需要某些原子状态转换机制,例如原子变量。

模式2:一次性安全发布(one-time safe publication)

在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。

//注意volatile!!!!!!!!!!!!!!!!!    
private volatile static Singleton instace;     
    
public static Singleton getInstance(){     
    //第一次null检查       
    if(instance == null){              
        synchronized(Singleton.class) {    //1       
            //第二次null检查         
            if(instance == null){          //2    
                instance = new Singleton();//3    
            }    
        }             
    }    
    return instance;    
}	

如果不用volatile,则因为内存模型允许所谓的“无序写入”,可能导致失败。——某个线程可能会获得一个未完全初始化的实例。

考察上述代码中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 instance 来引用此对象。这行代码的问题是:在Singleton 构造函数体执行之前,变量instance 可能成为非 null 的!

在解释这个现象如何发生前,请先暂时接受这一事实,我们先来考察一下双重检查锁定是如何被破坏的。假设上述代码执行以下事件序列:

  1. 线程 1 进入 getInstance() 方法。
  2. 由于 instance 为 null,线程 1 在 //1 处进入synchronized 块。
  3. 线程 1 前进到 //3 处,但在构造函数执行之前,使实例成为非null。
  4. 线程 1 被线程 2 预占。
  5. 线程 2 检查实例是否为 null。因为实例不为 null,线程 2 将instance 引用返回,返回一个构造完整但部分初始化了的Singleton 对象。
  6. 线程 2 被线程 1 预占。
  7. 线程 1 通过运行 Singleton 对象的构造函数并将引用返回给它,来完成对该对象的初始化。

模式3:独立观察(independent observation)

  • 安全使用 volatile 的另一种简单模式是:定期 “发布” 观察结果供程序内部使用。

    • 【例如】假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
  • 使用该模式的另一种应用程序就是收集程序的统计信息。

    • 【例】如下代码展示了身份验证机制如何记忆最近一次登录的用户的名字。将反复使用lastUser 引用来发布值,以供程序的其他部分使用。
public class UserManager {  
    public volatile String lastUser; //发布的信息  
  
    public boolean authenticate(String user, String password) {  
        boolean valid = passwordIsValid(user, password);  
        if (valid) {  
            User u = new User();  
            activeUsers.add(u);  
            lastUser = user;  
        }  
        return valid;  
    }  
}   

模式4:“volatile bean” 模式

volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通——即不包含约束!

@ThreadSafe  
public class Person {  
    private volatile String firstName;  
    private volatile String lastName;  
    private volatile int age;  
  
    public String getFirstName() { return firstName; }  
    public String getLastName() { return lastName; }  
    public int getAge() { return age; }  
  
    public void setFirstName(String firstName) {   
        this.firstName = firstName;  
    }  
  
    public void setLastName(String lastName) {   
        this.lastName = lastName;  
    }  
  
    public void setAge(int age) {   
        this.age = age;  
    }  
}  

模式5:开销较低的“读-写锁”策略

如果读操作远远超过写操作,您可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。

如下显示的线程安全的计数器,使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe  
public class CheesyCounter {  
    // Employs the cheap read-write lock trick  
    // All mutative operations MUST be done with the 'this' lock held  
    @GuardedBy("this") private volatile int value;  
  
    //读操作,没有synchronized,提高性能  
    public int getValue() {   
        return value;   
    }   
  
    //写操作,必须synchronized。因为x++不是原子操作  
    public synchronized int increment() {  
        return value++;  
    }  
}	

使用锁进行所有变化的操作,使用 volatile 进行只读操作。
其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作

参考文献

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值