线程安全-内存模型

内存模型

概述

:::info

注:这部分的主要目的用于梳理我自己对于内存模型的思路

:::

  • 先来简单了解为什么需要内存模型?
    • [中断机制]{.red}
      • 背景:[处理器同时只能够执行单个进程,效率非常低下]{.aqua}
      • 解决方式:[中断机制避免单个进程长时间独占处理器的使用权]{.aqua}
      • 产生的新问题:[中断机制导致进程间通信时对共享变量的修改结果不确定]{.aqua}
    • [缓存一致性问题]{.red}
      • 背景:[处理器的执行速度和内存读取数据的速度存在不匹配的情况]{.aqua}
      • 解决方式:[高速缓存技术]{.aqua}
      • 产生的新问题:[多核处理器下多个缓存之间 的存储的内容可能存在不一致的情况]{.aqua}
    • [指令重排序问题]{.red}
      • 背景:[单周期处理器吞吐量低下需要进行改善]{.aqua}
      • 解决方式:[流水线技术]{.aqua}
      • 产生的新问题:[多条指令可能存在相关性从而导致数据冒险问题]{.aqua}
      • 解决方式:[使用暂停或者转发技术解决数据冒险问题]{.aqua}
      • 又产生了新问题:[暂停使得处理器效率降低,转发无法解决所有数据冒险问题]{.pink}
      • 终极解决方式:[指令重排序]{.pink}
      • 还是存在问题:[指令重排序会让指令乱序执行从而导致 多线程下 执行的结果不确定]{.pink}
  • 如果你对内存模型存在基本的了解,那么你可以发现其三个问题的本质
    • 原子性问题:[其本质是操作系统采用中断驱动引入多进程/线程并发执行]{.red}
    • 可见性问题:[其本质是因为缓存的不一致性造成的]{.red}
    • 有序性问题:[其本质是因为 处理器和编译器 会采用指令重排序的优化手段造成的]{.red}
  • 如果你觉得上述概述已经解决你的问题了,那么接下来的内容就没有必要再看了,因为具体会涉及不少硬件的知识

为什么需要内存模型

高速缓存

:::primary

参考博客:终于有人把Java内存模型(JMM)说清楚了

:::

基本内容

[为什么需要高速缓存?]{.label .primary}

  • 核心原因:[处理器的执行速度和内存读写数据的速度存在不匹配的情况]{.red}

处理器在执行运算的过程需要频繁地从内存读取或者写入数据,从而完成用户给定的任务

早期的时候处理器的执行速度和内存的读写速度相差不多,所以处理器是可以忍受从内存读写数据的过程的

[但是随着处理器高速发展,处理器的执行速度远超过内存的读写速度,这就出现了处理器和内存速度不匹配的情况]{.red}

[每次处理器完成运算任务都需要长时间等待内存的读写,即使自己只需要花费极少的时间进行运算]{.red}

[什么是高速缓存?]{.label .primary}

  • 定义:[一种读写速度接近处理器的存储设备]{.red}

  • 特点:

    • [容量较小、价格昂贵]{.red}

    • [多级缓存:避免单级缓存无法容纳处理器需要使用的全部数据]{.red}

    • [每一级缓存的容量都会逐渐增大,缓存的制作难度也会逐渐变低]{.red}

    • [单级缓存都是每个处理器独占的,多级缓存可能会被多个处理器共享]{.red}

  • 图示:

    多级缓存

    高速缓存

[如何利用高速缓存解决速度不匹配的问题呢?]{.label .primary}

  • 解决方式:

    • [在处理器和内存之间添加 一层或者多层 运算速度接近处理器的高速缓存]{.red}
    • [采用高速缓存作为处理器和内存之间交换数据的缓冲区,避免处理器和内存直接交互造成时间的浪费]{.red}
  • 改进之后的处理器执行过程

    • [处理器在执行之前会先将自己需要使用的所有指令和数据全部从内存中拷贝到高速缓存中]{.red}
    • [此后处理器在运算过程中就直接可以在高速缓存读写数据,而不需要和内存进行交互]{.red}
    • [处理器在运算结束之后再将高速缓存中的内容同步到内存中去]{.red}

[总结]{.label .primary}

采用高速缓存避免了处理器直接和内存交互造成的长时间等待,而由高速缓存负责和内存进行数据的交互

缓存一致性

[什么是缓存一致性问题]{.label .primary}

  • 核心:[多核处理器下各自缓存之间 的存储的内容可能存在不一致的情况]{.red}
  • 细节:
    • [只有多核处理器才会出现缓存一致性问题,单核处理器完全不存在这个问题]{.pink}
    • [而多核处理器下只有多线程并行才会引发,只是单纯的并发是没有这个问题的]{.pinnk}

现代处理器基本都是多核处理器,每个处理器通常都具有自己独立的高速缓存,那么这时候问题就来了:

如果多个处理器需要同时使用内存中相同的数据空间,那么根据高速缓存的机制,[多个处理器显然会分别将内存中共享的数据拷贝到自己的缓存空间中,然后在各自的高速缓存中使用这些共享数据,彼此之间是不可见的]{.red}

[每个处理器执行的运算过程的不同就会导致每个处理器的缓存之间存储内容的不同,而在运算过程结束之后处理器就会分别将自己高速缓存中的数据同步到主存中,那么同步的数据究竟以谁为准?如果没有任何规定,那么后同步进去的数据肯定会覆盖先同步进去的数据,这显然是不能被允许的]{.red}

[如何解决缓存一致性问题]{.label .primary}

  • 解决方式:[内存模型制定相应的协议 规定处理器访问缓存的方式和时机,从而避免出现缓存不一致的问题]{.red}
  • 缓存协议:[MSI、MESI、MOSI、Synapse、Firefly]{.pink}
  • 细节:[不同的处理器架构采用的缓存一致性协议是不同的]{.pink}

[总结]{.label .primary}

计算机内存模型是如何制定协议解决缓存一致性问题的这里就不再详细展开,不同的处理器架构采用的缓存一致性协议是不同的,介绍缓存一致性的问题主要是为了明确 Java 虚拟机中出现的可见性问题的本质是什么,之后会详细讲述 Java 内存模型是如何解决缓存一致性问题,也就是可见性问题的

流水线技术

:::primary

参考博客:CPU 流水线

:::

基本内容

[为什么需要流水线技术]{.label .primary}

  • 核心原因:[单周期处理器的 吞吐量非常低并且总体时延非常高 ]{.red}
  • 细节:
    • [现代处理器几乎没有采用单周期设计的或者说几乎没有采用顺序一致性模型]{.pink}
    • [仅存在单个时钟周期,并且单个时钟周期的时间特别长]{.pink}

单周期处理器是如何工作的呢?

  • 核心内容:[单个时钟寄存器控制指令顺序执行]{.red}

[单周期处理器设计非常简单,每经过一个时钟周期,时钟信号上升沿到来之后,时钟寄存器读取一条指令,其余的指令必须等待下一个时钟信号上升沿到来,也就是再等待一个时钟周期才能够轮到自己]{.aqua}

微处理器体系结构-单周期

为什么会带来低吞吐量和高延迟呢?

  • 核心原因:[单个时钟寄存器为了确保耗时最长的指令正常执行而设定的很长的时钟周期,导致时钟频率很低]{.red}

[单周期处理器仅采用唯一的时钟寄存器控制指令的读取,而每个时钟寄存器设定的时钟周期都固定的]{.aqua}

[这就意味着每条指令无论实际执行时间的长短,消耗的时间都是完全相同,因为时钟周期被固定了,即使指令提前执行完成,下条指令也会因为等待时钟信号而无法立刻执行]{.aqua}

你可能会想,那么把时钟周期设置得短点不就行了?这肯定是不可以的,所有指令的实际执行时间是不同的,如果设置得太短会导致执行时间较长的指令在还没有执行完成的情况下,下条指令就被读取并执行了,指令之间可能就会相互干扰,所以必须保证耗时最长的指令能够正常结束

单周期处理器

[如何解决单周期处理器带来的问题呢?]{.label .primary}

  • 核心:[让多条指令能够并行执行就可以显著提升处理器的吞吐量]{.red}

  • 超标量处理器(空间并行):

    • 定义:[微处理器中成倍地增加硬件从而提高效率]{.red}
    • 细节:[微处理器中增加硬件是非常昂贵的,所以早期并没有采用这种方式]{.red}
  • 流水线技术(时间并行)

  • 总结:为什么需要流水线技术?

    • 单周期处理器造成的低吞吐量和高延迟
    • 超标量处理器带来高昂成本

[什么是流水线技术?]{.label .primary}

  • 核心内容:[多个时钟寄存器控制不同的阶段从而让多条指令可以同时处于不同的阶段,达到并行执行的效果]{.red}
  • 指令划分:
    • [取指(IF:Instruction Fetch):时钟寄存器获取指令地址后,从内存中读取指令]{.red}
    • [译码(ID:Instruction Decode):解析每条指令的含义,比如需要使用的常数或者寄存器]{.red}
    • [执行(EX:Execute):ALU 获取常数和寄存器中的内容后开始计算]{.red}
    • [访存(MEM:Memory):ALU 计算得到的数据写入内存中或者读取数据]{.red}
    • [写回(WB:Wirte Back):ALU 计算得到的数据更新到目标寄存器中去]{.red}
  • 细节:
    • [流水线划分阶段的行为会造成单条指令时延的略微上升]{.green}
    • [流水线技术提高了处理器的吞吐量并且降低处理器执行指令的总体时延]{.red}
    • [不是所有指令都需要 实际执行 上述五个步骤,但是所有指令都必须满足上述的流程]{.red}
      • 举个栗子,只对寄存器执行加法的指令,实际上是不需要去访问内存的
      • 但是由于流水线已经划分好阶段了,即使你不访问内存也是需要等待这个过程结束的
      • 这是流水线造成单条指令延时上升的,以及过深的流水线造成收益下降的原因
    • // TODO

流水线通用原理

[汇编指令在底层几乎都会分为上述五个步骤完成,每个步骤使用的硬件都是不同的]{.red}

[那么只要当前指令完成某个阶段的执行,该阶段使用的硬件就会空闲出来,那么 该阶段的时钟寄存器 就可以立刻让 下条指令进入这个阶段并执行,而当前指令就会进入下个阶段执行]{.red}

[这种方式就充分利用了硬件资源,避免单周期中因等待时钟信号造成的硬件空闲]{.red}

微处理器体系结构-流水线

流水线处理器是如何工作的呢?

[取指阶段的时钟寄存器读取第一条指令,并开始执行取指,此时无论如何其他指令都必须等待]{.red}

[第一条指令完成取指阶段之后就,译码阶段的时钟寄存器就会让第一条指令进入译码阶段,此时第二条指令就可以被取指阶段的时钟寄存器读取进入取指阶段]{.red}

[第一条指令完成译码阶段之后会相应进入执行阶段,第二条和第三条指令就会分别进入译码和取指的阶段,以此类推,这样多条指令可以处于不同阶段从而达到时间上并行执行的效果]{.red}

流水线-3

数据冒险

:::info

流水线技术引入带反馈的系统中就会引发很多问题。这些问题主要分为两类,第一类就是接下来要讲述的数据冒险问题,另一类是控制冒险问题,由于后者和指令重排序关系不大,所以不再讲述

:::

[什么是带反馈的流水线?]{.label .primary}

  • 定义:[采用流水线设计的处理器执行的指令之间存在相互依赖的关系,就称为带反馈的流水线]{.red}

[什么是数据冒险问题]{.label .primary}

  • 定义:[带反馈的流水线带来的指令相关性 可能 会造成处理器在计算过程中产生错误,就称为数据冒险问题]{.red}

举个栗子

数据冒险问题

[如何解决数据冒险问题呢?]{.label .primary}

  • 用暂停避免数据冒险

    • 定义:[在依赖前面指令结果的指令之前添加气泡指令,推迟该指令的执行]{.aqua}

    • 细节:

    • 图示:

      暂停避免数据冒险
  • 用转发避免数据冒险

    • 定义:[不等待前面的指令向寄存器更新结果,而是直接将结果从内存中传入该指令]{.aqua}

    • 细节:

    • 图示:

      转发避免数据冒险
  • 用加载 / 使用避免数据冒险

[总结]{.label .primary}

仔细观察就会发现,暂停能够解决所有数据冒险问题,但是处理器的效率显然会因此而降低;转发虽然不需要暂停处理器,但是没有办法解决所有问题;最后一种方法只是弥补转发的缺陷,但是仍然需要暂停

[对于追求性能的处理器来说,频繁的暂停显然是难以接受的,会严重影响处理器的执行效率,所以引入了优化方式:指令重排序]{.aqua}

指令重排序

:::primary

参考内容:

当目标CPU具有乱序执行的能力时,编译器做指令重排序优化的意义有多大?

什么是指令重排序?为什么要重排序?

《数字设计和计算机体系结构》

:::

[什么是指令重排序?]{.label .primary}

  • 定义:[处理器在确保和顺序执行结果一致的情况下,会将输入的指令重新进行排序]{.red}

    指令重排序
  • 细节:

    • [单个处理器(线程)内是无法感知到指令是否被重排序了,因为乱序和顺序的执行结果是一致的,这也就是很多文章或者书上解释的 “线程内部表现为串行的语义”]{.pink}
    • [多个处理器(线程)之间是无法感知到彼此执行代码的顺序的,也就是说线程与线程之间的执行顺序天然就是乱序的]{.pink}
  • [编译器重排序]{.red}

    • 定义:[编译器在对 所有代码进行编译 的时候就会调整代码执行的顺序]{.red}
    • 细节:
      • [Java 中提供指令重排序的是 JIT 即时编译器 而不是 Javac 前端编译器]{.red}
      • [编译器能够看到程序的全局视图,能够获取更多的信息进行指令重排序]{.red}
  • [处理器重排序]{.red}

    • 定义:[处理器在 执行指令的时候 才开始调整指令执行的顺序]{.red}
    • 细节:[处理器只能够在动态执行指令的时候重排序,只能依靠局部的信息进行重排序]{.red}
  • [内存重排序]{.red}

    • 定义:[由可见性或者说缓存一致性问题带来的“重排序”]{.pink}
    • 举个栗子:
      • 在多核处理器下,每个处理器缓存中存放的共享数据可能是不同的,也就是缓存一致性问题
      • 那么 CPU-Core-1 已经对共享数据进行修改了,但是 CPU-Core-2 在自己的缓存中无法得知
      • 接下来只要 CPU-Core-2 先于 CPU-Core-1 执行,那么就会呈现出先读取后修改的情况
      • 而实际情况却是先修改后读取,出现了这种类似于指令重排序的情况

大致介绍完指令重排序之后,你可能会产生一些疑问。最基本的问题,为什么要使用指令重排序,它和之前提到的流水线有什么关系吗?还有为什么要同时提供编译器和处理器两种重排序?不能够只提供一种吗?接下来会解答这些疑问

[为什么需要使用指令重排序?]{.label .success}

之前在流水线技术中已经提到,在带反馈的流水线系统中存在数据冒险的问题,而解决数据冒险使用的方式主要是三种:暂停、转发、加载 / 使用。这三种解决数据冒险的方式几乎都会造成处理器的停顿,而指令存在相关性的情况又非常多,所以处理器就会频繁的停顿,最终造成处理器性能的下降。

[指令重排序就可以将后续等待执行的无关指令重排到存在相关性的指令之间,使其提前执行,也就相当于用无关指令替代了气泡指令的存在,从而避免处理器的频繁暂停以提高处理器的执行效率]{.red}

处理器重排序

[为什么编译器也需要提供指令重排序的优化策略?]{.label .success}

[刚才在提到编译器和处理器重排序的时候,特意标注的内容就是编译器是审视所有的代码并进行重排序,而处理器仅仅只是在执行指令的时候对局部的指令进行重排,这也就意味着编译器能够比处理器获得更多的信息去执行指令重排序的优化,从而为处理器提供更加良好的代码,减轻处理器优化的压力]{.pink}

[那么既然编译器的优化能力更强,那为什么还要提供处理器指令重排序的功能呢?这是因为不同架构的处理器的最优指令顺序是完全不同的,编译器难以提供能够满足所有处理器的最优指令顺序,所以依然需要处理器提供指令重排序的能力进行优化]{.red}

[什么是指令重排序问题]{.label .primary}

  • 前提:[无论是单核处理器还是多核处理器,只要出现多线程,那么就会存在指令重排序问题]{.red}
  • 核心内容:[多线程 情况下由于指令重排序而造成 意想不到的结果 出现就称为指令重排序问题]{.red}

处理器或者编译器会对每个线程内的代码进行重排序,确保的是单个线程内执行的结果一致,但是多个线程之间可能会出现非常奇怪的结果,这里用代码举个栗子吧(MIPS 的汇编指令确实不太熟悉

// 线程-1 需要执行的方法
public void method1(){
if(flag){
System.out.println(num * num);
}else{
num = 1
System.out.println(num);
}
}
// 线程-2 需要执行的方法
public void method2(){
num = 2; // flag = true
flag = true; // num = 2
}

[处理器或者编译器认为线程-2执行的方法中两条指令都是不存在依赖的,可以随意交换执行的顺序并且 保证在线程-2中的执行结果是一致的,但是对于其余线程来说这种重排序会造成无法预知的结果]{.aqua}

[指令重排序之后,线程-2会先将 flag 变量修改为 true ,此时执行上下文切换,线程-1立刻进入为 true 的代码块中打印 num*num 的结果,此时 num 还没来得及赋值,所以结果就会出现 0,这个结果 0 其实就是意想不到的结果,因为你在不知道指令重排序的情况下只会认为结果为 4 或者 1]{.aqua}

[如何解决指令重排序问题?]{.label .success}

  • 解决方式:[内存模型使用内存屏障指令 从而避免流水线中的指令重排序的能力]{.red}
  • 细节:[不同架构的处理器其对应的内存屏障指令显然也是不同的]{.pink}

总结:这里依然不详细展开计算机内存模型是如何使用内存屏障避免指令重排序的,之后的 Java 内存模型中会详细提到

补充

  • 不一致的划分
  • 过深的流水线

什么是内存模型

计算机内存模型

  • 定义:[内存模型 制定特殊的协议 从而 规范处理器访问内存和高速缓存的过程 ]{.red}
    • [换句话说内存模型定义了 多线程 对内存和高速缓存读写操作的规范]{.pink}
    • [从而避免出现之前提到的缓存一致性、指令重排序等内存访问问题]{.pink}
  • 解决方式:[缓存一致性协议 + 内存屏障]{.red}
  • 细节:
    • [内存模型不是对物理硬件的抽象而是对处理器访问内存和高速缓存过程的抽象]{.pink}
    • [不同架构的处理器对应的内存模型是不同的,这也就导致在不同处理器编写多线程程序需要遵循的协议不同]{.red}
      • [有些处理器提供强内存模型,保证每个处理器在任何时候看到的都是相同的数据]{.pink}
      • [有些处理器提供弱内存模型,需要在程序执行的过程中插入特殊的指令才能够保证看到相同数据]{.pink}

总结:这里关于计算机内存模型是如何保证多线程程序的正确访问就不再叙述,不是本篇文章的重点

计算机内存模型

Java 内存模型

:::primary

参考博客:

嘿,同学,你要的 Java 内存模型 (JMM) 来了

全面理解Java内存模型(JMM)及volatile关键字

Java Memory Model

JSR 133 (Java Memory Model) FAQ

:::

基本内容

[概述]{.label .primary}

  • Java 内存模型:Java Memory Model(JMM)

  • 历史:JMM 在 JDK 2 建立,在 JDK 5 才被正式完善,直至 JDK 8 依然使用的依然是 JDK 5 的 JMM

  • 定义:

    • 核心:[JMM 规定 JVM 在计算机内存中的正确工作方式]{.red}
    • [规定线程如何正确和计算机内存及高速缓存交互,也就是线程何时同步数据到内存,何时去内存获取数据]{.pink}
    • [以及编译器和处理器启用优化策略的时机,也就是何时开启指令重排,何时禁用指令重排]{.pink}
  • 目的:

    • [确保 Java 并发程序不会出现缓存一致性、指令重排序、原子性等并发问题]{.red}

    • [屏蔽不同处理器内存模型间的差异,使得相同的 Java 并发程序可以在不同架构的处理器下正确运行]{.red}

      • [不同处理器的内存模型的不同,导致在不同处理器下编写多线程程序使用的 API 肯定是不同的]{.aqua}
      • [Java 为了实现跨平台的特性,制定了自己的内存模型规范,从而避免底层计算内存模型的异构性]{.aqua}
      JMM-屏蔽底层异构性
    • 细节:

      • [C/C++ 没有内存模型的概念]{.red}
        • C/C++ 并没有天生支持线程而是依靠其他第三方类库提供的线程支持
        • 所以 C/C++ 确保线程安全的方式是依靠不同的类库来保证的,没有统一的标准
      • [Java 内存模型 和 Java 虚拟机的内存结构是不同层面的划分,两者没有直接的联系]{.red}
        • 接下来我会详细讲述这两者的区别

[主要内容]{.primary}

  • 线程 & 工作内存 & 主内存

    • 线程:[Java 采用的是内核级线程,每个线程运行都会占用相应的处理器(可以直接认为线程就是处理器)]{.red}
    • 工作内存:
      • [每个线程都拥有自己 独立 的工作内存,存放的变量只能够被自己操作]{.red}
      • [每个线程必须将所有需要的变量 从主内存全部拷贝到工作内存 ,在工作内存中对变量进行操作]{.red}
        • 这里提到的变量指的是共享变量而不包括非共享变量,因为非共享变量本身就是线程安全的
        • 而内存模型关注的是线程和内存之间的正确交互,即是否线程安全,也就不属于讨论的范围了
      • [每个线程在结束之前会将 工作内存中的所有数据全部同步到主内存中]{.red}
    • 主内存:[存放所有共享变量的区域]{.pink}

    :::info

    到现在为止,你是不是觉得工作内存对应的就是虚拟机栈、主内存对应的就是堆空间呢?

    实际上这两者并没有这样直接的对应关系,或者说是两个层面的划分,接下来我会解释这是为什么。

    :::

    计算机内存模型
  • Java 虚拟机内存结构和计算机内存的关系

    [操作系统会在 JVM 进程初始化时 在内存中分配相应的内存空间,也就是说 虚拟机栈、堆空间、方法区、本地方法栈全部都是存放在内存中 的,但是操作系统为了 加快 JVM 进程的执行速度,会将 部分空间存放在高速缓存或者处理器的寄存器中]{.aqua}

    至于究竟将哪些空间存放在缓存中,那么空间依然存放在内存中完全取决于操作系统的优化措施

    JVM与计算机内存
  • Java 内存模型和 Java 虚拟机内存结构的关系

    [JMM 定义的工作内存和主内存实际都属于 JVM 使用的内存,也就是说你可以认为 JMM 重新将 JVM 进行了一次划分,也就是只把 JVM 内存划分成工作内存 + 主内存两部分,而不存在其余的什么虚拟机栈,堆空间等等,所以你也会经常看到说工作内存和主内存是一种抽象概念,因为这个概念只是方便 JMM 接下来制定规则的便利,而不是实际存在的区域,所有的虚拟机实际上肯定还是采用栈 + 堆的划分方式]{.aqua}

    [和刚才同样的道理,操作系统为了加快 JVM 进程的执行速度,也会将工作内存存放在寄存器或者高速缓存中,因为所有线程都是在工作内存中工作的嘛]{.aqua}

    JVM与JMM
  • 八大基本操作

    • 前提:八大操作规定了线程使用工作内存的变量,以及如何从主内存中获取变量
    • lock & unlock
      • lock:[将 主内存 中的某个变量变为线程独占状态]{.red}
      • unlock:[将 主内存 中某个处于锁定的变量释放出来]{.red}
    • read & load
      • read:[将 主内存 中的变量读取出来放入工作内存中]{.red}
      • load:[将 工作内存 中刚从主内存获取到的变量拷贝到相应的变量副本中去]{.pink}
    • use & assign:
      • use:[将 工作内存 的变量副本交付给执行引擎(处理器 \ 线程)使用]{.grey}
      • assign:[将 执行引擎 返回的值更新到工作内存的变量副本中]{.grey}
    • store & write:
      • store:[将 工作内存 中的变量放入到主内存中]{.pink}
      • write:[将 主内存 中刚从工作内存中获取到的变量写入主内存中]{.red}
    • 细节:[后续的工作内存和主内存交互协议被改为 4 种操作而不是 8 种:read、write、unlock、lock]{.red}
  • 执行顺序

    [每对操作必须按照顺序执行,但是不一定是连续执行,也就是可以在每对操作之间插入其他指令。JMM 为了进一步解决这个问题,更加严格规定了每种操作之间的执行顺序,从而从最底层的操作确保不会出现缓存一致性、指令重排序、原子性问题]{.blue}

    [这里不再详细介绍这些规则,因为没有人会按照这个规则去验证自己的程序是否是线程安全的,后面会介绍相应的等价规则,也就是先行先发生原则来判断。此外,之后所有的同步方式,比如 volatile、synchronized、final 等等,都是基于这这些执行顺序来的]{.blue}

  • 总结

    上述提到的都是 JMM 的基本规则,但是这些规则是如何解决此前提到的原子性问题、缓存一致性问题、以及指令重排序的问题的内容还没有详细介绍,接下来就会讲述这些规则如何解决这些问题

原子性

[什么是原子性?]{.label .primary}

  • 定义:[一个操作或者多个操作在执行的过程中不会被任何因素打断,要么全部执行,要么全部都不执行]{.red}

[什么是原子性问题?]{.label .primary}

  • 定义:[由于线程发生上下文切换从而导致线程的操作被中断,出现错误的结果]{.label .primary}

举个栗子

编写存在原子性问题的测试代码

private int count;

public void increament(){
// 快捷运算符不是六个基本操作,也就是不是原子性操作
// 等价于: read count、assing count、write count
count++;
}
// 这里需要多次测试,因为单次测试很有可能得到的是正确的结果
public static void main(String[] args){
Thread t1 = new Thread(()->{
for(int i = 0;i < 1000;i++){
// 调用方法:这里是伪代码,节省篇幅
increament();
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i < 1000;i++){
// 调用方法:这里是伪代码,节省篇幅
increament();
}
})
// 等待两个线程运行结束后输出结果
}

在不运行的情况下,你觉得正确答案应该是多少?2000?从理论上来说是这样,但是实际的测试中会出现非常奇怪的结果

原子性问题

为什么会出现这么多不同的结果?实际上就是因为非原子性的操作因为上下文切换被中断导致的。

刚才提到快捷运算符并不是原子性操作而是由多个操作组合而成的,从字节码的角度也可以证明这一点。[既然这样,线程-1在执行完加法之后,因为线程-2突然想要执行从而导致线程-1的操作被中断无法继续更新结果,那么线程-2此时看到变量值就还是原始的结果,线程-2执行完毕之后将新的结果写回变量,此时线程-1继续执行,但是线程-1并不知道其余线程做了什么,它依旧把自己的结果更新回去,这就导致虽然执行了两次加法,但是最后的结果却只加了一的情况]{.aqua}

// 获取共享变量
2: getfield #2
// 获取常量
5: iconst_1
// 将共享变量和常量相加
6: iadd
// 将结果写回去
7: putfield #2

[如何确保原子性?]{.label .primary}

  • 单个操作的原子性:JMM 提供的六个基本操作 read、load、store、write、use、assign 都是原子性操作
  • 多个操作的原子性:JMM 为了确保这些基本操作的组合是原子性的,还提供了 lock、unlock 的操作
  • 细节:
    • [lock、unlock 是最底层的指令没有对用户开放使用,而是将 monitorenter、monitorexit 开放给用户]{.red}
    • [这两条指令反映到代码中就是 synchronized 关键字,所以直接使用 synchronized 就可以确保原子性]{.red}

[实际确保原子性的方式]{.label .primary}

  • [synchronized]{.red}
  • [cas]{.red}
  • [aqs 锁 & juc 工具类]{.red}

可见性

[什么是可见性?]{.label .primary}

  • 定义:[当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值]{.red}

[什么是可见性问题?]{.label .primary}

  • 定义:[多个线程各自拥有的工作内存中存储的变量不相同]{.red}

  • 本质:此前提到的缓存一致性问题就是可见性问题

举个栗子

编写存在可见性问题的测试代码

static boolean run = true

public static void main(String[] args) throws InterruptedException{
Thread thread = new Thread(()->{
while(run){

}
})
thread.start();
TimeUnit.SECONDS.sleep(1);
// 主线程修改标志位
run = false;
}

[实际测试这段代码你会发现在主线程修改标志位之后,子线程会继续运行而不会停下来。这其实就是因为两个线程的工作内存不同,从而导致主线程修改的是自己工作内存中的 run 变量的值,而子线程的 run 变量依然为 true,这就是主线程中工作内存中的内容对于子线程来说是不可见的,其本质也就是因为缓存中对于共享变量的数据不一致]{.aqua}

[如何确保可见性?]{.label .primary}

[实际确保可见性的方式]{.label .primary}

  • [volatile]{.red}
  • [synchronized]{.red}

有序性

[什么是有序性?]{.label .primary}

[什么是有序性问题?]{.label .primary}


[如何确保有序性?]{.label .primary}

[实际确保有序性的方式]{.label .primary}

  • [final]{.red}
  • [volatile]{.red}
  • [synchronized]{.red}

happens-before 原则

Author: Fuyusakaiori
Link: http://example.com/2021/11/02/juc/多线程基础/线程同步-内存模型/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.