JUC(3)JMM

发布于:2025-09-15 ⋅ 阅读:(15) ⋅ 点赞:(0)

1. 概述

JMM Java Memory Model,它定义了主存(静态变量,成员变量等共享数据)、工作内存(指每个线程私有的数据)抽象概念,它们底层运作是非常复杂的,需要使用CPU寄存器、缓存、硬件内存、CPU指令优化等。(synchronized、volatile等等)
JMM是 Java 语言规范中定义的一套抽象规则和机制,用于规定多线程环境下线程如何以及何时能够看到其他线程对共享变量的修改
就是计算机底层太复杂了,Java提供了关键字等来帮助我们控制,比如是不是要用缓存啊、是不是要指令优化啊等等,屏蔽了我们和操作系统之间的直接操作
JMM体现在以下几个方面:
  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受到cpu缓存的影响
  • 有序性 - 保证指令不会收到cpu指令并行优化的影响

一句话概括:JMM 是 Java 为多线程程序建立的“游戏规则”,它告诉开发者在什么情况下,一个线程对共享变量的修改能被另一个线程看到,从而避免因硬件和编译器优化导致的诡异 bug。

2. 可见性

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
     Thread t = new Thread(()->{
     while(run){
           // ....
       }
     });
     t.start();
     sleep(1);
     run = false; // 线程t不会如预想的停下来
}
t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量 的值,结果永远是旧值
解决方法
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
synchronized既可以保证原子性也可以保证可见性,但是synchronized的基于Monitor来实现的,比较重量级,而且volatile比较轻量,但只保证可见性,不保证原子性
  • System.out 是什么?

    • System.out 是一个 PrintStream 类型的静态变量,代表标准输出流。
    • println() 方法是 PrintStream 类的方法。
  • 为什么看起来是原子的?

    • PrintStream 类的大多数输出方法(包括 println)都被声明为 synchronized
    • 所以System.out.println();既保证了原子性,也保证了可见性

简单来说,多个线程访问同一个共享变量,一定要加上volatile,保证多线程的可见性,保证每个线程看到的都是最新的结果

3. 有序性

指令重排是JIT编译器在运行时的一些优化

在单线程中,指令重排不会影响正确性,但在多线程会影响正确性

现代CPU拥有多个执行单元(如整数运算单元、浮点运算单元、加载/存储单元等),可以同时执行多条不同的指令。

  • 问题:如果指令严格按照代码顺序执行,可能会出现“等待”情况。

现代CPU采用指令流水线技术,将一条指令的执行分为取指、译码、执行、访存、写回等多个阶段。理想情况下,每个时钟周期都能完成一条指令。

举个例子:

CPU也是同理,指令重排能提高并发度,也就是提供吞吐量

多线程下指令重排出现的问题:

I_Result是一个对象,有一个属性r1用来保存结果,问,有几种可能?

1或4或0(指令重排导致,先ready = true 再r.r1 = num+num再num = 2)

怎么避免指令重排现象?

在ready前面加上volatile关键字,能保证ready之前的代码都不会出现指令重排的现象,这是使用了写屏障技术

4. volatile实现原理

可见性的保证

有序性的保证

volatile的内存屏障技术,既保证可见性,又保证有序性

5. DCL(double-checked locking

synchronized保证有序性只是保证执行被修饰的临界区代码块多线程必须串行执行,但并不能禁止内部进行指令重排序.
且前提条件是: 必须将共享变量完全交给synchronized来管理,双重锁这里就是将共享变量暴露在了synchronized外了,再加上指令重排序,所产生的问题

这段代码的问题是,因为new这个操作不是原子性的,会导致指令重排现象,也就是说,t2拿到的INSTANCE对象可能只是一个半初始化对象,这个时候直接return是有问题的

在INSTANCE前面加上volatile,依然是使用内存屏障技术,可以避免指令重排现象

6. happens-before

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() t1.join()等待 它结束)
  • 线程 t1 打断 t2interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted
  • 对变量默认值(0falsenull)的写,对其它线程对该变量的读可见
  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子:
volatile static int x;
static int y;
new Thread(()->{ 
     y = 10;
     x = 20;
},"t1").start();
new Thread(()->{
     // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
    // 因为volatile写屏障技术,不会把x之前的代码放到现在,既然你x都可见了,那么前面的y也可见
     System.out.println(x); 
},"t2").start();

7. 练习题

1. 单例模式

// 问题1:为什么加 final
// 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
public final class Singleton implements Serializable {
     // 问题3:为什么设置为私有? 是否能防止反射创建新的实例?
     private Singleton() {}
     // 问题4:这样初始化是否能保证单例对象创建时的线程安全?
     private static final Singleton INSTANCE = new Singleton();
     // 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由
     public static Singleton getInstance() {
         return INSTANCE;
     }
     public Object readResolve() {
         return INSTANCE;
     }
}
  • 问题1:不允许有子类继承它,重写修改它,保证了单例
  • 问题2:因为在反序列化的时候,会创建一个新的Singleton,这样就出现了两个Singleton,不是单例了,可以使用readResolve方法直接return,这样就不会创建一个新的了
  • 问题3:不能,可以暴力反射
  • 问题4:可以,因为有static变量,我们知道static变量是在类加载的时候赋值的,JVM会保证它的原子性
  • 问题5:使用方法会有更好的封装性,可以内部实现一些其他初始化操作,可以对创建单例时有更多的控制,定义泛型等

实现2

问题1: 枚举类里面其实就是静态成员变量
问题2: 不会,静态成员变量都是在类加载阶段完成的,由JVM保证线程安全
问题3: 枚举单例不能用反射来破坏单例
问题4: 也可以防反序列化破坏单例
问题5: 枚举也是静态成员变量.所以枚举也是饿汉式
问题6: 再写一些构造方法等等来写初始化的一些逻辑

实现3

静态内部类(类加载是懒惰性加载,只有调用到getInstance()时才会触发类加载内部类),在类加载时,JVM保证原子性