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 关键字可以禁止指令进行重排序优化。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值