Java内存模型

  • 《Java并发编程的艺术》第3章的标题《Java内存模型》,初一看自己还以为讲解的JVM的内存模型(堆、栈、方法区等)
  • 真正学习时,发现这一章的内容组织对自己来说比较难理解,学得迷迷糊糊的
  • 查看了一些资料,起码比不看的效果更好:
  • 发现《Java并发编程的艺术》第3章,就是在详细介绍Java并发编程的三大特性中的两个特性:可见性、有序性
  • 可见性
    • 可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。
    • Java 内存模型(简写JMM)是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的
    • 可见性的三种主要实现方式:volatile、 synchronized、final
    • 主要有三种实现可见性的方式:
  • 有序性
    • JMM中,为了提高程序的执行性能,允许编译器和处理器对指令序列重新进行排序
    • 这使得,本线程内观察,所有操作都是有序的;在其他线程观察当前线程,所有操作是无序的
    • 不太理解 😂,重点在指令重排序: 指令重排序不影响单线程程序的执行结果,但可能会影响多线程程序的执行结果
    • 重排序的前提:不会影响单线程程序的执行结果

1. 内存可见性

1.1 并发编程的2个关键问题:通信与同步

通信

  • 所谓通信,是指线程间以何种机制来传递信息
  • 线程的间的两种通信机制:共享内存、消息传递
    • 共享内存:通过读写共享内存,进行隐式通信
    • 消息传递:线程之间通过消息的发送与接收,进行显式通信

同步

  • 所谓同步,是指程序中用于控制不同线程间操作发生相对顺序的机制
  • 共享内存的通信机制中,程序员需要显式控制内存的互斥访问,是一种显式同步
  • 消息传递的通信机制中,消息的发送必定早于消息的接收,是一种隐式同步

1.2 JMM的抽象结构

缓存一致性问题

  • 操作系统中,为了寄存器和内存之间读写速度几个数量级的差异,引入了高速缓存
  • 每个处理器都有自己的高速缓存,如果缓存同一块内存区域,这些高速缓存中的数据可能不一致

JMM的抽象结构

  1. 线程间的共享变量存储在主内存中,每个线程都有一个自己的工作内存,又叫本地内存
  2. 工作内存中存储了线程需要读/写的共享变量的副本
  3. 线程只能操作工作内存中共享变量的副本,不同线程间共享变量的同步依靠主内存完成
    在这里插入图片描述
  • 一些说明:
    • 共享变量是指存储在堆内存中的变量,包括实例变量、静态变量、数组等
    • 堆是线程共享的,而存储局部变量、操作数栈等的虚拟机栈是线程私有的

1.3 内存可见性的重要性

理想的线程通信:

  • 线程A将本地内存中更新过的共享变量x的值写回主内存
  • 线程B从主内存读取线程A已经更新过的共享变量的值
  • 也就是说,线程读到的总是共享变量的最新值,而非本地内存中缓存的旧值
    在这里插入图片描述

实际的线程通信

  • 考虑这样的场景:初始时,共享变量a = b = 0

    时间线程A线程B备注
    t1a = 1; // A1b = 2; // B1线程A将主内存中a的值更新为1,线程B将主内存中b的值更新为2
    t2x = b; // A2y = a; // B2线程A从主内存获取b的最新值,x的值更新为2;
    线程B从主内存获取a的最新值,y的值更新为1
  • 上面的操作结果是处理器视角,它认为 A1 → \rightarrow A2(A1先于A2执行)

  • 由于本地内存的存在,其实际执行顺序:A2 → \rightarrow A1

    • A1操作的是本地内存,只有最后执行A3后,主内存中a的值才被更新为1
    • 因此,对内存来说,是先读取主内存中尚未更新的b,然后再更新a的值
      在这里插入图片描述

总结:本地内存影响了共享变量的可见性

  • 由于本地内存的存在,导致共享变量的值在本地内存发生更新后,其他线程无法感知这个修改。
  • 只有将修改后的共享变量的新值立即写回主内存,并且其他线程访问共享变量时,都从主内存而非本地内存获取最值,才能保证共享变量的"可见性":一旦修改,立马可感知

1.4 理想的内存模型 —— 顺序一致性内存模型

  • 顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型的设计都以该模型作为参考
  • 顺序一致性内存模型具有两大特性:
    • 一个线程中的所有操作必须按照程序的顺序来执行(不允许重排序)
    • 不管程序是否正确同步,所有线程都只能看到一个单一的执行顺序。每个操作都必须原子执行,且执行结果立即对其他线程可见
  • 对特性二的理解:
    • 可以看做一个单开关多灯模型,开关可以随意switch到某个灯,使其点亮
    • 也就是说,任意时刻只能有一个线程连接到内存,从而对内存进行读/写操作
    • 这样,多线程的并发执行被这个唯一的开关变成了串行,使得多线程中所有的操作具有全序

注意:64 bit变量的写操作不具有原子性

  • 32 bit的处理器,单个写操作原本是具有原子性的
  • 64 bit的处理器,对long或double类型变量的写操作将由两个写操作实现。
  • 此时,64 bit变量的写操作将不再具有原子性

2. 重排序

  • 为了提高程序的执行性能,编译器和处理器往往会对指令序列进行重排序

2.1 重排序的分类

3种类型的重排序

  • 编译器优化重排序:

    • 编译器在不改变单线程程序语义的情况下,可以重新安排语句的执行顺序
    • 例如,针对不同变量的写操作:
    // 原始顺序
    a = 10;  flag = true;
    // 重排序后的顺序
    flag = true;  a = 10;
    
  • 指令级并行重排序:

    • 现代处理器支持指令级并行(ILP)技术,可以将多条指令重叠执行
    • 若不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序
  • 内存系统重排序 (并未真正的重排序?)

    • 处理器使用缓存和读/写缓冲区,使得load和store操作看起来可能是乱序执行的
      在这里插入图片描述
  • 其中,指令级并行重排序和内存系统重排序统称为处理器重排序

  • 常见处理器所允许的重排序规则

    • 常见的处理器都不允许对存在数据依赖的操作进行重排序
    • 常见的处理器都允许Store - Load操作进行重排序
    • 从上到下,这些处理器允许的重排序规则逐渐增加,这是为了降低内存模型的束缚以追求更高的性能
      在这里插入图片描述

禁止重排序

  • 重排序可能会导致多线程之间出现内存可见性问题,有时需要禁止重排序
    • 对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序
    • 对于处理器重排序,JMM的处理器重排序规则会在编译器生成指令序列时,插入特定类型的内存屏障,从而禁止特定类型的处理器重排序

2.2 内存屏障

  • 内存屏障,是一组机器指令,用于实现对内存操作的顺序限制

  • 自己的理解:可以限制内存读/写操作先后顺序的一组机器指令

  • 共有4种内存屏障:对内存的操作只有两种 (读/写),内存屏障是针对这两种操作的组合

    屏障类型指令示例说明
    LoadLoadload1; LoadLoad; load2;load2及其后续的load操作执行前,load1中的load操作已全部完成
    StoreStorestore1; Store; store2;store2及其后续的store操作执行前,store1中的store操作已全部执行完成
    也就是store1中的操作的数据要先对其他处理器可见
    LoadStoreload1; LoadStore; store2;store2及其后续store操作执行前,load1中的load操作已全部执行完
    StoreLoadstore1; StoreLoad; load2;load2及其后续load操作执行前,store1中的store操作已全部执行完
  • StoreLoad是开销最昂贵的内存屏障,也是全能型的内存屏障,同时具有其他3种内存屏障的效果

    • StoreLoad屏障要求该屏障之前的所有内存访问指令(包括store和load)完成之后,才会执行该屏障之后的内存访问指令
    • 开销昂贵的原因:需要将写缓冲区中的数据全部刷新到内存中

絮絮叨叨

  • 其实,自己对内存屏障的理解也不是很透彻
  • 只知道:
    • ① 内存屏障可以对内存访问的顺序进行限制,进而可以禁止指令重排序
    • ② 根据读写操作的组合,有四种内存屏障;
    • ③ StoreLoad的开销最为昂贵且兼备其他三种内存屏障的效果

补充知识:

  • 后续的学习中,可以了解到:
    • 内存屏障可以进行优化,去除不必要的内存屏障,提高程序执行效率
    • 甚至在某些处理器中(x86),可以不使用某些内存屏障

2.3 数据依赖 & as-if-serial语义 (重排序对单线程的影响)

数据依赖

  • 两个操作访问同一个变量,且其中一个操作为写操作。此时,这两个操作之间存在数据依赖
    在这里插入图片描述
  • 单个处理器或单个线程中,上面的三种情况,两个操作的执行顺序一旦改变,程序执行结果将会改变。
  • 编译器和处理器(大多数)不会对存在数据依赖的操作重排序

as-if-serial语义

  • as-if-serial语义的规定:不管怎么重排序,单线程程序的执行结果不能被改变
  • 编译器、runtime和处理器,都需要遵守 as-if-serial 语义
  • as-if-serial 语义为单线程执行制造了一种假象:单线程程序是按照代码顺序依次执行的,但实际可能发生了重排序,只是因为执行结果未改变,程序员难以感知
  • 例如,计算圆面积的代码如下:数据依赖关系为 A → \rightarrow C,B → \rightarrow C,即操作A和操作B之间不存在数据依赖
    在这里插入图片描述
  • 因此,可以重排序为 B → \rightarrow A → \rightarrow C,重排序后的执行结果不变

2.4 重排序对多线程的影响

  • 从上面的讲解可知:单线程下,不存在数据依赖的操作可以重排序,而程序的执行结果不会被改变
  • 但在多线程下,即使不存在数据依赖,重排序也可能会影响程序的执行结果
  • 示例代码如下:
    在这里插入图片描述
  • 操作1和操作2不存在数据依赖,可以重排序
  • 重排序后,多线程访问将存在问题:(带箭头的虚线标识错误的读操作,带箭头的实线标识正确的读操作)
    • 线程B访问flag时,获取到的值为true,条件判断为真。
    • 随后,线程B读取变量a的值,为初始化时的0并非预期的1
      在这里插入图片描述

3. happens-before规则

  • happens-before规则,又叫先行发生原则,是JMM的的核心概念
  • 从JDK 5开始,Java使用JSR-133内存模型,该内存模型使用happens-before规则来阐述操作之间的内存可见性

3.1 happens-before的定义

设计者的考虑

  • JMM的设计者,在设计JMM时,应该考虑下面两个因素:
    • 一方面,要为程序员提供足够强的内存可见性保证
    • 另一方面,对编译器和处理器的限制要尽可能的放松(束缚过多,没有足够提高性能的优化空间)
  • JMM实质上遵循一个原则:在单线程和正确同步的多线程程序中,只要不改变程序的执行结果,编译器和处理器怎么优化都行
    • 程序执行过程中,锁只会被一个线程访问,则锁可以被消除
    • volatile变量只会被一个线程访问,则可以将其当做一个普通变量处理

happens-before的定义

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二操作可见,且第一个操作的执行顺序排在第二个操作之前
  2. 两个操作之间存在happens-before关系,并不意味着Java必须按照happens-before关系指定的顺序来执行。因为若重排序后的执行结果,与按照happens-before的执行结果一致,那允许进行重排序

说明

  • 这两个定义看起来相互矛盾,定义1已经规定了两个操作执行的先后顺序,定义2又说允许重排序
  • 定义1是JMM对程序员的承诺:A happens-before B,从程序员视角来看,A的执行结果对B可见,且A的执行顺序在B之前
  • 定义2是JMM对编译器和处理器重排序的约束:
    • 重排序的前提是,执行结果不能被改变。
    • 对程序员来说,他更关心的是执行结果而非执行过程
  • happens-before语义和as-if-serial语义本质相同:
    • 前者保证,正确同步的多线程程序执行结果不被改变;后者保证,单线程程序的执行结果不被改变
    • 前者让程序员误认为:正确同步的多线程程序是按照happens-before指定的顺序执行的
    • 后者让程序员误认为:单线程程序是按照程序的顺序来执行的

3.2 happens-before规则

程序顺序规则

  • 又叫单一线程原则:在一个线程内,程序前面的操作,happens-before程序后面的操作

监视器锁规则

  • 对一个锁的解锁操作,happens before 对这个锁的加锁操作

volatile变量规则

  • 对一个volatile变量的写操作,happens before对这个volatile变量的读

start()规则

  • 线程A执行threadB.start()操作启动线程B,则线程A的threadB.start()操作happens before 线程B中的任意操作
  • 很好理解:人家不启动你,你怎么可能执行自身操作

join()规则

  • 线程A执行threadB.join()并成功返回,则线程B中任意操作 happens before 线程A从threadB.join()操作成功返回

线程中断规则

  • 执行thread. interrupt() 操作,happens before被中断线程检测到中断事件的发生
  • 可以通过 interrupted() 或 isInterrupted() 方法,检测线程是否被中断

对象终结规则

  • 对象的初始化的完成,happens before 该对象的 finalize() 方法的开始

传递性规则

  • 如果A happens-before B,B happens-before C,则 A happens-before C
  • 这对理解happens-before原则如何提供跨线程的内存可见性非常重要

4. 基于内存语义,了解可见性保证

  • happens-before,是JMM在内存可见性问题上对程序员的承诺
  • 如何实现这个可见性承诺,需要依靠具体的内存语义来实现

4.1 volatile

4.1.1 volatile的内存语义

  • volatile变量的代码示例如下

volatile内存语义

  • 线程A写volatile变量时,JMM会把线程A对应的本地内存中的共享变量刷新到主内存
    • 从通信的角度看,是线程A向后续读volatile变量的线程发出了消息,告知它们共享变量已被修改
  • 线程B读volatile变量时,JMM会把线程B对应的本地内存置为无效;线程B需要从主内存读取共享变量,这时读取到的是共享变量的最新值
    • 从通信的角度看,是线程B接收到了向线程A发出的消息,知道了共享变量已被修改的事情
  • 整体来看,线程A通过主内存向线程B发送消息
    在这里插入图片描述

4.1.2 volatile内存语义的实现

  • volatile重排序规则如下
    • 第三行最后一个单元格为No,表示当第一个操作为普通读/写操作,第二个操作为volatile写操作时,编译器不能重排序这两个操作
      在这里插入图片描述

volatile写内存语义

  • 写操作之前插入一个StoreStore内存屏障,禁止前面的普通写与后面的volatile写重排序
    • 这样可以保证volatile写被刷新回主内存之前,前面的普通写已经刷新到内存
    • 这也是为什么,上面的写内存语义中说:将线程对应的本地内存中的共享变量刷新到主内存,而非:将线程对应的本地内存中的volatile变量刷新到主内存
  • 写操作之后插入一个StoreLoad内存屏障(全能型的),禁止前面的volatile写重排序与后面可能的volatile读/写重排序
    • 编译器无法判断是否需要在volatile写后面插入内存屏障,JMM采取保守策略:在volatile变量写之后或读之前插入一个StoreLoad内存屏障
    • 考虑到多读少写的场景更多,最终在volatile变量写之后插入一个StoreLoad内存屏障,以提升程序的执行效率
      在这里插入图片描述

volatile读内存语义

  • 不是很理解读内存语义,欢迎交流
  • 读操作之后,先插入一个LoadLoad内存屏障,禁止前面的volatile读操作与后面的普通读操作重排序
  • 读操作之后,再插入一个LoadStore内存屏障,禁止前面的volatile读操作与后面的普通写操作重排序
    在这里插入图片描述

内存语义的优化

  • 上面的读写内存语义是保守的,具体的代码中还可以对生成的指令序列进行优化,减少不必要的内存屏障
    在这里插入图片描述

  • 优化后的指令序列如下
    在这里插入图片描述

  • 如果是x86处理器,volatile读写操作的指令序列还可以简化如下

    • x86处理器只允许写 - 读操作重排序,可以直接省略读- 写、读 - 读, 写 - 写操作对应的内存屏障
      在这里插入图片描述

4.1.3 JSR-133对volatile内存语义的增强

  • JSR-133之前的旧JMM中,不允许volatile变量之间的重排序,但允许volatile变量与普通变量的重排序

  • 结合volatile的示例代码,这样的内存模型将存在一定问题:

    • 由于不存在数据依赖,volatile写操作前的普通变量写操作可能被置后
  • 线程A执行writer()方法后,线程B执行reader()方法

    • 线程B在执行操作4时,无法看到线程A对普通变量的修改
    • 但按照程序员的期望,既然都能看到volatile变量的修改,那之前的普通变量的修改也应该可见 😂
      在这里插入图片描述
  • 增强之后的volatile内存语义:

    • 根据volatile写操作前的StoreStore内存屏障,我们可知:writer()方法中,对共享变量i的写操作不会被重排序
    • 根据volatile读操作后的LoadLoad内存屏障,我们可知:reader()方法中,对共享变量i的读操作不会被重排序
  • 线程A执行writer()方法后,线程B执行reader()方法,执行顺序如下

    • 通过程序顺序规则,1 happens before 2, 3 happens before 4
    • 根据volatile规则,2 happens before 3
    • 根据传递性规则, 1 happens before 4

在这里插入图片描述

4.2 锁

4.2.1 锁的内存语义

  • 基于synchronized锁的代码如下:假设线程A执行writer()方法,更新共享变量;随后,线程B执行reader()方法
    在这里插入图片描述

  • 线程A释放锁时,JMM会把线程A对应的本地内存中的共享变量刷新到主内存

    • 从通信的角度看:线程A向后续的线程发出消息:共享变量已经被修改
  • 线程B获取锁时,JMM会把线程B对应的本地内存置为无效;从而,执行reader()方法时,需要从主内存获取共享变量的值

    • 从通信的角度看:线程B接收到了线程A修改共享变量的消息
  • 线程A释放锁,随后线程B获取锁,实际是线程A通过主内存向线程B发送消息

  • 总结:

    • 获取锁和释放锁,还隐藏了更新主内存中共享变量的值、获取主内存中共享变量的值
    • 对比volatile的内存语义,可知:锁的释放对应volatile变量的写操作,锁的获取对应volatile变量的读操作

示例代码中,锁的happens-before关系

  • 根据程序顺序规则, 1 happens before 2, 2 happens before 3, 4 happens before 5,5 happens before 6
  • 根据监视器锁规则, 3 happens before 4
  • 根据传递性,2 happens before 5

在这里插入图片描述

4.2.2 锁内存语义的实现

  • 本小节将基于可重入锁ReentrantLock了解锁内存语义的实现
  • ReentrantLock基于AQS(AbstractQueuedSynchronizer,同步器)实现,AQS中有一个int类型的volatile变量来维护同步状态
  • volatile变量是ReentrantLock内存语义实现的关键

公平锁的获取

  • 使用公平锁时,加锁方法lock()的调用轨迹如下

    1. ReentrantLock: lock()
    2. FairSync: lock()
    3. AbstractQueuedSynchronizer: acquire(1)
    4. FairSync: tryAcquire(1)
    
  • 第4步,tryAcquire(int acquires)是真正实现加锁的方法

    • 获取锁前,首先读取volatile变量的值
    protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState(); // 获取锁时,先读取volatile变量state
            if (c == 0) { // 成功获取锁,将当前线程设置为锁的拥有者
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) { // 通过查收将state更新为1
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {  // 当前线程重复获取锁
                int nextc = c + acquires;  // state加1
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);  // 直接更新state的值
                return true;
            }
            return false;
        }
    }
    

公平锁的释放

  • 使用公平锁时,解锁方法unlock()的调用轨迹如下

    1. ReentrantLock: unlock()
    2. AbstractQueuedSynchronizer: release(1)
    3. Sync: tryRelease(1)
    
  • 第3步,tryRelease(int releases)是实现释放锁的实际方法

    protected final boolean tryRelease(int releases) {
        int c = getState() - releases; 
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
        if (c == 0) {
            free = true;
            setExclusiveOwnerThread(null);
        }
        setState(c); // 释放锁的最后,写volatile变量state
        return free;
    }
    
  • 公平锁,释放锁的最写volatile变量state,获取锁时首先度volatile变量state的值

  • 根据volatile变量的happens-before规则,一个线程释放锁后,后续线程获取同一个锁,锁计数器的值立即可见

  • 非公平锁的释放,也是一样的调用轨迹

非公平锁的获取

  • 非公平锁的释放轨迹如下:

    1. ReentrantLock: lock()
    2. NonfairSync: lock()
    3. AbstractQueuedSynchronizer: compareAndSetState(int expect, int update)
    4.AbstractQueuedSynchronizeracquire(int arg) ---> NoFairSync: tryAcquire(int acquires)
    
  • 关键代码如下

    • 不管是否是首次获取,最终都将调用AbstractQueuedSynchronizer的compareAndSetState(int expect, int update)方法,原子更新state变量的值
    final void lock() {
        if (compareAndSetState(0, 1)) // 首次获取锁
            setExclusiveOwnerThread(Thread.currentThread());
        else // 重复获取锁
            acquire(1);
    }
    
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
    
  • CAS操作需要先读volatile变量的值,如果符合预期,再写volatile变量的值

  • CAS操作将同时具有volatile读和volatile写的内存语义

    • volatile读:编译器不会将volatile读与volatile读之后的任意内存操作重排序
    • volatile写:编译器不会将volatile写与volatile写之前的任意内存操作重排序
    • 综合起来,编译器不会将CAS与CAS前后的任意内存操作重排序
    • 从而保证非公平锁释放后、再获取,volatile变量state是内存可见的

总结

  • 公平锁或非公平锁的释放,最后都需要写volatile变量state
  • 公平锁获取,首先需要读volatile变量state;非公平锁的获取,使用CAS更新volatile变量state的值。
  • 由于CAS同时具有volatile读和写的内存语义,获取锁时volatile变量state是内存可见的

4.2.3 concurrent包的实现

  • Java concurrent包的通用实现方式;

    • 将状态变量定义为volatile共享变量
    • 借助volatile的读写内存语义、CAS同时具有volatile读写内存语义的特性,实现线程间的通信:对状态变量的修改是内存可见的
    • 同时,借助CAS解决多线程竞争锁的问题,从而实现线程间的同步
  • AQS、非阻塞数据结构、原子变量类,这些concurrent包中的基础类,几乎都使用这种模式实现的

  • 同时,concurrent包的高层类又是基于这些基础类实现的。

  • 可以说,上述实现方式在concurrent包中是通用的

4.3 final

4.3.1 final域的重排序规则

  • 写final域的重排序规则:
    • 在构造函数内对一个final域的写入,与随后把被构造对象赋值给一个引用变量,这两个操作之间不能重排序
    • 也就是说,对final域的写入不能被重排序到构造函数之外
  • 读final域的重排序规则:
    • 初次读包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
    • 也就是说,初次读final域不能被重排序到初次读对象的引用之前

写final域的重排序规则

  • JMM禁止编译器把final域的写重排序到构造函数之外
  • 编译器会在写final域、构造函数返回之前,插入一个StoreStore内存屏障,从而禁止处理器把final域的写重排序到构造函数之外
  • 代码示例如下
    在这里插入图片描述
  • 线程A执行writer()方法,随后线程B执行reader()
    • 写普通域操作被编译器重排序到了构造函数之外,线程B读到是普通域未初始化的值
    • 写final域的重排序规则将写final域操作限定在构造函数中,线程B可以正确读取final域初始化的值
      在这里插入图片描述
  • 总结:
    • 写final域的重排序规则确保,在对象引用为任意线程可见之前,对象的final域已经正确初始化

读final域的重排序规则

  • 由于读包含final域对象的引用、读final域之间存在间接依赖关系,编译器是不会对这两个操作进行重排序的
  • 大多数处理器也不会重排序,但少数处理器会对存在间接依赖关系的操作重排序
  • 因此,读final域重排序规则针对的是处理器,编译器会在读final域操作前插入LoadLoad内存屏障以禁止重排序
  • 仍然以上面的代码为例:
    • 读普通域操作被重排序到读对象引用之前,读到的普通域未正确初始化
    • 读final域被限定在读对象引用之后,读到的final域已经完成了初始化
      在这里插入图片描述
  • 总结:
    • 读final域的重排序规则确保,读对象的final域之前,一定会先读该对象的引用
    • 只要对象引用不为null(成功构造),则对象中的final域一定已经完成了初始化

自己的理解

  • 写final域的重排序规则,使得构造函数返回前,对象中的final域已经成功初始化 —— 这是前提
  • 读final的重排序规则,使得只要对象引用不为null,读到的对象final域一定成功初始化 —— 这是在该前提下的必然结果

x86处理器对final域重排序规则的支持

  • x86处理器不会对写 - 写操作重排序,因此写final域无需添加StoreStore屏障
  • x86处理器不会对存在间接依赖的操作重排序,因此读final域无需添加LoadLoad屏障
  • 也就是说,x86处理器针对final与的重排序规则天生就支持,无需借助额外的内存屏障指令来实现

4.3.2 final引用不能从构造函数溢出

  • 示例代码中,在构造函数返回前,被构造对象的引用可以被其他线程看见
    • 被构造对象的引用发生逸出,对象中的final与可能尚未初始化
    • 也就是说,final引用从构造函数逸出了,并未按照达到预期(构造函数返回前,final域已经初始化)
      在这里插入图片描述
  • 这使得线程B在访问对象引用时,对象引用虽然不为null,但是对象中的final域尚未初始化
    在这里插入图片描述

4.3.3 JSR-133对final语义的增强

  • 在旧的JMM中,一个线程看到的final域可能是尚未初始化,一段时间内再读final域发现值发生了变化
  • 这样的现象根本不符合final域的定义:不可变量
  • 增强后的final语义可以保证:只要final引用没有从构造函数中逸出,不需要使用同步就能保证任意线程看到的是final域初始化后的值

5. JMM在单例模式中的应用

5.1 不安全的DCL

  • 单例模式的相关知识,可以参考之前的博客:层层递进,实现单例模式

  • 针对最简单的饿汉模式,我们提出可以使用懒汉模式:在获取唯一实例时才进行创建

  • 原始的懒汉模式是非线程安全的,于是使用synchronized修饰全局访问点

  • synchronized实现同步,却导致了性能下降,于是提出使用DCL实现懒汉模式

    public class Singleton {
        private static Singleton instance;
    
        private Singleton() {
        }
    
        public static Singleton getInstance() {
            if (instance == null) { // 第一重检验,避免不必要的同步
                synchronized (Singleton.class) { // 同步锁
                    if (instance == null) { // 第二重检验,避免重复创建
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
    
  • 仔细分析发现,这样的实现也存在问题,主要是instance = new Singleton()对应多个操作
    在这里插入图片描述

  • 这些操作可能被JVM重排序如下
    在这里插入图片描述

  • 其他线程发现instance不为null并直接返回后,将会访问到一个尚未初始化的对象
    在这里插入图片描述

  • 这样的重排序没有违反intra-thread semantics,在没有改变单线程的执行结果的前提下,可以提高程序的执行性能

  • 解决办法:

    • 保证2和3不会被重排序,使得其他线程发现对象不为null时,对象已经初始化
    • 允许2和3重排序,但不允许其他线程看到这个重排序。也就是说,初次访问对象一定是在整个instance = new Singleton()执行完之后
      在这里插入图片描述

lock前缀指令

  • 在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。

  • Java代码instance = new Singleton();,对应的汇编代码如下:关键在lock前缀指令

    0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp); 
    
  • lock前缀指令相当于一个内存屏障,主要提供3个功能:

    • 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面
    • 强制将处理器缓存回写到内存
    • 如果是缓存回写操作,它会导致其他处理器的缓存行无效
  • 这样的话,volatile不仅能禁止指令重排序,还能保证变量的内存可见性

  • 参考链接:java内存屏障的原理与应用

5.2 volatile:禁止2和3重排序

  • 将instance定义为volatile类型,就可以禁止2和3重排序
  • 3是在写volatile变量,根据写volatile的内存语义,会在写操作前添加StoreStore屏障,从而禁止2重排序到3之后
  • 这样就可以保证其他线程在判断对象不为null时,对象已经初始化
    在这里插入图片描述

5.3 静态内部类

  • 允许2和3重排序,但要保证执行初次访问对象时,2和3都已执行,即对象已成功构建
  • 静态内部类刚好可以满足需求
    • 静态内部类在第一次有线程访问getInstance()方法,执行return LazyHolder.instance;时才会被加载 —— 满足延迟加载的需求
    • instance作为静态内部类的静态成员,在内部类被加载时就已经完成初始化
    • 一旦能访问到instance对象,instance对象已就绪
    public class Singleton {
        private Singleton() {}
        
        private static class LazyHolder {
            private static Singleton instance = new Singleton();
        }
    
        public static Singleton getInstance() {
            return LazyHolder.instance;
        }
    }
    
  • 为什么instance对象的构建是线程安全的?
    • Java语言规定,每个类或接口,都有一个唯一的初始化锁与之对应,从而类加载的整个过程是线程安全的
    • 因此,在类加载中完成初始化的instance也是线程安全的
      在这里插入图片描述

两种方案的对比

  • 静态内部类的实现方案更加简洁,但volatile不仅支持静态变量的初始化,还支持实例变量的初始化
  • 需要对实例字段实现线程安全的延迟初始化,可以使用volatile方案
  • 需要对静态字段实现线程安全的延迟初始化,可以使用静态内部类方案

6. JMM总结

  • JMM这一部分学习了好久,总结起来也挺费劲的

内存可见性

  • 所谓内存可见性:一个线程对共享变量的修改,其他线程立即可感知
  • 处理器中缓存的引入,带来的缓存一致性问题;JMM的抽象结构,以及本地内存带来的内存可见性问题(处理器和内存看到的执行顺序不一致)
  • 理想化的内存模型:顺序一致性内存模型,保证单线程内按程序顺序执行,多线程能看到一个统一的全序、且执行结果立即可见(单开关多灯模型)
  • Java程序的内存可见性保证:
    • 单线程程序:不会出现内存可见性问题,在执行结果与在顺序一致性内存模型中的执行结果一致的前提下, 可以进行重排序
    • 正确同步的多线程程序:执行结果与在顺序一致性内存模型中的执行结果一致,通过限制编译器和处理器的重排序来为程序员提供内存可见性
    • 为同步/正确同步的多线程程序:JMM提供最小安全性保障,线程读取到的值,要么是之前线程写入的值,要么是默认值

重排序

  • 重排序的分类:编译器重排序、处理器重排序(指令级并行重排序、内存系统重排序)
  • 常见处理器允许的重排序:禁止对存在数据依赖的操作重排序,都允许写 - 读操作重排序,x86处理器只允许写 - 读操作重排序
  • 如何禁止重排序:编译器重排序规则,编译器插入内存屏障禁止特定类型的处理器重排序
  • 四种内存屏障及其含义,开销最昂贵、全能型的内存屏障StoreLoad
  • 操作同一个变量时,数据依赖的三种情况;as-if-serial语义,不管怎么重排序单线程程序的执行结果不能被改变

happens-before语义

  • 定义:
    • 对程序员的承诺:1 happens before 2,则1的操作结果对2内存可见,且1在2之前执行
    • 放松对编译器和处理器的约束:在保证执行结果不变的前提下,允许不按照happens-before指定的顺序执行
  • as-if-serial语义的对比理解:一个针对正确同步的多线程程序,一个针对单线程程序
  • happens-before的几大规则:程序顺序规则、volatile规则、监视器锁规则、线程start规则、线程join规则、线程interrupt规则、对象finalize规则、传递性

volatile、锁、final的内存语义

  • volatile
    • 内存语义:写volatile,本地内存中的共享变量刷新回主内存;读volatile,本地内存失效,需要从主内存读最新值
    • 内存语义的实现:写volatile操作,之前插入StoreStore屏障,之后插入StoreLoad屏障;读volatile操作,之后先插入LoadLoad屏障,再插入LoadStore屏障
    • JSR-133对volatile内存语义的增强:禁止volatile变量与普通变量的重排序
    • 内存语义:释放锁,本地内存中的共享变量刷新回主内存;获取锁,本地内存失败,需要从主内存读最新值;与volatile内存语义的对比(二者具有对应关系)
    • 内存语义的实现:
      • 基于可重入锁ReentrantLock,关键在AQS中的volatile变量state
      • 公平锁和非公平锁释放时,最后都需要写volatile变量state
      • 公平锁获取时,首先读volatile变量state
      • 非公平锁通过CAS获取锁,同时具有volatile变量读写操作的内存语义
    • concurrent包的通用实现模式:
      • 将状态变量定义为volatile
      • 通过volatile变量读写内存语义、CAS同时具有volatile变量读写内存语义,实现线程通信
      • CAS更新状态变量,保证竞争锁时的线程同步
  • final
    • final域重排序规则:
      • 构造函数中,写final域操作不能重排序到构造函数之外。(写操作后,插入StoreStore屏障)
      • 读final域之前,需要先读final域所在的对象引用。(读操作前,插入LoadLoad屏障)
      • 前者保证对象引用被其他线程可见前,final域已经正确初始化(前提);后者保证,只要对象引用不为null,对象中的final域一定已初始化(前提的必然结果)
    • x86处理器的特殊性,无需插入任何屏障就能保证final域重排序规则
    • final引用从构造函数溢出,将无法保证对象引用不为null时,final域已初始化
    • JSR-133对final内存语义的增强,之前没有相关的重排序规则

JMM的在单例模式的应用
- 不安全的DCL存在的问题,如何解决这个问题:禁止对象初始化和引用赋值的重排序 ;允许重排序,但初次访问对象引用,不能提前
- 前者,使用volatile解决:lock前缀指令及其作用
- 后者,使用静态内部类解决:内部类的延迟加载、线程安全的初始化(初始化锁保证)
参考文档:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值