美文网首页
3. Java内存模型

3. Java内存模型

作者: ygxing | 来源:发表于2020-02-25 09:21 被阅读0次

    1. Java内存模型基础

    1.1 并发编程的两个关键问题
    1. 线程之间如何通信, 通信是指线程之间如何交换信息, 一般有两种方式
      • 共享内存, 通过读写内存公共状态进行隐式通信, Java采用的就是共享内存模型
      • 消息传递, 线程之间没有公共状态, 必须通过发送消息显示进行通信
    2. 线程之间如何同步, 同步是指程序中用于控制不同线程间操作执行的相对顺序
      • 共享内存并发模型中, 同步必须是显式的, 代码必须显示指定某个方法或者代码段需要在线程间互斥执行
      • 消息传递并发模型中, 消息发送必须在消息接收之前, 所以同步是隐式的
    1.2 Java内存模型抽象

    Java线程之间的通信有Java内存模型(JMM)控制, JMM决定了线程和主内存之间的抽象关系:

    • 每一个线程都有一个私有的本地内存, 本地内存中存储了该线程已读/写的共享变量的副本
    • 本地内存是JMM的一个抽象概念, 并不真实存在, 它包括了CPU缓存, 写缓冲区, 寄存器以及其他硬件和编译器优化


    如图, 如果线程A与线程B要通信的话, 必须经历下面2个步骤:

    • 线程A把本地内存A中修改的共享变量刷新到主内存中
    • 线程B到主内存中去读取线程A更新过的共享变量

    所有实例域, 静态域和数组元素都存储在堆内存中, 堆内存在线程之间共享

    局部方法, 方法定义参数和异常处理参数不会在线程之间共享, 也不会受内存模型的影响

    1.3 从源代码到指令序列的重排序

    重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

    在程序执行时, 为了提高性能, 编译器和处理器会对指令进行重排序, 重排序分为3种类型

    1. 编译器优化重排序, 编译器在不改变单线程语义的前提下, 可以重新安排语句的执行顺序
    2. 指令集并行的重排序, 现代处理器采用了指令集并行技术, 来将多条指令重叠执行, 如果不存在数据依赖性, 处理器可以改变语句对应机器指令的执行顺序
    3. 内存系统的重排序, 由于处理器使用缓存和读/写缓冲区, 这使得加载和存储操作看上去可能实在乱序执行

    重排序可能会导致多线程应用出现内存可见性问题, JMM可以运行在不同的处理器平台和操作系统上, 通过禁止特定类型的编译器重排序和处理器重排序, 为程序员提供一致的内存可见性保证

    • JMM编译器重排序规则会禁止特定类型的编译器重排序
    • 对于CPU重排序, JMM处理器重排序规则会要求Java编译器在生成指令序列时, 插入特定类型的内存屏障指令, 来禁止特定类型的指令重排序
    1.4 并发编程模型的分类

    现代处理器使用写缓冲区临时保存向内存写入的数据, 并以批处理的方式刷新写缓冲区, 以及合并写缓冲区对统一内存的多次写操作, 减少对内存总线的占用

    写缓冲区仅仅对它所在的处理器可见, 会导致CPU修改的数据还没有刷新到内存的时候, CPU会从内存中读取到之前的脏数据, 这样的话, 就导致了读写指令重排序

    由于现代CPU都会使用写缓冲区, 因此都会允许写-读操作进行重排序

    为了保证内存可见性, java编译器在生成指令序列的时候, 会在适当位置插入内存屏障指令来禁止特定类型的处理器重排序, 内存屏障指令分为4类

    屏障类型 指令实例 说明
    LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载优先于Load2以及所有后续装载指令的装载
    StoreStore Barriers Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
    LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存
    StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(刷新到内存)先于Load2及所有后续装载指令的装载

    StoreLoad Barriers是一个全能型的屏障, 同时具有其他三个屏障的效果, 开销最大

    StoreLoad屏障的作用是: 该屏障之前的所有内存访问指令(存储和装载指令)完成之后, CPU会将写缓冲区的数据全部刷新到内存中, 然后才执行该屏障之后的内存访问指令,

    1.5 happens-before

    在JMM中, 如果一个操作执行的结果需要对另一个操作可见, 那么这两个操作之间必须要存在happens-before关系

    两个操作具有happens-before关系, 并不意味着前一个操作必须要在后一个操作之前执行, happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见, 且前一个操作按顺序排在后一个操作之前

    happens-before规则如下:

    1. 程序顺序规则: 一个线程中的每个操作, happens-bofore与线程中的任意后续操作, 执行结果有序的, 虽然JVM和CPU会对指令进行重排序, 但不会影响程序的执行结果
    2. 监视器锁规则: 对一个锁的解锁, happens-before与随后对这个锁的加锁
    3. volatile变量规则: 对一个volatile域的写happens-before与对这个volatile域的读, 如果一个线程修改了volatile变量, 然后另一个线程读取这个volatile变量, 那么修改操作一定是happens-before与读操作的
    4. 传递性: 如果A happens-before B, B happens-before C, 那么A happens-before C
    5. start()规则, 如果线程A执行操作ThreadB.start(), 那么线程A的ThreadB.start()操作happens-before于线程B的任意操作
    6. join()原则, 如果线程A执行操作ThreadB.join(), 那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

    happens-before规则的好处在于, 避免了java程序员为了JMM提供的内存可见性保证而去学习复杂的重排序规则以及规则的具体实现方法

    2. 重排序

    重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

    2.1 数据依赖性
    名称 代码示例 说明
    写后读 a=1;b=a; 写一个变量之后, 再读这个变量
    写后写 a=1;a=2; 写一个变量之后, 再写这个变量
    读后写 a=b;b=1; 读一个变量之后, 再写这个变量

    对于上面三种情况, 如果重排序两个操作的执行顺序, 程序的执行结果就会被改变

    编译器和处理器在重排序的时候, 会遵守数据依赖性, 不会改变存在数据依赖性的两个操作的执行顺序, 数据依赖性仅针对于单线程, 不同处理器和不同线程之间的数据依赖性不被编译器和处理器考虑

    2.2 as-if-serial语义

    不管怎么重排序, 单线程的执行结果不能被改变, 编译器和处理器都必须遵守as-if-serial语义, 例如下面的代码:

    //步骤A
    int length = 10;
    //步骤B
    int width = 2;
    //步骤C
    int area = length * width;
    

    步骤C与步骤A,B存在数据依赖关系, 因此在最终执行的指令序列中, C不能被重排序到A和B的前面, 但A和B之间没有数据依赖关系, 编译器和处理器可以重排序A和B之间的执行顺序

    as-if-serial语义把单线程程序保护了起来, 开发人员不需要担心重排序会影响开发, 也不需要担心内存可见性问题

    在单线程程序中, 对指令进行重排序, 由于as-if-serial语义的缘故, 不会改变执行结果, 但在多线程程序中, 对指令进行重排序, 可能会改变程序的执行结果

    3. 顺序一致性

    顺序一致性模型是一个理论参考模型, 为程序员提供了极强的内存可见性保证, 有两大特性

    • 一个线程中的所有操作必须按照程序的顺序来执行
    • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序, 在顺序一致性内存模型中, 每个操作都必须是原子执行且立刻对所有线程可见

    顺序一致性模型有一个单一的全局内存, 每一个线程必须按照程序的顺序来执行内存读写操作, 在顺序一致性模型中, 所有操作之间具有全序操作

    数据竞争和顺序一致性

    当程序没有正确同步时, 就可能会存在数据竞争, 例如:

    在一个线程中写一个变量, 另一个线程读同一个变量, 而且读和写没有通过同步来排序, 那么可能会产生错误的结果
    如果程序是正确同步的, 程序的执行将具有顺序一致性, 即程序的执行结果与该线程在顺序一致性内存模型中的执行结果相同

    同步是指广义的同步, 包括对同步原语synchronized, volatile和final的正确使用

    临界区

    在任意时刻, 只允许一个线程访问临界资源(一次仅允许一个进程使用的共享资源)的代码区域, 也就是多个线程同时访问一个共享的全局变量

    3.1 JMM与顺序一致性的差异
    1. 同步程序的顺序一致性效果
      • 在顺序一致性模型中, 所有操作完全按程序的顺序串行执行
      • 在JMM中, 临界区的代码可以被重排序
    2. 未同步程序的执行顺序
      • 顺序一致性模型保证单线程内的操作按程序顺序执行, JMM不保证单线程的顺序会按程序的顺序执行
      • 顺序一致性模型保证所有线程只能看到一致的操作顺序, JMM不保证所有线程看到一致的操作顺序
      • 顺序一致性模型保证对所有内存读写操作都具有原子性, 而JMM不保证对63位的long类型和double类型变量的写操作具有原子性
    总线事务

    在计算机中, 数据通过总线在处理器和内存之间传递, CPU和内存之间的数据传递都是通过一系列步骤完成的, 这一系列步骤称之为总线事务, 总线事务分为读事务和写事物, 当一个处理器执行总线事务期间, 总线会禁止其他的CPU和IOS河北执行内存的读写操作

    总线的工作机制可以把所有的CPU对内存的访问以串行化方式来执行, 在任意时间段, 最多只有一个CPU可以访问内存, 这个特性确保了单个总线事务中内存读写操作具有原子性

    在一些32位的CPU上, 如果要求对64位数据的写操作具有原子性, 会有比较大的开销

    3.2 volatile内存语义

    volatile具有两个特性:

    1. 可见性, 对于一个volatile变量的读, 总是能看到任意线程对这个volatile变量最后的写入
    2. 原子性, 对于一个volatile变量的读写操作具有原子性, 但类似于volatile++这种复合操作不具有原子性
    volatile与重排序

    当代码分别进行联系两次读或写操作, √表示可重排序, ×表示不可重排序

    是否能重排序 第二次普通读或写操作 第二次volatile读 第二次volatile写
    第一次普通读或写操作 ×
    第一次volatile读 × × ×
    第一次volatile写 × ×
    1. 当第一次操作是volatile读时, 不能重排序, 确保volatile读操作之后的操作不会被编译器重排序到volatile读之前
    2. 当第二次操作是volatile写时, 不能重排序, 确保volatile写之前的操作不会被编译器排序到volatile写之后
    3. 当一次操作是volatile写, 另一次操作是volatile读是, 不能重排序
    volatile读写操作实现原理

    为了实现volatile的内存语义, 编译器在生成字节码的时候, 会在指令中插入内存屏障来禁止特定类型的处理器重排序

    1. volatile写操作
      • 在写操作之前插入StoreStore屏障: Store1; StoreStore; VolatileStore, 确保volatile写之前的其他操作都刷新到内存(对其他线程可见), 然后再执行volatile写操作
      • 在写操作之后插入StoreLoad屏障: VolatileStore; StoreLoad; Load2, 确保volatile写入的操作刷新到内存(对于其他线程可见)之后, 然后再执行后续装载指令
      • 总结: 指令执行顺序 Store1; StoreStore; VolatileStore; StoreLoad; Load2;
    2. volatile读操作
      • 在读操作后面插入一个LoadLoad屏障, VolatileLoad; LoadLoad; Load2; 确保VolatileLoad数据装载优先于其他数据装载
      • 在读操作后面插入一个LoadStore屏障, VolatileLoad; LoadStore; Store2; 确保在VolatileLoad装载之后, 再写入其他数据
      • 总结: 整个指令顺序, VolatileLoad; LoadLoad; LoadStore; Load2; Store2;

    4. 锁的内存语义

    锁是java并发编程最重要的同步机制, 锁除了让临界区互斥执行外, 还可以让释放锁的线程向获取同一个锁的一个或多个线程发送消息

    4.1 锁的释放与获取内存语义

    当线程A释放锁的时候, 会向将要获取这个锁的一个或者多个线程发出消息, 消息就是线程A对共享变量所做的修改

    • JMM会把线程A对应的本地内存中的共享变量刷新到主内存中, 锁释放与volatile写的内存语义相同
    • 在锁释放之前插入StoreStore屏障, 确保在锁释放之前, 把所有本地内存刷新到主内存(对其他线程可见), 然后再释放锁, 指令如下: Store1; StoreStore; 释放锁;

    当线程B获取锁的时候, 实际上是线程B接收了之前某个线程发出的消息, 消息内容是在释放这个锁之前对共享变量所做的修改

    • JMM会把改线程对应的本次内存置为无效, 从而使得监视器保护的临界区代码必须从主内存中获取, 锁获取与volatile读的内存语义相同
    • 在获取锁之前插入

    线程A释放锁, 随后线程B获取这个锁, 这个过程实质上是线程A通过主内存向线程B发送消息

    4.2 ReentrantLock源码

    5.final域的内存语义

    对final域的读写操作更像是普通变量访问

    5.1 final域的重排序规则

    对于final域, 编译器和处理器要遵守两个重排序规则

    1. 写final域
      • 在构造函数内对一个final域的写入, 与随后把这个被构造对象的引用赋值给一个引用变量, 这两个操作之间不能重排序
      • JMM禁止编译器把final域的写操作重排序到构造函数之外
      • 编译器会在final域的写操作之后, 构造函数return之前, 插入一个StoreStore屏障, 这个屏障禁止处理器把final域的写重排序到构造函数之外
    2. 读final域
      • 在一个线程中, 初次读一个包含final域对象的引用, 与随后初次读这个final域, 这两个操作不能重排序
      • 编译器会在读final域操作之前插入一个LoadLoad屏障, 这样保证了在读一个对象的final域之前, 一定会先读包含这个final与的对象的引用
    3. 由以上重排序规则, 可以确保final域必然会被初始化

    如果final域为引用类型, 那么需要使用同步原语来确保内存可见性

    5.2 final语义在处理器中的实现

    写final域的重排序规则会要求编译器在final域的写操作之后, 构造函数return之前插入一个StoreStore屏障, 读final域的重排序规则要求在编译器在读final域的操作之前插入一个LoadLoad内存屏障

    6. 双重检查锁定与延迟初始化

    在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销

    6.1 双重检查锁问题
    public class YanggxSingleton {
        // 单例对象
        private static YanggxSingleton instance;
        // 私有的构造函数,防止被实例化
        private YanggxSingleton() {}
    
        /**
         * 获取单例对象
         * @return 单例对象
         */
        public static YanggxSingleton getInstance() {
            //步骤1, 判断instance为null
            if (instance == null) {
                //步骤2, 如果instance为null, 那么锁住YanggxSingleton.class
                synchronized (YanggxSingleton.class) {
                   //步骤3, 再次判断instance是否为null,防止重复实例化
                    if (instance == null) {
                        //步骤4, 如果instance为null, 那么实例化instance
                        instance = new YanggxSingleton();
                    }
                }
            }
            return instance;
        }
    }
    

    当多线程同时访问这段单例代码的时候, 就可能会产生问题

    步骤4 instance = new YanggxSingleton()可以分解为3行伪代码

    memory = allocate();  //1.分配对象内存空间
    ctorInstance(memory); //2. 初始化内存空间
    instance = memory;    //3. 设置instance指向刚分配的内存空间
    

    上面3行代码, 2和3之间可能会被重排序, 排序之后执行时序如下

    memory = allocate();  //1.分配对象内存空间
    instance = memory;    //3. 设置instance指向刚分配的内存空间
    ctorInstance(memory); //2. 初始化内存空间
    

    JMM保证重排序不会改变单线程的执行结果, 2和3两步的重排序也没有影响到单线程的执行顺序, 所以这个重排序是允许的

    这样就会导致多线程同时访问代码块的时候, 有些线程获取的instance不为null, 但是并没有初始化内存空间, 导致执行代码报错

    6.2 使用volatile解决问题

    在上面的代码中, 我们使用volatile关键字修饰单例变量, 便可以实现线程安全的延迟初始化

    public class YanggxSingleton {
        // 单例对象, 使用了volatile修饰
        private volatile static YanggxSingleton instance;
        // 私有的构造函数,防止被实例化
        private YanggxSingleton() {}
    
        /**
         * 获取单例对象
         * @return 单例对象
         */
        public static YanggxSingleton getInstance() {
            //步骤1, 判断instance为null
            if (instance == null) {
                //步骤2, 如果instance为null, 那么锁住YanggxSingleton.class
                synchronized (YanggxSingleton.class) {
                   //步骤3, 再次判断instance是否为null,防止重复实例化
                    if (instance == null) {
                        //步骤4, 如果instance为null, 那么实例化instance
                        instance = new YanggxSingleton();
                    }
                }
            }
            return instance;
        }
    }
    

    volatile关键字会禁止伪代码2和3的重排序, 来保证线程安全的延迟初始化

    6.3 使用类初始化解决问题

    JVM在类的初始化阶段, 会去获取一个锁, 这个锁可以同步多个线程对同一个类的初始化

    public class YanggxSingleton {
    
        private static class InstanceHolder{
            //静态内部类
            public static YanggxSingleton instance = new YanggxSingleton();
        }
        
        public static YanggxSingleton getInstance(){
            return InstanceHolder.instance; //这里才初始化InstanceHolder
        }
        
         // 私有的构造函数,防止被实例化
        private YanggxSingleton() {}
    }
    

    相关文章

      网友评论

          本文标题:3. Java内存模型

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