java内存模型

引用自https://github.com/Snailclimb/JavaGuide

从CPU缓存模型说起 #

  • redis是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题,CPU缓存则是为了解决CPU处理速度和内存处理速度不对等的问题

    我们把内存看作外存的高速缓存,程序运行时把外存的数据复制到内存,由于内存的处理速度远高于外存,这样提高了处理速度

  • 总结,CPU Cache缓存的是内存数据,用于解决CPU处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题 CPU Cache示意图:

    ly-20241212141939038 CPU Cache通常分为三层,分别叫L1,L2,L3 Cache 工作方式: 先复制一份数据到CPUCache中,当CPU需要用的时候就可以从CPUCache中读取数据,运算完成后,将运算得到的数据,写回MainMemory中,此时,会出现内存缓存不一致的问题,例子:执行了i++,如果两个线程同时执行,假设两个线程从CPUCach中读取的i=1,两个线程做了1++运算完之后再写回MainMemory,此时i=2 而正确结果为3

  • CPU为了解决内存缓存不一致问题,可以通过制定缓存一致协议(比如MESI协议)或其他手段。这个缓存一致协议,指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范 ly-20241212141939314 操作系统,通过内存模型MemoryModel定义一系列规范来解决这个问题

Java内存模型 #

ly-20241212141939466

指令重排序 #

  • 什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行

  • 指令重排有下面2种

    • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
    • 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题

即Java源代码会经历 编译器优化重排—>指令并行重排—>内存系统重排,最终编程操作系统可执行的指令序列

极其重要★:指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,所以在多线程下指令重排可能导致一些问题

编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存清空无效队列,从而保障变量的可见性。

JMM(JavaMemoryMode) #

什么是 JMM?为什么需要 JMM? #

一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

实际上,对于Java来说,可以把JMM看作是Java定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,还规定了从Java源代码到CPU可执行指令的转化过程要遵守哪些和并发相关的原则和规范,主要目的是为了简化多线程编程增强程序可移植性

为什么要遵守这些并发相关的原则和规范呢?因为在并发编程下,CPU多级缓存和指令重排这类设计会导致程序运行出问题,比如指令重排,为此JMM抽象了happens-before原则

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理直接使用并发相关的一些关键字(比如 volatilesynchronized、各种 Lock)即可开发出并发安全的程序。

JMM 是如何抽象线程和主内存之间的关系? #

Java内存模型(JMM),抽象了线程和主内存之间的关系,比如线程之间的共享变量必须存储在主内存

JDK1.2之前,Java内存模型总是从主存(共享内存)读取变量;而当前的Java内存模型下,线程可以把变量保存本地内存(机器的寄存器)中,而不直接在主存中读写。这可能造成,一个线程在主存中修改了一个变量的值,而在另一个线程继续使用它在寄存器中的变量值的拷贝,造成数据不一致

上面所述跟CPU缓存模型非常相似

什么是主内存?什么是本地内存?

  • 主内存:★重要!!★所有线程创建的实例对象都存放在主内存中(感觉这里说的是堆?),不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)

  • 本地内存 :每个线程都有一个私有的本地内存存储共享变量的副本,并且,每个线程只能访问自己的本地内存无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本

Java 内存模型的抽象示意图如下:

ly-20241212141939625 如上,若线程1和线程2之间要通信,则

  1. 线程1把本地内存中修改过的共享变量副本的值,同步到主内存中
  2. 线程2到主存中,读取对应的共享变量的值

即,JMM为共享变量提供了可见性的保障

多线程下,主内存中一个共享变量进行操作引发的线程安全问题

  1. 线程1、2分别对同一个共享变量操作,一个执行修改,一个执行读取
  2. 线程2读取到的是线程1修改之前的还是修改之后的值,不确定

关于主内存和工作内存直接的具体交互协议,即一个变量,如何从主内存拷贝到工作内存如何从工作内存同步到主内存,JMM定义八种同步操作:

锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量

解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定

read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。

load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。

use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。

write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中

下面的同步规则,保证这些同步操作的正确执行: (没看懂)

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。

  • 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。

  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。

  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。

  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。

    ……

Java 内存区域和 JMM 有何区别? #

  • JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程增强程序可移植性的

happens-before 原则是什么? #

  • 通过逻辑时钟对分布式系统中的事件的先后关系进行判断

    逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。

  • JSR 133引入happens-before这个概念来描述两个操作之间的内存可见性

  • 为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。

  • happens-before原则的设计思想

    • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行
    • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序

    ly-20241212141939780

    JSR-133对happens-before原则的定义:

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

    这是 JMM 对程序员强内存模型的承诺。从程序员的角度来说,可以这样理解 Happens-before 关系:如果 A Happens-before B,那么 JMM 将向程序员保证 — A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java内存模型向程序员做出的保证,即Happens-before提供跨线程的内存可见性保证

    对于这条定义,举个例子(不代表代码就是这样的,这是一个概括性的假设情况)

    // 以下操作在线程 A 中执行
    i = 1; // a
    
    // 以下操作在线程 B 中执行
    j = i; // b
    
    // 以下操作在线程 C 中执行
    i = 2; // c
    

    假设线程 A 中的操作 a Happens-before 线程 B 的操作 b,那我们就可以确定操作 b 执行后,变量 j 的值一定是等于 1。

    得出这个结论的依据有两个:一是根据 Happens-before 原则,a 操作的结果对 b 可见,即 “i=1” 的结果可以被观察到;二是线程 C 还没运行,线程 A 操作结束之后没有其他线程会修改变量 i 的值。

    现在再来考虑线程 C,我们依然保持 a Happens-before b ,而 c 出现在 a 和 b 的操作之间,但是 c 与 b 没有 Happens-before 关系,也就是说 b 并不一定能看到 c 的操作结果。那么 b 操作的结果也就是 j 的值就不确定了,可能是 1 也可能是 2,那这段代码就是线程不安全的。

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

    例子:

    int userNum = getUserNum(); 	// 1
    int teacherNum = getTeacherNum();	 // 2
    int totalNum = userNum + teacherNum;	// 3
    

    如上,1 happens-before 2,2 happens-before 3,1 happens-before 3

    虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。

    happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

    举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。

happens-before 常见规则有哪些?谈谈你的理解? #

主要的5条规则:

  1. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作happens-before于书写在后面的操作
  2. 解锁规则:解锁happens-before于加锁
  3. volatile变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
  4. 传递规则:如果A happens-before B,且B happens-before C ,那么A happens-before C
  5. 线程启动规则:Thread对象的start() 方法 happens-before 于此线程的每一个操作

如果两个操作,不满足于上述任何一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序

happens-before 和JMM什么关系 #

  1. 根据happens-before规则,告诉程序员,有哪些happens-before规则哪些情况不会被重排序

    为了避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法,JMM 就出了这么一个简单易懂的 Happens-before 原则,一个 Happens-before 规则就对应于一个或多个编译器和处理器的重排序规则

    • as-if-serial 语义保证单线程内程序的执行结果不被改变,Happens-before 关系保证正确同步的多线程程序的执行结果不被改变
    • as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。Happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 Happens-before 指定的顺序来执行的。
  2. JMM定义的

ly-20241212141939931

再看并发编程三个重要特性 #

原子性,可见性,有序性

  • 原子性 一次操作或多次操作,要么所有的操作,全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行

    Java中,使用synchronized、各种Lock以及各种原子类实现原子性(AtomicInteger等)

    synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。

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

    在 Java 中,可以借助synchronizedvolatile 以及各种 Lock 实现可见性。

    如果我们将变量声明为 volatile ,这就指示 JVM这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

  • 有序性 由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序

    指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

    Java中,volatile关键字可以禁止指令进行重排序优化(注意,synchronized也可以)

总结 #

补充:线程join()方法,导致调用线程暂停,直到xx.join()中的xx线程执行完,调用join方法的线程才继续执行

Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                log.info("暂停5s");
                try {
                    TimeUnit.SECONDS.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread1.start();
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                log.info("暂停3s");
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread2.start();
        thread1.join();
        thread2.join();
        log.info("主线程执行");
/*结果
2022-11-23 13:57:06 下午 [Thread: Thread-1] 
INFO:暂停5s
2022-11-23 13:57:06 下午 [Thread: Thread-2] 
INFO:暂停3s
2022-11-23 13:57:11 下午 [Thread: main] 
INFO:主线程执行
*/

指令重排的影响,举例:【★很重要★】

public class Test {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; ; i++) {
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread other = new Thread(() -> {
                b = 1;
                y = a;
            });
            one.start(); other.start();;
            one.join(); other.join();
            if (x == 0 && y == 0) {
                String result = "第" + i + "次(" + x + ", " + y + ")";
                System.out.println(result);
            }
        }
    }

}
/*

因为线程one中,a和x并不存在依赖关系,因此可能会先执行x=b;而这个时候,b=0。因此x会被赋值为0,而a=1这条语句还没有被执行的时候,线程other先执行了y=a这条语句,这个时候a还是a=0;因此y被赋值为了0。所以存在情况x=0;y=0。这就是指令重排导致的多线程问题。
原文链接:https://blog.csdn.net/qq_45948401/article/details/124973903
*/
  • Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • CPU 可以通过制定缓存一致协议(比如 MESI 协议open in new window)来解决内存缓存不一致性问题。
  • 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
  • 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • JSR 133 引入了 happens-before 这个概念来**(极其重要又精简的话)描述两个操作之间的内存可见性**。