当前位置: 首页 > article >正文

Java并发编程(4) —— Java 内存模型(JMM)详解

一、CPU缓存一致性问题

1. CPU缓存模型

CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储容量相对就会越小。其中,在多核心的 CPU 里,每个核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。
在这里插入图片描述

2. MESI缓存一致性协议

多核CPU缓存则必然会有缓存与主存之间的一致性的问题,例如在核心1的L1/L2 cache中修改了某项数据但还没写回主存,那么核心2再读取这项数据时则读的旧的错误数据。要想实现缓存一致性,要满足以下两点:

  • 写传播:某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache。
  • 事务的串行化:多个 CPU 核心对一个数据的操作顺序,必须在其他核心看起来顺序是一样的。

基于总线嗅探机制的缓存一致性协议 MESI 就满足上面了这两点。CPU缓存中的每块数据中都有如下其中的一个状态标记,当数据变化时通过总线嗅探监听机制使其它CPU核心感知到并修改缓存数据的状态:
在这里插入图片描述

3. 弱缓存一致性

上述MESI协议虽然可以保证缓存的一致性,但又会影响性能,因此现代计算机中并不是完全遵守。关于这个问题的发展历程如下:

CPU 从单核发展为多核,导致出现了多个核间的缓存一致性问题 --> 为了解决缓存一致性问题,提出了 MESI 协议 --> 完全遵守 MESI 又会给 CPU 带来性能问题 --> CPU 设计者为了提高性能又在cache基础上增加 store buffer 和 invalid queue --> 又导致了缓存的顺序一致性变为了弱缓存一致性 --> 需要缓存的顺序一致性的,就需要软件工程师自己在合适的地方添加内存屏障,volatile 的作用之一就是给虚拟机看让其在对应的指令加入内存屏障。防止cpu级别的重排序,从而避免缓存一致性问题。

因此由于CPU弱缓存一致性的问题,在多线程中,一个线程对于一个共享变量的修改对其它线程可能是不可见的。

二、指令的重排序问题

指令重排序: 在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序会保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

例如如下创建单例对象的代码

uniqueInstance = new Singleton(); 

这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化uniqueInstance
  3. 将 uniqueInstance指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化,从而导致出错。

三、Java 内存模型(JMM)详解

Java是跨平台的,为解决不同平台下上述CPU弱缓存一致性带来的共享变量可见性以及指令的重排序等问题,并且方便程序员更加安全高效地实现多线程编程,Java提供一套内存模型以及并发编程规范以屏蔽系统差异。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile、synchronized、各种 Lock)即可开发出并发安全的程序。

1. Java 内存模型

Java 对内存的抽象模型如下,每个线程都有一块自己的私有内存(也称为工作内存),当线程使用变量时,会把主内存里面的变量复制到工作内存,线程读写变量时操作的是自己工作内存中的变量。线程的工作内存实际上就是对CPU缓存和寄存器的统一抽象。
在这里插入图片描述

为实现线程工作内存与主内存的同步,Java规范在内存模型中定义了以下八种同步操作(了解即可,无需死记硬背):

  • lock(锁定): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • unlock(解锁): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

我们在编写程序代码时使用volatile、synchronized和各种 Lock等关键字即可间接实现这些同步操作来解决前面提到的在多线程中可能会出现的问题。

以如下程序为例:

public class JMMTest {

    private boolean initFlag = false;
//    private volatile boolean initFlag = false;//volatile关键字可保证变量的可见性以及指令的有序性
    public static void main(String[] args) throws InterruptedException {
        JMMTest jmmTest = new JMMTest();
        new Thread(() -> {
            System.out.println("Thread1-start");
            //线程2对flag的修改对线程1不可见,故会陷入死循环
            while (!jmmTest.initFlag){
            }
            System.out.println("Thread1-end");
        }).start();
        Thread.sleep(100);
        new Thread(() -> {
            System.out.println("Thread2-start");
            jmmTest.initFlag = true;
            System.out.println("Thread2-end");
        }).start();
    }
}

当成员变量initFlag没有用volatile修饰时,线程1首先用read操作从主内存中读取initFlag的值,然后用load操作加载到工作内存的副本中,再用use操作使用值后进入循环,轮到线程2执行,也是先read -> load -> use,然后用assign操作将从执行引擎接收到的true值复制给工作内存中的initFlag副本,最后在某个时候用store -> write操作写入主内存。但是此时线程1仍然读取的是其工作内存中的值,因此就陷入了死循环。
在这里插入图片描述

当成员变量initFlag使用volatile修饰后,线程2修改initFlag后会立即写回主内存并且让线程1中的变量副本失效,因此线程1需要从主内存中重新读取最新的值,以此实现了变量的可见性,从而能够退出循环。

在这里插入图片描述

2. 内存屏障

内存屏障表示隔开两个内存同步操作,使其能够有序执行而不被重排序

在这里插入图片描述
在这里插入图片描述

内存屏障只是一种规范,真正落地的实现属于底层的细节,比如volatile的内存屏障底层是通过lock汇编指令实现的。

3. happens-before 原则

happens-before 原则表示程序中某些指令操作必发生在另一些指令操作前面,不允许重排序。

happens-before 原则的设计思想:

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

happens-before 的规则共 8 条,重点了解下面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规则提供的内存可见性保证来编程。

四、并发编程的三个重要特性

1. 原子性

原子性:一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
在 Java 中,可以借助synchronized 、各种 Lock 以及各种原子类实现原子性。
synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。

2. 可见性

可见性:当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
在 Java 中,可以借助synchronized 、volatile 以及各种 Lock 实现可见性。如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

3. 有序性

由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。我们上面讲重排序的时候也提到过:指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。在 Java 中,volatile 关键字可以禁止指令进行重排序优化。


http://www.kler.cn/a/7455.html

相关文章:

  • 如何利用WebSockets实现高效的实时通信应用
  • 六自由度双足机器人运动控制
  • 高亚科技签约美妥维志化工,提升业务协同与项目运营效率
  • 黑盒测试案例设计方法的使用(1)
  • K8S containerd拉取harbor镜像
  • .NET 9 中 IFormFile 的详细使用讲解
  • 勒索软件正在从 Windows 转向 Linux
  • C++ setsockopt() 函数
  • thymeleaf radio 获取及回显
  • 【Python】九十条简单py技巧打包(仅此一次哦)
  • 统一结果封装异常处理
  • 基于Pytorch的可视化工具
  • 手写一个llvm的mem2reg pass
  • chatGPT的未来应用有哪些-ChatGPT对未来工作的影响
  • 网络安全书籍推荐+网络安全面试题合集
  • 2023年五一数学建模竞赛来袭
  • MongoDB 查询文档(1)
  • 浅谈JVM(五):虚拟机栈帧结构
  • go语言for的三种形式
  • CF1748E Yet Another Array Counting Problem
  • 关于CH32F203程序下载方式说明
  • Linux VIM编辑器常用指令
  • ffmpeg关于视频前几秒黑屏的问题解决
  • 多线程的锁策略
  • Python 自动化指南(繁琐工作自动化)第二版:八、输入验证
  • 中间表示- 三地址码