Java内存模型(Java Memory Mode)
2021-01-29 / highPhone啊

CPU Cache模型

在学习JMM之前,我们先来了解下CPU Cache模型。在计算机中,所有的运算都由CPU来完成,而CPU计算需要从计算机主存(RAM)去读取数据,但是,我们都知道CPU的运算和读写速度很快,而主内存的读写速度要远远低于CPU,如果CPU要直接读取RAM,必将会大大降低CPU的速度和性能,于是就有了在CPU和主存之间增加了一层CPU Cache的设计。现在,CPU Cache分为三级,最靠近CPU的缓存叫做L1,然后一次是L2,L3和主内存,由于程序指令和程序数据的行为和热点分布差异很大,因此,L1缓存又被划分成了L1i(i是instuction的首字母)和L1d(d是data的首字母)这两种有各自专门用途的缓存。CPU缓存模型如下图所示:

Cache 缓存模型是为了解决CPU直接访问内效率低下问题的,其工作原理如下:程序在运行过程中,会将运算所需要数据从主存复制一份到CPU Cache中,这样CPU计算的时候就可以直接对CPU Cache中的数据进行读取和写入,当运算结束之后,再将CPU Cache中的最新数据刷新到主内存中。CPU 通过访问CPU Cache的方式,大大提高了CPU的效率。

CPU缓存一致性问题

引入CPU Cache来解决CPU直接读取主内存效率低下的问题,同时也会带来了CPU缓存不一致的问题。例如对于某个变量i的自增操作i++,具体过程如下:

  1. 把变量i拷贝一份到CPU Cache中
  2. CPU从缓存中读取i进行+1操作
  3. CPU将计算后的i写回CPU Cache中
  4. 将数据从CPU Cache中刷新到主内存
    以上过程,在单线程的情况下是没有问题的,但是在多线程的环境下就会有问题。每个线程都有自己的工作内存(对应于CPU的Cache),如果有多个线程同时操作变量i,那么i在每个线程的工作内存中都有一份副本,假设线程i初始值是0,线程一和线程二同时读取到这个i的值,线程一想对i进行+1操作,而线程二进行-1操作,而这两个线程互相都不知道对方的操作,这样就有可能线程一先完成计算,把i的值变为1刷回主内存,但线程二仍然用0去做了-1操作,然后把-1刷回主内存,这样的计算是不正确的,这就是缓存一致性问题。
    为了解决缓存一致性问题,通常有以下两种方式:
  • 通过总线加锁的方式
    CPU和其他组件的通信都是通过总线来进行的,如果采用总线加锁的方式,会阻塞其他CPU对其他组件的访问,从而使得只有一个CPU(即一个线程)能够访问这个变量的内存。但是这种方式效率低下。
  • 缓存一致性协议
    最出名的缓存一致性协议是Intel的MESI协议,它保证了每一个缓存中使用的共享变量都是一致的。它的大致原理是:当CPU在操作Cache的数据时,如果操作的变量是一个共享变量(其他Cache也有这个变量的副本),则有如下操作:
    1. 对于读取操作,不做任何处理
    2. 对于写入操作,发出信号通知其他CPU将该变量的Cache Line设置为无效状态,其他CPU在进行该变量读取的时候将再次到主内存获取数据。

Java内存模型

Java的内存模型(Java Memory Mode, JMM)指定了Java虚拟机如何与计算机的主存(RAM)进行工作。它决定了一个线程对共享变量的写入何时对其他线程可见。JMM定义了线程和主内存之间的抽象关系,具体如下:

  1. 共享变量放在主存中,每个线程都可以访问
  2. 每个线程都有各自的工作内存(也叫本地内存)
  3. 线程工作内存内只存储共享变量的副本
  4. 线程不能直接操作主内存,必须先操作工作内存后由工作内存写入主存
  5. 工作内存是抽象概念,其实并不存在,它涵盖了缓存、寄存器、编译器优化和硬件等。
    Java内存模型如下图:

JMM与并发编程三大特性

JMM与原子性

  • 原子性是指在一次或者多次操作中,要么所有的操作全部都得到了执行并且不会收到任何因素的干扰而中断,要么所有的操作都不执行,即要么都成功,要么都失败。最经典的例子就是银行转账业务。

在JAVA中:

  • 多个原子性操作放到一起后就不再是原子性操作了,如i++
  • 简单的读取与赋值操作是原子性的,将一个变量赋值给另外一个变量的操作不是原子性操作
  • Java内存模型只保证了基本读取和赋值的原子性操作,其他的均不保证。如果先要使某些代码片段具备原子性,需要使用关键字synchronized,或者使用JUC中的显示Lock。如果想要使得int类型自增操作具备原子性,可以使用JUC包下的原子封装类型java.util.concurrent.atomic.*。另外要注意的是Volatile关键字不具备保证原子性的语义

JMM与可见性

  • 可见性是指,当一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。

在JAVA中,有以下三种方式可以保证可见性

  1. 使用关键字Volatile,当一个变量被Volatile关键字修饰时,对于共享资源的读操作会直接在主存中进行(当然也会缓存到工作内存中,当其他线程对该共享资源进行了修改,则会导致当前线程在工作内存的共享资源失效,所以必须从主存中再次获取),对于共享资源的写操作当然是要先修改工作内存,但是修改结束后会立刻将其刷新到主内存中。
  2. 通过synchronized关键字可以保证可见性,它能保证同一时刻只有一个线程获得锁,然后执行同步方法,并且确保在释放锁之前,会将对变量的修改刷新到主内存中。
  3. 通过JUC提供的显示锁Lock也能保证可见性,Lock的lock方法能保证同一时刻只有一个线程获得锁,然后执行同步方法,并且确保在释放锁(unlock方法)之前会将对变量的修改刷新到主内存中。

JMM与有序性

  • 有序性是指程序代码在执行过程中的先后顺序。一般来说,处理器为了提高程序运行的效率,可能会对输入的代码指令做一定的优化,它不会百分百的保证代码的执行顺序严格按照编写的代码中的顺序来执行(指令重排序 Instruction Recorder),但是它会保证程序的最终运算结果是编码时所期望的那样。当然对指令的重排序要严格遵循指令之间的数据依赖关系,并不是可以任意进行重排序的。
    如可见性一样,JAVA提供了三种方式保证有序性:
  1. 使用Volatile关键字来保证有序性
    Volatile关键字语义直接禁止JVM和处理器对volatile关键字修饰的指令重排序。
  2. 使用synchronized关键字保证有序性
    加锁后,同步方法块或者代码块内代码同一时刻只有一个线程在执行,与单线程环境下执行是一样的,自然能保证顺序性(最终结果顺序性)
  3. 使用显示锁Lock保证有序性,与synchronized实现一样。

Happens-Before

除了以上三种方式外,java内存模型天生具备一些有序性规则,不需要任何手段就能够保证有序性,这些规则被称为Happens-Before规则:

  • 程序次序规则
    在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生在编写在前面的操作之后。虚拟机还是可能会对程序代码的指令进行重排序,但只要能确保在一个线程内最终的结果和代码顺序执行的结果一致即可(最终结果顺序性)。
  • 锁定规则
    一个unlock操作要先行发生于对同一个锁的lock操作。这里是指,如果同一个锁是锁定状态,必须要先执行释放锁操作,才能再进行锁操作。
  • volatile变量规则
    对一个被volatile关键字修饰的变量,对这个变量写操作要早于对其的读操作执行
  • 传递规则
    如果A操作先于B,B操作先于C,那么可以得出A操作先于C
  • 线程启动规则
    Thread对象的start()方法先行发生于该线程的任何动作
  • 线程的中断规则
    对线程执行interrupt()方法肯定要优先于捕获到中断信号
  • 线程的终结规则
    线程的所有操作都要先行发生于线程的终止检测,即线程的任务执行、逻辑单元执行肯定要发生于线程死亡之前
  • 对象的终结规则
    一个对象的初始化的完成先行发生于finalize()方法之前。

参考文献:《Java 高并发编程详解》-汪文君 著

本文链接:https://highphone.xyz/cff6dd61.html