美文网首页
Java-多线程-JMM&Volatile

Java-多线程-JMM&Volatile

作者: 蓝色_笔记本 | 来源:发表于2021-06-15 14:04 被阅读0次

    一、现代计算机模型

    冯诺依曼计算机是由控制器、运算器、存储器、输入、输出构成。但现代计算机模型要复杂的多,其中CPU是我们Java多线程方面需要注意的。


    image.png

    1、CPU内部结构

    内部结构划分

    (1)控制单元:控制单元是整个CPU的指挥控制中心,由指令寄存器IR、指令译码器ID和操作控制器OC等组成, 对协调整个电脑有序工作极为重要。它根据用户预先编好的程序,依次从存储器中取出各条指令,放在指令寄存器IR中,通过指令译码(分析)确定应该进行什么操作。
    (2)运算单元:运算单元是运算器的核心。可以执行算术运算(包括加减乘数等基本运算及其附加运算) 和逻辑运算(包括移位、逻辑测试或两个值比较)。
    (3)存储单元:存储单元包括CPU片内缓存Cache和寄存器组,是 CPU 中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据。

    2、CPU寄存器

    每个CPU都包含一系列的寄存器,它们是CPU内内存的基础。CPU在寄存器上执行操作的 速度远大于在主存上执行的速度。这是因为CPU访问寄存器的速度远大于主存。

    3、CPU缓存

    即高速缓冲存储器,是位于CPU与主内存间的一种容量较小但速度很高的存储器。由于 CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,Cache中保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调用, 减少CPU的等待时间,提高了系统的效率。 缓存分一级Cache(L1 Cache) 二级Cache(L2 Cache) 三级Cache(L3 Cache) ,速度由高到低,空间由小到大。

    4、CPU读取存储器数据过程

    (1)CPU要取寄存器XX的值,只需要一步:直接读取。
    (2)CPU要取L1 cache的某个值,需要1-3步(或者更多):把cache行锁住,把某个数据拿 来,解锁,如果没锁住就慢了。
    (3)CPU要取L2 cache的某个值,先要到L1 cache里取,L1当中不存在,在L2里,L2开始加锁,加锁以后,把L2里的数据复制到L1,再执行读L1的过程,上面的3步,再解锁。
    (4)CPU取L3 cache的也是一样,只不过先由L3复制到L2,从L2复制到L1,从L1到CPU。
    (5)CPU取内存则复杂:通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求, 等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定。

    5、多线程环境下存在的问题

    缓存一致性问题

    在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存 。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算任务都涉及同一 块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都 遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、 MESI、MOSI、Synapse、Firefly及DragonProtocol,等等。


    image.png

    6、指令重排序问题

    为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,单线程中保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。Java虚拟机的即时编译器中也有类似的指令重排序优化。

    二、多线程的CPU处理

    多线程在访问处理同一资源的时候,会造成多线程处理结果不一致的情况,针对这种情况,当代计算机有总线加锁、缓存一致性协议两种解决方法。

    1、总线加锁

    在多CPU的情况下,在一个CPU对主内存进行加锁后,另外的CPU就无法对主内存进行读写操作,这样的效率极低。

    2、缓存一致性协议MESI

    M:修改 E:独占 S:共享 I:无效,CPU与主内存间多了层缓存一致性协议,在多CPU操作同一缓存行的时候,能避免变量写回内存时有冲突。
    (1)变量X被CPU 1读取,设置变量X对应的状态为E独享状态。
    (2)变量X被CPU 2读取,设置变量X对应的状态为S共享状态。
    (3)CPU 2把变量X从3级缓存复制到寄存器中并运算,结果变量X值重新回到3级缓存中,同时设置变量X为M修改状态,修改完成CPU 2会把这消息通知缓存一致性协议。CPU 1通过缓存一致性协议的嗅探机制知道变量X已被其它CPU修改,把设置自己缓存变量X状态为无效I。
    (4)CPU 2把变量X从缓存写回到主内存中,并把CPU 2缓存变量X的状态改为独享E。CPU 1的无效操作完毕。
    (5)如果CPU 1还想读取操作变量X,需要重新到主内存中获取变量X。

    缓存一致性协议失效

    1、操作变量大小超过一个缓存行,横跨两个缓存行,这样只能使用总线锁了。注意:一个缓存行可以有多个变量,一个变量也可以横跨两个缓存行。
    2、本身CPU比较低级,不支持缓存一致性协议,比如早期的奔腾CPU。

    JMM模型是以上主内存、缓存、CPU的一种抽象。

    三、JAVA线程

    现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个Java程序,操作系统就会创建一个Java进程。现代操作系统调度CPU的最小单元是线程,也叫轻量级进程,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行。

    线程的实现可以分为两类
    1、用户级线程

    指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。另外,用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心。

    2、内核级线程

    线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度该进程的其他线程执行。在多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢得多,但是仍然比进程的创建和管理操作要快。

    JVM创建的线程存在于用户级线程(没有资格获取CPU的使用权),JVM创建线程会在JVM进程里面开辟出一块空间——线程栈空间,线程会通过栈帧指令通过 库调度器(Linux中的pThread)去操作真正的线程(有资格获取CPU的使用权),真正的线程存于内核空间,有库调度器生成。

    四、JMM模型

    1、概述

    JMM与JVM内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性三大特性展开。
    JMM模型是基于CPU、多级缓存、缓存一致性协议、主内存之间关系的一种抽象,是基于CPU缓存模型建立起来的,这样可以屏蔽硬件、操作系统的差异,由JVM实现,让Java具有跨平台的特性。


    image.png
    image.png

    2、JMM八大内存操作

    JMM八大操作用于线程的工作内存与操作系统的主内存,八大操作每个操作必须保证原子性,但全部不一定是原子,必须按顺序全部执行完数据才能写回主内存,但不一定连续一步到位。
    (1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态。
    (2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
    (3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
    (4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
    (5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
    (6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
    (7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
    (8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中。

    3、简单案例一

    private boolean initFlag = false;
    static Object object = new Object();
    
    public void refresh(){
        this.initFlag = true; //普通写操作,(volatile写)
        String threadname = Thread.currentThread().getName();
        System.out.println("线程:"+threadname+":修改共享变量initFlag");
    }
    
    public void load(){
        String threadname = Thread.currentThread().getName();
        int i = 0;
        while (!initFlag){
            i++;
        }
        System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i);
    }
    
    public static void main(String[] args){
        VolatileVisibilitySample sample = new VolatileVisibilitySample();
        Thread threadA = new Thread(()->{
            sample.refresh();
        },"threadA");
    
        Thread threadB = new Thread(()->{
            sample.load();
        },"threadB");
    
        threadB.start();
        try {
             Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadA.start();
    }
    

    (1)以上程序结果线程B无法跳出循环,initFlag是两个线程的共享资源,在线程A修改initFlag=true后,线程A在CPU分配的时间段完成后,会把initFlag=true写回主内存中,但是由于initFlag的写入为普通写,线程B无法嗅探的到,无法到主内存重新载入;同时线程B循环没有触发本身退出对CPU占用的原因。
    (2)若把load()替换以下的程序,线程B能跳出循环。object也为两个线程的共享资源,程序加上synchronized后,线程B循环有触发本身退出对CPU占用进行上下文切换,会重新到主内存重新载入initFlag。

    public void load(){
        String threadname = Thread.currentThread().getName();
        int i = 0;
        while (!initFlag){
            synchronized (object){
                i++;
            }
        }
        System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i);
    }
    

    (3)若把initFlag变量前面加上volatile,线程B也能跳出循环,线程A把initFlag变量volatile写入主内存后,会触发JMM的嗅探机制,线程B感应到后会重新到主内存载入initFlag变量。JMM中volatile保证了可见性,类似于操作系统缓存一致性中的嗅探机制。

    private volatile boolean initFlag = false;
    

    4、简单案例二

    private static volatile int counter = 0;
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    counter++; 
                }
            });
            thread.start();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter);
    }
    

    (1)以上counter结果小于等于10000,volatile保证了可见性,类似于操作系统的缓存一致性协议MESI,一个线程中counter发生改变后,counter状态由共享E变为修改M,其它线程根据嗅探机制,counter状态由共享E变为无效I,变量无效后代表该轮循环作废,需要进入下轮循环,注意:该时刻counter变量不一定已经刷回主内存,若没有,下轮循环会跳过counter变量相关逻辑代码,执行后续的程序指令,这就是指令重排。volatile不能保证原子性。
    (2)过多volatile+CAS会造成过多无效的操作,进而会造成线程工作内存高频率访问主内存,会对I/O带宽带来压力,若压力高达一定程度会造成总线风暴的现象。

    4、指令重排

    概述

    指令重排在单线程的情况下必须遵循结果不能被改变这一规则,但是在多线程并发的条件下不能保证结果不能被改变。

    发生时机

    (1)编译阶段,即时编译,字节码->机器码。
    (2)CPU运行时。

    禁止重排

    添加内存屏障可以防止指令重排。
    (1)volatile可以在某种情况下,会在指令间添加屏障,从而防止指令重排。


    image.png

    (2)手动添加内存屏障。
    Unsafe魔术类中loadFence()、storeFence()和fullFence()可以实现手动添加内存屏障,从而防止指令重排。Unsafe直接越过JVM去操作内存,机器不安全,Unsafe是bootstrap引导加载器引入的,需要反射的方式获取。

    public static Unsafe reflectGetUnsafe() {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            return (Unsafe) field.get(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
    

    (3)多线程下的单例模式

    对象创建过程,本质可以分文三步,第一步:申请空间;第二步:实例化对象;第三步:赋值。三个步骤中没办法保证原子性,同时也有可能产生指令重排。
    synchronized:保证原子性。
    volatile:防止指令重排。

    private volatile static Singleton myinstance;
    public static Singleton getInstance() {
        if (myinstance == null) {
            synchronized (Singleton.class) {
                if (myinstance == null) {
                    myinstance = new Singleton();//对象创建过程,本质可以分文三步
                }
            }
        }
        return myinstance;
    }
    
    public static void main(String[] args) {
        Singleton.getInstance();
    }
    

    相关文章

      网友评论

          本文标题:Java-多线程-JMM&Volatile

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