Java内存模型

本文深入探讨Java内存模型,解释了主内存与工作内存之间的交互规则,详细说明了volatile、final关键字的作用以及如何实现变量的原子性。同时,文章阐述了重排序的概念及其对多线程程序的影响,并介绍了Java内存模型如何保证程序执行结果的一致性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.硬件的效率与一致性
    cpu的运算速度和存储设备之间的差距是巨大的,所以现代计算机会在cpu和内存间加入一个高速缓冲cache来作为速度差的缓冲,让cpu作用于cache中数据副本做计算,运算结束后再将数据结果同步回主内存中。
    引入了cache很好的处理了内存和cpu速度间的矛盾,起码目前来说是比较好的方案,被广泛运用。但这也引入了另外一个问题:数据一致性。在多处理器系统中,每个处理器都有自己的缓存,他们作用于同一片主内存。如下图:这就需要一种协议来保证数据的一致性。例如:MSI、MESI、MOSI及Dragon Protocol等。


2.Java内存模型
  • 主内存和工作内存
            Java的内存模型规定了程序中变量的访问规则,即在虚拟机中从内存取出变量和存储变量到内存这些操作的细节。当然,这里的变量指的        不是线程私有的局部变量、方法参数等,而是会被线程共享的变量。
            它规定了
  • 所有的变量都存储在主内存中,每个线程还有自己的工作内存
  • 工作内存存储了主内存中数据的副本
  • 线程对变量的操作必须在工作内存中进行,不能直接操作主内存中的变量
  • 不同线程间不能直接访问对方的工作内存的变量
  • 线程间变量的传递需要通过主内存来完成
            
  • 内存间的交互
             关于主内存和工作内存间的交互,Java内存模型定义了以下8中操作:
    • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
    • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
    • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
    • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
    • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
    • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
    • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
    • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

          这里的read和load、store和write是成对的操作,必须按顺序执行,但不一定连续执行。关于这些操作,Java内存模型做出了一些规定:
    • 不允许read和load、store和write操作之一单独出现
    • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
    • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
    • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
    • 一个变量在同一时刻只允许一条线成对其进行lock操作,lock和unlock必须成对出现
    • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
    • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
    • 对一个变量执行unlock操作之前,必须先把次变量同步到主内存中(执行store和write操作)。
         PS:可能有人会对这里的read load 和 store write有疑问,为什么存取操作都要分成两步。这里做一个解释,仅为个人理解。上面说到的多线程环境下工作内存会引入数据的不一致性是个必须要解决的问题,那最简单的办法就是让工作内存和主内存时刻保持同步,但这等于强制让缓存降速,退化到了cpu-主内存的模型,不利于程序的效率。现在的做法是选择性同步,即当变量不需要同步时,只需要一次的load,就可以针对工作内存中的副本多次的read、write,避免和主内存的同步操作,这样可以保证cpu计算的高速运转;当变量需要同步时,再进行store操作,将变量写回主内存。


3.重排序

  • 为了提高执行性能,编译器和处理器经常会进行指令重排序,分为以下三种类型:
    • 编译器优化的重排序。编译器不改变单线程语意的前提下对语句的重排序。
    • 指令并行重排序。处理器在不不存数据依赖性的前提下,对语句对应的机器指令进行重排序。
    • 内存系统重排序。由于缓存和读写缓冲区的存在,加载和存储操作可能是乱序执行的。
        这些重排序仅保证在单处理器或单线程中的语意不发生改变,但是在多核或多线程环境中,就可能改变程序执行的结果。
  • Java内存模型对正确同步的多线程程序的内存一致性做了如下保证:
    • 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)--即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(马上我们将会看到,这对于程序员来说是一个极强的保证)。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile和final)的正确使用。
         这里的顺序一致性内存模型是一个理想化模型,它具有两个特性:
    • 一个线程内的所有操作必须按顺序执行
    • (不管是否同步)所有线程都只能看到一个单一的操作执行顺序。
    • 所有操作都必须是原子性的,且立即对其他线程可见。
  • 为保证内存的可见性,Java编译器在生成指令序列的适当位置会加入内存屏障来禁止一些特定类型的重排序。这些内存屏障分为以下四种:
        
  • 从JDK5开始,java使用新的JSR -133内存模型。JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的happens-before规则如下:
    • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
  • 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。
  • 线程上调用start()方法happens before这个线程启动后的任何操作。
  • 一个线程中所有的操作都happens before从这个线程join()方法成功返回的任何其他线程

4.volatile简介

        上面重排序的介绍中,提到了volatile,监视器和volatile都是比较对程序员直接有用的机制,且作用上有一些相似之处。理解volatile特性的一个好方法是:把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步。
        在多线程程序开发中,需要同步一些资源。同步往往意味着互斥,但不仅仅是互斥。释放监视器有把缓冲区刷新到主内存的作用,而获取监视器有使缓冲区失效需要重新拉取主内存变量的作用。volatile也是如此,在JSR -133内存模型中,不仅对volatile的操作不能被重排序,volatile操作附近的操作也不能被重排序了。当线程A写入一个volatile字段f的时候,如果线程B读取f的话 ,那么对线程A可见的任何东西都变得对线程B可见。实际上,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()方法。根据happens before规则,这个过程建立的happens before 关系可以分为两类:
    1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
    2. 根据volatile规则,2 happens before 3。
    3. 根据happens before 的传递性规则,1 happens before 4。
Java内存模型是怎样实现volatile规则的呢?前面提到过编译器和处理器两种级别的重排序,java内存模型分别对这两种类型的重排序做了限制:
  • 编译器限制
是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写

NO
volatile读 NO NO NO
volatile写
NO NO

举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读写,第二个操作为volatile变量的写时,不允许重排序。以此类推。

  • 为了实现这些限制,编译器是生成字节码时,插入了一些上面提到过的内存屏障,来禁止处理器的重新排序。
    • 在每个volatile写操作的前面插入一个StoreStore屏障。
    • 在每个volatile写操作的后面插入一个StoreLoad屏障。
    • 在每个volatile读操作的后面插入一个LoadLoad屏障。
    • 在每个volatile读操作的后面插入一个LoadStore屏障。
volatile可以视为更轻量级的线程间通信机制,虽然它只提供变量单一操作的原子性,功能不如监视器强大,但是他更灵活,执行效率也更高。在一些特定的情况,可以使用它来代替监视器锁。

ps:concurrent包的实现,利用了volatile操作的内存语意。

由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:

    1. A线程写volatile变量,随后B线程读这个volatile变量。
    2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
    3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
    4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

    1. 首先,声明共享变量为volatile;
    2. 然后,使用CAS的原子条件更新来实现线程之间的同步;
    3. 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图如下:

    


5.final
    对final变量的操作,编译器和处理器需要遵循下面两个规则:
  • 在构造函数内对一个final变量的写入操作,与随后构造函数外把这个构造对象赋值给一个引用变量的操作,这两个操作不能被重排序。
    • 编译器禁止重排
    • 写final和return间加入StoreStore内存屏障
  • 初次读一个包含final域的对象的引用,与初次读这个final域,这两个操作不能被重排序。
    • 编辑器在读final域操作前加入LoadLoad内存屏障
    写操作要保证这个效果,还要保证,在构造方法内,构造对象的引用不能“逸出”。因为构造内,final赋值操作和"逸出"操作,可能被重排序。如果其他线程用逸出引用访问final域,则可能读到未赋值前的默认值。

总结:

    Java内存模型类似于PC的混村策略,每个线程引入工作内存。线程不直接对主内存操作,而是读写工作内存,必要时再写会主内存。
        Java编译器会对指令进行重排序,仅以不影响单线程环境下的语意为前提。
        处理器会对没有依赖关系的指令重排序。
        基于以上三点,所以多线程环境下,一个线程的操作结果可能对其他想成不能立即可见。Java5之后引入了happens-before概念。happens-before关系是指,如果A操作happens-before操作B,那么对A所在线程可见的内容,对B所在线程也可见。其中比较重要的是对volatile语意的增强,当线程A写入一个volatile字段f的时候,如果线程B读取f的话 ,那么对线程A可见的任何东西都变得对线程B可见。实际上,JDK是通过阻止对volatile操作和附近的操作的重排序实现的。在编译器层面,只需按约定不对指令重排。处理器层面,编译器是通过在生成字节码时,加入内存屏障来阻值cpu的指令重排的。

下面这段话摘自《深入理解Java虚拟机》:

  “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2)它会强制将对缓存的修改操作立即写入主存;

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值