• 二、Java内存模型与volatile


    一、机器硬件CPU

    1、概述

    在计算机中,所有的运算操作都是由CPU的寄存器来完成的,CPU指令的执行过程需要涉及数据的读取和写入操作,CPU所能访问的所有数据只能是计算机的主存(通常是指RAM),虽然CPU的发展频率不断地得到提升,但受制于制造工艺以及成本的限制,计算机的内存反倒在访问速度上没有多大的提升,因此CPU的处理速度和内存的访问速度之间的差距越来越大,通常这种差距可以达到上千倍,极端情况下甚至会在上万倍以上。

    2、CPU Cache模型

    1、由于CPU和内存的速度不对等,通过传统FSB直连内存的访问方式很明显会导致CPU资源受到大量的限制,降低CPU整体的吞吐量,于是就有了在CPU和主内存之间增加缓存的设计,现在缓存的数量都可以增加到3级了,最靠近CPU的缓存称为L1,然后依次是L2,L3和主内存,如下图:

    在这里插入图片描述

    2、由于程序指令和程序数据的行为和热点分布差异很大,因此L1 Cache又被划分成了L1i(instruction)和L1d(data)这两种有各自专门用途的缓存,CPU Cache又是由多个Cache Line组成的,Cache Line可以认为是CPU Cache中最小的缓存单位,目前主流的CPU Cache的Cache Line大小都是64字节。
    3、Cache的出现是为了解决CPU直接访问内存效率低下问题的,程序在运行过程中,会将运算所需要的数据从主内存复制一份到CPU Cache中,CPU在进行计算时就可以直接对CPU Cache中的数据进行读取和写入,当计算完成后,再将CPU Cache中的最新数据刷新到主内存当中,极大的提高了CPU吞吐能力。

    在这里插入图片描述

    3、CPU缓存一致性问题

    1、由于缓存的出现,极大地提高了CPU的吞吐能力,但同时也引入了缓存不一致的问题,对于I++这个操作,在程序的运行过程中,首先将主内存中的数据复制一份存放到CPU Cache中,那么CPU寄存器在进行数值计算的时候就直接到Cache中读取和写入,当整个过程运算结束之后再将Cache中数据刷新到主内存中,具体过程如下:
    • 读取主内存的i到CPU Cache中。
    • 对i进行加1操作。
    • 将结果写回到CPU Cache中。
    • 将数据刷新到主内存中。
    2、i++在单线程的情况下不会出现任何问题,但是在多线程的情况下就会有问题,每个线程都有自己的工作内存(本地内存,对应CPU中的Cache),变量i会在多个线程的本地内存中都有一个副本。如果同时有两个线程执行i++操作,假设i的初始值为0,每一个线程都从主内存中获取i的值存入CPU Cache中,然后经过计算再写入主内存中,很有可能i在经历过两次自增之后结果还是1。这就是缓存不一致性的问题。

    4、解决缓存不一致性问题

    1、加锁方式:
    • 是一种悲观的实现方式,CPU和其他组件的通信都是通过总线(数据总线、控制总线、地址总线)来进行的,如果采用总线加锁的方式,则会阻塞其他CPU对其他组件的访问,从而使得只有一个CPU(抢到锁)能够访问这个变量的内存,效率低下。
    2、缓存一致性协议:最为出名的是Intel的MESI协议,MESI协议保证了每一个缓存中使用的共享变量副本都是一致的,它的大致思想是,当CPU在操作Cache中的数据时,如果发现该变量是一个共享变量,也就是说该变量在其他的CPU Cache中也存在一个副本,那么会进行如下操作:
    • 读取操作,不做任何处理,只是将Cache中的数据读取到寄存器中
    • 写入操作,发出信号通知其他CPU将该变量的Cache line设置为无效状态,其他CPU在进行该变量读取的时候不得不到主内存中再次获取(即获取到最新的值)

    在这里插入图片描述

    二、Java内存模型(JMM)

    1、JMM概述

    1、在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(共享变量)。局部变量(Local Variables),方法定义参数(Java语言规范称之为Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
    2、JMM是Java内存模型,即Java Memory Model,本身是一种抽象的概念,实际上并不存在。它定义了主存(所有线程共享的数据存放位置)、工作内存(每个线程私有的数据存放位置)等抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。JMM决定一个线程对共享变量的写入何时对另其他线程可见
    3、它描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。
    4、JMM规定:
    • 所有的共享变量都存储于主内存(所有线程都可以访问),这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题
    • 每一个线程还存在自己的工作内存(私有的),线程的工作内存,保留了被线程使用的共享变量的副本
    • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,首先要将共享变量从主内存拷贝到自己的工作内存中,然后对变量进行操作,操作完成后再将变量写会主内存,而不能直接读写主内存中的变量
    • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成

    在这里插入图片描述

    在这里插入图片描述

    5、主内存:
    • 虚拟机内存的一部分,是所有线程共享的,主要包括方法区和堆。
    • 从硬件角度来说就是内存条。
    6、工作内存:每个线程都有一个工作内存,不是共享的,工作内存中主要包括两个部分:
    • 一个是属于该线程私有的栈。
    • 对主存部分变量拷贝的寄存器(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)。

    2、JMM三大特性(并发三要素)

    1、可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到(CPU缓存引起)
    2、原子性一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行(分时复用、线程切换引起)
    3、有序性:程序执行的顺序按照代码的先后顺序执行(指令重排序引起)

    三、happens-before(先行发生)原则

    1、概述

    1、从JDK5开始,Java使用新的JSR -133内存模型,JSR-133提出了happens-before(先行发生)的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
    2、这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM保证a操作将对b操作可见。
    3、JSR-133对happens-before关系的定义如下:
    • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前
    • 如果两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
    4、举例说明:
    • x = 5(线程A执行)
    • y = x(线程B执行)
    • 如果线程A的操作(x=5)happens-before(先行发生)线程B的操作(y=x),那么可以确定线程B执行后y=5一定成立。
    • 如果不存在happens-before原则,那么y=5不一定成立。

    2、happens-before规则

    1、程序顺序原则:
    • 一个线程内,按照代码顺序,写在前面的操作happens-before(先行发生)于任意后续操作。也就是说前一个操作的结果可以被后续的操作获取。
    2、监视器锁规则:
    • 对一个锁的解锁操作happens-before(先行发生)于后面对同一个锁的加锁操作
    • 比如synchronized(obj){},线程A一定先解锁后,线程B才能获得该锁,A先行于B。
    3、volatile变量规则:
    • 对于一个volatile变量的写操作先行发生与后面对这个变量的读操作
    4、传递规则:
    • 如果操作A先行发生与操作B,操作B先行发生于操作C,那么操作A先行发生与操作C
    5、线程启动规则:
    • Thread对象的start()方法先行发生于此线程中的任意操作
    6、线程中断规则:
    • 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
    • 也就是说要先调用interrupt()方法设置过中断标志位,才能检测到发生中断(Thread.interrupted())
    7、线程终止规则:
    • 线程中所有操作都先行发生于对此线程的终止检测,可以通过Thread.isAlive()方法检测线程是否已经终止执行
    8、对象终结规则:
    • 一个对象的初始化完成(构造方法执行结束)先行发生于它的finalize()方法的开始,即对象没有完成初始化之前,是不能调用finalized()方法的

    3、示例分析

    private int value = 0;
    
    public void setValue(int value) {
        this.value = value;
    }
    
    public int getValue() {
        return value;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    1、假设存在A、B两线程,线程A先调用setValue方法,线程B调用同一个对象的getValue方法,那么线程B收到的返回值是什么?
    2、对此段代码进行一次happens-before规则分析:
    • 由于两个方法是由不同的线程调用,不在同一个线程中,所以肯定不满足程序顺序原则。
    • 两个方法都没有用锁,所以不满足锁定规则。
    • 变量没有被volatile修饰,所以不满足volatile变量规则。
    • 传递规则也不满足。
    • 结论:无法通过happens-before规则推导出线程A先行发生与线程B,虽然在时间线上线程A优先于线程B,但是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。
    3、解决方法:
    • 把getter、setter方法都定义成synchronized方法。
    • 变量用volatile关键字修饰。

    四、volatile关键字

    1、volatile三大特性

    1、volatile是Java虚拟机提供的轻量级的同步机制,用来确保将变量的更新操作通知到其他线程
    • 保证可见性
    • 不保证原子性
    • 禁止指令重排序
    2、volatile说明:
    • 可以用来修饰成员变量和静态成员变量,保证线程间变量的可见性,但不能保证原子性
    • 线程对共享变量进行修改后,要立刻写回主内存中。线程操作volatile变量都是直接操作主存
    • 线程对共享变量读取的时候,必须到主内存中读取变量的最新值,而不是缓存中

    2、可见性问题分析

    1、通过对JMM的了解,都知道各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存中,操作完成后再回写主内存中。这里可能存在线程A修改了共享变量的值还未写回主内存中时,线程B又对共享变量进行操作,但此时A线程工作内存中的变量对B线程来说是不可见的,这种工作内存与主内存同步延迟现象就造成了可见性问题。
    /**
     * @Date: 2022/4/27
     * 测试可见性
     */
    @Slf4j
    public class VolatileTest1 {
        static boolean status = false;
    
        public static void main(String[] args) {
            log.info("线程{}开始执行,status = {}", Thread.currentThread().getName() ,status);
            new Thread(()->{
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //将值修改为true
                status = true;
                log.info("线程{}修改status,status = {}", Thread.currentThread().getName() ,status);
            }, "A").start();
            while (true) {
                if (status) {
                    break;
                }
            }
            log.info("线程{},status = {}", Thread.currentThread().getName() ,status);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    1、运行程序,3s后程序并没有暂停执行,分析如下:初始状态,main线程刚开始从主内存读取了status的值到工作内存,JIT编译器会将status的值缓存至自己工作内存中的高速缓存中

    在这里插入图片描述

    2、3秒之后,A线程修改了status的值,并同步到主内存中了,而主线程是从自己工作内存中的高速缓存中读取status的值,结果永远是之前的值(false),因此主线程永远不会停止。

    在这里插入图片描述

    3、可见性问题解决方案

    1、Java中支持的可见性实现方式:
    • 加锁(如:synchronized、Lock
    • volatile关键字
    2、JMM关于同步的两条规定:
    • 线程解锁前,必须把共享变量的值刷新回主内存。
    • 线程加锁前,必须读取主内存的最新值到自己的工作内存(加锁解锁是同一把锁)。
    3、对加锁解决可见性问题的解释:
    • 因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。
    • 而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。
    /**
     * @Date: 2022/4/27
     * 使用volatile解决可见性
     */
    @Slf4j
    public class VolatileTest2 {
        volatile static boolean status = false;
    
        public static void main(String[] args) {
            log.info("线程{}开始执行,status = {}", Thread.currentThread().getName() ,status);
            new Thread(()->{
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //将值修改为true
                status = true;
                log.info("线程{}修改status,status = {}", Thread.currentThread().getName() ,status);
            }, "A").start();
            while (true) {
                if (status) {
                    break;
                }
            }
            log.info("线程{},status = {}", Thread.currentThread().getName() ,status);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    运行程序,3秒之后程序结束运行,说明volatile保证不同线程对共享变量操作的可见性

    在这里插入图片描述

    /**
     * @Date: 2022/4/27
     * 使用synchronized解决可见性
     */
    @Slf4j
    public class VolatileTest3 {
        final static Object obj = new Object();
    
        static boolean status = false;
    
        public static void main(String[] args) {
            log.info("线程{}开始执行,status = {}", Thread.currentThread().getName() ,status);
            new Thread(()->{
                synchronized (obj) {
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //将值修改为true
                    status = true;
                    log.info("线程{}修改status,status = {}", Thread.currentThread().getName(), status);
                }
            }, "A").start();
            while (true) {
                synchronized (obj) {
                    if (status) {
                        break;
                    }
                }
            }
            log.info("线程{},status = {}", Thread.currentThread().getName() ,status);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    4、原子性问题分析

    1、JAVA内存模型(JMM)要求保证原子性,但是volatile不能保证原子性
    /**
     * @Date: 2022/4/27
     * 验证volatile不保证原子性,10个线程,每个线程调用1000次addNum方法,如果volatile能够保证原子性,那么结果应该是10000
     */
    public class VolatileTest4 {
        volatile int num = 0;
    
        public static void main(String[] args) throws InterruptedException {
            VolatileTest4 test4 = new VolatileTest4();
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    for (int j = 1; j <= 1000; j++) {
                        test4.addNum();
                    }
                }, String.valueOf(i)).start();
            }
            //等待上面10个线程全部计算完成后,再用main线程取得最终结果值,为什么要判断线程数大于2,因为默认是有两个线程的,一个main线程,一个GC线程
            while (Thread.activeCount() > 2) {
                //yield表示不执行,礼让
                Thread.yield();
            }
            System.out.println(test4.num);
        }
    
        public void addNum(){
            num ++;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    1、多次运行程序,发现最后输出的结果值并没有10000,而且每次运行的结果值都不一样,也说明了volatile修饰的变量不能保证原子性。
    2、对addNum()这个方法的字节码进行分析,发现num++是一个复合操作,这一行代码被拆分了3个指令

    在这里插入图片描述

    3、假设没有加synchronized,假设有2个线程同时通过getfield指令拿到主内存中num的值,然后2个线程在各自的工作内存中进行加1操作,因为可见性,需要写回主内存,但是线程1在写入的时候,线程2也同时写入,导致线程1的写入操作被挂起,这样造成了线程2写入后线程1覆盖了线程2的值,造成了数据丢失问题,也就让最终的结果少于10000。

    5、原子性问题解决方案

    1、加锁(如:synchronized、Lock):由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性,同时也保证了共享变量的可见性。但是属于重量级操作,性能相对更低。
    2、使用JUC下的原子包装类(如:AtomicInteger
    /**
     * @Date: 2022/4/27
     * 使用synchronized保证原子性
     * 使用原子类保证原子性
     */
    public class VolatileTest5 {
        //使用volatile保证可见性
        volatile int num = 0;
    
        //原子类
        AtomicInteger atomicInteger = new AtomicInteger();
    
        public static void main(String[] args) throws InterruptedException {
            VolatileTest5 test4 = new VolatileTest5();
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    for (int j = 1; j <= 1000; j++) {
                        test4.addNum();
                        test4.addAtomicInteger();
                    }
                }, String.valueOf(i)).start();
            }
            //等待上面10个线程全部计算完成后,再用main线程取得最终结果值
            while (Thread.activeCount() > 2) {
                Thread.yield();
            }
            System.out.println("int type, finally number value:" + test4.num);
            System.out.println("atomicInteger type, finally number value:" + test4.atomicInteger);
        }
    
        /**
         * 在addNum方法上加synchronized关键字,保证原子性
         */
        public synchronized void addNum(){
            num ++;
        }
    
        /**
         * 使用原子类,每次加1
         */
        public void addAtomicInteger() {
            //以原子方式将当前值加1
            atomicInteger.getAndIncrement();
        }
    }
    /**
     * 运行结果:
     * int type, finally number value:10000
     * atomicInteger type, finally number value:10000
     */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    6、有序性(指令重排)问题分析

    1、有序性:程序执行的顺序按照代码的先后顺序执行。
    2、重排序:计算机在执行程序时,为了提升性能,在不改变程序执行结果的前提下,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。
    3、重排序的类型,源码到最终执行会经过如下类型的重排序:
    • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    • 指令级并行重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    • 内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

    在这里插入图片描述

    4、注意:
    • 编译器优化重排序属于编译重排序,指令集并行重排序与内存系统重排序属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。
    • 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
    • 对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
    • 处理器在进行重排序时必须要考虑指令之间的数据依赖性
    public void method01() {
        int a = 1;//1
        int b = 2;//2
        a = a + 3;//3
        b = a * a;//4
    }
    //代码的执行顺序可能是1234,也可以是2134或1324,
    //但是不能先执行4,因为存在数据的依赖性,没有办法把4排在第一位。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    /**
     * @Date: 2022/5/5
     * 指令重排序问题分析
     */
    public class VolatileTest6 {
        int a = 0;
        boolean flag = false;
    
        //线程2执行
        public void method1() {
            a = 2;
            //此处可能会发生指令重排序,可能先执行a=1也可能先执行flag=true
            flag = true;
        }
    
        //线程1执行
        public void method2() {
            if (flag) {
                a = a + 5;
                System.out.println("a的值为:" + a);
            } else {
                a = 1;
                System.out.println("a的值为:" + a);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    1、情况1线程1先执行,这是flag = false,所以进入else分支结果为1。
    2、情况2线程2先执行a = 2,但是还没有来得及执行flag = true,线程1执行,还是进else分支结果为1。
    3、情况3线程2先执行a = 2,再flag = true,线程1执行,进if分支结果为7。
    4、情况4线程2先执行flag = true,切换到线程1执行,进入if分支结果为5,再回线程2执行a = 2。
    总结:这种现象叫做指令重排。在多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测

    7、有序性问题解决

    1、使用volatile关键字修饰变量,可以禁止指令重排序
    2、加锁(synchronized、Lock):synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
    /**
     * @Date: 2022/5/5
     * 禁止指令重排序
     */
    public class VolatileTest6 {
        int a = 0;
        volatile boolean flag = false;
    
        //线程2执行
        public void method1() {
            a = 2;
            //此处可能会发生指令重排序,可能先执行a=1也可能先执行flag=true
            flag = true;
        }
    
        //线程1执行
        public void method2() {
            if (flag) {
                a = a + 5;
                System.out.println("a的值为:" + a);
            } else {
                a = 1;
                System.out.println("a的值为:" + a);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    五、内存屏障与volatile实现原理

    1、内存屏障

    1、内存屏障(Memory Barrier),又称内存栅栏,是一个CPU指令,是CPU或编译器在对内存随机访问操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,避免代码重排序。
    2、内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过内存屏障指令,volatile实现了JMM中的可见性和有序性(禁重排)。
    3、内存屏障要求:
    • 内存屏障之前的所有写操作都要写回主内存
    • 内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)
    4、作用:
    • 保证特定操作的执行顺序
    • 保证共享变量的内存可见性(利用该特性实现volatile的内存可见性)

    2、内存屏障分类

    1、写屏障(Store Memory Barrier)
    • 在写指令之后插入写屏障,强制把写缓存冲区的数据刷新到主内存中。
    • 当看到Store屏障指令时,就必须把该指令之前的所有写入指令执行完后才能继续向下执行,也就是将写屏障之前的所有存储在缓存中的数据同步到主内存中。
    2、读屏障(Load Memory Barrier)
    • 在读指令之前插入读屏障,让工作内存或CPU Cache中的缓存数据失效,重新回到主内存中获取最新数据。
    • 所有的读操作,都在读屏障之后执行,也就是说在Load屏障指令之后的读取数据指令一定能读取到最新的数据。
    3、通过底层源码又可以分为四种:

    在这里插入图片描述

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

    3、volatile保证有序性(禁重排)原理

    1、JMM针对编译器制定的volatile重排序规则表如下:
    第一个操作第二个操作普通读写第二个操作volatile读第二个操作volatile写
    普通读写可以重排可以重排不可以重排
    volatile读不可以重排不可以重排不可以重排
    volatile写可以重排不可以重排不可以重排
    2、通过上表可以得出看出:
    • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
    • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
    • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

    4、volatile读操作插入内存屏障

    1、volatile读操作JMM内存屏障插入策略如下:
    • 在每个volatile读操作的后面插入一个LoadLoad屏障
    • 在每个volatile读操作的后面插入一个LoadStore屏障
    2、说明:
    • LoadLoad屏障:用来禁止处理器把上面的volatile读与下面的普通读重排序。
    • LoadStore屏障:用来禁止处理器把上面的volatile读与下面的普通写重排序。

    在这里插入图片描述

    5、volatile写操作插入内存屏障

    1、volatile写操作JMM内存屏障插入策略如下:
    • 在每个volatile写操作的前面插入一个StoreStore屏障
    • 在每个volatile写操作的后面插入一个StoreLoad屏障
    2、说明:
    • StoreStore屏障:可以保证在volatile写之前,其前面的所有普通写操作已经刷新到主内存。
    • StoreLoad屏障:作用是避免volatile写与后面可能有的volatile读/写操作重排序。

    在这里插入图片描述

    3、对StoreLoad屏障的解释:因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:
    • 在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。
    • 从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。
    • 因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升(也就是说如果在读之前加StoreLoad屏障,就要加得非常多,降低程序效率)

    6、vloatile应用之单例模式DCL

    public class Singleton {
        //实例对象
        private static Singleton instance;
    
        /**
         * 构造器私有化,防止外部类能直接new
         */
        private Singleton() {}
    
        /**
         * 提供一个公有的静态方法,加入双重检查处理,解决线程安全问题,
         * 同时解决懒加载问题,也保证了效率
         * @return
         */
        public static Singleton getInstance() {
            //实例没创建,才会进入内部的synchronized代码块
            if (instance == null) {
                synchronized (Singleton.class) {
                    //此处再判断一次,可能会出现其他线程创建了实例
                    if (instance == null) {
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    1、对上面代码进行分析:
    • Double Checked Locking(双重检查机制,简称DCL)不一定线程安全,原因是有指令重排的存在,因此需要加入volatile可以禁止指令重排
    • 原因在于某一个线程在执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
    2、instance = new Singleton()可以分为以下步骤(伪代码)
    • memory=allocate();//1.分配对象内存空间
    • instance(memory);//2.初始化对象
    • instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null
    3、步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序执行的结果在单线程中并没有改变,因此这种重排优化是允许的:
    • memory=allocate();//1.分配对象内存空间
    • instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null但对象还没有初始化完
    • instance(memory);//2.初始化对象
    • 但是指令重排只会保证串行语义的执行一致性(单线程下),并不会关心多线程间的语义一致性
    • 所以当一条线程访问instance不为null时,由于instance实例未必完成初始化,也就造成了线程安全问题
    4、总结:
    • 加上volatile来禁止指令重排,就能保证多线程间的语义一致性,若不加,那么就会在某一次就会导致线程不安全的问题。
    • 如果在高并发多线程的版本里面,单例模式最终的写法就是加入双端检锁机制(也即加入同步代码块),并在需要单例的这个对象前面加入volatile来禁止指令重排
    public class Singleton {
        //实例对象
        private static volatile Singleton instance;
    
        /**
         * 构造器私有化,防止外部类能直接new
         */
        private Singleton() {}
    
        /**
         * 提供一个公有的静态方法,加入双重检查处理,解决线程安全问题,
         * 同时解决懒加载问题,也保证了效率
         * @return
         */
        public static Singleton getInstance() {
            //实例没创建,才会进入内部的synchronized代码块
            if (instance == null) {
                synchronized (Singleton.class) {
                    //此处再判断一次,可能会出现其他线程创建了实例
                    if (instance == null) {
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
  • 相关阅读:
    django自带的cache无法多进程共享
    【算法刷题】—7.26几何算法的解题,折线图线段数
    示波器探头对测量带宽的影响
    搭建 Vite + Vue3 + TypeScript + Electron 项目
    硬件寿命警告!Windows11在特定情况下对【固态硬盘】执行与【机械硬盘】相同的磁盘碎片整理。
    RocketMQ 顺序消息解析——图解、源码级解析
    C++Primer 第一章 开始
    HAProxy终结TLS双向认证代理EMQX集群
    云里黑白第十九回——我们无法判断你的电脑是否已准备好继续安装Windows 10
    探索X86架构C可变参数函数实现原理
  • 原文地址:https://blog.csdn.net/qq_42200163/article/details/126473902