JVM内存模型总结 | 青训营笔记

74 阅读11分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 18 天

今天学习了JVM中的内存模型相关

  • 每秒事务处理数(Transactions Per Second,TPS)

  • 除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序(Instruction Reorder)优化

  • JMM的意义:屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如C和C++等)直接使用物理硬件和操作系统的内存模型。因此,由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,所以在某些场景下必须针对不同的平台来编写程序

  • Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数

  • Java内存模型规定了所有的变量都存储在主内存(Main Memory),线程又各自拥有不同的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系

  • java堆中保存的数据

    • 对于HotSpot虚拟机来讲,Java堆保存了实例数据,Mark Word(存储对象哈希码、GC标志、GC年龄、同步锁等信息)、Klass Point(指向存储类型元数据的指针)及一些用于字节对齐补白的填充数据
  • JMM定义了工作内存和主内存交换数据通过lock,unlock,read,load,use,assign,store,write八种基本操作(这八种操作都是原子性的),后为了简化,将其简化为read、write、lock和unlock四种,并且将处理时需要满足的条件简化为happen-before原则

  • volatile

    • volatile是Java虚拟机提供的最轻量级的同步机制,保证可见性可以作为通知变量使用

    • volatile作用

      • 保证变量具有可见性(A线程更改后B线程能立马得知)
      • 禁止指令重排序优化,通过添加内存屏障,使得重排序时不能把后面的指令重排序到内存屏障之前的位置
    • 虽然变量具有可见性,但由于递增运算并不是原子性的(根本原因在于Java里面的运算操作符并非原子操作),从字节码层面上已经很容易分析出并发失败的原因了:当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步回主内存之中

    • 注意字节码也不一定是原子操作:一条字节码指令在解释执行时,解释器要运行许多行代码才能实现它的语义。如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令

    • volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说volatile就会比synchronized快上多少

    • 针对long和double型变量的特殊规则,允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)但实际上64位机上不会出现问题,32位机上出现这种问题也十分罕见,所以不用因为这个原因专门声明对应volatile变量

  • Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的

    • 原子性:

      • 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)
      • 如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性
    • 可见性:

      • Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新
      • 除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。
    • 有序性

      • Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的

        • 前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-SerialSemantics)
        • 后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
      • Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

    • happen-before原则

      • “先行发生”(Happens-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段,避免通过苦涩复杂的JMM来判断线程是否安全

      • 定义:操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等(所以先行发生不意味着时间上真的先运行)

      • 如果两个操作之间的关系不在happen-before原则中,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序(简言之happen-before原则可以作为判断是否能重排序的规则(能重排序不代表一定重排序))

      • 注意先行发生原则和时间没有关系,二者互不影响,详细见下分析

        • 示例一(先执行的不一定先行发生)

        image-20221125203205479

        • 线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么? 答案:可能是0,也有可能是1

        • 分析:由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,换句话说,这里面的操作不是线程安全的

        • 解决方法:

          • 要么把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;
          • 要么把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来实现先行发生关系
        • 示例二(满足先行发生的不一定真的时间上先发生)

        image-20221125203607563

        • 根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这一点
    • 线程相关

      • Thread类与大部分的Java类库API有着显著差别,它的所有关键方法都被声明为Native。在Java类库API中,一个Native方法往往就意味着这个方法没有使用或无法使用平台无关的手段来实现(恰好也是java线程是映射到系统线程的体现)

      • 实现线程主要有三种方式

        • 使用内核线程实现(1:1实现)

          • 使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT) 就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler) 对线程进行调度,并负责将线程的任务映射到各个处理器上

          • 每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)

          • 程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LightWeight Process,LWP) ,轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型

            • PS:概念上只要不是内核线程都应该被视为用户线程,但LWP是映射在KLT上的,不具有一般意义上的用户线程优点,故仍然被视为内核线程