【JUC并发编程】Volatile关键字底层原理(中)(重排序/ 内存屏障/ happens-before)


1. 有序性(重排序)

重排序并没有严格的定义。整体上可以分为两种:
1.真·重排序:编译器、底层硬件(CPU等)出于“优化”的目的,按照某种规则将指令重新排序(尽管有时候看起来像乱序);

2.伪·重排序:由于缓存同步顺序等问题,看起来指令被重排序了;
重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。

2. 什么是重排序

注意(指令重排序的前提是,重排序指令不能够影响结果)
指令重排序在单线程的情况下,不会影响结果 但是在多线程的情况可能会影响结果

	// 可以重排序
	int a = 10;// 指令1
	int b = 20;// 指令2
	System.out.println(a + b);
	// 不可重排序
	int c = 10;
	int d = c - 7;
	System.out.println(d);

3. 为什么需要重排序

执行任务的时候,为了提高编译器和处理器的执行性能,编译器和处理器(包括内存系统,内存在行为没有重排但是存储的时候是有变化的)会对指令重排序。编译器优化的重排序是在编译时期完成的,指令重排序和内存重排序是处理器重排序

1.编译器优化的重排序,在不改变单线程语义的情况下重新安排语句的执行顺序
2.指令级并行重排序,处理器的指令级并行技术将多条指令重叠执行,如果不存在数据的依赖性将会改变语句对应机器指令的执行顺序
3.内存系统的重排序,因为使用了读写缓存区,使得看起来并不是顺序执行的

4. 重排序的例子分析


new Thread(() -> {
    while (flag) {
        int c = 0;
        if (flag) {
            c = num * 2;
        } else {
            c = 1;
        }
        if (c == 0) {
            System.out.println(c);
        }

    }

}, "线程1").start();
new Thread(() -> {
    while (true) {
        num = 2;
        flag = true;
    }
}, "线程2").start();
/**
 * c的结果可能分析:
 * 情况1:如果线程1先执行 则flag=false  则c的值=1
 * 情况2:如果线程2先执行 num=2 flag=true  则在执行线程1 flag=true 则c的值=4;
 * 情况3: 如果线程2发生了重排序flag = true; 在执行    num = 2; 则c的值=0
 */

在这里插入图片描述

5. 重排序的好处

执行任务的时候,为了提高编译器和处理器的执行性能,编译器和处理器(包括内存系统,内存在行为没有重排但是存储的时候是有变化的)会对指令重排序。编译器优化的重排序是在编译时期完成的,指令重排序和内存重排序是处理器重排序

编译器重排序 指令级并行重排序(cpu处理器重排序)

1.编译器优化的重排序,在不改变单线程语义的情况下重新安排语句的执行顺序
2.指令级并行重排序,处理器的指令级并行技术将多条指令重叠执行,如果不存在数据的依赖性将会改变语句对应机器指令的执行顺序
3.内存系统的重排序,因为使用了读写缓存区,使得看起来并不是顺序执行的
在这里插入图片描述

6. 重排序会产生什么问题

1.重排序可能会导致多线程程序出现内存可见性问题。(工作内存和主内存,编译器处理器重排序导致的可见性)
2.重排序会导致有序性问题,程序的读写顺序于内存的读写顺序不一样(编译器处理器重排序,内存缓冲区(是处理器重排序的内容))

7. 编译重排序

CPU只读一次的x和y值。不需反复读取寄存器来交替x和y值。

优化前优化后
int x=1;
int y=2;
##cpu在执行的过程中需要读取两次x和y的值
int a1=x1; load x=1
int b1=y
1; load y=2;
int a2=x2; load x=1
int b2=y
2; load y=2
int x=1;
int y=2;

int a1=x1; load x
int a2=x
2; x2
int b1=y
1; load y
int b2=y2; y

8. 处理器重排序

Num=0;
Flag=false
new Thread(() -> {
    while (true) {
        int c = 0;
        if (flag) {
            c = num * 2;
        } else {
            c = 1;
        }
        System.out.println(c);

    }

}, "线程1").start();
new Thread(() -> {
    while (true) {
        flag = true;
        num = 2;


    }
}, "线程2").start();
1.	如果先执行 线程1   c结果=1 在执行线程2  num改成=2 flag=true
2.	如果先执行 线程2 num=2,在修改flag=true 在执行线程1 c结果=4
3.	C=0 cpu发生重排序   线程2先执行 flag =true 还没有执行num=2 线程1在执行
的时候 C=0*2 =0

注意:不是随便重排序,需要遵循as-ifserial语义。

as-ifserial:不管怎么重排序(编译器和处理器为了提高并行的效率)
单线程程序执行结果不会发生改变的。
也就是我们编译器与处理器不会对存在数据依赖的关系操作做重排序。

CPU指令重排序优化的过程存在问题
as-ifserial 单线程程序执行结果不会发生改变的,但是在多核多线程的情况下
指令逻辑无法分辨因果关系,可能会存在一个乱序中心问题,导致程序执行结果错误。

9. 内存屏障

为了解决上述问题,处理器提供内存屏障指令(Memory Barrier):
读内存屏障(Load Memory Barrier):在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
写内存屏障(Store Memory Barrier):在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

内存屏障 1.禁止重排序 2.保证线程可见性

写内存屏障:

new Thread(() -> {
    while (true) {
        num = 2; // 及时刷新到主内存中
        flag = true;// ready 读 Volatile赋值带写屏障
        // 加上写屏障  1.处理器将存储缓存值写回主存 2.写屏障之前的代码不会发生在写屏障后面
    }
}, "线程2").start();

读内存屏障:

new Thread(() -> {
    while (true) {
        int c = 0;
// 读屏障 读屏障之后的代码读取主内存中的最新的数据
// 读屏障 之前的代码不会发生在读屏障之后执行
        if (flag) {
            c = num * 2;
        } else {
            c = 1;
        }
        System.out.println(c);
    }

}, "线程1").start();

volatile读前插读屏障,写后加写屏障,避免CPU重排导致的问题,实现多线程之间数据的可见性。

10. 硬件上面的内存屏障

Load屏障,是x86上的”ifence“指令,在其他指令前插入ifence指令,可以让高速缓存中的数据失效,强制当前线程从主内存里面加载数据
Store屏障,是x86的”sfence“指令,在其他指令后插入sfence指令,能让当前线程写入高速缓存中的最新数据,写入主内存,让其他线程可见。
Java里面的四种内存屏障

  1. LoadLoad屏障:举例语句是Load1; LoadLoad; Load2(这句里面的LoadLoad里面的第一个Load对应Load1加载代码,然后LoadLoad里面的第二个Load对应Load2加载代码),此时的意思就是,在Load2及后续读取操作从内存读取数据到CPU前,保证Load1从主内存里要读取的数据读取完毕.

  2. StoreStore屏障:举例语句是 Store1; StoreStore; Store2(这句里面的StoreStore里面的第一个Store对应Store1存储代码,然后StoreStore里面的第二个Store对应Store2存储代码)。此时的意思就是在Store2及后续写入操作执行前,保证Store1的写入操作已经把数据写入到主内存里面,确认Store1的写入操作对其它处理器可见。

  3. LoadStore屏障:举例语句是 Load1; LoadStore; Store2(这句里面的LoadStore里面的Load对应Load1加载代码,然后LoadStore里面的Store对应Store2存储代码),此时的意思就是在Store2及后续代码写入操作执行前,保证Load1从主内存里要读取的数据读取完毕。

  4. StoreLoad屏障:举例语句是Store1; StoreLoad; Load2(这句里面的StoreLoad里面的Store对应Store1存储代码,然后StoreLoad里面的Load对应Load2加载代码),在Load2及后续读取操作从内存读取数据到CPU前,保证Store1的写入操作已经把数据写入到主内存里,确认Store1的写入操作对其它处理器可见。

11. happens-before规则

happens-before表示的是前一个操作的结果对于后续操作是可见的,它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程.

  1. 程序次序规则:在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
    as-ifserial:不管怎么重排序(编译器和处理器为了提高并行的效率)
  2. 管程锁定规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)
private static int i;
private static Object objectLock = new Object();

public static void main(String[] args) {
    new Thread(() -> {
        synchronized (objectLock) {
            // 修改操作 写操作
            i = 10;

        }
        //线程解锁 i 会主动的刷新到主内存中,对另外线程可见的
    }, "线程1").start();
    new Thread(() -> {
        synchronized (objectLock) {
            System.out.println("i:" + i);
        }
    }, "2").start();
}


  1. volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

  2. 线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
    线程start前对变量的写,对该线程开始后对该变量是可见的

private static int i;
i=20;
//线程start前对变量的写,对该线程开始后对该变量是可见的
new Thread(()->{
    System.out.println("i:"+i);
}).start();

  1. 线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则。
private static int i;

public static void main(String[] args) throws InterruptedException {
    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            i = 2021;// 赋值 写的操作     // 线程结束前对变量的写,对其它线程得知它结束后的读可见
        }
    });
    thread.start();
    thread.join();

    System.out.println("i:" + i);
}


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

  2. 传递性规则:这个简单的,就是happens-before原则具有传递性,即hb(A, B) , hb(B, C),那么hb(A, C)。

private static int num = 0;
private volatile  static boolean flag = false;

new Thread(() -> {
    while (true) {
        num = 2;
        flag = true;
// 插入写屏障
    }
}, "线程2").start();

new Thread(() -> {
    while (true) {
        System.out.println(num +,+flag );
    }
}, "线程1").start();



  1. 对变量默认值(0,false,null)的写,对其它线程是可见的;
  • 4
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

超级码里喵

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

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

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

打赏作者

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

抵扣说明:

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

余额充值