深入理解JVM之Java内存模型

 约 12 分钟

要了解Java内存模型,首先我们要了解什么是Java内存模型,它有什么作用?
描述Java内存模型(简称:JMM)的规范提案JSR-133标题《Java Memory Model and Thread Specification》,通过这个标题,可以看出JMM是和线程相关的规范。此规范地指定的 JMM Web Site 上对规范的说明如下:

The Java Memory Model defines how threads interact through memory.

通过以上描述,说明JMM规范主要是解决在多线程场景下线程间如何通信。

硬件内存架构

要了解JMM,我们先来从硬件角度,看看多核CPU场景下,多线程程序会存在什么问题。

硬件内存架构.PNG

如上图所示,在多核(多CPU)硬件架构中,系统中有两个CPU,分布运行了一个线程,对象obj保存在主内存(RAM)中。由于RAM的速度远低于CPU,为了加快数据的访问,当CPU(线程)需要使用obj对象时,会预先把obj对象加载到CPU的缓存(CPU Cache)中,处理完毕后,再把对obj对象的更新回写到到RAM。
每个CPU有自己独立的缓存,一个CPU无法访问其他CPU的缓存,也就是CPU间无法直接交换数据,CPU间所有的数据交换都需要借助主内存来完成。

假设线程执行的是 +1 操作。在上图示例中,两个线程并发执行。初始状态,主内存中obj.num=1;线程1先读取了obj对象,并执行+1操作,结果obj.num=2;在线程1的修改还未从CPU缓存回写到主内存的时候,线程2从主内存中读取了obj对象,此时线程2读取到的obj.num=1;此后,线程1和线程2分别把obj回写到主内存;按正常业务逻辑,obj.num被+1了两次,结果应该是3,但上述情况,最终主内存中obj.num=2。这是因为两个线程对数据并发访问冲突导致线程读到的数据不一致。

Java内存模型

Java是平台无关的语言,为了实现跨平台运行,Java虚拟机(JVM)上运行的是Java字节码(Java bytecode)。Java内存模型(Java Memory Model,JMM)是Java虚拟机规范定义的,用来屏蔽掉Java程序在各种不同的硬件和操作系统对内存的访问的差异,实现Java程序在各种不同的平台上都能达到内存访问的一致性。和硬件内存架构类似,JMM把内存分为主内存工作内存,主内存由所有线程共享,工作内存为线程私有。
JMM规范主要定义程序变量操作的规则,规范中定义的主内存、工作内存的概念和JVM运行时内存分区中定义的堆、栈区域不是同一纬度的概念,不能互相对应,不过为了便于理解,可把主内存类比为堆,工作内存类比为栈。

虽然工作内存和栈可以类比,但两者是不同的概念。
JMM管理的程序变量,主要是指在对象实例字段、静态字段、构成数组字段的元素等,不包括方法参数、方法局部变量等保存在栈里的变量,因为栈本身就是线程私有的,并不存在线程一致性问题。
JMM规范规定所有的变量都要在主内存中产生,而线程不允许直接操作主内存中的变量,线程需要把变量副本拷贝到工作线程中进行操作,操作完后再回写到主内存。

JVM内存模型.PNG

主内存
JMM规定所有的变量都必须在主内存中产生。

工作内存
JVM中每个线程都有自己的工作内存,是线程私有的,可以类比CPU的高速缓存。线程的工作内存保存了线程需要的变量在主内存中的副本。

数据交互接口

JMM中定义了8个用于主内存和工作内存见数据互操作的接口,用于在两者间传输数据,这些操作都是原子性的。

  1. lock(锁定)
    作用于主内存变量,属于互斥锁,一个变量同时只能一个线程锁定
  2. unlock(解锁)
    作用于主内存变量,lock的反操作,释放变量的锁
  3. read(读取)
    作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用
  4. load(载入)
    作用于线程工作内存变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中
  5. use(使用)
    作用于线程工作内存变量,表示把工作内存中的一个变量的值传递给字节码指令
  6. assign(赋值)
    作用于线程工作内存变量,表示把字节码指令执行返回的结果赋值给工作内存中的变量,字节码赋值操作
  7. store(存储)
    作用于线程工作内存变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用
  8. write(写入)
    作用于主内存变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

数据交互原则

  1. 变量只能在主内存中产生。
  2. 线程对主内存变量的操作必须在线程的工作内存中进行,不能直接操作主内存中的变量。
  3. 不同的线程之间也不能相互访问对方的工作内存。线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。
  4. read和load操作、store和write必须成对使用,即:不允许从主内存中读取了变量,工作内存不接收,或者工作内存回写了变量,主内存不接收。
  5. assign操作后的变量必须回写到主内存。
  6. 不允许回写没有修改(即未assign)的变量到主内存。
  7. 一个变量同时只能被一个线程对其进行lock操作,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。
  8. 对变量执行lock操作,就会清空工作空间该变量的值,使用时需要重新读取;对一个变量执行unlock之前,必须先把变量同步回主内存中。

指令重排(Reordering)

计算机在执行程序时,为了提高性能,编译器和处理器会对指令做重排,再对乱序执行之后的结果进行重组,保证结果的正确性。也就是说在真正的执行过程中,指令执行的顺序并不一定按照代码的书写顺序来执行,但可以保证结果与顺序执行的结果一致,这种现象成为指令重排(Reordering),指令重排优化包括以下三种情况。

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

针对编译器重排序,JMM的编译器重排序规则会禁止volatile变量synchronizedfinal等特定指令的编译器重排序;针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序

内存并发一致性原则

上述的内存并发一致性问题,在JMM中定义了三个原则来避免,分别是原子性、可见性和有序性。

原子性(Atomicity)

原子性表示不可被中断的一个或一组操作。操作一旦开始,就一直运行到结束,中间不会有任何线程切换(context switch)。

可见性(Visibility)

可见性是指多个线程访问同一个变量是,一个线程修改了变量的值后,其他线程可以立即读取到这个变量的最新值。

有序性(Ording)

指程序按代码书写时希望的顺序执行,这在指令重排后尤其重要,有序性包括单线程内执行的有序性和多线程间执行的有序性。

as-if-serial
as-if-serial语义,是指不管指令怎么重排序,单线程程序的执行结果不能被改变。遵守as-if-serial语义的编译器,指令执行顺序虽然和代码书写顺序不一致,但可以保证执行的结果是正确的。

先行发生(Happens-before)
重排后的指令,在多线程同时执行情况下,从其它线程的视角来看,被指令重排的线程执行过程是不确定的,线程间执行的可见性无法保证。happens-before概念用来指定两个操作之间的执行顺序,可以提供跨线程的内存可见性保证,其具体定义如下。

  1. 如果动作A先行于动作B发生,则动作A的执行结果对于动作B可见,而且动作A的执行顺序排在动作B之前。
  2. 先行发生并不要求重排后的指令严格按先行发生的顺序执行,只要保证先后发生的动作的结果(可见性)符合先行发生原则即可。

先行发生的具体规则如下

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
  8. 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

一致性保证的方法

volatile变量

volatile是Java中最轻量级的同步机制,JMM对volatile变量定义了特殊的操作规则,使得变量具有同步的特性,相关规则如下。

  1. 线程对volatile变量的load和use操作必须连续出现,即变量需要使用时,必须先从主内存中读取最新值;assign和store操作也必须连续出现,即线程对变量赋值后,必须马上写入主内存。通过这两点,可以保证变量对所有线程的可见性
  2. 对volatile修饰的变量,JVM禁止指令重排优化,指令按代码顺序执行,保证代码运行的有序性

需要注意的是,虽然volatile变量可以保证对所有线程的可见性,但是并不能保证变量是线程安全的,多线程并发操作下,还是会出现文章前面出现的obj.num并发冲突的问题,这是由于变量本身 +1 操作并不是原子性的,它可以分为两个步骤,即变量加载到工作内存(read、load、use)、变量赋值后回写主内存(assign、store、write),而这两个步骤并不是原子性的。A、B两个线程的执行顺序可能是这样的:

  1. 线程A读取变量obj.num=1
  2. 线程B读取变量obj.num=1
  3. 线程A执行+1,obj.num=1+1=2,并回写到主内存
  4. 线程B执行+1,obj.num=1+1=2,并回写到主内存,此时覆盖了线程A写入主内存的值

在这种情况下,要保证线程间数据同步,就需要使用lock锁住变量,这在Java语法中,表现为 synchronized 关键字。

synchronized

JMM的lock和unlock操作,对应到字节码指令是monitorenter和monitorexit两条指令,而对应的Java代码中,就是synchronized代码块或者synchronized方法。
由于lock同时只能被一个线程获取,所以可以保证操作的原子性;另外lock会触变量重读,unlock会触发变量回写,所以可以保证操作对其他线程的可见性;另外lock保证同时只有一个线程执行对应代码快,可以保证操作的有效性。

final关键字

在JMM中,final关键字确保变量初始化安全性(initialization safety)成为可能,让不可变对象不需要同步就能安全地被访问和共享。
在JMM中,通过内存屏障禁止编译器把final域的写重排序到构造函数之外,在对象引用为任意线程可见之前,对象的final域已经被正确初始化了。
对于final域,编译器和处理器遵循两个重排序规则:

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

参考资料

  1. JSR-133: JavaTM Memory Model and Thread Specification
  2. The Java Memory Model
阅读 357

推荐阅读
目录