美文网首页并发编程学习
并发编程学习三、volatile底层-多线程的实现原理

并发编程学习三、volatile底层-多线程的实现原理

作者: valentine_liang | 来源:发表于2018-12-25 01:06 被阅读0次

    转载请标明出处,https://www.jianshu.com/u/64f43a5add47

    JMM怎么解决原子性、可见性、有序性的问题?
    在java中提供了一系列和并发处理相关的关键字,比如vloatile、synchronized、find、juc(java.util.concurrent)等,这些就是Java内存模型封装了底层的实现后提供给开发人员使用的关键字,在开发多线程代码的时候,我们可以直接使用synchronized等关键字来控制并发,使得我们不需要关心底层的编译器优化、缓存一致性的带来的问题的,所以在java内存模型中,除了定义一套规范,还提供了开放的指令在底层进行封装后,提供给开发人员使用。

    原子性保障
    在java中提供了两个高级的字节码指令monitorenter和monitorexit,在java中对应的synchronized来保证代码块的操作是原子性的。

    可见性
    java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都是从主内存刷新,因此可以使用volatile来保证多线程操作时变量的可见性,除了volatile,java中的synchronized和final两个关键字都可以实现可见性。

    有序性
    在java中,可以使用synchronized和volatile来保证多线程之间操作的有序性,实现方式有所区别:volatile关键字会禁止指令重排;synchronized关键字保证同一时刻只允许一条线程操作。

    volatile如何保证可见性
    下载hsdis工具 ,https://sourceforge.net/projects/fcml/files/fcml-1.1.1/hsdis-1.1.1-win32-amd64.zip/download
    hsdis(Hotspot Disassembler)是hotspot 的一个工具,能够打印出JIT过程编译出的汇编代码。
    解压后存放到xxx\jre\bin\server路径下,然后跑main函数,跑main函数之前,加入如下虚拟机参数:-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*ThreadDemo.getInstance(替换成实际运行的代码)

    示例图
    示例图
    示例代码
    public class ThreadDemo {
        // 加了volatile的时候会有一个加锁的操作: 0x03060356: lock addl $0x0,(%esp)
        private volatile static ThreadDemo instance = null;
        // 没有加volatile是不会有lock的
        // private static ThreadDemo instance = null;
    
        public static ThreadDemo getInstance() {
    
            if (null == instance) {
                instance = new ThreadDemo();
            }
            return instance;
        }
    
        public static void main(String[] args) {
            ThreadDemo.getInstance();
        }
    }
    

    运行代码,会发现成员变量加了volatile的时候会有一个加锁的操作


    出现一个加锁的操作

    volatile变量修饰的共享变量,在进行写操作的时候会导出一个lock前缀的汇编指令,这个指令在前面我们讲解CPU高速缓存的时候提到过,会触发总线锁或者缓存锁,通过缓存一致性协议来解决可见性问题。
    对于声明了volatile的变量进行写操作,jvm都会向处理器发送一条lock前缀的指令,把这个变量所在的缓存的数据写会到系统内存(主内存),再根据前面提到过的MESI缓存一致性协议,来保证多CPU下的各个高速缓存中的数据的一致性。

    volatile防止指令重排序
    指令重排的目的是为了最大化地提高CPU利用率以及性能,CPU的乱序执行优化在单核时代并不影响正确性,但是在多核时代下,多线程能够在不同的核心上实现真正的并行,这样一旦线程之间共享数据,就可能会出现一些不可预料的问题。
    指令重排序必须遵从的原则是,不影响代码执行的最终结果,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序(这里的数据依赖性仅仅针对单个处理器中执行的指令和单个线程中执行的操作),这个语义实际上就是as-if-serial语义,不管怎么重排序,单线程程序的执行结果不会改变,编译器、处理器都必须遵守as-if-serial语义。

    多核心多线程下的指令重排影响
    示例代码

    public class VolatileDemo {
        
        private static int x = 0, y = 0;
        private static int a = 0, b = 0;
        
        public static void main(String[] args) throws InterruptedException {
    
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });
    
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            // t1.join() 如果t1没有执行完,就会在这里等待,等待执行完为止,基于wait-notify的
            t1.join();
            t2.join();
            System.out.println("x=" + x + "y=" + y);
        }
    }
    

    如果不考虑编译器重排序和缓存可见性的问题,上面这段代码可能出现的结果是
    x=0,y=1; x=1,y=0; x=1,y=1这三种结果,因为可能是先后执行t1/t2,也可能是反过来,还可能是t1/t2交替执行,但是这段代码的执行结果也有可能是x=0,y=0。这就是在乱序执行的情况下会导致的一种结果,因为线程t1内部的两行代码之间不存在数据依赖,因
    此可以把x=b乱序到a=1之前;同时线程t2中的y=a也可以早于t1中的a=1执行,那么他们的执行顺序可能是
    t1: x=b
    t2:b=1
    t2:y=a
    t1:a=1
    所以从上面的例子来看,重排序会导致可见性问题。但是重排序带来的问题的严重性远远大于可见性,因为并不是所有指令都是简单的读或写,比如DCL(双重检查加锁:Double Check Lock)的部分初始化问题。所以单纯的解决可见性问题还不够,还需要解决处理器重排序问题。

    内存屏障
    内存屏障是解决我们前面提到的两个问题,编译器的优化乱序和CPU的执行乱序;我们分别可以使用优化屏障和内存屏障这两个机制来解决。

    从CPU层面来了解一下什么是内存屏障
    CPU的乱序执行,本质还是由于在多CPU的机器上,每个CPU都存在cache,当一个特定数据第一次被特定一个CPU获取时,由于在该CPU缓存中不存在,就会从主内存中去获取,被加载到CPU高速缓存中,这样CPU就能快速访问缓存。当某个CPU进行写操作时,它必须确保其他的CPU已经将这个数据从他们的缓存中移除,这样才能让其他CPU安全地修改数据。显然,存在多个cache时,必须要通过一个cache一致性协议来避免数据不一致的问题,而这个通讯的过程又可能导致乱序访问,即运行时的内存乱序访问。
    现在的CPU架构中都提供了内存屏障的功能,在x86的cpu中,实现了相应的内存屏障,写屏障(store barrier)、读屏障(load barrier)和全屏障(full barrier),它们的主要作用是:1、防止指令之间的重排序;2、保证数据的可见性

    store barrier
    store barrier称为写屏障,相当于storestore barrier, 强制所有在storestore内存屏障之前的store指令都要在它之前执行,并发送缓存失效的信号。所有在storestore barrier之后的store指令,都必须在storestore barrier之前的store指令执行完后才能被执行。也就是禁止了写屏障前后的指令进行重排序,是的所有store barrier之前发生的内存更新都是可见的(这里的可见指的是修改值可见以及操作结果可见)


    写操作

    load barrier
    load barrier称为读屏障,相当于loadload barrier,强制所有在load barrier读屏障之后的load指令,都在loadbarrier屏障之后执行。也就是禁止对load barrier读屏障前后的load指令进行重排序, 配合store barrier,使得所有store barrier之前发生的内存更新,对load barrier之后的load操作是可见的


    读操作

    Full barrier
    full barrier成为全屏障,相当于storeload,是一个全能型的屏障,因为它同时具备前面两种屏障的效果。强制了所有在storeload barrier之前的store/load指令,都在该屏障之前被执行,所有在该屏之后的的store/load指令,都在该屏障之后被执行。禁止对storeload屏障前后的指令进行重排序。


    读写操作

    总结:内存屏障只是解决顺序一致性问题,不解决缓存一致性问题,缓存一致性是由cpu的缓存锁以及MESI协议来完成的。而缓存一致性协议只关心缓存一致性,不关心顺序一致性。所以这是两个问题编译器层面如何解决指令重排序问题在编译器层面,通过volatile关键字,取消编译器层面的缓存和重排序。保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。这就保证了编译时期的优化不会影响到实际代码逻辑顺序。

    如果硬件架构本身已经保证了内存可见性,那么volatile就是一个空标记,不会插入相关语义的内存屏障。如果硬件架构本身不进行处理器重排序,有更强的重排序语义,那么volatile就是一个空标记,不会插入相关语义的内存屏障。
    在JMM中把内存屏障指令分为4类,通过在不同的语义下使用不同的内存屏障来进制特定类型的处理器重排序,从而来保证内存的可见性LoadLoad Barriers, load1 ; LoadLoad; load2 , 确保load1数据的装载优先于load2及所有后续装载指令的装载
    StoreStore Barriers,store1; storestore;store2 , 确保store1数据对其他处理器可见优先于store2及所有后续存储。

    指令的存储
    LoadStore Barries, load1;loadstore;store2, 确保load1数据装载优先于store2以及后续的存储指令刷新到内存
    StoreLoad Barries, store1; storeload;load2, 确保store1数据对其他处理器变得可见, 优先于load2及所有后续装载指令的装载;这条内存屏障指令是一个全能型的屏障,在前面讲cpu层面的内存屏障的时候有提到。它同时具有其他3条屏障的效果。

    JIT的了解
    通常javac将程序源码编译,转换成java字节码,JVM通过解释字节码将其翻译成相应的机器指令,逐条读入,逐条解释翻译。显然,字节码经过解释运行,其运行速度必定会比可运行的二进制字节码程序慢,所以为了提高运行速度,引入了JIT技术。

    在执行时JIT会把翻译过的机器码保存起来,已备下次使用,因此从理论上来说,採用该JIT技术能够,能够接近曾经纯编译技术。
    JIT是just in time,即时编译技术。使用该技术,可以加速java程序的运行速度。 JIT并不总是奏效,不能期望JIT一定可以加速你代码运行的速度,而且它是有可能减少代码的运行速度,但这取决于你的代码结构,不过在非常多的情况下还是可以加速java程序的运行速度的。

    由于JIT对每条字节码都进行编译,会造成了编译过程负担过重。为避免这种情况,大多情况下JIT仅对常运行的字节码进行编译,如循环等。
    更加深入了解JIT:https://www.cnblogs.com/linghu-java/p/8589843.html

    学习来源https://www.gupaoedu.com/

    相关文章

      网友评论

        本文标题:并发编程学习三、volatile底层-多线程的实现原理

        本文链接:https://www.haomeiwen.com/subject/rfnmkqtx.html