JVM技术之旅-带你吃透JMM知识体系

每日一句

为了生活中努力发挥自己的作用,热爱人生吧。 —— 罗丹

前体概要

CPU和内存之间同样存在一致性问题。很多人认为CPU是一个计算组件,并没有数据一致性的问题但事实上,由于内存的发展速度跟不上CPU的更新,在CPU和内存之间,存在着多层的高速缓存。

原因就是由于多核所引起的,这些高速缓存,往往会有多层。如果一个线程的时间片跨越了多个CPU,那么同样存在同步的问题

import java.util.stream.IntStream;
public class JMMDemo {
    int value = 0;
    void add() {
        value++;
    }
    public static void main(String[] args) throws Exception {
        final int count = 100000;
        final JMMDemo demo = new JMMDemo();
        Thread t1 = new Thread(() -> IntStream.range(0, count).forEach((i) -> demo.add()));
        Thread t2 = new Thread(() -> IntStream.range(0, count).forEach((i) -> demo.add()));
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(demo.value);
复制代码
void add();
    descriptor: ()V
    flags:
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field value:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field value:I
        10: return
      LineNumberTable:
        line 7: 0
        line 8: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   LJMMDemo;
复制代码

另外,在执行过程中,CPU 可能也会对输入的代码进行乱序执行优化,Java 虚拟机的即时编译器也有类似的指令重排序优化,缓存内存机制的优化。整个函数的执行步骤就分的更加细致,看起来非常的碎片化(比字节码指令要细很多),不管是字节码的原因,还是硬件的原因,在粗粒度上简化来看,比较浅显且明显的因素,那就是线程 add 方法的操作并不是原子性的。

image.png

为了解决这个问题,我们可以在add方法上添加synchronized关键字,它不仅保证了内存上的同步,而且还保证了CPU的同步。这个时候,各个线程只能排队进入add方法

synchronized void add() {
    value++;
}
复制代码

image.png

着重看一下 add 方法,可以看到一个简单的 i++ 操作,竟然有这么多的字节码,而它们都是傻乎乎按照“顺序执行”的。当它自己执行的时候不会有什么问题,但是如果放在多线程环境中,执行顺序就变得不可预料了。

24195226-7f2463d15e5e7182.png

由上图可以看出来CPU私有人Cache只有L1和L2,L3和L4(新版支持),都属于公用的。

问题分析

  • 1.(可见性问题+原子性问题)上图展示了这个乱序的过程。线程 A 和线程 B “并发”执行相同的代码块 add,执行的顺序如图中的标号,它们在线程中是有序的(1、2、5 或者 3、4、6),但整体顺序是不可预测的。

  • 2.(可见性问题)线程 A 和 B 各自执行了一次加 1 操作,但在这种场景中,线程 B 的 putfield 指令直接覆盖了线程 A 的值,最终 value 的结果是 101。

  • 3.(可见性问题)上面的示例仅仅是字节码层面上的,更加复杂的是,CPU 和内存之间同样存在一致性问题。很多人认为 CPU 是一个计算组件,并没有数据一致性的问题。但事实上,由于内存的发展速度跟不上CPU的更新,在CPU和内存之间,存在着多层的高速缓存,原因就是由于多核所引起的,这些高速缓存,往往会有多层。如果一个线程的时间片跨越了多个 CPU,那么同样存在同步的问题。

  • 4.(有序性问题)另外,在执行过程中,CPU 可能也会对输入的代码进行乱序执行优化,Java 虚拟机的即时编译器也有类似的指令重排序优化。整个函数的执行步骤就分的更加细致,看起来非常的碎片化(比字节码指令要细很多)。

并发场景

  • 单线程:CPU 核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题

  • 单核 CPU,多线程:进程中的多个线程会同时访问进程中的共享数据,CPU 将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效

  • 多核 CPU,多线程:每个核都至少有一个 L1 缓存 和 L2 缓存 。 多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的Cache中保留一份共享内存的缓冲,由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的 Cache 之间的数据就有可能不同

另一种情况(缺乏标准化和统一化)

  1. 在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,换了个系统就出现各种问题;

  2. 这是因为不同的处理器,在处理器优化和指令重排等方面存在差异,造成同样的代码,在经过不同的处理器优化和指令重排后,最后执行出来的结果可能不同,这是我们所不能接受的。

  3. JMM就应运而生了,究其根本就是为了解决在并发环境下,保证数据的安全,满足场景的可见性、原子性、有序性

基本概念

  • JMM是一个抽象的概念,它描述了一系列的规则或者规范,用来解决多线程的共享变量问题,比如 volatile、synchronized 等关键字就是围绕 JMM 的语法这里所说的变量,包括实例字段、静态字段,但不包括局部变量和方法参数,因为后者是线程私有的,不存在竞争问题。

  • JMM试图定义一种统一的内存模型,能将各种底层硬件,以及操作系统的内存访问差异进行封装,使Java程序在不同硬件及操作系统上都能达到相同的并发效果

JMM规定了共享变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存保存了主内存的副本拷贝,对变量的操作在工作内存中进行,不能直接操作主内存中的变量。不同线程间无法直接访问对方的工作内存变量,需要通过主内存完成。

JMM的结构

JMM分为主存储器(Main Memory)和工作存储器(Working Memory)两种

  • 主存储器是实例位置所在的区域,所有的实例都存在于主存储器内。比如,实例所拥有的字段即位于主存储器内,主存储器是所有的线程所共享的
  • 工作存储器是线程所拥有的作业区,每个线程都有其专用的工作存储器。工作存储器存有主存储器中必要部分的拷贝,称之为工作拷贝(Working Copy)

在这个模型中,线程无法对主存储器直接进行操作。如下图,线程A想要和线程B通信,只能通过主存进行交换。

b74a8efb53744cdf9aab1efe7ba3bd25_tplv-k3u1fbpfcp-watermark.jpg

  • 这些内存区域都是在哪存储的呢?如果非要有个对应的话,你可以认为主存中的内容是Java堆中的对象,而工作内存对应的是虚拟机栈中的内容
  • 但实际上,主内存也可能存在于高速缓存,或者CPU的寄存器上;工作内存也可能存在于硬件内存中,我们不用太纠结具体的存储位置

8个操作类型

支持JMM,Java定义8种原子操作,用来控制主存与工作内存之间的交互。

(1)read(读取)作用于主内存,它把变量从主内存传动到线程的工作内存中,供后面的 load 动作使用。

(2)load(载入)作用于工作内存,它把read操作的值放入到工作内存中的变量副本中。

(3)store(存储)作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用。

(4)write (写入)作用于主内存,它把store传送值放到主内存中的变量中。

(5)use(使用)作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时,将会执行这个动作。

(6)assign(赋值)作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时,执行该操作。

(7)lock(锁定)作用于主内存,把变量标记为线程独占状态。

(8)unlock(解锁)作用于主内存,它将释放独占状态。

237ce65a0193426b927fac077728da99_tplv-k3u1fbpfcp-watermark.jpg

如上图所示,把一个变量从主内存复制到工作内存,就要顺序执行read 和 load;而把变量从工作内存同步回主内存,就要顺序执行 store 和 write 操作

三大特征

  • (1)原子性

    • JMM 保证了 read、load、assign、use、store 和 write 六个操作具有原子性,可以认为除了long 和 double 类型以外,对其他基本数据类型所对应的内存单元的访问读写都是原子的

    • 如果想要颗粒度更大的原子性保证,就可以使用lock和 unlock 这两个操作

  • (2)可见性

    • 可见性是指当一个线程修改了共享变量的值,其他线程也能立即感知到这种变化前面的图中可以看到,要保证这种效果,需要经历多次操作。一个线程对变量的修改,需要先同步给主内存,在另外一个线程的读取之前刷新变量值。

    • volatile、synchronized、final和锁,都是保证可见性的方式。

    • 这里要着重提一下volatile,因为它的特点最显著。使用了 volatile 关键字的变量,每当变量的值有变动时,都会把更改立即同步到主内存中;**而如果某个线程想要使用这个变量,则先要从主存中刷新到工作内存上(store),这样就确保了变量的可见性(load)。(可见性+有序性)

    • 而锁和同步关键字就比较好理解一些,它是把更多个操作强制转化为原子化的过程。由于只有一把锁,变量的可见性就更容易保证。(原子操作)

  • (3)有序性

    • Java程序很有意思,从上面的 add 操作可以看出,如果在线程中观察,则所有的操作都是有序的;而如果在另一个线程中观察,则所有的操作都是无序的。

    • 除了多线程这种无序性的观测,无序的产生还来源于指令重排。

    • 指令重排序是 JVM 为了优化指令,来提高程序运行效率的,在不影响单线程程序执行结果的前提下,按照一定的规则进行指令优化。在某些情况下,这种优化会带来一些执行的逻辑问题,在并发执行的情况下,按照不同的逻辑会得到不同的结果。

    • 我们可以看一下 Java 语言中默认的一些“有序”行为,也就是先行发生(happens-before)原则,这些可能在写代码的时候没有感知,因为它是一种默认行为。

    • 先行发生是一个非常重要的概念,如果操作 A 先行发生于操作 B,那么操作 A 产生的影响能够被操作 B 感知到。

  • 程序次序:一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作。
  • 监视器锁定:unLock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile:对一个变量的写操作先行发生于后面对这个变量的读操作。
  • 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以- 得出操作 A 先行发生于操作 C。
  • 线程启动:对线程 start() 的操作先行发生于线程内的任何操作。
  • 线程中断:对线程 interrupt() 的调用先行发生于线程代码中检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测是否发生中断。
  • 线程终结规则:线程中的所有操作先行发生于检测到线程终止,可以通过 Thread.join()、Thread.isAlive() 的返回值检测线程是否已经终止。
  • 对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 方法的开始。

内存屏障

那我们上面提到这么多规则和特性,是靠什么保证的呢?

内存屏障(Memory Barrier)用于控制在特定条件下的重排序和内存可见性问题

JMM内存屏障可分为读屏障和写屏障,Java 的内存屏障实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。Java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。

下面介绍一下这些组合。

  • Load-Load Barriers : 保证 load1 数据的装载优先于 load2 以及所有后续装载指令的装载。对于 Load Barrier 来说,在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据

    • load1
    • LoadLoad
    • load2
  • Load-Store Barriers:保证load1数据装载优先于store2以及后续的存储指令刷新到内存。

    • load1
    • LoadStore
    • store2
  • Store-Store Barriers:保证 store1 数据对其他处理器可见,优先于 store2 以及所有后续存储指令的存储。对于 Store Barrier 来说,在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

    • store1
    • StoreStore
    • store
  • Store-Load Barriers:在Load2及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。这条内存屏障指令是一个全能型的屏障,它同时具有其他 3 条屏障的效果,而且它的开销也是四种屏障中最大的一个

    • store1
    • StoreLoad
    • load2
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享