漫游JVM(二):JVM内存模型

361 阅读7分钟

本节是JVM的第二篇文章,我们来看看容易与JVM内存结构混淆的概念:JVM内存模型。如果对内存结构不是很了解的小伙伴,可以先去看看: 漫游JVM(一):JVM内存结构

内存模型(Java Memory Model),是线程间通讯的机制,描述了JVM中【主内存】和【线程本地内存】的关系,在Java虚拟机设计的时候,就参考了计算机中的【内存】与【CPU的高速缓存】的交互,因此两者JVM的内存模型与计算机的十分相似。

1 JVM的内存模型

JMM抽象了【主内存】和【线程本地内存】的概念。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在【主内存】中,每个线程都有一个私有的【线程本地内存】,【线程本地内存】中存储了该线程以读/写共享变量的副本

JMM

2 三大性质

在描述分析Java并发编程的时候,常常会提及到【原子性】、【可见性】和【有序性】作为切入点,我们在学习JMM的时候,同样也可以以此来切入。

2.1 原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,而在JMM中,【主内存】和【线程本地内存】的交互,就是通过下面8个原子操作来实现的:

  • read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
  • use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
  • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
  • store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
  • write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
  • lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

JMM_8atomic

2.2 可见性

可见性是指当一个线程修改了工作内存的变量后,其他线程能够立即得知这个修改。

为方便描述,下面将主存中的变量A称为【主A】,而各自线程对此的备份为【线A1】和【线A2】。

两个线程不可见:
1. 【主A】的数据为10
2. 线程1和线程2访问主存获得【主A】的备份,分别为【线A1】和【线A2】
3. 线程1对【线A1】进行修改,例如赋值为15
4. 此时线程2中的【线A2】仍然是10

如何解决这个问题呢?

MESI(缓存一致性协议)

在java中,如果使用volatile关键字来修饰上述的变量A,就可以解决上述的可见性问题。其实其原理是使用了【MESI(缓存一致性协议)】,MESI要求某个线程修改了本地内存中的变量时,必须将变量立马写入主存,同时其他线程将其本地内存的副本标记为失效,再次使用时,需要重新到主存中加载最新的数据。

具体的情况,我们结合JMM再来说明一下当修饰了volatile关键字后的情况:

  1. 修饰volatile关键字后,开启MESI
  2. 当某个线程对本地内存中的变量[assign]时,会立即将数据[store]->[write],从而将数据立马写入到主存中
  3. 在写入的过程实际上还会加锁,即[loca]->[store]->[write]->[unlock],因为上了锁,其他线程如果想在再获取数据,只能等待写入完成且释放锁后,才能再次读取到主存的数据。
  4. 其他线程都会因开启MESI而持续监听总线,当监听到其他线程有数据即将写入(到达总线),则会将自己的本地内存中的副本标记为失效,如果仍需使用这份数据时,只能再次到主存中重新读取一份最新的数据。

以上,就是通过添加volatile关键字来解决可见性的问题,此外,volatile关键字除了解决可见性问题,还解决了有序性的问题,下面再来看看有序性。

2.3 有序性

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

为什么需要重排序呢? 主要是为了在不改变程序语义的前提下,尽可能的减少寄存器的读取、存储次数,充分复用寄存器的存储值。如:

int a = 1;
② int b = 2;
③ int c = a+1;
  • 假设编译器没有重排序,那么①完成后需要与寄存器交互一次(存入),而③完成后又要与寄存器交互一次(取出)。
  • 如果编译器使用了重排序,执行的顺序会变成①③②,在①完成后,先不存入主存,而是让③完成处理后,再存入主存,这样就减少了一次取出操作了。

这样的重排序在单线程的情况下并不会出问题,因为其最终结果都是正确的,但在多线程的环境下,这种重排序就可能导致线程不安全。如另一个线程需要读取a时,a因重排序而先不存入主存,此线程读取的数据已经不是最新值。

在Java里面,可以通过volatile关键字来通知编译器禁止对此变量进行重排序。

happens-before原则

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

1. 程序次序规则:

在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。

2. 管程锁定规则:

就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)

3. volatile变量规则:

就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

4. 线程启动规则:

在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。

5. 线程终止规则:

在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则。

6. 线程中断规则:

对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。

7. 传递性规则:

这个简单的,就是happens-before原则具有传递性,即hb(A, B) , hb(B, C),那么hb(A, C)。

8. 对象终结规则:

这个也简单的,就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

3 小结

本节的主要内容是围绕JVM的内存模型:

  • JVM的内存模型
    • 主存
    • 线程工作线程
  • 三大性质
    • 原子性
      • 8个原子操作
    • 可见性
      • MESI(缓存一致性协议)
    • 有序性
      • happens-before原则