七、Java内存模型详解

所谓JMM

JMM,全称 java memory model ,中文翻译为:java内存模型。在了解它之前,首先要对一些知识进行铺垫。

CPU知识普及

线程是CPU调度的最小单位。线程中的字节码都将会在CPU中执行。Java中的所有数据都是存放在主内存中的。 而CPU在执行字节码的过程中免不了要和 主内存打交道。随着CPU技术的发展,CPU的速度越来越快,而内存的读写速度跟不上CPU。

因此为了让CPU的性能最大化,达到高并发的效果。CPU中添加了高速缓存Cache的概念。

执行任务时,CPU会将运算所需数据 从 主内存复制到 高速缓存中,让运算快速进行。当运算完成之后,再将运算结果刷回主内存。这样,在运算过程中就不必这么频繁地与主内存交互了。

看似是提高了CPU的运行效率,但是问题随之而来。

每个处理器都有自己的高速缓存,而他们都在操作同一块主内存。当多个处理器同时操作主内存时,可能导致数据不一致。 这就是 缓存一致性问题

缓存一致性问题

现在的移动设备很多都是 多个CPU,每个CPU还有多核。每个CPU都能在一个时刻运行一个线程。也就意味着,如果你的Java程序是多线程的,就有可能存在 多个线程在同一时刻被不同的cpu执行的情况。 举例说明:

以上代码展示了两个变量 x,y 被两个线程同时操作的情况。如果要在两个线程都执行完毕 之后,打印出r1r2的值,那么结果会有几种呢?

如果p1先执行完毕,而后 p2执行。那么结果就是 r1=0;r2=1; 如果p2先执行完毕,而后 p1执行,那么结果是r1=2;r2=0;

以上两种情况是理想状况,可是现实中,可能有2个cpu c1和c2,分别来执行这两个线程,那么情况就会变得复杂:

p1 中 int r1=x;这句代码在 c1中执行完毕,并且刷回主内存,那么r1就是0. 然后紧接着 c2执行了int r2=y; 那么 r2就是0. 后续不管怎么执行,r1=0;r2=0 的结果是不受影响的.

出现以上多种情况的根本原因,就是CPU在执行完某个字节码之后,它将数据刷新回主内存的时机是不确定的。而 CPU的调度受系统策略的影响,通常不受人为控制。

指令重排

为了使CPU的内部运算单元能够被充分利用,处理器可能对 输入的字节码指令进行重新排序,这也叫 处理器优化。(比如,jvm的即时编译 JIT) 如下图所示代码:

指令重排之后的执行顺序可能就是:

也就是说,在CPU层面,代码并不会严格按照你写的顺序来执行。!!!

如果按照这种思路来看上面r1,r2的例子,那么还有第四种情况:

经过重排的线程p2如上图右边所示,如果按照这个顺序(图中红色 1,2,3,4)来执行的话, 结果就是 r1=2; r2=1;

内存模型的概念

为了解决这种一致性问题,内存模型应运而生。 所谓内存模型 ,就是一套共享内存系统中,多线程读写操作行为的规范。这套规范屏蔽了底层硬件和操作系统的内存访问的差异。解决了CPU多级缓存,CPU优化,指令重排等因素导致的内存访问问题。从而保证java程序,尤其是多线程java程序 在各种平台下对内存访问的结果一致性。

在java内存模型中,CPU的高速缓存,被抽象为:工作内存。每一个线程都有自己的 工作内存.

线程之间的共享数据存储在 主内存中.

先行发生原则

也就是 happends-before 原则。它是 内存模型中最重要的原则。

它用于描述两个操作的内存可见性。通过可见性,让程序免于数据竞争的干扰。

它的定义为:如果一个操作A happends-before 操作B,那么操作A的结果将对B可见

反过来理解,如果想要让操作A的结果对操作B可见,那么必须让操作A happends-before B

举例说明:

以上用 set方法和get方法对value进行读写操作。

很有可能 这两个操作出现在不同的线程中。 那么假设,set 被 线程A 执行, get被线程B执行。那么B的执行结果是多少呢?

结果显然是不确定的。

这里分两个情况

  • set操作 happends-before get操作 set的结果对get可见,此时get的结果为1
  • set操作 没有 happends-before get操作 set的结果对get不可见,此时,get的结果为0

那么如何判定java中的两个操作符合happends-before规则呢?

happends-before 先行发生原则

程序次序原则

单线程内部,如果一段代码的字节码顺序也隐式符合 happends-before原则,那么逻辑靠前的字节码执行结果一定是对逻辑靠后的字节码 可见。

java 复制代码
int a = 10;
b = b + 1 ;

举例说明,如上代码中,单线程中执行了两个操作,int a = 10;以及 b = b + 1;, 按照此原则,a=10 这个结果,对 后面的操作,是可见的,但是,后面的b=b+1; 并没有对a变量有依赖。此时cpu就有可能发生指令重排,将对a有依赖的操作,挪到int a=10; 的后面. 就像下面的:

java 复制代码
int a = 10;
b = b + 1 ; // 原始顺序
a = a + 1 ;

在最终执行字节码时的顺序,可能就变成了:

java 复制代码
int a = 10;
a = a + 1; // 指令被重排之后
b = b + 1;

而如果是这样,b = b + a,直接对a有依赖,则不会指令重排优化:

java 复制代码
int a = 10;
b = b + a ; // 不会指令重排优化
a = a + 1 ; 

锁定规则

如果一个锁,处于被锁定的状态,那么必须先执行 unlock,然后才能执行lock操作。

变量规则

volatile 关键字,保证了线程的可见性,如果一个线程写了 volatile修饰的变量,然后另一个线程去读这个变量,那么结果一定是 写操作 happends-before 读操作。

线程启动规则

线程对象的start方法,先行发生于此线程的每一个动作。 如果线程A,在执行过程中,通过B.start方法启动了线程B, 那么在启动B之前的A对主内存中共享变量的操作操作结果,都对B可见。

解释一下:

java 复制代码
int x = 0;
int y = 1;

new Thread(new Runnable(){
        @override
        run(){
            x = 100;
            y = 200;


            new Thread(new Runnable(){
                    @override
                    run(){
                        System.out.println(x + " - " + y);

                    }
                }
            ).start();

        }
    }
).start();

上面代码中,打印出的x和y的值,一定是在 100 和 200.

线程中断规则

对线程interrupt()方法的调用先行发生于被中断线程的代码检测,直到中断事件的发生。 举例说明:

java 复制代码
public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(new LongRunningTask());
        thread.start();

        // 主线程休眠一段时间后,中断长时间执行的任务
        try {
            Thread.sleep(2000); // 休眠2秒钟
            thread.interrupt(); // 中断线程
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class LongRunningTask implements Runnable {
    @Override
    public void run() {
        try {
            System.out.println("长时间执行的任务开始...");
            for (int i = 0; i < 10; i++) {
                System.out.println("执行第 " + i + " 步");
                Thread.sleep(1000); // 模拟耗时操作

                // 检测线程是否被中断
                if (Thread.currentThread().isInterrupted()) {
                    System.out.println("任务被中断,提前结束1");
                    return;
                }
            }
            System.out.println("长时间执行的任务完成");
        } catch (InterruptedException e) {
            System.out.println("任务被中断,提前结束2");
        }
    }
}

以上代码,模拟了 主线程 中启动了子线程,并且在主线程睡眠一段时间之后中断子线程的情况。 在 先行发生原则的作用下,当主线程调用了 子线程的中断方法interrupt之后,子线程立即就检测到了自己被中断,从而停止了代码执行。

如果没有这个先行发生原则,那么就有可能在 interrupt之后,子线程继续执行一段代码之后才知道自己被中断了,而执行了多少,存在不确定性。

上面代码的打印结果为:

java 复制代码
长时间执行的任务开始...
执行第 0 步
执行第 1 步
任务被中断,提前结束2

线程终结规则

线程中的所有操作,都发生在终止检测之前。

Thread.join()方法是 等待此线程结束。 Thread.isAlive()的返回值表示的是 此线程是否已终止。

如果线程A在执行过程中调用了线程B的join方法,那么A线程会等待B执行完,那么B对 主内存中共享变量的操作就是对A可见的。

这个很好理解。如下代码:

java 复制代码
public class Main {
    static int sharedVariable = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread threadB = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            sharedVariable = 10;
        });

        Thread threadA = new Thread(() -> {
            try {
                System.out.println("线程A开始执行");
                threadB.start();
                threadB.join();
                System.out.println("线程B执行完毕");
                System.out.println("线程A中的 sharedVariable = " + sharedVariable);
                sharedVariable = 200;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        
        System.out.println("主线程开始");
        threadA.start();
        threadA.join();

        System.out.println("主线程结束");
        System.out.println("主线程中的 sharedVariable = " + sharedVariable);
    }
}

有3个线程,main,threadA,thread。 上面的代码描述了,main启动threadA,threadA启动了threadB,并且 A中等待B执行完,main则等待A执行完,最后分批打印 共享变量 sharedVariable 的值。 执行的结果为:

java 复制代码
主线程开始
线程A开始执行
线程B执行完毕
线程A中的 sharedVariable = 10
主线程结束
主线程中的 sharedVariable = 200

虽然有多个线程,但是,B的执行结果对A是可见的,A的执行结果对main是可见的。所以共享变量的结果都是可预测的,没有不确定性。

对象终结原则

一个对象的初始化发生在它的finalized方法之前。

happends-before的可传递性

如果操作A happends-before 操作B,操作B happends-before 操作C,那么操作A 一定 happends-before 操作C。

Java内存模型的应用

上面提到的 happends-before 原则是判断 数据是否存在竞争,线程是否存存在竞争的主要依据。

Java提供了一系列关键字,可以让原本不是 happends-before 的 情况,变得符合 happends-before原则。

volatile

以最简单的 多线程对同一个变量的get set操作为例:

如果没有volatile原则的话,多线程读取 value 的结果是不可控的。 但是一旦给value加上了 volatile ,所有线程对于 value的操作,对后续的线程都是可见的,因为每次操作完了都会将value写入到主内存。

synchronized

同样,同步关键字 synchronized,也能保证对value的操作对后续的线程可见。


总结

JMM(Java Memory Model) java内存模型 的诞生主要是因为 CPU 的 高速缓存 与 主内存的交互,指令重排等特性,会导致多线程执行结果的不可控。

JMM本身是一套规范,这个规范中有一个最重要的 先行发生原则 happends-before。

JMM的运用主要有两个关键字 volatile 和 synchronized 等, 让程序符合 happends-before原则。

相关推荐
时清云33 分钟前
【算法】合并两个有序链表
前端·算法·面试
J老熊5 小时前
Spring Cloud Netflix Eureka 注册中心讲解和案例示范
java·后端·spring·spring cloud·面试·eureka·系统架构
我爱学Python!5 小时前
面试问我LLM中的RAG,秒过!!!
人工智能·面试·llm·prompt·ai大模型·rag·大模型应用
OLDERHARD5 小时前
Java - LeetCode面试经典150题 - 矩阵 (四)
java·leetcode·面试
银氨溶液6 小时前
MySql数据引擎InnoDB引起的锁问题
数据库·mysql·面试·求职
小飞猪Jay19 小时前
C++面试速通宝典——13
jvm·c++·面试
睡觉然后上课1 天前
c基础面试题
c语言·开发语言·c++·面试
xgq1 天前
使用File System Access API 直接读写本地文件
前端·javascript·面试