刨根问底-Volatile原理&Java内存模型
引言
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。保证共享变量的可见性可以通过锁也可以通过volatile,今天我们来讲volatile。
内存模型相关概念
既然要深入分析volatile,那么它与Java的内存模型有很大的联系,所以在理解volatile之前我们需要先了解有关Java内存模型的概念,后面会详细讲解其与happens-before、as-if- serial等关系。
操作系统语义
计算机在运行程序时,每条指令都是在CPU中执行的,在执行过程中势必会涉及到数据的读写。我们知道程序运行的数据是存储在主存中,这时就会有一个问题,读写主存中的数据没有CPU中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了CPU高速缓存。CPU高速缓存为某个CPU独有,只与在该CPU运行的线程有关。
有了CPU高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性。在程序运行中,会将运行所需要的数据复制一份到CPU高速缓存中,在进行运算时CPU不再也主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后才会将数据刷新到主存中。
//多线程执行
i++
单线程执行i++没有问题,可多线程环境下,比如两个线程从主存中读取i的值(1)到各自的高速缓存中,然后线程A执行+1操作并将结果写入高速缓存中,最后写入主存中,此时主存i==2,线程B做同样的操作,主存中的i仍然=2。所以最终结果为2并不是3。这种现象就是缓存一致性问题。
解决缓存一致性方案有两种:
- 通过在总线加LOCK#锁的方式
方案1存在一个问题,它是采用一种独占的方式来实现的,即总线加LOCK#锁的话,只能有一个CPU能够运行,其他CPU都得阻塞,效率较为低下。 - 通过缓存一致性协议
第二种方案,缓存一致性协议(MESI协议)它确保每个缓存中使用的共享变量的副本是一致的。其核心思想如下:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知该变量的缓存行是无效的,因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据。
Java内存模型
下面我们来看一下Java内存模型为我们提供了哪些保证以及在Java中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。我们分别从:原子性、可见性、有序性,三方面来看。
- 原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。(volatile是无法保证复合操作的原子性)
非原子操作
i++
包含了三个操作:读取i值、i + 1 、将+1结果赋值给i;
在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32位的JDK环境下,对64位数据的读取不是原子性操作*,如long、double)。要想在多线程环境下保证原子性,则可以通过锁来确保。
-
可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。(Java中的volatile可以保证可见性)
当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。 当然,synchronize和锁都可以保证可见性。 -
有序性:即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。
Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。
volatile原理
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。
上面的内容总结为两句话:
- 保证可见性、不保证原子性;
- 禁止指令重排序。
下面重点说指令重排序,在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
- 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
指令重排序对单线程没有什么影响,它并不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个稍后回答,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推导出来,那么指令实际运行就不保证有序性,可以随意进行重排序。其定义如下:
- 程序顺序规则:同一个线程中的,前面的操作 happen-before 后续的操作;
- 监视器锁规则:监视器上的解锁操作 happen-before 其后续的加锁操作;
- volatile变量规则:对volatile变量的写操作 happen-before volatile变量的读操作;
- start()规则【线程启动】:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作;
- join()规则【线程终止】:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回;
- 如果 a happen-before b,b happen-before c,则a happen-before c;
- 对象finalize规则:一个对象的初始化完成(构造函数执行结束)happen-before 发生它的finalize()方法的开始;
- 线程中断规则:对线程interrupt()方法的调用 happen-before 被中断线程的代码检测到中断事件的发生。
着重看第三点volatile规则:volatile变量写 happen-before volatile变量读。为了实现volatile内存语义,JMM会禁止重排序。
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。
volataile的内存语义及其实现
volatile的内存语义是:
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。
那么volatile的内存语义是如何实现的呢?对于一般的变量则会被重排序,而对于volatile则不能,这样会影响其内存语义,所以为了实现volatile的内存语义JMM会限制重排序。其重排序规则如下:
-
如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前;
-
当第二个操作为volatile写是,则不管第一个操作是啥,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后;
-
当第一个操作volatile写,第二操作为volatile读时,不能重排序。
volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:
-
在每一个volatile写操作前面插入一个StoreStore屏障
-
在每一个volatile写操作后面插入一个StoreLoad屏障
-
在每一个volatile读操作后面插入一个LoadLoad屏障
-
在每一个volatile读操作后面插入一个LoadStore屏障
- StoreStore屏障可保证在volatile写之前,其前面所有普通写操作都已经刷新到主内存中。
- StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。
- LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
- LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
八种原子操作:
-
lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
-
unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
-
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;
-
load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本
-
use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
-
assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
-
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;
-
write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
四种基本内存屏障:
内存屏障分类:
- 按照可见性保障来划分
内存屏障可分为:加载屏障(Load Barrier)和存储屏障(Store Barrier)。
- 加载屏障:StoreLoad屏障可充当加载屏障,作用是使用load 原子操作,刷新处理器缓存,即清空无效化队列,使处理器在读取共享变量时,先从主内存或其他处理器的高速缓存中读取相应变量,更新到自己的缓存中。
- 存储屏障:StoreLoad屏障可充当存储屏障,作用是使用 store 原子操作,冲刷处理器缓存,即将写缓冲器内容写入高速缓存中,使处理器对共享变量的更新写入高速缓存或者主内存中。
这两个屏障一起保证了数据在多处理器之间是可见的。
- 按照有序性保障来划分
内存屏障分为:获取屏障(Acquire Barrier)和释放屏障(Release Barrier)。
- 获取屏障:相当于LoadLoad屏障与LoadStore屏障的组合。在读操作后插入,禁止该读操作与其后的任何读写操作发生重排序;
- 释放屏障:相当于LoadStore屏障与StoreStore屏障的组合。在一个写操作之前插入,禁止该写操作与其前面的任何读写操作发生重排序。
这两个屏障一起保证了临界区中的任何读写操作不可能被重排序到临界区之外。
synchronized与volatile字节码对比:
- Synchronized 底层原理(保证有序性,可见性,原子性与线程安全):
synchronized编译成字节码后,是通过monitorenter(lock原子操作抽象而来)和 monitorexit(unlock原子操作抽象而来)两个指令实现的,具体过程如下:
synchronized底层:
- 通过获取屏障和释放屏障的配对使用保证有序性;
- 加载屏障和存储屏障的配对使用保正可见性;
- 最后又通过锁的排他性保障了原子性与线程安全。
- Volatile 底层原理(保证有序性,可见性):
读操作:
写操作:
经过对比,可以发现 volatile 少了两个指令 monitorenter 与 monitorexit 用来保证原子性与线程安全。
Java内存模型
happens-before
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。
i = 1; //线程A执行
j = i ; //线程B执行
j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,如果他们不存在happens-before原则,那么j = 1 不一定成立。
happens-before原则定义如下:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
happens-before具体的8条准则上面已经列举出来,下面对这些准则进行更详细的解读:
-
程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。
-
锁定规则:无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。
-
volatile变量规则:它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
-
传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C
-
线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
-
线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。
上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:
- 将一个元素放入一个线程安全队列的操作Happens-Before从队列中取出这个元素的操作
- 将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
- 在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
- 释放Semaphore许可的操作Happens-Before获得许可操作
- Future表示的任务的所有操作Happens-Before Future#get()操作
- 向Executor提交一个Runnable或Callable的操作Happens-Before任务开始执行操作
如果两个操作不存在上述(前面8条 + 后面6条)任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。
as-if-serial
as-if-serial语义的意思是,所有的操作均可以为了优化而被重排序,但是必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守as-if-serial语义。注意as-if-serial只保证单线程环境,多线程环境下无效。
volatile与happens-before
public class VolatileVisibilitySample {
private volatile boolean initFlag = false;
private int i = 0;
// Thread A
public void refresh(){
this.i = 2; //1
this.initFlag = true; //2
}
// Thread B
public void load(){
if (initFlag){ //3
System.out.println(i); //4
}
}
}
依据happens-before原则,就上面程序得到如下关系:
- 依据happens-before程序顺序原则:1 happens-before 2、3 happens-before 4;
- 根据happens-before的volatile原则:2 happens-before 3;
- 根据happens-before的传递性:1 happens-before 4
操作1、操作4存在happens-before关系,那么1一定是对4可见的。volatile除了保证可见性外,还有就是禁止重排序。所以A线程在写volatile变量之前所有可见的共享变量,在线程B读同一个volatile变量后,将立即变得对线程B可见。
参考:
https://www.cnblogs.com/lemos/p/9252342.html
总结:
觉得有用的客官可以点赞、关注下!感谢支持🙏谢谢!