JMM内存模型
2024-04-09 18:10:07  阅读数 2546

什么是JMM内存模型

内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型。

JMM(Java内存模型)源于CPU架构的内存模型(用于解决多处理器架构系统中的缓存一致性问题)。JVM为了屏蔽各个硬件平台和操作系统对内存访问机制的差异化,提出了JMM概念。因此它不是对物理内存的规范,而是在虚拟机基础上进行的规范从而实现平台一致性。

Java内存模型(Java Memory Model,JMM)是一种抽象的概念,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。

JMM结构规范

JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成

主内存&工作内存

主内存

主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中(除开开启了逃逸分析和标量替换的栈上分配和TLAB分配),不管该实例对象是成员变量还是方法中的本地变量,也包括共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行非原子性操作时可能会发生线程安全问题

工作内存

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

交互协议

八大原子操作
主内存与工作内存之间的具体交互协议,Java内存模型定义了八种操作来完成:

  • Lock(锁定)
    作用于主内存的变量,把一个变量标记为一条线程独占状态
  • Read(读取)
    作用于主内存的变量,把一个变量从主内存传输到线程的工作内存中
  • Load(加载)
    作用于工作内存的变量,把Read操作从主内存中得到的变量值放入工作内存的变量副本中
  • Use(使用)
    作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
  • Assign(赋值)
    作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • Store(存储)
    作用于工作内存的变量,把工作内存中的一个变量的值传递到主内存中
  • Write(写入)
    作用于主内存的变量,把Store操作从工作内存中得到的变量的值放入主内存的变量中
  • Unlock(解锁)
    作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定


同步原则

  • 不允许一个线程无原因地(没有发生过assign操作)把数据从工作内存同步回主内存中
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即对一个变量实施use和store之前,必须先执行assign和load操作
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

与JVM内存结构的区别

JVM内存结构和JMM内存模型是完全两个不同的概念。

JVM内存结构是处于Java虚拟机层面的,是运行时对Java进程占用的内存进行的一种逻辑上的划分,通过不同数据结构来对申请的内存进行不同使用。对操作系统来说,本质上JVM还是存在于主存中

JMM是Java语言与OS和硬件架构层面的,本质上JMM并不能说是某种技术实现,而是一种在多线程并发情况下对于共享变量读写的规范。JMM屏蔽了不同操作系统差异,是跨平台可用的内存模型,用来描述线程的数据在何时从主内存读取,何时写入主内存,解决线程间数据共享和传递的问题

OS与JVM线程关系

Java线程的实现是基于一对一的线程模型。所谓一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型。

我们在使用Java线程时,如new Thread(Runnable);,JVM内部是调用当前操作系统的内核线程来完成当前Runnable任务。我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,而是创建一个应用线程映射到一个内核线程,然后通过该线程调用内核线程,进而由操作系统内核将任务映射到各个处理器。这种应用线程与内核线程间一对一的关系就称为Java程序中的线程与OS的一对一模型。

三大特性

JMM是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的

原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

基本类型数据的访问大都是原子操作,long 和 double 类型的变量是64位的,在32位JVM中,32位的JVM会将64位数据的读写操作分为2次32位的读写操作来进行,这就导致long、double类型的变量在32位虚拟机中是非原子性操作,数据有可能会被破坏,也就意味着多线程在并发访问的时候是线程非安全的

可见性

一个线程对共享变量做了修改后,其他的线程立即能够看到该变量的这种修改

对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中,工作内存与主内存同步延迟现象就会造成可见性问题,另外重排序也可能导致可见性问题

有序性

对于一个线程的代码而言,代码的执行总是从前往后依次执行的

在单线程环境下,代码由编码的顺序从上往下执行,就算发生指令重排序,由于所有硬件优化的前提都是必须遵守 as-if-serial 语义,所以不管怎么排序,都不会且不能影响单线程程序的执行结果。对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排序现象,重排后的指令与原指令的顺序未必一致(因为指令重排序现象以及工作内存与主内存同步延迟现象导致)。

解决方案

对于原子性问题,JVM提供了对基本数据类型读写操作的原子性,对于单个变量(包括64位long和double)可以用volatile关键字来保证读写操作的原子性,但volatile关键字对多个volatile操作或类似volatile++这种复合操作不具有原子性;对于方法级别或代码块级别的原子性操作,可以使用 synchronized 关键字或Lock锁来保证程序执行的原子性。
对于工作内存与主内存同步延迟现象导致的可见性问题,可以使用加锁或volatile关键字来解决
对于指令重排序导致的可见性问题和有序性问题,可以利用volatile关键字解决
同时,JMM内部还定义了一套happens-before原则来保证多线程环境下两个操作间的原子性、可见性以及有序性

as-if-serial语义

无论什么语言,只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这就是as-if-serial语义

对于单线程来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题

对于多线程来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。编译器和CPU只能保证每个线程的 as-if-serial 语义,线程之间的数据依赖和相互影响,需要上层来确定。

happens-before原则

从JDK5开始,Java使用新的JSR-133内存模型。JSR-133提出了 happens-before 概念,通过这个概念来阐述操作之间的内存可见性。
happens-before 表达的是:前一个操作的结果需要对后续操作是可见的。这里提到的两个操作既可以是在一个线程内,也可以是不同线程之间。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //x的值是多少呢?
    }
  }
}
  • 程序次序规则
    在一个线程中,按照代码的顺序,前面的操作happens-before于后面的任意操作
    例如:赋值操作 x = 42 先于 v = true 执行
  • volatile变量规则
    对一个volatile变量的写操作happens-before与后续对这个变量的读操作
  • 传递规则
    如果 A happens-before B,并且 B happens-before C,则 A happens-before C
    "x=42" happens-before 写变量 "v=true"。(程序次序规则)
    写变量"v=42" happens-before 读变量"v==true"。(volatile变量规则)
    根据传递性规则,得到结果 "x=42" happens-before 读变量"v==true"

  • 监视器锁规则
    对一个锁的解锁 happens-before 于随后对这个锁的加锁
    线程A执行完代码后x的值会变成12,线程B进入代码块,能够看到线程A对x的写操作,也就是线程B能够看到x==12
synchronized (this) { // 此处自动加锁
    // x是共享变量,初始值=10
    if (this.x < 12) {
        this.x = 12;
    }
} // 此处自动解锁
  • 线程启动规则
    如果线程A调用线程B的start()方法来启动线程B,则start()操作happens-before于线程B中的任意操作
    线程A启动线程B之后,线程B能够看到线程A在启动线程B之前的操作,在线程B中访问到x变量的值为100
// 在线程A中初始化线程B
Thread threadB = new Thread(() -> {
    // 此处的变量x的值是多少呢?答案是100
});
// 线程A在启动线程B之前将共享变量x的值修改为100
x = 100;
// 启动线程B
threadB.start();
  • 线程终结规则
    线程A等待线程B完成(在线程A中调用线程B的join()方法实现),当线程B完成后,线程A能够访问到线程B对共享变量的操作
Thread threadB = new Thread(() -> {
    // 在线程B中,将共享变量x的值修改为100
    x = 100;
});
// 在线程A中启动线程B
threadB.start();
// 在线程A中等待线程B执行完成
threadB.join();
// 此处访问共享变量x的值为100
  • 线程中断规则
    对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生
// 在线程A中将x变量的值初始化为0
private int x = 0;

public void execute() {
    // 在线程A中初始化线程B
    Thread threadB = new Thread(() -> {
        // 线程B检测自己是否被中断
        if (Thread.currentThread().isInterrupted()) {
            // 如果线程B被中断,则此时x的值为100
            System.out.println(x);
        }
    });
    // 在线程A中启动线程B
    threadB.start();
    // 在线程A中将共享变量x的值修改为100
    x = 100;
    // 在线程A中中断线程B
    threadB.interrupt();
}
  • 对象终结规则
    一个对象的初始化完成happens-before于它的finalize()方法的开始
public class TestThread {
    public TestThread() {
        System.out.println("构造方法");
    }

    @Override
    public void finalize() throws Throwable {
        System.out.println("对象销毁");
    }

    public static void main(String[] args) {
        new TestThread();
        System.gc();
    }
}

运行结果

构造方法
对象销毁