volatile有序性和可见性底层原理

声明:本文为作者原创,如若转发,请指明转发地址

1、缓存一致性

1、首先,编译之后Java代码会被编译成字节码.class文件,在运行时会被加载到JVM中,JVM会将.class转换为具体的CPU执行指令,CPU加载这些指令逐条执行。
在这里插入图片描述

2、由于计算机的主存和CPU的运算速度相差很大,读写主存中的数据没有CPU中执行指令的速度快,所以会在处理器和主存之间加入一层或多层的高速缓存:将运算需要使用的数据复制到缓存中,在进行运算时CPU不再和主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后再从缓存同步到主存之中,这样处理器就无须等待缓慢的内存读写了。

3、但是加入缓存引入了缓存一致性问题。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。缓存一致性协议(MESI协议)它确保每个缓存中使用的共享变量的副本是一致的。

在这里插入图片描述

2、JMM

JAVA内存模型是JAVA虚拟机用来屏蔽硬件和操作系统内存读取差异,以达到各个平台下都能达到一致的内存访问效果。Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节

1、java内存模型分为主内存和线程工作内存两种

主内存:所有线程都共享的内存。如下图所示,方法区和堆属于主内存区域。

线程工作内存:每个线程独享的内存。如下图所示,虚拟机栈、本地方法栈、程序计数器属于线程独享的工作内存。

在这里插入图片描述

**2、java内存模型规定了所有的变量都必须存储在主存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主存副本,线程对变量的所有操作(读写、赋值)都必须在工作内存中进行,而不能直接读写主存中的数据。**不同的线程之间也无法访问其他工作内存中的变量,线程中变量值的传递均通过主存来完成。

线程、主内存、工作内存的关系如图:

在这里插入图片描述

3、举例:

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
        // ....
        }
    });
    t.start();
    sleep(1);
    run = false; // 线程t不会如预想的停下来
}
  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

在这里插入图片描述

  1. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,
    减少对主存中 run 的访问,提高效率
    在这里插入图片描述

  2. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量
    的值,结果永远是旧值

在这里插入图片描述

  1. 解决方法:在变量run前加入volatile修饰

    它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

3、volatile可见性原理

当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。

1、lock前缀指令角度

缓存行(cache line):CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行。

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障。

lock指令在多核处理器下会引发下面的事件:

将当前处理器的缓存行的数据写回到系统内存,同时使其他CPU里缓存了该内存地址的数据置为无效。

为了提高处理速度,处理器一般不直接和内存通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作完成后并不知道处理器何时将缓存数据写回到内存。

但如果对加了volatile修饰的变量进行写操作,JVM就会向处理器发送一条lock前缀的指令,将这个变量在缓存行的数据写回到主存。这时只是写回到主存,但其他处理器的缓存行中的数据还是旧的,要使其他处理器缓存行的数据也是新写回到主存的数据,就需要实现缓存一致性协议。

即在一个处理器将自己缓存行的数据写回到系统内存后,其他的每个处理器就会通过嗅探在总线上传播的数据来检查自己缓存的数据是否已过期,当处理器发现自己缓存行对应的内存地址的数据被修改后,就会将自己缓存行缓存的数据设置为无效,当处理器要对这个数据进行修改操作的时候,会重新从系统内存中把数据读取到自己的缓存行,重新缓存。

总结下:volatile可见性的实现就是借助了CPU的lock指令,通过在写volatile的机器指令前加上lock前缀,使写volatile具有以下两个原则:

(1) 写volatile时处理器会将缓存写回到主内存。

(2) 一个处理器的缓存写回到内存会导致其他处理器的缓存失效。

2、内存屏障角度

内存屏障(memory barriers):一组处理器指令,用于实现对内存操作的顺序限制。

java数据原子操作(内存间交互操作):

read(读取):从主存中读取数据

load(载入):将读取到的数据写入到工作内存中

use(使用):从工作内部中读取数据并计算

assign(赋值):将计算好的值重新赋值到工作内存中

store(存储):将工作内存中的数据写入到主存

write(写入):将store过去的变量值赋值给主存中的变量
在这里插入图片描述

如果把一个变量从主存中拷贝到工作内存中,就要按顺序执行read和load操作,同理如果把变量从工作内存同步到主存中,就要按顺序执行store和write指令,java内存模型要求上面的两个操作必须按顺序执行,但是并不要求连续执行。

为了保证可见性:

load、use的执行顺序不被打乱 (保证使用变量前一定进行了load操作,从主存拿最新值来)

assign、wirte的执行顺序不被打乱(保证赋值后马上就是把值写到主存)。

**所以需要使用读屏障和写屏障:**一组处理器指令,用于实现对内存操作的顺序限制。

读操作时在读指令use插入读屏障,重新从主存加载最新值进来,让工作内存中的数据失效,强制从新从主内存加载数据。(读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据 )

写操作时在写指令assign插入写屏障,能让写入工作内存中的最新数据更新写入主内存,让其他线程可见。(写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中,其他线程就可以读到最新的结果了 )

public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 赋值带写屏障
    // 写屏障
}
public void actor1(I_Result r) {
    // 读屏障
    // ready 是 volatile 读取值带读屏障
    if(ready) {
    	r.r1 = num + num;
    } else {
    	r.r1 = 1;
    }
}

在这里插入图片描述

4、volatile有序性原理

1、指令重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。(好处)

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:

在这里插入图片描述

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

2、内存屏障角度

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

在这里插入图片描述

还是那句话,不能解决指令交错:
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
而有序性的保证也只是保证了本线程内相关代码不被重排序

在这里插入图片描述

5、happens-before规则

JSR-133使用happens- before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

管程锁定规则 :一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。

volatile变量规则 :对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。

线程启动规则 :Thread对象的start()方法先行发生于此线程的每一个动作。

线程终止规则 :线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。

线程中断规则 :对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。

对象终结规则 :一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

传递性 :如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

1、线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见:

//共享变量
static int x;
//对象锁
static Object m = new Object();
new Thread(()->{
        synchronized(m) {
   //加入synchronized能够保证有序性、原子性、可见性,第一个线程对该变量的写对其他线程对该变量的读可见。
        x = 10;
    }
},"t1").start();
new Thread(()->{
    synchronized(m) {
        //因为加了synchronized,因此对于x变量的写操作已经写入到主存中去了,因此对其他线程的读可见。
    	System.out.println(x);
    }
},"t2").start();

2、线程对 volatile 变量的写,对接下来其它线程对该变量的读可见 :

//读共享变量x加了voilate关键字,那么就能够有序性和可见性
volatile static int x;
new Thread(()->{
    //能够保证对当前变量的写操作对其他线程的读操作可见
	x = 10;
},"t1").start();
new Thread(()->{
	System.out.println(x);
},"t2").start();

3、线程 start 前对变量的写,对该线程开始后对该变量的读可见 :

static int x;
//线程开始前对该变量的写
x = 10;
new Thread(()->{
    //对该线程开始后对该变量的读可见
	System.out.println(x);
},"t2").start();

4、线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待
它结束)

static int x;
Thread t1 = new Thread(()->{
    //线程结束前对变量的写
	x = 10;
},"t1");
t1.start();

//主线程等待t1线程执行结束
t1.join();
//对其它线程得知它结束后的读可见
System.out.println(x);

5、线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过
t2.interrupted 或 t2.isInterrupted)

static int x;
public static void main(String[] args) {
    Thread t2 = new Thread(()->{
        while(true) {
        if(Thread.currentThread().isInterrupted()) {
            System.out.println(x);
            break;
        }
    }
},"t2");
t2.start();
    
new Thread(()->{
    sleep(1);
    x = 10;
    //线程 t1 打断 t2(interrupt)前对变量的写
    t2.interrupt();
},"t1").start();
    
while(!t2.isInterrupted()) {
    Thread.yield();
    }
    //对于其他线程得知 t2 被打断后对变量的读可见
    System.out.println(x);
    }
}

6、对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
7、具有传递性,如果x hb-> y并且 y hb-> z那么有x hb-> z ,配合 volatile 的防指令重排

volatile static int x;
static int y;
new Thread(()->{
    y = 10;
    x = 20;
},"t1").start();
new Thread(()->{
    // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
    System.out.println(x);
},"t2").start();

6、as-if-serial语义

1、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

在这里插入图片描述

上面的三种情况,只要重排序代码的顺序,结果就会改变。编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

2、as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

int a=2;  	//A
int b=3;  	//B
int c=a+b;	//C

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

在这里插入图片描述

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

7、指令重排序面试题

面试题:什么是指令重排序?既然重排序有这么好处,为什么还要禁止指令重排序?重排序有什么后果?

class ReorderExample {
    int a = 0;
    boolean flag = false;
    //写操作
    public void writer() {
            a = 1;          //1
            flag = true;    //2
    }
    //读操作
    Public void reader() {
        if (flag) {          //3
        int i = a * a;      //4
  		}
    }
}

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?

答案是:不一定能看到。由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序

在这里插入图片描述

操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程程序的语义被重排序破坏了!

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

  • 5
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我一直在流浪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值