Java内存模型详解

一、前言

1. 硬件内存架构

就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是CPU直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。这就是CPU、缓存以及主内存间的简要交互过程。如下图所示

Java内存模型详解

总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。

2. 并发编程的三大特性

  • 原子性

    即一个或者多个操作作为一个整体,要么全部执行,要么都不执行,并且操作在执行过程中不会被线程调度机制打断;而且这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换。

  • 可见性

    当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  • 有序性

    程序执行的顺序按照代码的先后顺序执行。

二、什么是Java内存模型

了解完前面的准备知识,我们再来看看Java内存模型。

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范

下图为Java内存模型图

Java内存模型详解

先介绍一下上图涉及到的两个概念:主内存和工作内存

  • 主内存:主要存储的是Java实例对象所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。
  • 工作内存:Java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的。线程的工作内存保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。

如上图所示,Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

三、内存间的交互

Java内存模型规定了所有的变量都存储在主存中,每个线程都有自己的工作内存,工作内存中保存了该线程要用到的主存中变量的副本,线程对变量的操作都是在工作内存中完成,不能直接操作主存,线程之间也无法直接访问其他线程的工作内存,线程间变量值的传递需要通过主存来完成,这就涉及到了主存与工作内存间的交互操作。

1. 内存间交互操作

JMM 定义了 8 个操作来完成主内存和工作内存之间的交互操作。JVM实现时必须保证下面介绍的每种操作都是原子的。(对于 double 和 long 型的变量来说,load、store、read、和 write 操作在某些平台上允许有例外 )

操作命令 操作名称 说明
lock 锁定 将主内存变量加锁,标识为线程独占状态
unlock 解锁 将主内存变量解锁,解锁后其他线程可以锁定该变量
read 读取 从主内存读取数据
load 载入 将主内存读取到的数据写入工作内存
use 使用 从工作内存读取数据进行计算
assgin 赋值 将计算好的值重新赋值到工作内存中
store 存储 将工作内存数据写入主内存中
write 写入 将store过去的变量值赋值给主内存中的变量
  • 如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作
  • 如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作

注意:JMM只是规定了必须顺序执行,而没有保证是连续执行,因此中间可以插入其他指令。

2. 交互操作的规则

JMM规定了上述 8 种基本操作需要满足以下规则

  • 不允许read和load、store和write操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况
  • 不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
  • 不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何assign操作,是不允许将该变量的值回写到主内存
  • 变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作。也就是说在执行usestore之前必须对相同的变量执行了loadassign操作
  • 一个变量在同一时刻只能被一个线程对其进行lock操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。
  • 对变量执行lock操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值
  • 不允许对没有lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作
  • 对一个变量执行unlock之前,必须先把变量同步回主内存中,也就是执行storewrite操作

四、JMM内存操作的问题

类似于物理内存模型面临的问题,JMM存在以下两个问题

  • 工作内存数据一致性

    各个线程操作数据时会保存使用到的主内存中的共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,将导致各自的的共享变量副本不一致。如果真的发生这种情况,数据同步回主内存以谁的副本数据为准? Java 内存模型主要通过一系列的数据同步协议、规则来保证数据的一致性。

  • 顺序一致性

    Java编译器或运行时环境为了优化程序性能,通常会对指令进行重新排序执行。重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。 同样的,指令重排序不是随意重排序,它需要满足以下两个条件:

    • 在单线程环境下不能改变程序运行的结果。即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。
    • 存在数据依赖关系的不允许重排序

    然而as-if-serial仅仅保证在单线程情况下的正常执行,多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令重排序导致运行结果与预期不同。

五、JMM提供的解决方案

根据前言,我们知道Java并发三大特性:原子性,可见性以及有序性。Java中提供了一系列和并发处理相关的关键字及规则来保证这三大特性。

1. happens-before原则

1.1 happens-before定义

以下的定义来自《Java并发编程的艺术》一书中

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

1.2 happens-before规则

  • 程序次序规则
    一个线程内,按代码顺序,在前面的操作先行发生于在后面的操作;一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。

  • 锁定规则
    一个unLock操作先行发生于后面对同一个锁的lock操作。

  • volatile变量规则
    对一个变量的写操作先行发生于后面对这个变量的读操作。这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。

  • 传递规则
    如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;体现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C。

  • 线程启动规则
    Thread对象的start()方法先行发生于此线程的每个一个动作。假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见

  • 线程中断规则
    对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

  • 线程终结规则
    线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。

  • 对象终结规则
    一个对象的初始化完成先行发生于它的finalize()方法的开始。

上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:

  • 将一个元素放入一个线程安全的队列的操作Happens-Before从队列中取出这个元素的操作
  • 将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
  • CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
  • 释放Semaphore许可的操作Happens-Before获得许可操作
  • Future表示的任务的所有操作Happens-Before Future#get()操作
  • Executor提交一个RunnableCallable的操作Happens-Before任务开始执行操作

1.3 总结

如果两个操作不存在上述(8条 + 6条)任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的

2. volataile

2.1 内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。

当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

2.2 volatile作用

volatile关键字有如下两个作用

  • 保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。
  • 禁止指令重排序优化。

详细的内容本文暂不深究,后续会针对volatile的原理专门写一篇文章。

3. JMM如何解决原子性、可见性和有序性

  • JMM对原子性的保证方式
    • 使用对应的关键字Synchronized来保证代码块内的操作是原子的
    • lock加锁方式
  • JMM对可见性的保证方式
    • 使用volatile来保证共享变量的可见性
    • 配合使用Synchronized和lock锁
  • JMM对有序性的保证方式
    • 使用volatile禁止指令重排序
    • 配合使用Synchronized
    • happens-before原则