java 内存模型之 volatile 核心原理与应用

2022/9/3 5:22:54

本文主要是介绍java 内存模型之 volatile 核心原理与应用,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

1. happens-before规则
https://blog.csdn.net/qq_39935047/article/details/120384799

2. Juc12_Volatile的可见性、不保证可见性、有序性、使用、内存屏障四大指令StoreStore、StoreLoad 、LoadLoad、LoadStore
https://blog.csdn.net/TZ845195485/article/details/117601980

3.java 内存模型之 volatile 核心原理与应用

https://blog.csdn.net/m0_70565884/article/details/124864429

 

————————————————————————————————————————————————————————————————————————————————

1. happens-before规则
https://blog.csdn.net/qq_39935047/article/details/120384799

 

1. 定义
JSR-133使用happens-before的概念来指定两个操作之间的执行顺序,由于这两个操作可能是一个线程内,也有可能是两个线程内的,所以JMM可以通过happens-before来项程序员提供跨线程的可见性保证(如果A线程的a操作和B线程的b操作之间有happens-before关系,那么JMM保证a操作对b操作可见)。

其实并不会保证执行顺序,也是保证了最终结果是一致的就行。

as-if-serial:保证单线程内程序的执行结果不会被改变。创造出一个幻境:单线程程序是按照程序的顺序来执行的。

happens-before:保证正确同步的多线程程序的执行结果不被改变。创造出一个幻境:正确同步的多线程程序是按照happens-before指定的顺序执行的。

 

2. 程序顺序规则

一个线程中的每个操作,happens-before于该线程的任意后续操作。

也就是说,单个线程内部每个操作都对后续操作可见

类似于as-if-serial

 

3. 监视器锁规则
对一个锁的解锁,happens-before于随后对这个锁的加锁

也就是针对于解锁操作,对后续的加锁操作可见。

根据传递性,也就是说解锁前的操作也对加锁操作可见,一个线程在持有锁期间的操作对后续另一个线程持有这把锁的期间的操作可见

 

 

 

根据第(1)条规则,1 happens-before 2 且3 happens-before 4,当前规则是 2 happens-before 3,由传递性可知,1 happens 4,所以在1处修改的共享变量会在4处可见,更深一点就是A线程中解锁前的操作对B线程加锁后的操作可见。

 

4. volatile变量规则
对一个volatile域的写操作,happens-before与后续任意对这个volatile域的读操作

也就是对于volatile域来说,一直可以读取到最新的值

 

 

 

同上:1 happens-before 2,3happens-before 4,当前2 happens-before 3,所以 1 happens-before 4,那么1处的修改对4处可见。

 

5. 传递性
A happens-before B,且B happens-before C,那么A happens-before C

 

6. start()
如果线程A执行了线程B.start(),那么线程A的start()操作happens-before于线程B的任意操作

也就是线程A的操作对线程B可见

 

 

 

可知,1 happens before 2,当前 2 happens-before 4,所以1 happens-before 4,也就是A线程在执行B线程.start()之前的操作,对B线程执行过程中都可见。

 

7. join规则
如果线程A执行线程B.join()并成功返回,那么线程B中的任意操作happens-before与线程A从线程B.join()操作成功返回

 

 

 

可知:2 happens-before 3,4 happens-before 5,当前2 happens-before 4,可知,2 happens-before 5,所以当在A线程执行B线程.join()并成功返回之后,B线程的任何操作对A线程的后续操作可见。 

 

——————————————————————————————————————————————————————————————————————————————————————————

volatile 是什么?

volatile 的特性

volatile 是如何保证可见性的?

volatile 是如何保证有序性的?

volatile 可以保证原子性吗?

使用 volatile 变量的条件是什么?

volatile 和 synchronized 的区别

volatile 和 atomic 原子类的区别是什么?

这一章主要是讲解 volatile 的原理,在开始本文前,我们来看一张 volatile 的思维导图,先有个直观的认识。

 

 

 

什么是 volatile?
目前的操作系统大多数都是多 CPU,当多线程对一个共享变量进行操作时,会出现数据一致性问题

Java 编程语言允许线程访问共享变量,那么为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量,或者把这个变量声明成 volatile,可以理解 volatile 是轻量级的 synchronized。

使用 volatile 可以在 Java 线程内存模型确保所有线程看到这个变量的值是一致的,在多个处理器中保证了共享变量的“可见性”。

volatile 两核心三性质?
两大核心:JMM 内存模型(主内存和工作内存)以及 happens-before

三条性质:原子性,可见性,有序性

volatile 性质?
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)

禁止进行指令重排序。(实现有序性)

只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。(不能实现原子性)

volatile 不会引起上下文的切换和调度

总结:volatile 保证了可见性和有序性,同时可以保证单次读/写的原子性

相关的 Cpu 术语说明

 

 


什么是可见性?

在单核 cpu 的石器时代,我们所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

例如在下面的图中,线程 A 和线程 B 都是操作同一个 CPU 里面的缓存,所以线程 A 更新了变量 a 的值,那么线程 B 之后再访问变量 a,得到的一定是 a 的最新值(线程 A 写过的值)。

 

 

 

在多核 CPU 的时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 a 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的值读到内部缓存(L1,L2 或者其他)后再进行操作,但是操作完不知道何时再写回内存。

 

 

 

从上面的分析,我们可以知道,多核的 CPU 缓存会导致的可见性问题。

volatile 是如何保证可见性的
 

instance = new Singleton();//instance是volatile变量

 

让我们来看看在处理器下通过工具获取 JIT 编译器生成的汇编指令来查看对 volatile 进行写操作的时候,cpu 会做什么事?

有 volatile 修饰的共享变量进行写操作的时候会多出第二行汇编代码,也就是 jvm 会向处理器发送一条 Lock 前缀的指令,Lock 前缀的指令在多核处理器下会引发两件事情:

将当前处理器缓存行的数据写回到系统内存

这个写回内存的操作会使在其他 CPU 缓存了该内存地址的数据无效,保证各个处理器的缓存是一致的 (通过一致性协议来实现的)

一致性协:每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是否过期了,当处理器发现自己的缓存行对应的内存过期,在下次访问相同内存地址时,强制执行缓存填充,从系统内存中读取。

简单理解:volatile 在其修饰的变量被线程修改时,会强制其他线程在下一次访问该变量时刷新缓存区。

volatile 的两条实现原则

Look 前缀指令会引起处理器缓存回写到内存。Lock 前缀指令导致在执行指令期间,声言处理器的 LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大,对干 intel486 和 Pentiuln 处理器,在锁操作时,总是在总线上声言 LOCK#信号。但在 P6 和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言 LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32 处理器和 Iniel 64 处理器使用 MESI (修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候, IA-32 和 Intel64 处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在 Pentium 和 P6famaly 处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处干共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充

小结 Lock 前缀的指令会引起处理器缓存写回内存; 一个处理器的缓存回写到内存会导致其他处理器的缓存失效; 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

volatile 是如何保证有序性
在解释有序性前,我们先来看看什么是指令重排?

导致程序有序性的原因是编译优化,指令重排序是 JVM 为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。但是在多线程环境下,有些代码的顺序改变,有可能引发逻辑上的不正确。有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们的程序的性能就堪忧了,所以合理的方案是按需禁用缓存或者编译优化。

接下来我们来看一个著名的单例模式双重检查锁的实现

 

class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {
}
public static Singleton getInstance() {
    if (instance == null) {
        //步骤1
        synchronized (Singleton.class) {
            if (instance == null) 
            //步骤2
            instance = new Singleton();
            //步骤3
        }
    }
    return instance;
}}

 

在以上代码中,instance 不用 volatile 修饰时,输出的结果会是什么呢?我们的预期中代码是这样子执行的:线程 A 和 B 同时在调用 getInstance()方法,线程 A 执行步骤 1,发现 instance 为 null,然后同步锁住 Singleton 类,接着执行步骤 2 再次判断 instance 是否为 null,发现仍然是 null,然后执行步骤 3,开始实例化 Singleton。这样看好像没啥毛病,可是仔细一想,发现事情并不简单。 这时候,我们来我们先了解一下对象是怎么初始化的?

对象在初始化的时候分三个步骤

 

memory = allocate();
//1、分配对象的内存空间
ctorInstance(memory);
//2、初始化对象
instance = memory;
//3、使instance指向对象的内存空间
复制代码

 


程序为了优化性能,会将 2 和 3 进行重排序,此时执行的顺序是 1、3、2,在单线程中,对结果是不会有影响的,可是在多线程程序下,问题就暴露出来了。这时候我们回到刚刚的单例模式中,在实例化的过程中,线程 B 走到步骤 1,发现 instance 不为空,但是有可能因为指令重排了,导致 instance 还没有完全初始化,程序就出问题了。为了禁止实例化过程中的重排序,我们用 volatile 对 instance 修饰。

 

 

 

volatile 内存语义如何实现?

对于一般的变量则会被重排序(重排序分析编译器重排序和处理器重排序),而对于 volatile 则不能,这样会影响其内存语义,所以为了实现 volatile 的内存语义 JMM 会限制重排序。

其重排序规则如下:

 

 

 

如果第一个操作为 volatile 读,则不管第二个操作是啥,都不能重排序。这个操作确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前,其前面的所有普通写操作都已经刷新到主内存中;

如果第一个操作 volatile 写,不管第二个操作是 volatile 读/写,禁止重排序。

如果第二个操作为 volatile 写时,则不管第一个操作是啥,都不能重排序。这个操作确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后;

如果第二个操作为 volatile 读时,不管第二个操作是 volatile 读/写,禁止重排序

volatile 的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM 采用了保守策略。如下:

在每一个 volatile 读操作后面插入一个 LoadLoad 屏障,用来禁止处理器把上面的 volatile 读与后面任意操作重排序 在每一个 volatile 写操作前面插入一个 StoreStore 屏障,用来禁止 volatile 写与前面任意操作重排序 在每一个 volatile 写操作后面插入一个 StoreLoad 屏障,用来禁止 volatile 写与后面可能有的 volatile 读/写操作重排序 在每一个 volatile 读操作前面插入一个 LoadStore 屏障,用来禁止 volatile 写与后面可能有的 volatile 读/写操作重排序

保守策略下,volatile 的写插入屏障后生成的指令示意图:

 

Storestore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了,Storestore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

这里比较有意思的是, volatite 写后面的 StoreLoad 屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序。

因为编译器常常无法准确判断在一个 volatile 写的后面是否需要插入一个 StoreLoad 屏障。为保证能正确实现 volatile 的内存语义,JMM 在采取了保守策略,在每个 volatile 写的后面,或者在每个 volatile 读的前面插入一个 StoreLoad 屏障。

保守策略下,volatile 的读插入屏障后生成的指令示意图:

 

 

 

 

 

 

上面的内存屏障插入策略非常保守,在实际执行中,只要不改变 volatile 写-读的内存语义,编译器可根据情况省略不必要的屏障

举个例子:

 

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

public synchronized void read(){        
if(flag){            
System.out.println("---i = " + i);         
}    
}
}

 


复制代码

针对 readWrite 方法,编译器在生成字节码的时候可以做到如下的优化:

 

 

 

注意:最后一个 storeLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return,此时编译器无法精准判断后面是否会有 vaolatile 读或者写。

如何正确使用 volatile 变量
在某些情况下,如果读操作远远大于写操作,volatile 变量可以提供优于锁的性能优势。

可是 volatile 变量不是说用就能用的,它必须满足两个约束条件:

对变量的写操作不依赖于当前值。

该变量没有包含在具有其他变量的不变式中。

第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然 i++ 看上去类似一个单独操作,实际上它是一个读取-修改-写入三个步骤的组合操作,必须以原子方式执行,而 volatile 不能保证这种情况下的原子操作。正确的操作需要使 i 的值在操作期间保持不变,而 volatile 变量无法做到这一点。

volatile 和 synchronized 区别
volatile 比 synchronized 执行成本更低,因为它不会引起线程上下文的切换和调度

volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。

volatile 只能用来修饰变量,而 synchronized 可以用来修饰变量、方法、和类。

volatile 可以实现变量的可见性,禁止重排序和单次读/写的原子性;而 synchronized 则可以变量的可见性,禁止重排序和原子性。

volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

volatile 和 atomic 原子类区别
Volatile 变量可以确保先行关系,即写操作会发生在后续的读操作之前

但是 Volatile 对复合操作不能保证原子性。例如用 volatile 修饰 i 变量,那么 i++操作就不是原子性的。

atomic 原子类提供的 atomic 方法可以让 i++这种操作具有原子性,如 getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作,但是 atomic 原子类一次只能操作一个共享变量,不能同时操作多个共享变量。

总结
总结一下 volatile 的特性``

volatile 可见性;对一个 volatile 的读,总可以看到对这个变量最终的写 volatile 有序性;JVM 底层采用“内存屏障”来实现 volatile 语义 volatile 原子性;volatile 对单个读/写具有原子性(32 位 Long、Double),但是复合操作除外,例如 i++


 



这篇关于java 内存模型之 volatile 核心原理与应用的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程