前言

了解Java内存模型之前,先思考下为啥需要内存模型。这一点要从硬件内存架构去了解。

硬件内存架构

在单核计算机中,计算机中的 CPU 计算速度是非常快的,但是与计算机中的其它硬件(如 IO、内存等)同 CPU 的速度比起来是相差甚远的,所以协调 CPU 和各个硬件之间的速度差异是非常重要的,要不然 CPU 就一直在等待,浪费资源。单核尚且如此,在多核中,这样的问题会更加的突出。

CPU设置多级缓存,例如L1、L2、L3高速缓存(Cache)。和JMM的内存布局相似,前者是系统级别,解决缓存一致性问题;后者是应用级别的,解决的是内存一致性问题。

  • 这些高速缓存一般都是独属于CPU内部的,对其他CPU不可见,此时又会出现缓存和主存的数据不一致现象,CPU的解决方案有两种

    • 总线锁定:当某个CPU处理数据时,通过锁定系统总线或者是内存总线,让其他CPU不具备访问内存的访问权限,从而保证了缓存的一致性
    • 缓存一致性协议(MESI):缓存一致性协议也叫缓存锁定,缓存一致性协议会阻止两个以上CPU同时修改映射相同主存数据的缓存副本
    • MESI实现是依靠处理器使用嗅探技术保证它的内部缓存、系统主内存和其他处理器的缓存的数据在总线上保持一致
    • 例:处理器打算回写脏内存地址,而此内存处于共享状态(Share);那么其他处理器会嗅探到,并将使自身的对应的缓存行无效,在下次访问相应内存地址时,刷新该缓存行
  • 缓存数据状态有如下四种(MESI):

    缓存状态 描述
    M(Modifed) 在缓存行中被标记为Modified的值,与主存的值不同,这个值将会在它被其他CPU读取之前写入内存,并设置为Shared
    E(Exclusive) 该缓存行对应的主存内容只被该CPU缓存,值和主存一致,被其他CPU读取时置为Shared,被其他CPU写时置为Modified
    S(Share) 该值也可能存在其他CPU缓存中,但是它的值和主存一致
    I(Invalid) 该缓存行数据无效,需要时需重新从主存载入

我们先大概梳理下这个流程:当我们的计算机要执行某个任务或者计算某个数字时,主内存会首先从数据库中加载计算机计算所需要的数据,因为内存和 CPU 的速度相差较大,所以有必要在内存和 CPU 间引入缓存(根据实际的需要,可以引入多层缓存),主内存中的数据会先存放在 CPU 缓存中,当这些数据需要同 CPU 做交互时会加入到 CPU 寄存器中,最后被 CPU 使用。 事实上,在单核情况下,基于缓存的交互可以很好的解决 CPU 与其它硬件之间的速度匹配,但是在多核情况下, 每个处理器都有自己的高速缓存,同时又共同操作同一块主内存,当多个处理器同时操作主内存时,可能导致数据不一致,因此需要“缓存一致性协议”来保障。比如,MSI、MESI等。

图片说明

Java内存模型

Java内存模型即Java Memory Model,简称JMM。用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各平台下都能够达到一致的内存访问效果。

我们在开发时会经常遇到这样的场景,我们开发完成的代码在我们自己的运行环境上表现良好,但是当我们把它放在其它硬件平台上时就
会出现各种各样的错误,这是因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某系
统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。

为了解决这个问题,Java 内存模型(JMM)的概念就被提出来了,它的出现可以屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果,实现平台的一致性,使得 Java 程序能够一次编写,到处运行

这样的描述的好像有点熟悉啊,这不是 JVM 的概念描述么,它们两者有什么区别啊?

JVM 与 JMM 间的区别?

实际上,JMM 是 Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本,本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。而 JVM 则是描述的是 Java 虚拟机内部及各个结构间的关系。

小伙伴这时可能会有疑问,既然 JMM 是定义线程和主内存之间的关系,那么它的出现是不是解决并发领域的问题啊?没错,我们先回顾一下并发领域中的关键问题。

并发领域中的关键问题?

  • 线程之间的通信

在编程中,线程之间的通信机制有两种,共享内存消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。

消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在 java 中典型的消息传递方式就是 wait()和 notify()

  • 线程间的同步

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

事实上,Java 内存模型(JMM)的并发采用的是共享内存模型。

图片说明

主内存、工作内存的定义

  • 主内存

主内存主要存储的是 Java 实例对象,即所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

  • 工作内存

工作内存主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),即每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关 Native 方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

NOTE:这里的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区不是同一层次的内存划分,这两者基本上没有关系。

内存的交互操作

主内存与工作内存的交互操作有 8 种,虚拟机必须保证每一个操作都是原子的,这八种操作分别是:

  • Lock(锁定)

作用于主内存的变量,把一个变量标识为一条线程独占状态。

  • unlock(解锁)

作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

  • read(读取)

作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用

  • load(载入)

作用于工作内存的变量,它把 read 操作从主存中变量放入工作内存中

  • use(使用)

作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令

  • assign(赋值)

作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中

  • store(存储)

作用于工作内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用

  • write(写入)

作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中

单看这八种类型的原子操作可能有点抽象,我们画一个操作流程图仔细梳理下。

操作流程图:

从图中可以看出,如果要把一个变量从内存中复制到工作内存中,就需要顺序的执行 read 和 load 操作,如果把变量从工作内存同步到主内存中,就需要执行 store 和 write 操作。

NOTE: Java 内存模型只要求上述操作必须按顺序执行,却没要求是连续执行。

我们以两个线程为例梳理下操作流程:

假设存在两个线程 A 和 B,如果线程 A 要与线程 B 要通信的话,首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去;然后,线程 B 到主内存中读取线程 A 之前已经更新过的共享变量。

指令重排序和内存屏障指令

  • 为提高程序性能,编译器和处理器经常会对指令做重排序,分别是编译器优化的重排序指令并行级别的重排序内存系统的重排序

综合上面从硬件层面和JVM层面的分析,我们知道在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如下图:
图片说明
其中2和3属于处理器重排序。而这些重排序都可能会导致可见性问题(编译器和处理器在重排序时会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,编译器会遵守happens-before规则和as-if-serial语义)。
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排 序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。正是因为volatile的这个特性,所以单例模式中可以通过volatile关键字来解决双重检查锁(DCL)写法中所存在的问题。

  • JMM层面的内存屏障

在JMM 中把内存屏障分为四类:

img

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多数处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

happen-before原则

  • 内存屏障是相对于jvm,cpu级别的内存一致性(内存可见性)的解决方案;为了让java程序员更容易理解,jsr-133使用happens-before的概念来说明不同操作之间的内存可见性
    • 程序次序规则:同一个线程,任意一操作happens-before同线程之后的全部操作
    • 监视器锁(synchronized)规则:对一个监视器锁的解锁,happens-before随后对这个锁的加锁
    • volatile变量规则:对volatile变量的写操作,happens-before该volatile变量之后的任意读操作
    • 传递性:如果A先于B;B先于C;则A先于C
  • happens-before部分规则是基于内存屏障实现的

竞争现象

如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。

如下图所示,线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。

如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。

要解决上面的问题我们可以使用java synchronized代码块。synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。

Java 内存模型的实现

在 Java 多线程中,Java 提供了一系列与并发处理相关的关键字,比如volatilesynchronizedfinalconcurren包等。其实这些就是 Java 内存模型封装了底层的实现后提供给程序员使用的一些关键字

事实上,Java 内存模型的本质是围绕着 Java 并发过程中的如何处理原子性可见性顺序性这三个特征来设计的,这三大特性可以直接使用 Java 中提供的关键字实现,它们也是面试中经常被问到的题目。

synchronized内存语义

根据程序次序规则,1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens- before 6。 根据监视器锁规则,3 happens-before 4。根据happens-before的传递性得 2 happens-before 5.执行结果如下图

图片说明

  • 线程释放锁时内存语义:JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
  • 线程获取锁时内存语义:JMM会把该线程对应的工作内存置为无效

volatile的内存语义

  • volatile变量具有可见性,Java线程内存模型确保所有线程看到这个变量的值是最新的,并且单个volatile变量的读/写具有原子性;java编译器对volatile变量处理如下
    • 在每个volatile写操作的前面插入一个StoreStore屏障
    • 在每个volatile写操作的后面插入一个StoreLoad屏障
    • 在每个volatile读操作的前面插入一个LoadLoad屏障
    • 在每个volatile读操作的后面插入一个LoadStore屏障
  • 注意i++是复合操作,即使 i 是volatile变量,也不保证i++是原子操作
volatile Object instance;
instance = new Object();
//相应汇编代码
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
  • 当volatile变量修饰的共享变量进行写操作的反汇编代码会出现0x01a3de24: lock addl $0×0,(%esp),其实就是插入了内存屏障导致的结果,lock表示volatile变量写时被缓存锁定了(MESI协议),作用如下
    • 禁止指令重排序
    • 将当前处理器缓存行的数据写回到系统内存
    • 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
int a = 0; volatile boolean v = false;

线程A
a = 1;    //1 
v = true; //2

线程B
v = true; //3
System.out.println(a);//4  
  • 根据程序次序规则,1 happens-before 2;3 happens-before 4。根据volatile变量规则,2 happens-before 3。 根据happens-before的传递性规则,1 happens-before 4。程序的执行结果表现如下图

图片说明

  • volatile写的内存语义:写volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
  • volatile读的内存语义:读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
  • 非基本字段不应该用volatile修饰。其原因是volatile修饰对象或数组时,只能保证他们的引用地址的可见性

final内存语义

  • final写内存语义:

    • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。保障对象被引用之前,fianl域里的变量都是被初始化的
    • 实现原理:编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
    public class Example { 
        int i; //普通类型
        final int j; // 引用类型 
        public Example () { // 构造函数 
            i = 0;  j = 1;
        }
        public static void writer () { // 写线程A执行 
            obj = new Example (); 
        }
        public static void reader () { // 读线程B执行 
            Example object = obj; // 读对象引用 
            int a = object.i; // 读普通域 
            int b = object.j; // 读final域 
        }
    }
    • final只会禁止对其修饰变量的写操作,被重排序到构造函数之外;普通变量 i 的赋值可能会被重排到序构造函数之外
    • A线程创建obj,可能让线程B拿到初始化一半的obj;final变量 j 被初始化,而普通变量 i 还没初始化
    • 疑问:内存屏障不是会禁止指令重排吗?个人猜想应该是编译器先重排序,此时普通变量已经在构造器外了,再根据final类型插入内存屏障。上面的代码执行可能有如下情况:

图片说明

  • final读内存语义

    • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
    • 实现原理:要求编译器在读final域的操作前面插入一个LoadLoad屏障
  • 当使用final修饰引用对象或者数组时,final只保证在构造器返回之前对引用对象的操作先于构造器返回之后的操作

    public class Example { 
        final int[] intArray; // intArray 是引用类型 
        public Example () { // 构造函数 
            intArray = new int[1]; 
            intArray[0] = 1; //此操作对获取该对象引用的线程是可见的
        }
    }

8 synchronized,volatile内存语义的原理梳理

图片说明

9 应用题:延迟加载双重锁定是否真的安全

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

代码第7行instance=new Singleton();创建了一个对象。这一行代码可以分解为如下的3行伪代码

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

假如2和3之间重排序之后的顺序如下

memory = allocate(); // A1:分配对象的内存空间 
instance = memory;  //A3:instance指向刚分配的内存地址,此时对象还没有被初始化
ctorInstance(memory); // A2:初始化对象
  • 假如发生A3、A2重排序,线程是不保障赋值初始化对象两步骤操作结果会一起同步到主存

  • 因此第二个线程执行到if (instance == null);// 4:第一次检查时,可能会得到一个刚分配的内存而没初始化的对象(此时没有加锁,锁的happens-before规则不适用)

  • 相应的两个解决方法

    • 在锁内使用volatile修饰instance,volatile保障指令禁止重排序,并且保障变量的内存可见性:private volatile static Instance instance;
    • 使用类加载器的全局锁,在执行类的初始化期间,JVM会去获取一个锁;这个锁可以同步多个线程对同一个类的初始化,每个线程都会试图获取该类的全局锁去初始化类
    public class InstanceFactory { 
        private static class InstanceHolder { 
            public static Instance instance = new Instance();
        }
        public static Instance getInstance() {
            // 这里将导致InstanceHolder类被初始化 
            return InstanceHolder.instance ; 
        } 
    }

应用题:延迟加载双重锁定是否真的安全

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

代码第7行instance=new Singleton();创建了一个对象。这一行代码可以分解为如下的3行伪代码

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

假如2和3之间重排序之后的顺序如下

memory = allocate(); // A1:分配对象的内存空间 
instance = memory;  //A3:instance指向刚分配的内存地址,此时对象还没有被初始化
ctorInstance(memory); // A2:初始化对象
  • 假如发生A3、A2重排序,线程是不保障赋值初始化对象两步骤操作结果会一起同步到主存

  • 因此第二个线程执行到if (instance == null);// 4:第一次检查时,可能会得到一个刚分配的内存而没初始化的对象(此时没有加锁,锁的happens-before规则不适用)

  • 相应的两个解决方法

    • 在锁内使用volatile修饰instance,volatile保障指令禁止重排序,并且保障变量的内存可见性:private volatile static Instance instance;
    • 使用类加载器的全局锁,在执行类的初始化期间,JVM会去获取一个锁;这个锁可以同步多个线程对同一个类的初始化,每个线程都会试图获取该类的全局锁去初始化类
    public class InstanceFactory { 
        private static class InstanceHolder { 
            public static Instance instance = new Instance();
        }
        public static Instance getInstance() {
            // 这里将导致InstanceHolder类被初始化 
            return InstanceHolder.instance ; 
        } 
    }

10 题外话:伪共享(false sharing)

  • 伪共享
    • 前面介绍到每个CPU都有属于自己的高速缓存,但是缓存数据大小是怎样的呢?
    • 这个大小并不是我们需求存多大就存多大的,而是一个固定的大小-64字节,缓存的加载更新都是以连续的64字节内存为单位,称之为缓存行
    • 一缓存行是可以存在多个变量的,比如long类型(64位==8字节),可以存入8个

图片说明

  • 假如变量A和变量B是在同一连续的内存,CPU缓存加载A时,B也会被读取;反之亦然,A的脏回写导致在其他CPU相应内存失效的同时,同一缓存行的B内存也被标识为Modified(同舟共渡,一起翻船)
  • 设想变量A和B没有关联,却刚好在同一缓存行;然后A被CPU-X处理,B被CPU-Y处理;因为CPU-X对A的缓存更新而导致B的缓存失效;CPU-Y要处理B,则要读取更新后的缓存行(B实际是没被更新),造成没必要的内存读取开销。这就是伪共享

图片说明

  • 伪共享的解决方法:


1- 填充字节,将对应的变量填充到缓存行的大小。如下面定义的类,声明额外的属性

public final static class FilledLong {
    /**value 加 p1 - p6;加对象头8个字节正好等于一缓存行的大小 */
    //markWord + klass (32位机,64位是16字节) 8字节 
    public volatile long value = 0L; // 8字节
    public long p1, p2, p3, p4, p5, p6; //48字节
}

2- 使用jdk的注解@Contended修饰变量,jvm会自动将变量填充到缓存行的大小。注意的是需要加入启动参数 -XX:-RestrictContended