目 录CONTENT

文章目录

第三章 Java内存模型

FatFish1
2024-10-21 / 0 评论 / 0 点赞 / 69 阅读 / 0 字 / 正在检测是否收录...

Java内存模型基础

并发编程的两个核心问题

需要分析的是:线程之间的如何通信,以及如何同步?

答:共享内存和消息传递。即要么线程间共享一块内存,进行隐式的通信,要么不共享内存,完全依赖消息传递进行通信

java本质上是共享内存的模式

java内存模型结构

java内存有共享部分和私有部分,共享部分包括实例域、静态域、数组元素,即堆内存;局部变量、方法定义参数、异常处理器参数是不共享的私有部分。

此外,java线程在公共内存的基础上(主存),还有自己的私有本地内存,每个线程在自己的本地内存中读/写共享变量的副本。主存和本地内存的概念是抽象概念。主存和本地内存的模型和操作可以参考深入理解JVM部分

因此,一个n核处理器,一个变量a的存储数量最多有n+1个。如果线程A修改了变量a,这其中就有隐式通信:

  • 线程A把本地内存A中更新过的共享变量刷新到主存

  • 线程B取主存中读取线程A更新过的变量值

得出结论:JVM是通过控制主存与每个线程的本地内存之间的交互,实现内存的可见性

指令重排序和内存屏障

从java源码到计算机指令,有三种重排序方案:

  • 编译器优化重排序:保证不改变单线程语义进行重排序

  • 指令级并行重排序:不存在数据依赖性时处理器级别进行重排序

  • 内存系统重排序:对处理器使用缓存的读/写操作是非原子的,并且是针对写缓冲区的,因此交替执行看上去像是乱序

可见:重排序保证单线程语义,但可能造成多线程内存可见性问题

举例:

加入初始状态是a = b = 0;
// 线程1
a = 1;
x = b
// 线程2
b = 2;
y = a;

这时对每个线程来说,语义改变是不影响的,且是不存在数据依赖的。但是由于每个线程有写缓冲区存在,有可能产生,线程1向写缓冲区存入了a=1,但是还未同步到主存,线程2拿到a还是0

这时,看起来就像是重新对指令进行了排序,即a=1在y=a后面执行。对于这种场景,可以用内存屏障解决。

内存屏障是一种特殊的指令。有如下分类:

  • LoadLoad:要求Load1数据的装载先于Load2及所有后续装载指令

  • StoreStore:要求Store1数据先于Store2及所有后续存储指令

  • LoadStore:要求Load1数据装载先于Store2及后续所有存储指令

  • StoreLoad:要求Store1先于Load2及后续所有装载指令

StoreLoad是一个全能型的屏障,大多数处理器都支持。

java对内存屏障的应用是通过volatile关键字的,该关键字修饰的变量应用内存屏障。

happens-before原则

总结happens-before原则规则:

  • 程序顺序规则:一个线程中每个操作,happens-before于该线程的任意后续操作

  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁

  • volatile规则:对一个volatile变量的写,happens-before于任意后续对这个变量的读

  • 传递性:happens-before具有传递性

happens-before原则存在的目的就是限制某种类型的处理器重排序

深入了解重排序

重排序遵循的几条规则:

  • as-if-serial语义:编译器和处理器不会对存在数据依赖关系的操作做重排序。

  • 具有happens-before顺序的操作不会重排序

顺序一致性

顺序一致性模型

顺序一致性可以理解为,JVM中所有线程共享一个全局的内存,同一时刻只有一个线程能读/写内存,多个线程并发时,其各个操作在内存中的执行顺序是串行化的,但是同时符合各个线程操作的顺序。例如:

线程1执行:A1、A2、A3

线程2执行:B1、B2、B3

串行顺序可能是:

在使用monitor锁的情况下:A1、A2、A3、B1、B2、B3

不使用同步锁的情况下: B1、A1、B2、A2、B3、A3

顺序一致性模型是一个理想化模型,实际上在不改变程序执行结果的前提下,Java编译器和处理器会尽可能进行优化,这种优化只针对单线程

而对于未同步或未正确同步的多线程,java内存模型只提供最小的安全性,即线程执行时读取到的值要么是之前某个线程写入的值,要么是默认值(0,Null,False),而不会无中生有获取值。

因此得到结论:

  • 虽然顺序一致性模型要求单线程内操作按程序顺序执行,但JVM实际不保证这一点,会有限地进行重排序

  • 虽然顺序一致性模型要求所有线程只能看到一致的操作执行顺序,但JVM实际也不保证这一点

总线和总线事务

处理器和内存之间做数据连接是通过总线完成的,每次数据传递经过的一系列步骤称为一个总线事务。总线事务包括读事务和写事务

既然是事务就意味着这一已经是一个不可分割的最小单元了。这是因为,总线总会将试图并发使用总线的事务进行同步化,即一个总线事务执行时,总线会禁止其他处理器和I/O设备执行内存读写。这样就实现了一个总线事务的操作的原子性。

但是对于一些64位的数据,一个总线事务可能处理不完,比如long/double类型的变量的写操作,可能会拆成2个32位的写事务,分别完成高32位和低32位的写操作。这时,对于long/double类型的写操作就不再是原子性的了,可能产生脏读

volatile的内存语义和实现

volatile的特性

volatile修饰的变量,可以理解为使用同一个锁对它的读/写操作做了同步。因为锁的happens-before原则保证锁释放和获取两个线程之间内存可见性,因此一个volatile变量的读,总是可以看到另一个线程(包括自己)对这个volatile最后的写入

同时锁的语义决定了锁临界区内的代码块具有原子性,因此即使是64位的long和double类型变量,只要是volatile变量,其读写也可以具有原子性

总结下来就是volatile对可见性和原子性的支持:

  • 可见性:一个volatile变量的读,总能看到任意线程对它最后的写

  • 原子性:对单个volatile的读/写通过锁进行原子性约束

volatile对内存可见性的影响

volatile写和锁释放,volatile读和锁获取,语义是类似的。例如:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1; // 1
        flag = true; // 2
    }

    public void reader() {
        if (flag) { // 3
            int i = a; // 4
        }
    }
}

加入线程A执行writer(),线程B执行reader(),根据volatile禁止重排序的原则,1先于2,3先于4,根据volatile写先于读的原则,2先于3,最终执行顺序就是1、2、3、4。如果不加volatile,通过sychronized给方法加锁,那么a = 1和flag = true就有可能出现临界区内的重排序。

volatile写先于读的原则,总结下来就是:

  • 线程A写一个volatile变量,实质上是线程A向接下来要读这个volatile变量的某个线程发出了消息,要求其失效本地缓存

  • 线程B读一个volatile变量,实质上是线程B接收到之前某个线程发出的修改消息

  • 线程A写,线程B读,实质上是线程A通过主内存向线程B发送消息

volatile内存语义的实现

假如有两个操作,其中:

  • 若第一个操作是volatile读,则禁止重排序

  • 若第二个操作是volatile写,则禁止重排序

这就保证了volatile读之后的操作不会跑到写前面,volatile写前面的操作不会跑到写后面

在字节码层面,通过插入内存屏障来禁止特定的重排序:

  • 在每个volatile写操作前面插入StoreStore屏障,后面插入StoreLoad屏障

  • 在每个volatile读操作后面插入1个LoadLoad屏障和1个LoadStore屏障

这是最保守的做法,在一些架构下,编译器可能会根据实际代码情况,优化减少屏障。

锁的内存意义和实现

锁的内存意义

锁的内存意义与volatile类似,锁获取类似于volatile读,锁释放类似volatile写。即:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的线程发出了对共享变量所做的修改的消息。

  • 线程B获取一个锁,实质上是线程B接收到之前某个线程发出的修改共享变量的消息,从而将本地内存设为无效并重新拉取

  • 线程A释放锁,线程B获取锁,相当于线程A通过主内存向线程B发送消息

锁内存语义的实现 - 以ReentrantLock为例

以ReentrantLock为例,加锁和释放锁实际上是对volatile变量state做CAS操作,详细代码可以参考ReentrantLock部分

java concurrent包的源代码实现的一个通用模式就是:

  • 首先声明共享变量为volatile

  • 然后使用CAS的原子条件来更新线程之间的同步

  • 同时,配合以volatile的读/写和CAS所具有的的volatile读和写的内存语义来实现线程之间的通信

使用这一套模式的包括concurrent中的基础包(AQS、非阻塞数据结构、原子变量类例如atomicxx),以及基于这些基础包的高层类(Lock、同步器、阻塞队列、Executor、并发容器)等都遵循这一套通用模式

final域的内存语义和实现

final域的重排序规则

final域变量,编译器和处理器遵守两个重排序规则:

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序

  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作直接不能重排序

理解这两个点:

final变量的写的重排序

构造函数对final变量的写入后面会加一个storestore屏障,阻止这个final的写入操作被重排序到构造函数外面,而普通变量就没有这个限制

例如一个类:

public class FinalExample {

    int i; // 普通变量
    final int j; // final变量
    static FinalExample obj;

    public FinalExample () { // 构造函数
        i = 1; // 写普通域
        j = 2; // 写final域
    }

    public static void writer () { // 写线程A执行
        obj = new FinalExample ();
    }

    public static void reader () { // 读线程B执行
        FinalExample object = obj; // 读对象引用
        int a = object.i; // 读普通域
        int b = object.j; // 读final域
    }
}

线程A执行FinalExample的初始化,线程B直接取读取它的类变量,这时i是非final就有可能被重排序到后面,导致线程B读到错误的值,而j是final变量,禁止重排序,因此B读到就是正确值

final变量读的重排序

在同一个线程中,初次读对象引用与初次读该对象包含的final域,处理器禁止重排序,而编译器会在读final域操作前面加一个loadload屏障。换句话说,读一个final变量之前,必定先读这个变量所属的对象。如果对象是null就空指针了,如果非null说明构造函数一定执行了,结合写final变量的规则,final变量就一定有值

还看上面的案例,也就是说FinalExample object = obj这一句和int b = object.j这一句不会重排序。

如果是变量i,这里可能重排序,导致int a = object.i在object初始化之前执行,结果线程B拿到错误的i值

final变量是引用类型

当final变量是引用类型,例如:

final int[] intArray;

对于这种场景,有约束:在构造函数内对一个final变量的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

public class FinalReferenceExample {

    final int[] intArray; // final是引用类型
    static FinalReferenceExample obj;

    public FinalReferenceExample () { // 构造函数
        intArray = new int[1]; // 1 对final域的写入
        intArray[0] = 1; // 2 是对这个final域引用的对象的成员域的写入
        // obj = this;  7 在构造函数内将构造对象赋值给引用对象暴露出去
    }

    public static void writerOne () { // 写线程A执行
        obj = new FinalReferenceExample (); // 3 把被构造的对象的引用赋值给某个引用变量
    }

    public static void writerTwo () { // 写线程B执行
        obj.intArray[0] = 2; // 4
    }

    public static void reader () { // 读线程C执行
        if (obj != null) { // 5
            int temp1 = obj.intArray[0]; // 6
        }
    }
}

在这个例子里面就是说步骤2是对final变量的成员域的写入,3是在构造函数外赋值给一个引用变量,即对于引用类型,除了1和3不能重排序,2和3也不能重排序

这时线程C执行时,只是能看到下标为0的数据,而线程B执行的写操作则不在重排序的限定范围内,如果并发需要使用同步手段。

但需要注意的时,如果有步骤7执行,即在构造函数中暴露构造对象,这时final变量也会产生同步问题,因为规则并没有阻止构造函数内的重排序,可能导致obj更早被赋值,而int Array还没完成初始化

final内存语义的实现

总结下来,就是在final变量写之后,return之前插入一个StoreStore屏障,在final变量读之前插入一个LoadLoad屏障

happens-before

happens-before和有条件的重排序

happens-before被用来指定两个操作的执行顺序,这两个操作可能在一个线程内也可能不在一个线程内。

目前JMM把happens-before要求禁止的重排序限定为:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如一个锁只可能被单线程访问,那么就把锁优化掉;一个volatile变量只会被单线程访问,那么就优化成普通变量……

因此happens-before规则被解释为:

  • 如果一个操作happens-before于另一个操作,那么第一个操作的执行结果对第二个操作可见

  • 由于目的是结果可见,因此两个操作并不一定非要按固定顺序执行,只要重排序后执行结果一致,那么这种重排序也不非法(as-if-serial语义)

从这看来,在不合理的异步场景中,可能一个线程中互相不影响的两条指令,潜在对其他线程产生影响,也可能会被重排序。

因此as-if-serial语义只是一种虚假的保证。

happens-before规则

happens-before包括以下规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意后续操作

  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁

  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

  • 传递性:A 早于 B,B 早于 C,则A早于C

  • start()规则:线程A调用ThreadB.start()(启动线程B),那么线程A的ThreadB.start()操作happens-before于线程B的任意操作

  • join()规则:如果线程A执行ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

double check lock - 双重检查锁的原理

double check lock失效的原理

public class DoubleCheckedLocking { // 1

    private static Instance instance; // 2

    public static Instance getInstance() { // 3
        if (instance == null) { // 4:第一次检查
            synchronized (DoubleCheckedLocking.class) { // 5:加锁
                if (instance == null) // 6:第二次检查
                    instance = new Instance(); // 7:问题的根源出在这里
            } // 8
        } // 9
        return instance; // 10
    } // 11
}

一个基础版本的dcl实现,不使用volatile,仅通过sychronized对new流程加锁,为什么会出问题?

instance = new Instance();

这个代码过程可以分解为三行伪代码:

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

在单线程中,调换2、3的顺序,并不会导致访问实例出错,因此2和3本质上是可以进行指令重排序的。

但是在多线程中,没有合理的同步策略,在2和3交换的情况下,其他的线程可能拿到一个还没有执行初始化的对象,导致出错。

volatile版双重检查锁

public class DoubleCheckedLocking { // 1
    private volatile static Instance instance; // 2
    public static Instance getInstance() { // 3
        if (instance == null) { // 4:第一次检查
            synchronized (DoubleCheckedLocking.class) { // 5:加锁
                if (instance == null) // 6:第二次检查
                    instance = new Instance(); // 7:问题的根源出在这里
            } // 8
        } // 9
        return instance; // 10
    } // 11
}

这是dcl的第一种优化方案,将单例instance定义为volatile单例。

这是利用了volatile的特性:第一个操作是volatile读/第二个操作是volatile写,不允许重排序

ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址,这里是volatile写

因此这两行内容也不会被重排序

基于类初始化的方案

基于类初始化方案也可以做到单例在线程中安全

public class InstanceFactory {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }
    public static Instance getInstance() {
        return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化
    }
}

InstanceHolder是内部类,在执行getInstance方法时使其被初始化。而类的初始化是线程安全的。

类的初始化可以参考《深入理解JVM虚拟机》部分,这里简单总结:

当下面任意情况发生时,一个类或接口类型T将被立即初始化:

  • T是一个类,且T类型的实例被创建

  • T是一个类,且T中声明的一个静态方法被调用

  • T中声明的一个静态字段被赋值

  • T中一个非常量静态字段被使用

  • T是一个顶级类,且一个断言语句嵌套在T内部执行

getInstance方法符合情况2,假如A和B并发调用getInstance,A拿到初始化锁并调用构造函数

ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址,这里是volatile写

此时步骤2、3允许重排序,但是线程B无法看得到这个重排序

与volatile相比,使用类初始化方案代码更简洁,且降低了初始化类或创建实例的开销,但是增加了访问被延迟初始化字段的开销。这里按需使用

Java内存模型综述

各种内存模型

顺序一致性内存模型是一个理论参考模型

在顺序一致性模型的基础上,放松一些操作顺序,做一些重排序,产生了不同版本的内存模型:

  • TSO模型:在顺序一致性等待基础上放松写-读顺序

  • PSO模型:在TSO的基础上放松写-写操作

  • RMO和PowerPC模型:在PSO的基础上放松读-写顺序和读-读顺序

需注意的是:上面放松操作顺序都是以不存在数据依赖性为前提的

越追求性能的语言,内存模型约束性就会越弱

内存可见性保证

  • 单线程:保证不出现内存可见性问题。

  • 正确同步的多线程程序:具有一定顺序性,程序执行结果与该程序在顺序一致性内存模型中的执行结果相同。这也是程序员需要关注的重点。

  • 未同步/未正确同步:只提供最小安全保证,即线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)

0

评论区