内存

内存

1. JMM

1.1 内存模型

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

JMM 作用:

  • 屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
  • 规定了线程和内存之间的一些关系

根据 JMM 的设计,系统存在一个主内存(Main Memory),Java 中所有变量都存储在主存中,对于所有线程都是共享的;每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量;线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成

image-20240124163819760

主内存和工作内存:

  • 主内存:计算机的内存,也就是经常提到的 8G 内存,16G 内存,存储所有共享变量的值
  • 工作内存:存储该线程使用到的共享变量在主内存的的值的副本拷贝

JVM 和 JMM 之间的关系:JMM 中的主内存、工作内存与 JVM 中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来:

  • 主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域
  • 从更低层次上说,主内存直接对应于物理硬件的内存,工作内存对应寄存器和高速缓存

1.2 内存交互

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作,每个操作都是原子

非原子协定:没有被 volatile 修饰的 long、double 外,默认按照两次 32 位的操作

image-20240124164334444

  • lock:作用于主内存,将一个变量标识为被一个线程独占状态(对应 monitorenter)
  • unclock:作用于主内存,将一个变量从独占状态释放出来,释放后的变量才可以被其他线程锁定(对应 monitorexit)
  • read:作用于主内存,把一个变量的值从主内存传输到工作内存中
  • load:作用于工作内存,在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
  • use:作用于工作内存,把工作内存中一个变量的值传递给执行引擎,每当遇到一个使用到变量的操作时都要使用该指令
  • assign:作用于工作内存,把从执行引擎接收到的一个值赋给工作内存的变量
  • store:作用于工作内存,把工作内存的一个变量的值传送到主内存中
  • write:作用于主内存,在 store 之后执行,把 store 得到的值放入主内存的变量中

1.3 三大特性

1.3.1 可见性

可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

存在不可见问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的。但是 final 修饰的变量是不可变的,就算有缓存,也不会存在不可见的问题

main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

static boolean run = true;  // 添加volatile
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
        // ....
        }
    });
    t.start();
    sleep(1);
    run = false; // 线程t不会如预想的停下来
}

原因:

  • 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
  • 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
  • 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

image-20240124165004524

1.3.2 原子性

原子性:不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被分割,需要具体完成,要么同时成功,要么同时失败,保证指令不会受到线程上下文切换的影响

定义原子操作的使用规则:

  1. 不允许 read 和 load、store 和 write 操作之一单独出现,必须顺序执行,但是不要求连续
  2. 不允许一个线程丢弃 assign 操作,必须同步回主存
  3. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步会主内存中
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(assign 或者 load)的变量,即对一个变量实施 use 和 store 操作之前,必须先自行 assign 和 load 操作
  5. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁,lock 和 unlock 必须成对出现
  6. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新从主存加载
  7. 如果一个变量事先没有被 lock 操作锁定,则不允许执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量
  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)

1.3.3 有序性

有序性:在本线程内观察,所有操作都是有序的;在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序

CPU 的基本工作是执行存储的指令序列,即程序,程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程,为了提高性能,编译器和处理器会对指令重排,一般分为以下三种:

源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令

现代 CPU 支持多级指令流水线,几乎所有的冯•诺伊曼型计算机的 CPU,其工作都可以分为 5 个阶段:取指令、指令译码、执行指令、访存取数和结果写回,可以称之为五级指令流水线。CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(每个线程不同的阶段),本质上流水线技术并不能缩短单条指令的执行时间,但变相地提高了指令地吞吐率

处理器在进行重排序时,必须要考虑指令之间的数据依赖性

  • 单线程环境也存在指令重排,由于存在依赖性,最终执行结果和代码顺序的结果一致
  • 多线程环境中线程交替执行,由于编译器优化重排,会获取其他线程处在不同阶段的指令同时执行

补充知识:

  • 指令周期是取出一条指令并执行这条指令的时间,一般由若干个机器周期组成
  • 机器周期也称为 CPU 周期,一条指令的执行过程划分为若干个阶段(如取指、译码、执行等),每一阶段完成一个基本操作,完成一个基本操作所需要的时间称为机器周期
  • 振荡周期指周期性信号作周期性重复变化的时间间隔

2. volatile

2.1 同步机制

volatile 是 Java 虚拟机提供的轻量级的同步机制(三大特性)

  • 保证可见性
  • 不保证原子性
  • 保证有序性(禁止指令重排)

性能:volatile 修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小

synchronized 无法禁止指令重排和处理器优化,为什么可以保证有序性可见性

  • 加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的
  • 线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中(JMM 内存交互章节有讲)

2.2 指令重排

volatile 修饰的变量,可以禁用指令重排

指令重排实例:

  • example 1:

    public void mySort() {
    int x = 11; //语句1
    int y = 12; //语句2  谁先执行效果一样
    x = x + 5;  //语句3
    y = x * x;  //语句4
    }

    执行顺序是:1 2 3 4、2 1 3 4、1 3 2 4

    指令重排也有限制不会出现:4321,语句 4 需要依赖于 y 以及 x 的申明,因为存在数据依赖,无法首先执行

  • example 2:

    int num = 0;
    boolean ready = false;
    // 线程1 执行此方法
    public void actor1(I_Result r) {
      if(ready) {
        r.r1 = num + num;
      } else {
        r.r1 = 1;
      }
    }
    // 线程2 执行此方法
    public void actor2(I_Result r) {
    num = 2;
    ready = true;
    }

    情况一:线程 1 先执行,ready = false,结果为 r.r1 = 1

    情况二:线程 2 先执行 num = 2,但还没执行 ready = true,线程 1 执行,结果为 r.r1 = 1

    情况三:线程 2 先执行 ready = true,线程 1 执行,进入 if 分支结果为 r.r1 = 4

    情况四:线程 2 执行 ready = true,切换到线程 1,进入 if 分支为 r.r1 = 0,再切回线程 2 执行 num = 2,发生指令重排

2.3 底层原理

2.3.1 缓存一致

使用 volatile 修饰的共享变量,底层通过汇编 lock 前缀指令进行缓存锁定,在线程修改完共享变量后写回主存,其他的 CPU 核心上运行的线程通过 CPU 总线嗅探机制会修改其共享变量为失效状态,读取时会重新从主内存中读取最新的数据

lock 前缀指令就相当于内存屏障,Memory Barrier(Memory Fence)

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

内存屏障有三个作用:

  • 确保对内存的读-改-写操作原子执行
  • 阻止屏障两侧的指令重排序
  • 强制把缓存中的脏数据写回主内存,让缓存行中相应的数据失效

2.3.2 内存屏障

保证可见性

  • 写屏障(sfence,Store Barrier)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

    public void actor2(I_Result r) {
      num = 2;
      ready = true; // ready 是 volatile 赋值带写屏障
      // 写屏障
    }
  • 读屏障(lfence,Load Barrier)保证在该屏障之后的,对共享变量的读取,从主存刷新变量值,加载的是主存中最新数据

    public void actor1(I_Result r) {
      // 读屏障
      // ready 是 volatile 读取值带读屏障
      if(ready) {
        r.r1 = num + num;
      } else {
        r.r1 = 1;
      }
    }

    image-20240124171645110

  • 全能屏障:mfence(modify/mix Barrier),兼具 sfence 和 lfence 的功能

保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其他线程的读跑到写屏障之前

  • 有序性的保证也只是保证了本线程内相关代码不被重排序

    volatile i = 0;
    new Thread(() -> {i++});
    new Thread(() -> {i--});

    i++ 反编译后的指令:

    0: iconst_1           // 当int取值 -1~5 时,JVM采用iconst指令将常量压入栈中
    1: istore_1           // 将操作数栈顶数据弹出,存入局部变量表的 slot 1
    2: iinc       1, 1    

    image-20240124172252211

2.3.3 交互控制

对于 volatile 修饰的变量:

  • 线程对变量的 use 与 load、read 操作是相关联的,所以变量使用前必须先从主存加载
  • 线程对变量的 assign 与 store、write 操作是相关联的,所以变量使用后必须同步至主存
  • 线程 1 和线程 2 谁先对变量执行 read 操作,就会先进行 write 操作,防止指令重排

2.4 双端检锁

2.4.1 检锁机制

Double-Checked Locking:双端检锁机制

DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入 volatile 可以禁止指令重排

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;

    public static Singleton getInstance() {
        if(INSTANCE == null) { // t2,这里的判断不是线程安全的
            // 首次访问会同步,而之后的使用没有 synchronized
            synchronized(Singleton.class) {
                // 这里是线程安全的判断,防止其他线程在当前线程等待锁的期间完成了初始化
                if (INSTANCE == null) { 
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

不锁 INSTANCE 的原因:

  • INSTANCE 要重新赋值
  • INSTANCE 是 null,线程加锁之前需要获取对象的引用,设置对象头,null 没有引用

实现特点:

  • 懒惰初始化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 第一个 if 使用了 INSTANCE 变量,是在同步块之外,但在多线程环境下会产生问题

2.4.2 DCL 问题

getInstance 方法对应的字节码为:

0:  getstatic       #2      // Field INSTANCE:Ltest/Singleton;
3:  ifnonnull       37
6:  ldc             #3      // class test/Singleton
8:  dup
9:  astore_0
10: monitorenter
11: getstatic       #2      // Field INSTANCE:Ltest/Singleton;
14: ifnonnull 27
17: new             #3      // class test/Singleton
20: dup
21: invokespecial   #4      // Method "<init>":()V
24: putstatic       #2      // Field INSTANCE:Ltest/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic       #2      // Field INSTANCE:Ltest/Singleton;
40: areturn
  • 17 表示创建对象,将对象引用入栈
  • 20 表示复制一份对象引用,引用地址
  • 21 表示利用一个对象引用,调用构造方法初始化对象
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

步骤 21 和 24 之间不存在数据依赖关系,而且无论重排前后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的

  • 关键在于 0:getstatic 这行代码在 monitor 控制之外,可以越过 monitor 读取 INSTANCE 变量的值
  • 当其他线程访问 INSTANCE 不为 null 时,由于 INSTANCE 实例未必已初始化,那么 t2 拿到的是将是一个未初始化完毕的单例返回,这就造成了线程安全的问题

image-20240124172912966

2.4.3 解决方法

指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性

引入 volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性:

private static volatile SingletonDemo INSTANCE = null;

3. happens-before

happens-before 先行发生

Java 内存模型具备一些先天的“有序性”,即不需要通过任何同步手段(volatile、synchronized 等)就能够得到保证的安全,这个通常也称为 happens-before 原则,它是可见性与有序性的一套规则总结

不符合 happens-before 规则,JMM 并不能保证一个线程的可见性和有序性

  1. 程序次序规则 (Program Order Rule):一个线程内,逻辑上书写在前面的操作先行发生于书写在后面的操作 ,因为多个操作之间有先后依赖关系,则不允许对这些操作进行重排序

  2. 锁定规则 (Monitor Lock Rule):一个 unlock 操作先行发生于后面(时间的先后)对同一个锁的 lock 操作,所以线程解锁 m 之前对变量的写(解锁前会刷新到主内存中),对于接下来对 m 加锁的其它线程对该变量的读可见

  3. volatile 变量规则 (Volatile Variable Rule):对 volatile 变量的写操作先行发生于后面对这个变量的读

  4. 传递规则 (Transitivity):具有传递性,如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C

  5. 线程启动规则 (Thread Start Rule):Thread 对象的 start()方 法先行发生于此线程中的每一个操作

    static int x = 10;//线程 start 前对变量的写,对该线程开始后对该变量的读可见
    new Thread(()->{ System.out.println(x);  },"t1").start();
  6. 线程中断规则 (Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生

  7. 线程终止规则 (Thread Termination Rule):线程中所有的操作都先行发生于线程的终止检测,可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行

  8. 对象终结规则(Finaizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始

🌟 如果您喜欢我的文章,欢迎赞赏支持,您的支持是我创作的最大动力!🌟
🖋 作者:Enndfp
🔗链接:https://blog.enndfp.cn
📜版权声明:您可以自由转载,但请务必注明原文地址,感谢您的尊重与支持~
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇