深入理解JVM读书笔记五: Java内存模型与Volatile关键字

 2019-12-10 16:22  阅读(2078)
文章分类:Java Core

12.2硬件的效率与一致性

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地理解了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)在多处理器系统中,每个处理器都有自己的高数缓存,而它们又共享同一主内存(Main Memory),如图 12-1 所示。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly 及 Dragon Protocol 等。在本章中将会多次提到的 “内存模型” 一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也有自己的内存模型,并且这里介绍的内存访问操作与硬件的缓存访问操作具有很高的可比性。
20191210001772\_1.png

除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类型,Java 虚拟机的即时编译器中有有类似的指令重排序(Instruction Reorder)优化。

12.3 Java 内存模型

Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

12.3.1 主内存与工作内存

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

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

20191210001772\_2.png

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

副本拷贝:
假设线程中访问一个10MB的对象,也会把这10MB的内存复制拷贝一份出来吗?
事实上并不会如此,这个对象的引用、对象中在某个在线程中访问到的字段是可能存在拷贝的,但不会有虚拟机实现成把整个对象拷贝一次的。

12.3.2内存间交互操作

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

  • 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 程序中哪些内存访问操作在并发下是安全的。由于这种定义相当严谨但又十分烦琐,实践起来很麻烦,所以在后面笔者将介绍这种定义的一个等效判断原则——先行发生原则,用来确定一个访问在并发环境下是否安全。

12.3.3 对于 volatile 型变量的特殊规则

关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制。

当一个变量定义为 volatile 之后,它将具备两种特性,第一是**保证此变量对所有线程的可见性,这里的 “可见性” 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。**而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再从主内存进行读取操作,新变量值才会对线程 B 可见。

关于 volatile 变量的可见性,经常会被开发人员误解,认为以下描述成立:“volatile 变量对所有线程是立即可见的,对 volatile 变量所有的写操作都能立刻反应到其他线程之中,换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是安全的”。这句话的论据部分并没有错,但是其论据并不能得出 “基于 volatile 变量的运算在并发下是安全的” 这个结论。volatile 变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在不一致性问题),但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的,我们可以通过一段简单的演示来说明原因,请看代码清单 12-1 中演示的例子。

/** * volatile 变量自增运算测试 * * @author mk */  
    public class VolatileTest {  

        public static volatile int race = 0;  

        public static void increase() {  
            race++;  
        }  

        private static final int THREADS_COUNT = 20;  

        public static void main(String[] args) {  
            Thread[] threads = new Thread[THREADS_COUNT];  
            for (int i = 0; i < THREADS_COUNT; i ++) {  
                threads[i] = new Thread(new Runnable() {  
                    @Override  
                    public void run() {  
                        for (int i = 0; i < 10000; i++) {  
                            increase();  
                        }  
                    }  
                });  
                threads[i].start();  
            }  

            // 等待所有累加线程都结束 
            while (Thread.activeCount() > 1)   
                Thread.yield();  

            System.out.println(race);  
        }  
    }

这段代码发起了 20 个线程,每个线程对 race 变量进行 10000 次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是 200000。读者运行完这段代码之后,并不会获得期望的结果,而且发现每次运行程序,输出的结果都不一样,都是一个小于 200000 的数字,这是为什么呢?

问题就出现在自增运算 “race++” 之中,我们用 javap 反编译这段代码后会得到代码清单 12-2,发现只有一行代码的 increase() 方法在 Class 文件中是由 4 条字节码指令构成的(return 指令不是由 race++ 产生的,这条指令可以不计算),从字节码层面上很容易就分析出并发失败的原因了:当 getstatic 指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值在此时是正确的,但是在执行 iconst_1、iadd 这些指令的时候,其他线程可能已经把 race 的值加大了,而在操作栈顶的值就变成了过期的数据,所以 putstatic 指令执行后就可能把较小的 race 值同步会主内存之中。
代码清单 12-2 VolatileTest 的字节码

public static void increase();  
      flags: ACC_PUBLIC, ACC_STATIC  
      Code:  
        stack=2, locals=0, args_size=0  
           0: getstatic     #13 // Field race:I 
           3: iconst_1  
           4: iadd  
           5: putstatic     #13 // Field race:I 
           8: return  
        LineNumberTable:  
          line 13: 0  
          line 14: 8

客观地说,笔者在此使用字节码来分析并发问题,仍然是不严谨的,因为即使编译出来只有一条字节码指令,也并不意味着执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令,此处使用 -XX:+PrintAssembly参数输出反汇编来分析会更严峻一些,但考虑读者阅读的方便,并且字节码已经能说明问题,所以此处使用字节码来分析。

由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然需要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性。

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

而在像如下的代码清单 12-3 所示的这类场景就很适合使用 volatile 变量来控制并发,当 shutdown() 方法被调用时,能保证所有线程中执行的 doWork() 方法都立即停下来。

代码清单 12-3 volatile 的使用场景

volatile boolean shutdownRequested;  

    public void shutdown() {  
        shutdownRequested = true;  
    }  

    public void doWork() {  
        while (!shutdownRequested) {  
            //do stuff 
        }  
    }

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

上面的描述仍然不太容易理解,我们还是继续通过一个例子来看看为何指令重排序会干扰程序的并发执行,演示程序如代码清单 12-4 所示。

代码清单 12-4 指令重排序

Map configOptions;  
    char[] configText;  
    // 此变量必须定义为 volatile 
    volatile boolean initialized = false;  

    // 假设以下代码在线程 A 中执行 
    // 模拟读取配置信息,当读取完成后将 initialized 设置为 true 以通知其他线程配置可用 
    configOptions = new HashMap();  
    configText = readConfigFile(fileName);  
    processConfigOptions(configText, configOptions);  
    initialized = true;  

    // 假设以下代码在线程 B 中执行 
    // 等待 initialized 为 true,代表线程 A 已经把配置信息初始化完成 
    while (!initialized) {  
        sleep();  
    }  
    // 使用线程 A 中初始化好的配置信息 
    doSomethingWithConfig();

代码清单 12-4 中的程序是一段伪代码,其中描述的场景十分场景,只是我们在处理配置文件时一般不会出现并发而已。如果定义 initialized 变量时没有使用 volatile 修饰,就可能由于指令重排序的优化,导致位于线程 A 中最后一句的代码 “initialized=true” 被提前执行(这里虽然使用 Java 作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是指这句话对应的汇编代码被提前执行),这样在线程 B 中使用配置信息的代码就可能出现错误,而 volatile 关键字则可以避免此类情况的发生。(注:volatile 屏蔽指令重排序的语义在 JDK 1.5 中才被完全修复,此前的 JDK 中即使将变量声明为 volatile 也仍然不能完全避免重排序所导致的问题(主要是 volatile 变量前后的代码仍然存在重排序问题),这点也是在 JDK 1.5 之前的 Java 中无法按期地使用 DCL(双锁检测)来实现单例模式的原因。

指令重排序是并发编程中最容易让开发人员产生疑惑的地方,除了上面伪代码的例子之外,笔者再举一个可以实际操作运行的例子来分析 volatile 关键字是如何禁止指令重排序优化的。代码清单 12-5 是一段标准的 DCL 单例代码,可以观察加入 volatile 和未加入 volatile 关键字时所生成汇编代码的差别

public class Singleton {  

        private volatile static Singleton instance;  

        public static Singleton getInstance() {  
            if (instance == null) {  
                synchronized (Singleton.class) {  
                    if (instance == null) {  
                        instance = new Singleton();  
                    }  
                }  
            }  
            return instance;  
        }  

        public static void main(String[] args) {  
            Singleton.getInstance();  
        }  
    }

编译后,这段代码对 instance 变量赋值部分如代码清单 12-6 所示。

0x01a3de0f: mov    $0x3375cdb0,%esi   ;...beb0cd75 33  
                                            ;   {oop('Singleton')}  
    0x01a3de14: mov    %eax,0x150(%esi)   ;...89865001 0000  
    0x01a3de1a: shr    $0x9,%esi          ;...c1ee09  
    0x01a3de1d: movb   $0x0,0x1104800(%esi)  ;...c6860048 100100  
    0x01a3de24: lock addl $0x0,(%esp)     ;...f0830424 00  
                                            ;*putstatic instance  
                                            ; - Singleton::getInstance@24

通过对比就会发现,关键变化在于有 volatile 修饰的变量,赋值后(前面 mov %eax,0x150(%esi) 这句便是赋值操作)多执行了一个操作:

lock addl $0x0,(%esp)

这个操作相当于一个**内存屏障(Memory Barrier 或 Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个 CPU 访问内存时,并不需要内存屏障;但如果有两个或更多 CPU 访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。**这句指令中的 “addl $ 0x0, (%esp)”(把 ESP 寄存器的值加 0)显然是一个空操作(采用这个空操作而不是空操作指令 nop 是因为 IA32 手册规定 lock 前缀不允许配合 nop 指令使用),关键在于 lock 前缀,查询 IA32 手册,它的作用是使得本 CPU 的 Cache 写入了内存,该写入动作也会引起别的 CPU 或者别的内核无效化(Invalidate)其 Cache,这种操作相当于对 Cache 中变量做了一次前面介绍 Java 内存模式中所说的 “store 和 write” 操作。所以通过这样一个空操作,可以让前面 volatile 变量的修改对其他 CPU 立即可见。

那为何说它禁止指令重排序呢?从硬件架构上讲,指令重排序是指 CPU 采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU 需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。譬如指令 1 把地址 A 中的值加 10,指令 2 把地址 A 中的值乘以 2,指令 3 把地址 B 中的值减去 3,这时指令 1 和 指令 2 是有依赖的,它们之间的顺序不能重排——(A + 10)* 2 与 A * 2 + 10 显然不相等,但指令 3 可以重排到指令 1、2 之前或者中间,只要保证 CPU 执行后面依赖到 A、B 值的操作是能获取到正确的 A 和 B 值即可。所以在本内 CPU 中,重排序看起来依然是有序的。因此 lock addl $0x0, (%esp) 指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障” 的效果。

volatile 能让我们的代码比使用其他的同步工具更快吗?
在某些情况下,volatile 的同步机制的性能确实要优于锁(使用 synchronized 关键字或 java.util.concurrent 包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难量化地认为 volatile 就会比 synchronized 快多少。如果让 volatile 自己与自己比较,那可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁低,我们在 volatile 与锁之中选择的唯一依据仅仅是volatile 的语义能否满足使用场景的需求。

在本节的最后,我们回头看一下 Java 内存模型中对 volatile 变量定义的特殊规则。假定 T 表示一个线程,V 和 W 分别表示两个 volatile 型变量,那么在进行 read、load、use、assign、store 和 write 操作时需要满足如下规则:

  • 只有当线程 T 对变量 V 执行的前一个动作是 load 的时候,线程 T 才能对变量 V 执行 use 动作;并且,只有当线程 T 对变量 V 执行的后一个动作是 use 的时候,线程 T 才能对变量 V 执行 load 动作。线程 T 对变量 V 的 use 动作可以认为是和线程 T 对变量 V 的 load、read 动作相关联,必须连续一起出现(这套规则要求在工作内存中,每次使用 V 前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量 V 所做的修改后的值)。
  • 只有当线程 T 对变量的前一个动作是 assign 的时候,线程 T 才能对变量 V 执行 store 动作;并且,只有当线程 T 对变量 V 执行的后一个动作是 store 的时候,线程 T 才能对变量 V 执行 assign 动作。线程 T 对变量 V 的 assign 动作可以认为是和线程 T 对变量 V 的 store、write 动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次修改 V 后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量 V 所做的修改)。
  • 假定动作 A 是线程 T 对变量 V 实施的 use 或 assign 动作,假定动作 F 是和动作 A 相关联的 load 或 store 动作,假定动作 P 是和动作 F 相应的对变量 V 的 read 或 write 动作;类似的,假定动作 B 是线程 T 对变量 W 实施的 use 或 assign 动作,假定动作 G 是和动作 B 相关联的 load 或 store 动作,假定动作 Q 是和动作 G 相应的对变量 W 的 read 或 write 动作。如果 A 先于 B,那么 P 先于 Q(这条规则要求 volatile 修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)。

博主备注:
volatile并不能保证操作的原子性,在读取、写入变量的过程中仍然可能被其他线程打断导致意外结果发生。
volatile关键字只能保证线程读到最新的,但是不能控制线程写的瞬间那个值还是最新的

深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)PDF版下载:
http://download.csdn.net/detail/xunzaosiyecao/9648998

作者:jiankunking 出处:http://blog.csdn.net/jiankunking

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 深入理解JVM读书笔记五: Java内存模型与Volatile关键字

相关推荐