探索Java内存模型

·  阅读 1200

原文博客地址:pjmike的博客

前言

本文主要是对《深入理解 Java 内存模型——程晓明》和《深入理解Java虚拟机》内存模型部分的一个知识总结,其中也参考了一些其他优秀文章。

操作系统语义

计算机在运行程序时,每条指令都是在CPU中执行的,而程序运行的数据都存在主存里,但是读写主存中的数据没有CPU中执行指令的速度快,如果每次都读取主存。效率就会比较低,所以现代操作系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache) 来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了,其中大致结构如下图所示:

cpu_cache

cpu cache 作为主内存和CPU寄存器之间的缓冲区,以便CPU可以快速的读写数据。高速缓存本质是为了调和CPU得过快访问速度和内存过慢的速度的一个硬件,现代计算机一般都有三级高速缓存,L1、L2、L3,访问速度依次递减。因为CPU要从内存中读取数据的时候会很慢,大部分时间会浪费在等待上,所以引入cache,把预计将要读取的数据先存放到cache中,这样CPU就可以先到cache中读取,从而节约了等待时间,如果cache中没有要读取的数据,那么继续往下到内存中读取。以上提及的CPU cache,寄存器和主存实际上都属于计算机存储结构的内容,如下图所示(摘自 blog.csdn.net/qq_27680317…)

os_save_structure

关于计算机存储结构更细致的介绍,可以查阅经典书籍《深入理解计算机系统-第六章》的内容。

CPU高速缓存虽好,但是它却带来了一个问题就是:缓存一致性问题。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器都有自己的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的问题,比较典型的就是执行 i++ 这类非原子操作。

为了解决缓存一致性问题,需要各个处理器访问缓存时遵循缓存一致性协议(最常见的MESI协议),在读写时要根据协议来进行操作。在一致性协议下,处理器、高速缓存、主内存间的交换关系可以像下图这样描述 (出自《深入理解Java虚拟机》):

cpu_cache

而内存模型也由此孕育而生,内存模型可以理解为在特定的操作协议对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不同的内存模型,而JVM也有自己的内存模型(后面要谈到的Java内存模型)。在内存模型中也存在处理器的重排序,对输入代码进行乱序执行进而达到优化的目的。

下面将进入本文的主题Java内存模型的介绍

Java 内存模型的抽象结构

Java虚拟机规范试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java 程序在各种平台下都能达到一致的内存访问结果。下面从线程通信的角度来看一下Java内存模型是如何定义的

线程间通信

线程之间的通信机制有两种:共享内存和消息传递,当然在进程间通信也有这两种机制。在共享内存中,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信;而在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过发送消息来显示进行通信。

Java并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行的。这里的共享内存指的是当多个线程处于同一进程时,多个线程共享进程的地址空间和其他资源等,简单说就是有一个内存区域供多个线程共同使用

Java 线程之间的通信由 Java 内存模型(简称JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。

Java 内存模型抽象

从抽象的角度看,JMM定义了线程与主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每一个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写 共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。JMM抽象示意图如下:

java_memory

  • 线程A 把本地内存A 中更新过的共享变量刷新到主内存中去
  • 线程B到主内存中去读取线程A之前已更新过的共享变量

线程之间的通信图如下:

java_memory_message

从上图看出线程A向线程B发送消息,要经历主内存。

总结一下,JMM是一种规范,或者说一种抽象模型,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器重排序、处理器重排序造成的问题。

重排序

为了提高性能,编译器和处理器常常会对指令做重排序,分为以下三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java 源代码到最终实际执行的指令序列,会分别经历下面 3 种重排序,如图所示:

java_resort

上述的1属于编译器重排序,2 和 3 属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。

这里的处理器重排序与CPU多级缓存之间会出现乱序执行优化操作含义一样

处理器重排序

现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。

每个处理器上的写缓冲区,仅对它所在的处理器可见,这个特性会对内存操作的执行顺序产生影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!

举个例子说明:

java_processor

假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:

java_processor_memory

这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。

以处理器A以例,虽然处理器A 执行内存操作的顺序为:A1——>A2 ,但内存操作实际发生的顺序是 A2 ——> A1 ,这就发生了写-读操作重排序。现代的处理器都会允许对 写-读操作进行重排序。

内存屏障指令

为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如表所示:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。 StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

happens-before 简介

JSR-133 内存模型使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

与程序员密切相关的 happends-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作
  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁
  • volatile 变量规则:对一个 volatile域的写,happens-before 于任意后续对这个 volatile 域的读
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C.

happens-before 的本质是要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这不意味着 前一个操作必须在后一个操作之前执行。

happens-before 与 JMM 的关系如下图所示:

happen-before

如上图所示,一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为如下3种类型:

名称 代码示例 说明
写后读 a = 1;b=2 写一个变量后,再读这个变量
写后写 a = 1;a=2 写一个变量后,再写这个变量
读后写 a = b;b =1 读一个变量后,再写这个变量

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

注意:这里说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑,实际开发中,还是多线程居多

as-if-serial 语义

as-if-serial 语义的意思是: 不管怎样重排序,单线程程序的执行结果不会被改变,编译器,runtime和处理器都必须遵循as-if-serial 语义

为了遵守 as-if-serial 编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间没有数据依赖关系,这些操作就可能被编译器和处理器重排序。

程序顺序规则

程序的执行顺序实际上是根据 happens-before 的程序顺序规则,JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前,至于执行顺序就不一定。

重排序对多线程的影响

在单线程程序中,对存在控制依赖的操作做重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制的操作做重排序的原因);但在多线程程序中,对存在控制依赖的重排序操作,可能会改变程序的执行结果。

volatile 的内存语义

volatile的特性

举例说明:

public class VolatileExample {

    volatile long vl = 0L;//使用volatile声明64位的long型变量

    public long getVl() {
        return vl;  //单个volatile变量的读
    }

    public void setVl(long vl) {
        this.vl = vl; //单个 volatile变量的写
    }

    public void getAndIncrement() {
        vl++; //复合(多个) volatile变量的读/写
    }
}
复制代码

假设有多个线程分别调用上面程序的三个方法,这个程序在语义上和下面程序等价:

public class VolatileExample {

    long vl = 0L;//声明64位的long型普通变量

    public synchronized long getVl() {
        return vl;  //对单个普通变量的读用一个锁同步
    }

    public void setVl(long vl) {
        this.vl = vl; //对单个普通变量的写用一个锁同步
    }

    public void getAndIncrement() { //普通方法调用
        long temp = getVl();        //调用已同步的读方法
        temp += 1L;                 //普通写操作
        setVl(temp);                //调用已同步的写操作
    }
}
复制代码

一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用一个锁来来同步,它们之间的执行效果相同

锁的 happens-before 规则来保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile变量的读,总是能看到任意线程对这个 volatile变量最后的写入

锁的语义决定了临界区代码的执行具有原子性,这一点跟进程间同步机制是类似的。多个volatile操作或类似于 volatile ++ 这种复合操作,这些不具有原子性。比如我将上面那段代码中的getAndIncrement()javap反编译得到如下字节码:

public void getAndIncrement();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=5, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field vl:J
         5: lconst_1
         6: ladd
         7: putfield      #2                  // Field vl:J
        10: return
复制代码

原方法中只有 v1++一条语句,通过反编译得到的字节码却有好几条,这也说明v1++是个复合操作。

当 getfield访问字段指令将 v1的值放在操作数栈顶时,volatile保证了 v1 此时是正确的,但是在执行 lconst_1、ladd这些指令时,其他线程可能已经把v1的值增大了,而在操作数栈顶的值就变成了过期的数据,所以 putfield 执行后就可能把较小的值同步回主内存中。

总之,volatile变量有如下特性:

  • 可见性: 对一个 volatile变量的读,总是能看到任意线程对这个 volatile变量最后的写入
  • 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性

volatile 写-读的内存语义

volatile 写-读的内存语义 是:

  • 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存读取共享变量

假定一个volatile 变量 flag,普通变量 a,下面图示展示了共享变量的状态示意图:

java_volatile

volatile内存语义的实现

下表是 JMM 针对 编译器制定的 volatile 重排序规则表:

java_volatile_resort_table

从表中可以看出:

  • 当第二个操作为 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则保证 volatile 写之前的操作不会被 编译器重排序到 volatile 写之后
  • 当第一个操作为 volatile 读时,不管第二个操作是什么,都不能重排序,这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采用保守策略,下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
  • 在每个 volatile 读操作的后面插入一个 LoadLoad屏障
  • 在每个 volatile 读操作的后面插入一个 LoadStore屏障

下面是保守策略下,volatile 写操作 插入内存屏障后生成的指令序列示意图:

java_volatile_write

下面是保守策略下,volatile 读操作插入内存屏障后生成的指令序列示意图

java_volatile_read

在实际执行时,只要不改变 volatile 写 - 读 的内存语义,编译器可以根据具体情况省略不必要的屏障。示例如下:

public class VolatileTest2 {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        int i = v1;     //第一个volatile 读
        int j = v2;     //第二个volatile 读
        a = i + j;      //普通写
        v1 = i + 1;     //第一个volatile 写
        v2 = j * 2;     //第二个volatile 写
    }
}
复制代码

针对 readAndWrite() 方法,编译器在生成字节码时可以做如下优化:

java_volatile_read_and_write

锁的内部语义

锁的释放和获取的内存语义

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中去
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

锁内存语义的实现

在Java中一般有两种同步方式:Synchronized和可重入锁ReentrantLock。《并发编程的艺术》书中是借助 ReentrantLock 来分析锁内存语义的实现的

public class ReentrantLockExample {
    int a = 0;
    ReentrantLock lock = new ReentrantLock();

    public void writer() {
        lock.lock();               //获取锁
        try {
            a++;
        } finally {
            lock.unlock();         //释放锁
        }
    }

    public void reader() {
        lock.lock();               //获取锁
        try{
            int i = a;
            System.out.println("i : " + a);
        } finally {
            lock.unlock();         //释放锁
        }
    }
}
复制代码

在 RenntrantLock中,调用 lock() 方法获取锁,调用 unlock() 方法释放锁

而 ReentrantLock 的实现依赖于 Java 同步器框架 AbstractQueuedSynchronizer (简称AQS),AQS 使用一个整型的 volatile 变量来维护同步状态,这个 volatile 变量是 ReentrantLock 内存语义实现的关键。

concurrent包的实现

仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式

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

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

java_concurrent

final的内存语义

final 域的重排序规则

对于 final域,编译器和处理器要遵守两个 重排序规则。

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

写 final 域的重排序规则

写 final 域的 重排序规则禁止把 final 域 的写重排序到构造函数之外。这个规则的实现 包含下面 2个方面:

  • JMM 禁止编译器把 final 域的写重排序到构造函数之外
  • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障,这个屏障禁止处理器把 final 域的写重排序到构造函数之外

读 final 域的重排序规则

读 final 域的重排序规则是,在一个线程中,初次读对象引用 与初次读该对象包含的 final 域,JMM 禁止 处理器重排序这两个操作(注意,这个规则仅仅针对 处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad屏障

final 域为引用类型

对于 引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:

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

Java 内存模型总结

Java 内存模型可以说是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的:

原子性

即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么不执行

在单线程环境下我们一般的操作都是原子性操作,Java内存模型中的 volatile 关键字也只是保证操作在单线程环境下是原子的,但是在多线程环境下,Java 只保证了基本类型的变量和赋值操作才是原子性的。((注:在32位的JDK环境下,对64位数据的读取不是原子性操作,如long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。

可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改

比如对于 volatile变量,Java内存模型是通过在变量修改后将新值立即同步回主内部才能,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性

有序性

有序性是指程序执行的顺序按照代码的先后顺序执行

在Java内存模型中,为了效率会发生编译器重排序和处理器重排序,重排序一般不会影响单线程的执行结果,但是会对多线程产生影响

Java提供了 volatile 和 synchronized 关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了 禁止指令重排序的语义,而synchronized 则是由 "一个变量在同一时刻只允许一条线程对其进行Lock操作"。

再来说说 happens -before 原则,它阐述了操作之间的内存可见性,这个在Java内存模型中比较重要。JMM 把 happens-before 要求禁止的重排序分为下面两类:

  • 会改变程序执行结果的重排序
  • 不会改变程序执行结果的重排序

JMM 对这两种不同性质的重排序,采用了不同的策略,如下:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求 (JMM允许这种重排序)

happen_before

小结

以上内容是一个关于Java内存模型的知识笔记,算是对Java内存模型的一个大致认识。要认识Java内存模型,首先我们应该了解CPU的高速缓存机制,认识操作系统层面的内存模型,然后再到Java虚拟机定义的内存模型,Java虚拟机定义Java内存模型是为了屏蔽掉各种硬件和操作系统的内存访问差异,最后是Java内存模型中的一些核心内容,比如重排序,Volatile内存语义,锁的内存语义等等。

参考资料 & 鸣谢