# volatile与final 原理分析

# 并发编程比非并发编程时多了哪些问题?

# 并发编程的问题点

image-20230321113448215

根据上图推理出:

CPU控制计算机的所有硬件设备,而操作系统OS控制CPU,进而控制所有的硬件,而操作系统OS是一段代码,因为Linux直接把OS映射为进程的虚拟地址空间的高一个G,与进程并行放在一起,成为进程的一部分,成为进程的内核态代码,OS并不是软件

OS给用户态的代码称之为系统调用,进程通过调用 系统调用 与OS沟通,OS调用CPU执行各种代码;

OS是有代码、数据构成,用户态的APP-1和APP-2也由代码、数据构成,那么OS就存在并发问题解决,APP多个线程访问,也存在并发问题解决,而APP-1和APP-2之间不存在并发问题,因为他们之间数据彼此独立,那么我们去研究并发编程问题,主要就看三点:

1、看OS的并发问题(不需要咱们去解决)

2、看APP的多线程引起的并发问题(需要咱们去解决)

3、看CPU提供的基本原子性操作(需要咱们去了解)

# 原子性

# 原语:CPU提供的原子性指令

image-20230321113938577

CPU-1操作程序数据A,CPU-2也操作程序数据A,两个同时操作数据又写回,这时会发生什么样的特点?数据紊乱了

由于CPU-1在执行加1操作,此时CPU-2 可能执行最后的赋值操作 ,然后CPU-1又执行赋值操作,此时数据A=2,本来经过两次的加1操作,数据A的值应该变为3的,可是由于同时操作造成了数据紊乱,这是并发问题的根源所在

image-20230321114022102

造成数据紊乱的根本原因:穿插执行

想要解决数据紊乱问题,需要CPU提供指令集级别的原子性操作,保证其原子性操作,防止穿插执行;

CPU本身要支持原子性的指令,CPU必须提供一种机制来支持原子性的操作,如果CPU不提供,那么程序员也没法子,因为CPU不提供一个原子性的指令,那么上层应用怎么去封装自己的原子性操作呢?

所以只需要让CPU支持原子性的操作,就能解决数据紊乱问题

那么如何让CPU去支持这个原子性操作呢?

多个CPU去控制内存,是通过总线控制的,所以提供一个指令能够锁总线,就可能将一串指令,变为原子性指令,从而解决穿插执行的问题

但是它的弊端也很明显,几乎所有的操作都要操作内存的,若每次操作都去锁总线,由于总线是共用结构,不只有自己的数据访问总线的,如果其他CPU并没有与你执行的CPU穿插执行,还被你给锁住总线了,那么性能效率将大大降低,于是该怎么解决这个弊端呢?锁缓存行

CPU-1和CPU-2是有自己的缓存的(L1、L2)等,缓存是解决CPU与内存之间速度不匹配的问题的,一旦有了缓存,我还需要锁总线吗?我直接锁缓存不就行了吗~锁的就是填充缓存的虚拟地址,它并不会影响到其它的虚拟地址,这个就相当于原来我使用Redis来锁整个业务总线,锁住整个业务,现在我将我的锁细粒度化,不同业务线各自使用各自的key,从粗锁到细粒度的锁,锁的细化,导致并行度提高

# 管程

通过利用单个原子性指令去实现 多个指令的原子性,设计管程出现了;

image-20230321114947444

如何利用单个原子性指令去实现 多个指令的原子性呢?

利用标志位;

多个线程通过CPU的原子性指令去操作标志位,有且仅有一个成功,成功的那个线程去执行代码段即可,不成功的那个线程去等待 排队去

那么排队的这个入队,出队是不是也要原子性的操作,那么就继续套用单指令原子性操作

于是 我们将上述的这些包装起来,打包到一起,给外部调用的东西就做 管程 (管理程序)

这一套,CPU执行原子性的指令,抢锁执行,以及执行完后,唤醒其它线程,没有抢到锁的线程进行排队等操作,称之为管程(操作系统里的名词),此程序封装给外部调用,就变为了函数库,内部实现尽皆于此

# 各个语言的并发库,底层是一样的

image-20230321115344740

上图大致说明了各个语言的并发库的执行逻辑

# volatile做了什么?

# volatile的前序

image-20230321165544259

所有CPU 操作,等价于 向内存 提交一组 访问序列而已

内存提交顺序的研究,也即 volatile 的前序,而 volatile 就是要解决以什么样的方式,以什么样的顺序去提交到内存,让其保证不会出现一些恶心的情况,恶心的情况稍后解释,也即有序性和可见性

image-20230321165056976

之前看的JUC相关的代码atomic有关原子操作的包,涉及到原子性操作的都会,以volatile 关键字修饰,这是重点的核心

volatile 关键字 涉及到多个层面

了解这三个层面关于 volatile 是否有以及如何解决的?

从三个层面 解释 volatile 到底干了什么事情;

先将Linux内核 它是如何看 volatile 的以及 volatile 它到底想干啥,在看Linux内核之前,先要了解 抽象CPU模型

# 抽象CPU模型

# 一个重排序现象

image-20230321171312222

我们发现,只要 CPU-1 和 CPU-2 按照语序执行,不管如何组合,都不可能发生 Q == &B, D== 2 的情况

由于 CPU 在执行时,发生了程序指令 没有 按照编码顺序执行的现象,也即指令之间 相对顺序 发生了乱序执行的现象

再次强调,这只是现象,我们需要找到导致这种现象的原因!!!!!!

# MOB问题

发生上面这种乱序现象,有一种原因是由于CPU模型导致的,即MOB(Memory Ordered Buffer内存定序缓冲区)问题

image-20230321174333540

下面分析的所有的前提条件是:缓存行协议规则(写变量时,若缓存行中不存在该变量,那么需要将该值从内存中加载缓存行中,才能写入) 写屏障的作用 CPU-1 发出了 STORE A和 STORE B 指令出来,等价于向内存中提交了一组访存顺序,由于缓存行协议的规则,要求A和B必须要加载到自身的缓存行中,才能写入,为了充分利用这个等待时间,将A和B放入了Store Buffer中,入队操作,先进先出; 此时,若B已经在缓存行中,但是A不在缓存行中,理论上说,写A在写B之前,但是由于CPU的设计,它允许谁在缓存行中谁先写,则乱序现象产生了,因为A不在缓存行中就要等待加载到缓存行中,B在直接写,于是STORE A和 STORE B发生了乱序现象,这种乱序现象的原因是由于写入时的乱序,即CPU抽象模型中的MOB的写入乱序; 解决这种乱序现象,要在STORE A和 STORE B之间加入写屏障(store barriers),加入该屏障的效果是当A不在缓存行中,就去等待A加载缓存行后,并且A写入了,才会执行屏障之后的STORE B的操作,但是在特定的平台下例如:Intel-CPU,该CPU会将store buffer中的顺序按照编码顺序写入,自身保证不会写入乱序,没有store store 写入乱序问题; 读屏障的作用 上一步中CPU-1执行 STORE A和 STORE B操作,由于缓存行协议规则,CPU-1要与CPU-2通讯,告知CPU-2 缓存行中的A和B要置为失效了,并要求CPU-2回复一下,此时,若CPU-2忙于处理别的事情,顾不上回复CPU-1,导致CPU-1的STORE A 和 STORE B 迟迟不能写入,因为CPU-1要确认收到CPU-2的回复信息才能写入A和B,所以为了能够快速响应CPU-1,CPU-2会将失效A和失效B将存入Invalid Buffer中,然后回复CPU-1即可; 若CPU-2不去处理 Invalid Buffer中的失效信息,直接执行 LOAD A和 LOAD B,此时,拿到A和B都是旧值,这种是允许的,因为相对顺序并没有发生改变; 若CPU-2处理 Invalid Buffer中的失效A,那么执行LOAD A和 LOAD B,此时,拿到A的新值,B的旧值,这也是允许的; 若CPU-2顺序处理 Invalid Buffer中的失效A和失效B,执行LOAD A和 LOAD B,此时,拿到A的新值,B的新值,这也是允许的; 若CPU-2执行LOAD A时,没有处理 Invalid Buffer,执行LOAD B时,B不在缓存行中,那么问题就来了,根据缓存行协议,CPU-2会去询问其它CPU有没有B的数据,或者直接从内存中拿B的数据(新值),加载到缓存行中,此时,拿到A的旧值,B的新值,产生乱序现象,事实上 Invalid Buffer 的失效A和失效B一直没有处理,于是对于这种情况,需要在LOAD A和 LOAD B 之间加个读屏障; 读屏障的作用是,LOAD A时,检测到它之后是读屏障,那么CPU-2就去处理 Invalid Buffer中的失效A和失效B,然后执行LOAD A 和 LOAD B,这就保证,当读到B是最新值时,A一定是最新值

MOB-模型 每个CPU有自己的缓存行,各个CPU的缓存行由缓存行协议保证一致,由Store Buffer用于缓存CPU写入内存的操作,而Invalid Buffer/queue 用来接收失效的消息,例如: CPU-1中修改了值,要告诉CPU-2该值要失效,这时候CPU-1要等待CPU-2的响应,于是为了缩短等待时间,将每个CPU中加入Invalid Buffer/queue,用于保存失效消息,先不处理,立马给CPU-1发送响应消息(ack),这时CPU-1就可以写缓存行和内存; 由于会发生各种乱序的现象,那么要想保证顺序,于是引入内存屏障,(写屏障、读屏障、数据依赖屏障)

# 解释MOB导致的乱序现象

image-20230321174945020

CPU-1: 执行store B; store P;操作,由于加入写屏障,所以保证顺序写出,

CPU-2: 执行load P; load *P;操作,当load P时,没有处理Invalid Buffer 仍旧存在两个失效信息,而且P不在缓存行中,于是加载P的最新值(B的地址)到缓存行中,P指向B的地址,而B在缓存行中,于是读出B的旧值;于是为了避免这种情况,要在load P; load *P;之间加入数据依赖屏障(data dependency barrier),其作用是将Invalid Buffer 里面的与*P关联的失效信息先处理掉,再去执行 load *P;

若在load P; load *P;之间加入读屏障(read barrier),其作用是将Invalid Buffer 里面的所有失效信息处理掉,再去执行 load *P;

# 抽象CPU模型的最低限度保证

逻辑问题:CPU 的执行 不可能打破逻辑!

不管 CPU 咋整,只要执行结果 和 你写的代码逻辑一样,别管我咋整!

CPU 可以根据自身的特性,来把不违反逻辑的指令优化来满足性能需求!

# CPU的执行乱序

CPU为什么要发生指令重排序这种行为呢?

举例:一段程序,先是两个嵌套for循环,里面进行加密运算(取余操作% 很耗性能),然后是读取操作

for(){
     for(){
            i %= 33;
      }
}
int a = b;
int d = c;
1
2
3
4
5
6
7

image-20230321172604552 这种情况下,CPU会如何做呢?执行单元在执行for时,检测到会很耗时间,就让读取单元将后面的b和c先读取了,CPU为什么这样做呢?结合CPU的流水线原理,一个指令分为5个部分:IF(指令提取)、ID(指令译码)、MEM(访存)、EXEC(执行)、WB(写回),其实还有一个是中断检测部分

使用这种步骤构建的计算机,叫做流水线计算机,可以提升吞吐量,但是增高了时延,为什么?同时执行的指令多了,但由于中间引入了锁存器,所以导致时延上升,这就要考虑平衡吞吐量和时延

CPU在执行到for循环时,可以通过指令提取单元将后面的指令先提取过来,放到ICACHE(后面即将执行的指令),当for循环结束后,直接开始赋值,而不需要等待,于是CPU就按照上面所述进行了 优化

image-20230321172919621

CPU-1 将b和c的值读取(预读取)了,但是CPU-2 却将b和c的值修改了,相当于CPU-1 读取了一个脏值,也即 读取代码重排序到 修改b和c的值代码之前了,所以会产生这种重排序的假象

因为计算机有流水线,它需要尽可能地填满流水线,CPU 需要去优化自己的硬件,来提升程序执行性能,于是会对代码进行各种排序行为,又或许是可见性问题,这些行为导致了产生重排序的现象,也即 重排序的现象, 底层造成的因素非常多, 所以导致了 这块内容 非常难

我们一般的服务器使用的CPU平台,大多数Intel的,所以针对特定平台(Intel)对于其CPU的性能优化有哪些?咱们需要了解一下

image-20230323155002743

image-20230323155105262

以上两张图,都是查阅Intel开发手册,翻译过来的,都是对于内存排序上,Intel声明的多个保证

导致 程序员 看到 重排序的现象的原因主要为两大类:编译器、CPU

目的都是为了最大化的提升程序执行性能

幸亏Java提供了JMM

JMM 是Java内存模型( Java Memory Model),简称JMM。它本身只是一个抽象的概念,并不真实存在,它描述的是一种规则或规范,是和多线程相关的一组规范。通过这组规范,定义了程序中对各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。需要每个JVM 的实现都要遵守这样的规范,有了JMM规范的保障,并发程序运行在不同的虚拟机上时,得到的程序结果才是安全可靠可信赖的。如果没有JMM 内存模型来规范,就可能会出现,经过不同 JVM 翻译之后,运行的结果不相同也不正确的情况。

简化导致 重排序现象的行为,告诉你编码规则,你符合规则就可以了,至于底层 怎么实现,交给 JVM的开发者就行

# Linux内核定义的屏障

# 屏障的定义

屏障的定义,在所有的地方都通用,屏障用来隔离指令;指令两端的行为就接受屏障的控制了;

image-20230323150444752

也即 隔离屏障两边的指令,使它们满足该屏障所能保证的意义

于是,要研究屏障两边的指令意义,是不是等价于 去学习有哪些屏障?它们实现了什么语义呢?

而我们又说,不同 CPU 提供的 屏障和实现的语义 都不一样,于是,我们通过宏定义来解决,同时我们可以使用 空实现的方式来 适配不同CPU,Linux内核就是这样实现的,提供 统一接口 给开发者 使用! 也即 接口!

# 什么是内存屏障

正如上面所描述的那样,没有数据依赖,独立的内存操作将拥有随意的执行顺序,CPU 和 编译器 可以根据自身的优化特性,在保证正确语序(满足依赖性)的情况下随意重排序指令来加速执行,得到最好性能。但这可能会对于 CPU 和 CPU 、CPU 和 IO 之间带来问题,因为此时在多 CPU 中看到的顺序将会不一样,正如上面我们看到的那样,虽然 在 单个 CPU中乱序了不会造成任何问题,但,在 多CPU 中由于 多个指令并行执行,一旦一个 CPU乱序,那么将会得到不同的执行结果。于是,我们需要一种机制,来干预 编译器 和 CPU 这种因为优化性能 导致指令乱序执行的行为。而内存屏障便是这种机制,我们可以使用屏障 来约束屏障两边的的内存访问顺序。

这种屏障机制对于 内核尤为重要,因为 CPU 和 其他外设 可以使用:重排序手段、延迟组合内存访问、数据预读、分支预测、CPU 缓存技术 等机制来提升自身性能,这种提升往往对于自身而言没有什么额外的影响,但他们一起配合,这种"自私"的行为,将会导致彼此配合出现问题。而内存屏障的出现,使得我们可以干预并控制这些行为来保证 它们 按照我们预先的顺序来执行。

# 屏障的种类

# 写屏障(store barriers)

写内存屏障保证了在屏障之前的所有的 STORE 操作,将出现在屏障之后所有STORE操作之前(相对于系统的其他组件而言),也即 写屏障后的写指令不会重排序到屏障之前的写指令之前。

image-20230323151104039

# 读屏障(read barriers)

读屏障是数据依赖屏障的升级版,用于保证所有 读屏障前的 load 操作 不会重排序到 读屏障后的 load 操作 后面。同 写屏障 一样,读屏障只针对 load 读取操作,对于读屏障前后的 store 写操作将不会影响。

image-20230323151254350

# 数据依赖屏障(data dependency barriers)

数据依赖屏障是一个弱化过后的 读屏障。来看这样一个例子:执行两个加载指令时,第二个加载指令将使用 第一个加载指令的结果( 例如:第一个 加载操作 从内存中获取了一个地址值,而第二个 加载操作 将使用第一个操作获取到的这个地址值,去内存中加载数据 ),那么此时就需要一个数据依赖性屏障,以确保在第二个加载操作在读取对应内存地址的数据时,第一个操作先完成并获取到了正确的地址。

# 通用内存屏障(全屏障)

通用内存屏障同时包含 写屏障和读屏障 的功能,用于保证 屏障前 的所有 读操作(load) 和 写操作(store)不会重排序到 屏障后的 所有 读操作 和 写操作 之后。这就意味着,全屏障 可以用于代替 写屏障、读屏障、数据依赖屏障,但,意味着 性能的下降。

# 读、写屏障配合使用的模型-发布(release)-订阅(acquire)模型

写屏障和读屏障 需要配对使用,也即:写屏障 保证了 写出顺序,读屏障保证了 数据可见

image-20230323151646623

上图所示,T1线程先写入了共享数据(access_shared_data),然后执行写屏障(这里是release函数,其实执行的就是写屏障),最后写入X一个新值(store(X));而T2线程一直读取X(load(X)),然后执行读屏障(这里是acquire函数,其实执行的就是读屏障),若load(X)是新值,则最后一个访问的access_shared_data共享数据就是T1线程所写入的

这是个典型的发布、订阅模型,其在Java里的JUC包大量可见

image-20230323152626674

由上图可以看出,Java的Unsafe提供的putOrederedInt,找到x86_64平台下的代码,是空操作,通过查看Intel的开发手册,由于 Intel的 StoreBuffer 保证了 不会发生乱序现象,所以membar_release也即 putOrdered 操作为空操作,而被volatile关键字标识的d变量,找到源码,发现执行membar_volatile函数,此函数执行了汇编指令lock,通过查阅Intel手册,发现该指令会刷新StoreBuffer;

# C语言的volatile关键字实现了什么语义?

物有本末,事有始终,知其先后,则近道矣;所以再了解Java的volatile关键字,不妨先了解一下C语言的volatile关键字所实现的语义;

image-20230323155414274

通过查阅C99标准对于volatile关键字的定义:禁止编译器优化;让 编译器 生成指令时,每次都从内存中读取该volatile 修饰的变量

image-20230323160547581

上图展示的是编译器屏障的实际用法

# 由于编辑器优化,导致的可见性问题

以下样例程序为C语言代码

vim demo1.c
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>

int a = 1;

void * threadfunc(void * arg){
    printf("Sub Thread start\n");
    while (a T== 1){
    
    }
};

int main(void)
{
    pthread_t t1;
    int s=0;
    s=pthread_create(&t1,NULL,threadfunc,NULL);
    if(s!=0)
        perror("pthread create failed\n");
    sleep(1);
    a = 2;
    printf("Thread exit\n");
    exit(1);
}
gcc -pthread demo1.c
./a.out
// 主、子线程都能停止
gcc -O4 -pthread demo1.c
./a.out
// 主、子线程都能停止

vim demo2.c // 去掉子线程的输出语句
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>

int a = 1;

void * threadfunc(void * arg){
    while (a T== 1){
    
    }
};

int main(void)
{
    pthread_t t1;
    int s=0;
    s=pthread_create(&t1,NULL,threadfunc,NULL);
    if(s!=0)
        perror("pthread create failed\n");
    sleep(1);
    a = 2;
    s=pthread_join(t1,NULL);
    if(s!=0)
        perror("pthread join failed\n");
    printf("Thread exit\n");
    exit(1);
}
gcc -O4 -pthread demo2.c
./a.out
// 执行结果:卡住了一直循环执行子线程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

image-20230323161120150

4007a9:  eb fe             jmp   4007a9 <threadfunc+0x9>  // 编译器优化为  直接跳同一个地址 while(a == 1){}
while循环不再检测任何变量值,直接优化为跳同一个地址的代码,于是如何解决编译器瞎优化的行为呢?
就是上面的两种方法:
1、加上volatile(粗粒度)int a = 1; 变为 volatile int a = 1; 
2、加上编译器屏障 __asm__ __volatile__("":::"memory");// 编译器屏障(细粒度版本);while(a == 1){} 加上编译器屏障 即可
1
2
3
4
5
6
7

再来看Java样例

Demo.java
public class Demo {
    public static int a = 1;

    public static void test() {
        while (a == 1) {

        }
    }

    public static void main(String[] args) throws Exception{
        new Thread(() -> test()).start();
        Thread.sleep(200);
        a = 2;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

在Java语言如何让上述代码停止下来?

Java的编译是分层编译,先有C1再有C2C1有三个,C2是最终的,那么将C1C2去掉,让其纯解释执行

JVM的参数 -Xint 说明解释器是没有问题的

-XX:-TieredStopAtLevel=3 让编译器停止在哪个阶段,0是不分层,不使用C1,与解释执行是一样的

让其停在3,属于C1的范畴

运行上面程序,可以停止下来,说明C1也是没有问题的

让其停在4,属于C2的范畴

-XX:-TieredStopAtLevel=4

运行上面程序,发现停不下来了,说明问题就是出在C2编译器这里

-XX:-TieredCompilation 关闭C1分层编译,直接使用C2编译器

运行上面程序,发现停不下来了

再次验证,Java源文件Demo1.java通过C2编译器,是否生成与C语言一样的汇编指令,一直跳相同的地址,而不受任何变量的改变呢?接下来通过在linux系统上编译hotspot源码,来跟踪程序,为了方便跟踪程序,将变量的a的值改为0xcffc,这样更容易定位信息,将main函数最后执行System.exit(0); 如果不让主线程退出,会一直打印主线程的汇编指令,根本停不下来,所以在睡眠2s后强制退出,将JVM的日志内容,导出即可

1、编译好hotspot虚拟机后,加入JVM参数

2、运行,然后获取JVM的日志

Demo1.java
public class Demo1 {
    public static volatile int a = 0xcffc;

    public static void test() {
        while (a == 0xcffc) {
               //MyUtils.getUnsafe().fullFence();
        }
    }

    public static void main(String[] args) throws Exception{
        new Thread(() -> test()).start();
        Thread.sleep(100);
        a = 0xcffa;
        Thread.sleep(2000);
        System.gc();// 因为 JIT 的 C2代码中 存在了 poll page(polling page) 线程安全点的操作
        System.out.println("main exit");
        System.exit(0);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

3、截取日志内容

image-20230323162016707

综上所述:不管是 C语言还是Java 的 JIT(C2 编译器) 最终导致 循环无法停下来的 根本与CPU 后面的 乱序执行 和 MOB 没有任何关系!!!!! 全是编译器在搞鬼,于是,我们只需要针对编译器 优化 来解决问题即可,最简单的是 加volatile

# Hotspot虚拟机如何基于规范实现Java的volatile的语义?

打开hotshot源码,找到 orderAccess.hpp 文件 Memory Access Ordering Model 这是对JSR-133规范的实现

Memory Access Ordering Model JVM抽象定义了四个内存屏障(抽象概念) LoadLoad: Load1(s); LoadLoad(rmb); Load2 Load2不允许重排序到Load1前面,LoadLoad相当于读屏障,Intel中实现是空操作

StoreStore: Store1(s); StoreStore(wmb); Store2 Store2不允许重排序到Store1前面,StoreStore 相当于写屏障,Intel中实现是空操作

LoadStore: Load1(s); LoadStore(); Store2 Store2不允许重排序到Load1前面,LoadStore Intel中实现是空操作

StoreLoad: Store1(s); StoreLoad(); Load2 Load2不允许重排序到Store1前面,StoreLoad相当于全屏障(lock指令前缀)

image-20230323164306885

去看具体实现,打开 orderAccess_linux_x86.inline.hpp 文件

image-20230323164420640

再看一下高版本的hotshot 12,找到 orderAccess_linux_x86.hpp 文件,非常清晰

image-20230323164756550

总结:在Java中 读取一个被volatile修饰的变量,在读取变量之后加上 LoadLoad LoadStore ;保证语义:当前读操作 volatile后面的读操作 普通变量的读写操作不会重排序到当前读操作前面

写出一个被volatile修饰的变量,在写之前加上 StoreStore , 写完后加上StoreLoad;前面的StoreStore保证了前面的那些个普通变量或者volatile不会重排序到写变量之后,写变量也不会重排序它之前;

后面的StoreLoad屏障保证后面的读写操作不会重排序到写变量之前;

image-20230323165039174

JVM提供volatile两种手段

image-20230323165248973

对于volatile的操作,和普通变量的操作

JVM提供了两种手段:

1、按照解释器自己去保证volatile的读写操作(加入JVM内存屏障)

2、一种是由Unsafe类越过解释器,自己去保证volatile的语义

说完volatile关键字,在说一说final关键字

JSR133中定义了 Java 内存模型和线程规范

重点关注final的语义,因为该语义是通过虚拟机去改变它的,来实现final的语义

final的语义,一个线程初始化赋值了final变量,那么该final变量在其它线程中可见并且一定是第一个线程赋值的值

final 关键字的重排序规则:final 域的写入,与随后把构造完成的对象赋值给另一个引用,这两个操作之间不能重排序

JVM实现上,分为两种情况,即写 final 域的实现和读 final 域的实现

1、写 final 域的实现

  • JMM禁止编译器把 final 域的写入重排序到构造函数之外
  • 编译器会在写 final 域之后,构造函数返回之前插入一个 storestore 屏障

2、读 final 域的实现

  • 所有编译器都遵守间接依赖,因此读包含 final 域的对象和读 final 之间不会重排序
  • 编译器会在读 final 域对象之前插入 loadload 屏障

# 总结

image-20230323162810645

CPU屏障的功能:禁止执行乱序,刷新MOB

CPU这一块不会导致可见性问题,只是会延迟一会,最终会全部刷出去的,最终是可见的,延迟的一会,顶多导致乱序的现象

导致可见性的问题,出现在编译器;

1、编译器优化,直接不检测变量值,导致循环一直跳同一个地址,无论怎么修改变量的值,循环都停不下来,这就意味着不可见,出现可见性的问题

2、变量值一直使用寄存器(eax)中的值,导致循环中一直使用寄存器(eax)的值,它再也不检测了,这就意味着不可见,出现可见性的问题

编译器屏障分为两种:

1、细粒度控制(__asm__ __volatile__("":::"memory"))

2、全局控制(volatile)

JMM作用是承上启下,给上层的Java语言提供一个简易的编码过程,即volatile不会导致重排,不会编译器优化等,给下层JVM定义规则去实现,至于如何实现,不用管,是禁止编译器优化,还是禁止CPU乱序执行,或是刷新CPU的MOB,都可以,实现即可

内核抽象定义了,读屏障,写屏障,全屏障,数据依赖屏障

volatile:

一层是C语言的volatile

一层是Java语言的volatile

C语言的volatile就是禁止编译器对代码优化

而Java语言的的volatile 后面有编译器的存在,CPU执行存在,编译器可以乱序,CPU执行乱序,CPU的MOB导致乱序现象,于是,为了屏蔽这些乱序现象,那么JMM规定了一个模型,Happend-Before规则,也即对volatile写操作发生在读操作的前面,那么一定能读取到最新值