volatile 作用及其实现原理

x33g5p2x  于2021-12-18 转载在 其他  
字(3.0k)|赞(0)|评价(0)|浏览(275)

一、并发编程中的三个概念

在并发编程中,我们会遇到三个概念:原子性、可见性、有序性

  • 原子性:一个或多个操作为一个整体,要么都执行且不会受到任何因素的干扰而中断,要么都不执行,synchronized 可以保证代码块的原子性。
  • 可见性:当多个线程共享同一变量时,若其中一个线程对该共享变量进行了修改,那么这个修改对其他线程是立即可见的。
  • 有序性:程序执行的顺序按照代码的先后顺序执行。

二、内存模型对比

1、物理计算机内存模型

在物理机中,为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存中的数据读取到处理器内部的高速缓存中,然后再进行操作,如下图所示。

2、Java 内存模型

Java 内存模型规定了所有的变量都存储在主内存中,同时每条线程都有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的副本,线程对该变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据,不同的线程之间也无法直接访问对方工作内存中的变量,不同线程之间变量值的传递均需要通过主内存来完成,如下图所示。

这里所讲的主内存、工作内存和 Java 堆、栈、方法区等并不是同一个层次的堆内存的划分,如果非要勉强对应起来的话,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应虚拟机栈中部分区域。

三、volatile 的作用及其实现原理

volatile 主要有两个作用:

  1. volatile 保证了可见性
  2. volatile 禁止指令重排,保证了有序性

下面我们来逐条分析其实现原理

1. volatile 如何保证可见性?

Java 内存模型对 volatile 变量定义的特殊规则如下所示(V 表示一个 volatile 变量):

  • 在工作内存中,每次使用 V 前必须先从主内存刷新最新的值,用于保证能看见其他线程对 V 所做的修改。
  • 在工作内存中,每次修改 V 后都必须立刻同步回主内存中,用于保证其他线程可以看到当前线程对 V 所做的修改。
  • volatile 所修饰的变量不能被指令重排序优化,从而保证代码的执行顺序和编写顺序相同。

上述三个规则中的前两个保证了 volatile 变量的可见性(第三条规则保证了 volatile 变量的有序性)。

volatile 虽然保证了可见性,但在并发情况下它仍然可能是线程不安全的,因此在不符合以下两条规则的运算场景中,我们仍需要通过加锁(使用 sychronized、java.util.concurrent 中的锁或原子类)来保证原子性:

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

下面我们来从更底层的角度来看一下 volatile 是如何保证可见性的:如果对声明了 volatile 的变量执行写操作,JVM 会向处理器发送一条 Lock 前缀指令,该指令在多核处理器下会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

下面我们来详细说明一下 Lock 前缀指令引发的两件事情。

  • Lock 前缀指令会引起处理器缓存回写到内存中。Lock 前缀指令在执行指令期间,会声言处理器的 LOCK# 信号,在多处理器环境中,LOCK# 信号会确保在声言该信号期间,处理器可以独占任何共享内存。比较老的处理器通过 LOCK# 信号锁总线来达到独占共享内存的目的;但现在的处理器不会声言 LOCK# 信号,而是会锁定这块内存区域的缓存并回写到内存,同时使用缓存一致性机制来确保修改的原子性,这一操作被称为缓存锁定,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域。
  • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。处理器通过 EMSI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。

2. volatile 如何保证有序性?

volatile 关键字会禁止指令重排,它有两层语义:

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作肯定已经全部进行,且结果对后面的操作已经可见,并且在其后面的操作肯定还没有进行。
  • 不能将 volatile 变量后面的语句放在其前面执行,也不能把 volatile 变量前面的语句放到其后面执行。

上面这两句话可能有些抽象,我们举一个例子,代码如下:

/** * x,y 为非 volatile 变量 * flag 为 volatile 变量 */
x = 1; 			// 语句一
y = 2; 			// 语句二
flag = 3; 		// 语句三
x = 4; 			// 语句四
y = 5; 			// 语句五

上述代码在进行指令重排时有如下限制:

  • 语句一和语句二不能重排到语句三的后面,但语句一和语句二可以在语句三前面进行重排
  • 语句四和语句五不能重排到语句三的前面,但语句四和语句五可以在语句三后面进行重排
  • 当执行到语句三时,语句一和语句二必须已经执行完毕,且执行结果对语句三、语句四和语句五可见
  • 当执行到语句三时,语句四和语句五必须都还未执行

四、volatile 的应用场景

1. 双重检测锁实现单例模式

代码如下:

public class Singleton {
    private volatile static Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这里使用 volatile 关键字的作用是禁止指令重排,上述代码中 instance = new Singleton() 实际上不是原子性操作,可以拆分为以下三个步骤:

memory = allocate();	//1 分配对象的内存空间
initInstance(memory);	//2 初始化对象
instance = memory;		//3 设置 instance 指向刚分配的内存地址

如果没有 volatile 修饰变量 instance 的话,上述伪代码的顺序就可能变为:

memory = allocate();	//1 分配对象的内存空间
instance = memory;		//3 设置 instance 指向刚分配的内存地址(此时对象还未初始化)
initInstance(memory);	//2 初始化对象

这样的话返回的可能就是还未初始化的对象,有可能会造成程序运行错误。

而如果加上了 volatile 的话,因为 volatile 具有禁止指令重排的作用,对象就可以正常进行初始化了。

2. 状态标记量

下面代码使用了 volatile 保证可见性的作用

volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}

下面代码使用了 volatile 禁止指令重排的作用

volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            
 
//线程2:
while(!inited ){
	sleep()
}
doSomethingwithconfig(context);

最后,强烈推荐大家阅读这篇文章:Java并发编程:volatile关键字解析

相关文章