3_Java内存模型结构

Java 内存模型 JMM

1. JMM 的简单概述

JMM 关注的问题是多线程并发访问的原子性,有序性,可见性,原子性再上一章已经重点讲过了,本章重点关注有序性和可见性

JMM即 Java Memory Model, 从Java 的层面定义了主存和工作内存的抽象概念,底层对应着CPU寄存器,缓存,硬件内存,CPU指令优化等等

JMM主要体现在如下几个方面:

  • 原子性:保证指令不会受到线程上下文切换的影响
  • 可见性:保证指令不会受到CPU缓存的影响
  • 有序性:保证指令的执行不会受到CPU指令并行优化的影响

2. 可见性

2.1 退不出的循环问题

先看一个现象,main线程对run变量的修改对于t线程不可见,导致t线程无法停止:

package org.example.a32;

public class Demo1 {
    static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
           while (run){

           }
        });
        t.start();
        Thread.sleep(1000);
        System.out.println("主线程想要停止t线程的运行");
        run = false;    // t线程不会预想的停下来
    }
}

我们发现t线程并没有停止下来,这是为什么呢?接下来我们从JMM的结构出发分析一下:

Java 内存模型将内存划分为了CPU Cahe和工作内存,其中主内存为所有线程共享的内存,而工作内存是不同线程私有的运行时内存。

  1. 初始状态,t线程刚开始从主存里读取到run变量的值到t线程自己的工作内存中

    image-20240718104313475

  2. 以为t线程需要频繁的从主存中读取run值,JIT编译器就会将run的值缓存至自己的工作内存中的高速缓存中,以减少对主存run的访问,提高效率

    image-20240718105425856

  3. 1s后,main线程修改了run的值,并同步到了主存中,但是t线程并不知道这个最新结果,因为t线程读取的run一直就是从自己的工作内存中读取的,这个结果永远都不是最新值

解决办法

可以给共享变量加上volatile关键字,凡是加了volatile关键字的变量表示每次取值不会从工作内存中的缓存中读取,而是从CPU高速缓存中读取值。

package org.example.a32;

public class Demo1 {
    static volatile boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
           while (run){

           }
        });
        t.start();
        Thread.sleep(1000);
        System.out.println("主线程想要停止t线程的运行");
        run = false;    // t线程不会预想的停下来
    }
}

运行结果如下:

主线程想要停止t线程的运行

除了使用volatile关键字,使用synchronized关键字也可以保证变量的可见性。

package org.example.a32;

public class Demo2 {
    static boolean run = true;
    // 准备一个锁对象
    static final Object lock = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
           while (run){
               synchronized (lock){
                   if (!run){
                       break;
                   }
               }
           }
        });
        t.start();
        Thread.sleep(1000);
        System.out.println("主线程想要停止t线程的运行");
        run = false;    // t线程不会预想的停下来
    }
}

运行结果如下:

主线程想要停止t线程的运行

2.2 可见性 VS 原子性

前面额例子体现的是可见性问题,它是保证多个线程之间,一个线程对volatile变量的修改对另一个线程的可见性,但不能保证原子性,仅用在一个写线程,多个读线程的情况。

接下来,我们分析一下使用volatile关键字解决可见性问题下原理,我们首先使用javap -v -c Target.class命令反编译一下该字节码,并找出其中的核心代码:

getstatic		run			// 线程 t 获取 run true
getstatic		run			// 线程 t 获取 run true
getstatic		run			// 线程 t 获取 run true
putstatic		run			// 线程 main 修改 run false,仅此一次
getstatic		run			// 线程 t 获取 run 

比较一下之前我们举i++ 和 i-- 的例子,只能保证看到最新值,不能解决指令集交错的问题,那这里能使用volatile关键字来修饰吗?不行!volatile只能解决看到值的最新值,并不能解决指令交错问题!

// 假设 i 初始值为0
getstatic		i	//线程2-获取静态变量i的值,线程内i=0
getstatic		i	//线程1-获取静态变量i的值,线程内i=0
iconst_1			//线程1-准备常量1
iadd				//线程1-自增 线程内i=1
putstatic		i	// 线程i将修改后的值存到静态变量i中 静态变量i=2

iconst_1			//线程2-准备常量1
isub				//线程2-自减 线程内i=1
putstatic		i	//线程2将修改后的值存到静态变量i中 静态变量i=1

注意:

sunchronized 既可以保证原子性,也可以保证可见性,但缺点就是synchronized操作是重量级锁,性能相对较低。

如果在前面的代码的死循环中加入System.out.println()方法会发现即使不加volatile修饰符,线程t也能正确看到run修改的变化,这是因为print方法的底层使用了synchronized关键字来修饰

2.3 终止模式之两阶段终止模式

在一个线程T1中如何优雅的终止线程T2,这里的【优雅】指的是给T2一个料理后事的机会

2.3.1 错误思路

  • 使用线程对象的stop方法停止运行线程
  • stop方法会真正杀死线程,如果这个线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁了,其它线程将永远无法获取到锁
  • 使用 System.exit(int)方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序都会停止

2.3.2 使用 valatile 来解决

package org.example.a32;

class TwoPhaseTermination{
    // 监控线程
    private Thread monitorThread;
    // stop == true ==> 退出循环
    private volatile boolean stop = false;


    // 启动监控线程
    public void start(){
        monitorThread = new Thread(()->{
           while (true){
               Thread current = Thread.currentThread();
               // 是否被打断
               if (stop){
                   System.out.println("料理后事");
                   break;
               }
               try{
                   Thread.sleep(1000);
                   System.out.println("执行监控记录");
               } catch (InterruptedException e) {
               }
           }
        },"monitor");
        monitorThread.start();
    }

    // 停止监控程序
    public void stop(){
        stop = true;
    }
}

2.4 同步模式之Balking(犹豫)

package org.example.a32;

public class Demo4 {

}

class TwoPhaseTermination{
    // 监控线程
    private Thread monitorThread;
    // stop == true ==> 退出循环
    private volatile boolean stop = false;

    private boolean hasStarting = false;    //表示监控程序是否已经开启


    // 启动监控线程
    public void start(){
        synchronized (this){
            if (hasStarting){
                return;
            }

            hasStarting = true;
        }
        monitorThread = new Thread(()->{
           while (true){
               Thread current = Thread.currentThread();
               // 是否被打断
               if (stop){
                   System.out.println("料理后事");
                   break;
               }
               try{
                   Thread.sleep(1000);
                   System.out.println("执行监控记录");
               } catch (InterruptedException e) {
               }
           }
        },"monitor");
        monitorThread.start();
    }

    // 停止监控程序
    public void stop(){
        stop = true;
    }
}

由于模式是用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做这件事情了,直接结束返回,如上面的代码。

3. 有序性

3.1 指令重排序(CPU角度分析)

JVM会在不影响正确性的前提前,可以调整语句的执行顺序,思考下面一段代码

static int i;
static int j;

// 在某个线程内执行如下赋值操作
i=...;
j=...;

可以看到,至于先执行i,还是限制性j,队最终的结果都不会产生影响。我们把JVM调节指令的顺序的特性称之为指令重排,多线程指令重排会影响正确性。为什么会有指令重排?这里从CPU指令指令的原理来理解一下:

在计算机组成原理中,CPU执行一条指令的步骤大致如下:

  1. 取指令
  2. 对取出来的指令进行译码
  3. 执行指令
  4. 内存访问
  5. 数据回写
  6. 中断处理(可选)

image-20240718140144613

如果一条指令的指令是以穿行的方式执行,如上面所示,则效率会很低,因为有很多CPU时间都浪费了。为此CPU将多个指令的不同部分一起来执行,这样就提供了在指令级别的并发度,这也就是指令流水线技术,如下图所示:

image-20240718140504142

在最理想的情况下,在单位时间内会执行一整条指令(IPC=1)。当然,指令重排序的前提是不能影响到结果,例如:

// 可以重排序的例子
int a = 10;
int b = 20;
System.out.println(a+b);

// 不能重排序的例子
int a = 10;
int b = a - 5;

3.2 禁用指令重排序

禁用指令重排序很简单,那就是在变量上加入 volatile关键字。

3.3 volatile 的原理

volatile 可以保证共享变量的可见性和有序性(禁用指令排序),它的底层是如何实现了?

volatile 底层的原理是内存屏障,Memory Barrier

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

3.3.1 如何保证可见性

写屏障保证的是在该屏障之前,对共享变量的改动,都同步到主存中,如下面的代码:

boolean volatile ready = false;

public void actor2(I_Result r){
    num = 2;
    ready = true;	// ready 是 volatile 修饰的
    // 写屏障
}

写屏障保护的是之前的代码,也就是说会在 volatile 修饰变量的赋值操作之前的所有改动(写屏障之前的所有改动)都会同步到主存中。

读屏障保护的是之后的代码,也就是说保障的是读屏障之后的代码,对共享变量的读取加载的是主存中最新的数据,如下面的例子

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

image-20240718150946717

3.3.2 如何保证有序性

  • 写屏障会确保指令重排时,不会将写屏障之前的代码排在写屏障之后
boolean volatile ready = false;

public void actor2(I_Result r){
    num = 2;
    ready = true;	// ready 是 volatile 修饰的
    // 写屏障
}
  • 读屏障会确保指令重排时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r){
    // 读屏障
    if(ready){	// ready 是 volatile 修饰
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

image-20240718151636466

读写屏障只能保证共享资源在多线程环境下的有序性和可见性,但仍不能保证原子性,这是因为读写屏障不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它的前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

image-20240718153135748

#### 3.3.3 double-checked-locking 问题

以著名的 double-checked-locking 单例模式为例:

public final Singleton{
    private Singleton(){}
    private static Singleton INSTANCE = null;
    public static Singleton() getInstance(){
        // 首次访问会同步,而之后的使用就不会进入 synchronized
        if(INSTANCE == null){
            synchronized(Singleton.class){
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
        	}
        }
        return INSTANCE;
    }
}

以上实现的特点:

  • 懒惰实例化
  • 首次使用getInstance()方法才会使用 synchronized 关键字加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个if使用了INSTANCE变量,是在同步块之外

但在多线程条件下,上面的代码是有问题的,getInstance 方法对应的字节码为:

image-20240718155159738

其中:

  • 17表示创建对象,将对象引用入栈 // new Singleton
  • 20表示复制一份对象引用 // 引用地址
  • 21表示利用一个引用对象,调用构造方法
  • 24表示利用一个对象引用,赋值给static INSTANCE

也许Java虚拟机会优化为:先执行24,再执行21.如果两个线程t1, t2按如下时间序列运行:

image-20240718155616829

关键在于0:getstatic这行代码在monitor控制之外,它就像之前举例中不守规则的人,可以越过monitor读取NSTANCE变量的值

这时t1还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么2拿到的是将是一个未初始化完毕的单例

对NSTANCE使用volatile修饰即可,可以禁用指令重排,但要注意在JDK5以上的版本的volatile才会真正有效

3.3.4 double-checked-locking 问题的解决

public final Singleton{
    private Singleton(){}
    private static volatile Singleton INSTANCE = null;
    public static Singleton() getInstance(){
        // 首次访问会同步,而之后的使用就不会进入 synchronized
        if(INSTANCE == null){
            synchronized(Singleton.class){
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
        	}
        }
        return INSTANCE;
    }
}

但是字节码上看不出来 volatile 关键字的效果!

image-20240718160647231

image-20240718161352083

如上面的注释内容所示,读写volatile变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  • 可见性
    • 写屏障(sfence)保证在该屏障之前的tl对共享变量的改动,都同步到主存当中
    • 而读屏障(lfence)保证在该屏障之后t2对共享变量的读取,加载的是主存中最新数据
  • 有序性
    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  • 更底层是读写变量时使用1ock指令来多核CPU之间的可见性与有序性(总线嗅探技术)

3.4 happens-before 规则

happens-.before规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的对具有可见性
static int x;
static object m new object();

new Thread(()->{
    synchronized(m){
    	x=10;
    }
},"t1").start();

new Thread(()->{
    synchronized(m){
    	System.out.println(x);
    }
},"t2").start();
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;

new Thread(()->{
    x = 10;
},"t1").start();


new Thread(()->{
    System.out.println(x);
},"t2").start();    
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;
new Thread(()->{
    System.out.println(x);
},"t1").start();
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用tl.isAlive(0或tl.join0等待它结束)
static int x;
Thread t1 = new Thread(()->{
    x = 10;
},"t1");
t1.start();

t1.join();
System.out.println(x);

  • 线程tl打断t2(interrupt)前对变量的写,对于其他线程得知2被打断后对变量的读可见(通过t2.interrupted t2.isInterrupted)
static int x;
public static void main(String[] args){
    Thread te = new Thread(()->{
        while(true){
            if(Thread.currentThread().interrupted()){
            	System.out.println(x);
            	break;
        	}
        }
    },"t2");
    t2.start();
    
    new Thread(()->{
        sleep(1);
        x = 10;
        t2.interrupted()
    },"t1").start();
    
    while(t2.isInterrupted()){
        Thread.yield();	// 主线程空循环 
    }
    System.out.println(x);
}
  • 对变量默认值(0, false, null)的写,对其它线程来讲该变量的读是可见的
posted @   LilyFlower  阅读(11)  评论(0编辑  收藏  举报
编辑推荐:
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示