深入理解并发内存模型JMM与内存屏障

2021-06-10  本文已影响0人  YonchanLew

(1)多核并发缓存架构



早期计算机先把数据(硬盘数据)加载到主内存,然后CPU再到内存中取。由于现在CPU发展很快,CPU的运算速度比主内存高得多,为了避免受主内存读取速度的影响,所以现在会在CPU中有CPU缓存,速度接近CPU,比主内存快得多,只要数据在CPU缓存,那么CPU的速度就没有太大的限制,发挥到最大



L1、L2、L3就是CPU的高速缓存

(2)JMM内存模型



Java多线程内存模型跟CPU缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽掉了低层不同计算机的区别。
假设主内存中有共享变量 int a=6,线程1把它改为7,线程2是未必能看到最新值的,因为线程和主内存之间还有一个工作内存,存储这共享变量副本,线程1会先把工作内存的值改为7,在刷到主内存中,但线程2的工作内存值还是6,未必感知得到
证明:

public class VolatileVisibilityTest {

    private static boolean initFlag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("waiting data...");
            while(!initFlag){

            }
            System.out.println("===============success");
        }).start();

        Thread.sleep(2000);

        new Thread(() -> prepareData()).start();
    }

    public static void prepareData(){
        System.out.println("prepare data...");
        initFlag = true;
        System.out.println("prepare data end...");
    }

}

在initFlag变为了true之后,但线程1还没感知得到,仍处于死循环中

(3)volatile
只要使用了volatile修饰,就能马上知道最新值,退出while循环
private static volatile boolean initFlag = false;

(4)volatile怎么保证线程可见性
内存模型原子操作:
read(读取):从主内存读取数据
load(载入):将主内存读取到的数据写入工作内存
use(使用):从工作内存读取数据来计算
assign(赋值):将计算好的值重新赋值到工作内存中
store(存储):将工作内存数据写入主内存
write(写入):将store过去的变量值赋值给主内存中的变量
lock(锁定):将主内存变量加锁,标识为线程独占状态
unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量


没有volatile的流程

下面先慢慢展开

(5)缓存一致性协议(MESI)
多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效

(6)查看volatile汇编指令
首先下载hsdis-amd64.dll,然后复制到如C:\Program Files\Java\jdk1.8.0_261\jre\bin中,然后在运行java程序的时候,要先配置一些jvm参数
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileVisibilityTest.prepareData 如果不同类名方法名的还得改改这里的内容


如果提示了PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output,说明是PrintAssembly功能以及开启,但系统不支持,把hsdis-amd64.dll复制到如C:\Program Files\Java\jdk1.8.0_261\jre\bin\server目录吧,亲测可行,以下就是输出的汇编信息


其中*putstatic initFlag是JVM指令码,作用是给静态变量赋值。lock、mov这些都是汇编语言。
没有volatile修饰的时候,没有lock指令,volatile修饰的变量在赋值的时候会有lock。

(7)volatile缓存可见性实现原理
底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存
IA-32和Intel 64架构软件开发者手册对lock指令的解析:
Ⅰ、会将当前处理器缓存行的数据立即写回到系统内存
Ⅱ、这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI协议)
Ⅲ、提供内存屏障功能,使lock前后指令不能重排序

其中第一点中,lock能使变量赋值完后立即执行store和write,没有lock的话是不会立即执行的,会不知道什么时候执行。所有lock会二话不说把数据刷回主内存中。立即刷回到主内存的目的是让多线程能及时感知到修改,强调及时性。
第二点,MESI,指定的是
M状态(修改)
E状态(独享)
S状态(共享)
I状态(无效)
把变量改为Invalid状态,数据就无效了。MESI内容比较多,可以自己搜索一下。
第三点后面再说。

假如面试提到volatile的原理,大概吹一下是通过汇编lock前缀指令、然后这个指令的行为(立即、MESI)就差不多了

(8)指令重排序与内存屏障

(9)什么是有序性
程序在执行的时候是有顺序行的,下面看一个例子,猜一下可能会输出什么

public class VolatileSerialTest {

    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        Set<String> resultSet = new HashSet<>();

        for (int i = 0; i < 10000000; i++) {
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(() -> {
                a = y;
                x = 1;
            });

            Thread other = new Thread(() -> {
                b = x;
                y = 1;
            });

            one.start();
            other.start();
            one.join();
            other.join();

            resultSet.add("a=" + a + ",b=" + b);
            System.out.println(resultSet);
        }
    }

}

就这么一看,ab的组合可能有00、01、10,但不会有11。
但实际上,随着程序的运行,是会出现11的情况,也就是a=1,也就是y=1,也就是要先other线程先执行完成,这样b应该是0才对,但为什么会是1,这个就是指令重排序的影响。
在最终指令执行之前,可能会出现几种情况的重排序,如编译器优化重排序、指令级并行重排序、内存系统重排序,所以出现11的情况可能是执行了
x=1; => y=1; => a=y; => b=x;
为什么会自作主张重排序,大概是cpu认为当前指令比较耗时,而后面的指令结果会在其他地方使用,就先执行了,让其他地方可以不用等这么久,然后再执行那个耗时的操作。就是编译器和处理器为了提高并行度。
这就出bug了,但不是计算机的bug,是你代码的bug,是你自己没考虑到11的情况,而不是理所当然的不可能有11,这就是并发的难点。

(10)重排序原则
操作系统不会乱排序,而是有依据的,会遵循as-if-serial与happens-before原则。
as-if-serial:不管怎么重排序,(单线程)程序的执行结果不能被改变。
假如有a=y和x=a,这个就是不能重排序的,因为有依赖关系,重排序的话x的值是变化的
像之前的程序,a=y和x=1,并没有任何关系,重排序并不影响结果,所以是允许重排序的,它不管是不是影响了其他线程,as-if-serial只管单线程

假设oracle的JDK产品经理要求“a=y和x=a”的时候不能重排序,“a=y和x=1”的时候可以重排序,那么开发人员如何实现JDK这个软件?
编译原理会进行语义分析生成语义树,判断代码之间有没有依赖关系,有依赖就不能重排序

happens-before:
①程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
②锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

obj.lock();
obj.unlock();
obj.lock();
obj.unlock();
就是第2第3行不能重排序

③volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
④线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
⑤传递性:A先于B,B先于C,那么A必然先于C
⑥线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功放回后,线程B对共享变量的修改将对线程A可见。
⑦线程终端规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
⑧对象终结规则:对象的构造函数执行,结束先于finalize()方法。

(11)阿里面试题:双重检测锁DCL对象半初始化问题
在阿里巴巴手册中,有一个推荐做法



早期的时候,双重检测锁单例模式就是这么写的,主要是解决并发的问题,避免重复初始化

public class DoubleCheckLockSingleton {

    private static DoubleCheckLockSingleton instance = null;

    private DoubleCheckLockSingleton(){}

    //双重检测锁单例
    public static DoubleCheckLockSingleton getInstance(){
        if(instance == null){       //一重检测
            synchronized (DoubleCheckLockSingleton.class){      //锁
                if(instance == null){       //二重检测
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        DoubleCheckLockSingleton instance = DoubleCheckLockSingleton.getInstance();
    }

}

但它存在点问题,我们先在idea中安装jclasslib插件,看看这个类的指令码




其中synchronized对应的就是monitorenter至monitorexit


10 monitorenter
    获取静态变量
11 getstatic #2 <tuling/DoubleCheckLockSingleton.instance>
    判断是不是为null
14 ifnonnull 27 (+13)
    是的话就new
17 new #3 <tuling/DoubleCheckLockSingleton>
20 dup
    执行init方法(下面简单说明一下,和这部分内容无关)
21 invokespecial #4 <tuling/DoubleCheckLockSingleton.<init>>
    给变量赋值,即instance=xx
24 putstatic #2 <tuling/DoubleCheckLockSingleton.instance>
27 aload_0
28 monitorexit

对象创建的主要流程



执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(主要,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
回到问题上,究竟是会出现什么问题?就是 invokespecial 和 putstatic 有可能重排序,因为没有违背as-if-serial与happens-before原则。
对于单线程下,重排序的结果并没有影响


个人理解
重排序之后:先把17行的地址给24行,instance已经不为空了,但初始化是还没完成的,未执行完init都是未完成,这时是对象的半初始化。当其他线程拿到了这个还没完成初始化的变量时,就会出现问题。(这种问题只有极端情况才会偶尔出现)
所以要加volatile,就不会有重排序。

(12)内存屏障
volatile底层会帮我们实现内存屏障。如何理解内存屏障?
如a=y; x=1; 如果不想这两行代码重排序,在它们中间加一行标记性代码,跟重排序(CPU )做好约定,当遇到这个标记的时候就不能对它前后的代码进行重排序,那么这个标记性代码就叫做内存屏障

屏障类型 指令示例 说明
LoadLoad Load1; LoadLoad; Load2 保证load1的读取操作在load2及后续读取操作之前执行
StoreStore Store; StoreStore; Store2 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
LoadStore Load1; LoadStore; Store2 在Store2及其后的写操作执行前,保证load1的读操作已读取结束
StoreLoad Store1; StoreLoad; Load2 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

这些屏障是jdk规定的,就是jdk的程序员实现的,load就是加载,store就是写
LoadLoad:如b=a; c=a; 对应a来说,都是加载,只有它们之间加了LoadLoad,就不允许重排序。

a=2;  //volatile写,a为volatile变量
StoreStore屏障
a=1;  //volatile写
StoreLoad屏障
b=a;  //volatile读
LoadLoad屏障
LoadStore屏障

也就是jdk的程序员在实现volatile的时候,就是在对它的操作前后都要加屏障,具体实现的话看看源码 openjdk - https://github.com/openjdk/jdk/blob/master/src/hotspot/share/interpreter/zero/bytecodeInterpreter.cpp


这个switch就是对不同类型的volatile进行各自的操作,最后都会执行一个 OrderAccess::storeload();,可以看看这个链接orderAccess_linux_x86.hpp

前面asm就是调用汇编语言的意思,后面的就是汇编语言,还记得这个汇编语言吗?回看上方第6点提到的没有volatile修饰的时候,没有lock指令,volatile修饰的变量在赋值的时候会有lock。
lock前缀指令:在上方第7点中也提到提供内存屏障功能,使lock前后指令不能重排序,当很多的硬件看到这个lock前缀指令,就不会对前后左右的代码进行重排序,就是一个约定好的代码指令,看到就不能重排序。

(13)从Spring Cloud微服务框架源码看下并发编程的应用
从github下载nacos源码,后续再看

上一篇 下一篇

猜你喜欢

热点阅读