Java 内存模型

Java 内存模型

《Java 虚拟机规范》中曾试图定义一种 “Java 内存模型”(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如 C 和 C++ 等)直接使用物理硬件和操作系统的内存模型。因此,由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,所以在某些场景下必须针对不同的平台来编写程序。

定义 Java 内存模型并非一件容易的事情,这个模型必须定义得足够谨慎,才能让 Java 的并发内存访问操作不会产生歧义;但是也必须定义得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。经过长时间的验证和修补,直至 JDK 1.5(实现了 JSR-133)发布后,java 内存模型才终于成熟、完善起来了。

1. 主内存与工作内存

Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。为了获得更好的执行效能,Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。

Java 主内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互如下图所示:

线程-主内存-工作内存三者的交互关系

这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。 如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

2. 内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java 内存模型中定义了以下 8 种操作来完成。Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外,这个问题稍后讨论,会在主题 4 中说明)

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 store 和 write 操作。注意,Java 内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说 read 与 load 之间、store 与 write 之间是可插入其他指令的,如对主内存中的变量 a、b 进行访问时,一种可能出现的顺序是 read a、read b、load b、load a。除此之外,Java 内存模型还规定了在执行上述 8 种基本操作时必须满足如下规则:

  • 不允许 read 和 load、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use、store 操作之前,必须先执行 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作以初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)。

这 8 种内存访问操作以及上述规则限定,再加上接下来会对 volatile 关键字的一些说明,就已经能准确地描述出 Java 程序中哪些内存访问操作在并发下才是安全的。这种定义相当严谨,但也是极为繁琐,实践起来更是无比麻烦,后来 Java 设计团队也意识到了这个问题,将 Java 内存模型的操作简化为 read、write、lock、unlock 这 4 种。后面会介绍这种定义的一个等效判断原则——先行发生原则,用来确定一个操作在并发环境下是安全的。

3. 对于 volatile 型变量的特殊规则

关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整的理解。了解 volatile 变量的语义对后面理解多线程操作的其他特性很有意义。

Java 内存模型为 volatile 专门定义了一些特殊的访问规则,在介绍这些比较拗口的规则之前,先用一些易懂的言语描述来介绍一下关键字的作用。

当一个变量被定义成 volatile 之后,它将具备两项特性:

  1. 保证此变量对所有线程的立即可见性

    这里的 “可见性” 是指,当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要主内存来完成。比如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成之后再对主内存进行读取操作,新变量值才会对线程 B 可见。

    关于 volatile 变量的可见性,经常会被误解:volatile 变量对所有线程是立即可见的,对 volatile 变量所有的写操作都能立刻反映到其他线程之中。换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是线程安全的。

    这句话的论据部分并没有错,但是由其论据,并不能得出 “基于 volatile 变量的运算在并发下线程安全的” 这样的结论。volatile 变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中 volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是 Java 里面的运算操作符并非原子操作,这导致 volatile 变量的运算在并发下一样是线程不安全的,我们通过以下代码示例来进一步说明:

    package org.example;
    
    /**
     * volatile 变量自增运算
     * java 中的运算操作符是非原子性的
     */
    public class Volatile {
        /**
         * 自增变量
         */
        public static volatile int race = 0;
    
        public static void incr() {
            race++;
        }
    
        /**
         * 线程数量,规定为 20
         */
        private final static int THREAD_COUNT = 20;
    
        public static void main(String[] args) {
            Thread[] threads = new Thread[THREAD_COUNT];
            for (int i = 0; i < THREAD_COUNT; i++) {
                threads[i] = new Thread(() -> {
                    for (int k = 0; k < 10000; k++) {
                        incr();
                    }
                });
                threads[i].start();
            }
    
            // 等待所有累加线程都结束
            while (Thread.activeCount() > 1) {
                Thread.yield();
            }
            System.out.println(race);
        }
    
    }
    

    输出结果:

    199900
    

    这段代码发起了 20 个线程,每个线程对 race 变量进行 10000 次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是 200000。但是我们可以从上面的结果来看,是小于 200000 的,这是为什么?

    问题就出在自增运算 race++ 之中,我们用 Javap 反编译这段代码后会得到代码清单

    javap -c Volatile.class
    
    Compiled from "Volatile.java"
    public class org.example.Volatile {
      public static volatile int race;
    
      public org.example.Volatile();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."<init>":()V
           4: return
    
      public static void incr();
        Code:
           0: getstatic     #2                  // Field race:I
           3: iconst_1
           4: iadd
           5: putstatic     #2                  // Field race:I
           8: return
    
      public static void main(java.lang.String[]);
        Code:
           0: bipush        20
           2: anewarray     #4                  // class java/lang/Thread
           5: astore_1
           6: iconst_0
           7: istore_2
           8: iload_2
           9: bipush        20
          11: if_icmpge     41
          14: aload_1
          15: iload_2
          16: new           #4                  // class java/lang/Thread
          19: dup
          20: invokedynamic #5,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
          25: invokespecial #6                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
          28: aastore
          29: aload_1
          30: iload_2
          31: aaload
          32: invokevirtual #7                  // Method java/lang/Thread.start:()V
          35: iinc          2, 1
          38: goto          8
          41: invokestatic  #8                  // Method java/lang/Thread.activeCount:()I
          44: iconst_1
          45: if_icmple     54
          48: invokestatic  #9                  // Method java/lang/Thread.yield:()V
          51: goto          41
          54: getstatic     #10                 // Field java/lang/System.out:Ljava/io/PrintStream;
          57: getstatic     #2                  // Field race:I
          60: invokevirtual #11                 // Method java/io/PrintStream.println:(I)V
          63: return
    
      static {};
        Code:
           0: iconst_0
           1: putstatic     #2                  // Field race:I
           4: return
    }
    
    

    通过上面反汇编出的代码,来具体看一下 incr() 方法:

    public static void incr();
        Code:
           0: getstatic     #2                  // Field race:I
           3: iconst_1
           4: iadd
           5: putstatic     #2                  // Field race:I
           8: return
    

    incr() 方法在 Class 文件中是由 4 条字节码指令构成(return 指令不是由 race++ 产生的,这条指令可以不计算),从字节码层面上已经很容易分析出并发失败的原因了:当 getstatic 指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值此时是正确的,但是在执行 iconst_1iadd 这些指令的时候,其他线程可能已经把 race 的值改变了,而操作栈顶的值就变成了过期的数据,所以 putstatic 执行后就可能把较小的 race 值同步回主内存之中。

    实事求是地说,这里使用字节码来分析并发问题仍然是不严谨的,因为即使编译出来只有一条字节码指令,也并不意味着执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器要运行多行代码才能实现它的语义。如果是编译执行,一条字节码指令也可能转化为若干条本地机器码指令。此处使用 -XX:+PrintAssembly 参数输出反汇编来分析才会更加严谨一些。

    由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性:

    1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
    2. 变量不需要与其他的状态变量共同参与不变约束。

    而在像如下代码场景中就很适合使用 volatile 变量来控制并发:

    volatile boolean shutdownRequested;
    
    public void shutdown() {
        shutdownRequested = true;
    }
    
    public void doWork() {
        while(!shutdownRequested) {
            // 代码的业务逻辑
        }
    }
    

    shutdown() 方法被调用时,能保证所有线程中执行的 doWork() 方法都立即停下来。

  2. 禁止指令重排序优化

    普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是 Java 内存模型中描述的所谓 “线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值