前言:

随着技术的发展,CPU的运算速度也突飞猛进,与内存(主存)、磁盘的访问速度逐减拉开了距离。除了提升频率,后来甚至引入了L1、L2、L3三级缓存。

电脑访问磁盘的一个过程:

img

在存储层面,速度最快的是 CPU 中的寄存器(小于1kb),CPU比内存速度快很多,由 CPU 直接访问内存效率较低,为了提高内存访问速度,在 CPU 和内存间增加了高速缓存(高速小容量存储器)。

寄存器:种类挺多,CPU访问寄存器几乎没有延迟,但是容量很小。早期的计算机用的是8位寄存器,到后来16位、32位、64位,简单的理解就是单位时间内能处理二进制数据的长度。

在执行程序时为了提高性能,编译器和CPU常常会对指令重排序

指令队列在CPU执行时不是串行的, 当某条指令执行时消耗较多时间时, CPU资源足够时并不会在此无意义的等待,而是开启下一个指令,开启下一条指令是有条件的, 即上一条指令和下一条指令不存在相关性。

同样JVM 允许在不影响代码最终结果的情况下,可以乱序执行。但是在单线程环境的执行结果是不能被改变的,所以我们无需担心重排顺序会干扰到他们。

在《深入理解Java虚拟机》中,描述到:

JVM(Java Virtual Machine,Java虚拟机):是一种抽象的计算机,基于堆栈架构,它有自己的指令集和内存管理,它加载 class 文件,分析、解释并执行字节码。JVM 主要分为三个子系统:类加载器运行时数据区执行引擎。java虚拟机规范定义了java内存结构和内存模型,用于屏蔽各种硬件和操作系统的内存访问差异,以实现跨平台的效果。

  • 类加载器

主要功能是处理类的动态加载,还有链接,并且在第一次引用类时进行初始化

  • 运行时数据区

它约定了在运行时程序代码的数据比如变量、参数等等的存储位置。主要包括:堆、java栈、方法区、pc寄存器和本地方法栈。

1)堆:线程共享,存放类实例对象和数组。堆所占内存的大小由-Xmx指令和-Xms指令来调节。

2)栈:线程私有,存储局部变量表、操作栈、动态链接、方法出口,对象指针,JVM只会对java栈执行两种操作压栈或出栈。

3)方法区:线程共享,存储被装载类型的信息;

JDK1.6及之前运行时常量池逻辑包含字符串常量池存放在方法区;

JDK1.7 字符串常量池被从方法区拿到了堆中,运行时常量池剩下的东西还在方法区;

JDK1.8 移除了永久代用元空间取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(堆外内存)。

4)pc寄存器:保存有当前正在执行的JVM指令的地址;

5)本地方法栈:服务于 Native 方法;

  • 执行引擎

运行时数据区存储着要执行的字节码,执行引擎将会读取并逐个执行。包括:解释器JIT编译器GC垃圾收集器

在java中每个线程创建时JVM都会为其创建一个工作内存(即栈空间),工作内存是每个线程的私有数据区域,而共享变量则都存储在主内存中,主内存是共享内存区域,所有线程都可以访问。

如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。为了解决这个问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则就是JMM(Java Memory Model Java,java内存模型),它就是用来描述多线程如何正确的通过内存进行交互和使用共享数据。

在JMM中,有两条规定:

1)线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写

2)不同线程之间无法访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

JMM整体是围绕着程序执行的原子性、有序性、可见性展开的。

  • 原子性

原子性是指是一个操作是不可中断的,即多线程环境下两个线程间的操作互不干扰。JMM规定了内存交互操作有8种。其中read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)六个指令直接提供原子操作。我们可以认为java基本数据类型的变量(除了long和double)的读写操作都是原子的。对于lock(锁定)、unlock(解锁),虚拟机没有将操作直接开放给用户使用,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者Lock锁接口的实现类来保证程序执行的原子性。volatile不具备原子性。

例如:线程对主内存的读操作:

img

read可能远早于use, 在它们中间可能会发生的其他线程的读写。所以在多线程情况下,可能发生线程安全问题。

  • 可见性
    可见性就是指当一个线程修改了共享变量值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量这种依赖主内存作为传递媒介的方式来实现可见性。其中被volatile修饰的变量,可以在修改后强制刷新到主存,并在使用时从主存获取刷新,普通变量则不行。除了volatile修饰的变量,Java还有两个关键字能实现可见性,他们是synchronized和final。
  • 有序性

在执行程序时为了提供性能,做了指令重排。java提供了volatile和synchronized两个关键字保证线程之间的有序性。

当然频繁的加锁和使用volatile来解决多线程安全问题,势必会影响性能,所以,其实JMM中还为我们提供了happens-before原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它可能判断线程是否安全。

内存模型

JMM

内存模型

Java 内存模型是 Java Memory Model(JMM),本身是一种抽象的概念,实际上并不存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式

它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。

JMM 体现

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

JMM 作用:

  • 屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
  • 规定了线程和内存之间的一些关系

根据 JMM 的设计,系统存在一个主内存(Main Memory),Java 中所有变量都存储在主存中,对于所有线程都是共享的;每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量;线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成

image-20220804114024616

主内存和工作内存:

  • 主内存:计算机的内存,也就是经常提到的 8G 内存,16G 内存,存储所有共享变量的值
  • 工作内存:存储该线程使用到的共享变量在主内存的的值的副本拷贝

JVM 和 JMM 之间的关系:JMM 中的主内存、工作内存与 JVM 中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来:

  • 主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域
  • 从更低层次上说,主内存直接对应于物理硬件的内存,工作内存对应寄存器和高速缓存

内存交互

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作,每个操作都是原子

非原子协定:没有被 volatile 修饰的 long、double 外,默认按照两次 32 位的操作

  • lock:作用于主内存,将一个变量标识为被一个线程独占状态(对应 monitorenter)
  • unclock:作用于主内存,将一个变量从独占状态释放出来,释放后的变量才可以被其他线程锁定(对应 monitorexit)
  • read:作用于主内存,把一个变量的值从主内存传输到工作内存中
  • load:作用于工作内存,在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
  • use:作用于工作内存,把工作内存中一个变量的值传递给执行引擎,每当遇到一个使用到变量的操作时都要使用该指令
  • assign:作用于工作内存,把从执行引擎接收到的一个值赋给工作内存的变量
  • store:作用于工作内存,把工作内存的一个变量的值传送到主内存中
  • write:作用于主内存,在 store 之后执行,把 store 得到的值放入主内存的变量中

参考文章:https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E5%B9%B6%E5%8F%91.md


三大特性:

可见:

可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

存在不可见问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的。但是 final 修饰的变量是不可变的,就算有缓存,也不会存在不可见的问题

main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

1
2
3
4
5
6
7
8
9
10
11
static boolean run = true;	//添加volatile
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}

原因:

  • 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
  • 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
  • 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

image-20220804122026018

image.png

解决方法 :

volatile(易变关键字)

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存


原子性

原子性:不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被分割,需要具体完成,要么同时成功,要么同时失败,保证指令不会受到线程上下文切换的影响

定义原子操作的使用规则:

  1. 不允许 read 和 load、store 和 write 操作之一单独出现,必须顺序执行,但是不要求连续
  2. 不允许一个线程丢弃 assign 操作,必须同步回主存
  3. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步会主内存中
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(assign 或者 load)的变量,即对一个变量实施 use 和 store 操作之前,必须先自行 assign 和 load 操作
  5. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁,lock 和 unlock 必须成对出现
  6. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新从主存加载
  7. 如果一个变量事先没有被 lock 操作锁定,则不允许执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量
  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)

有序性

有序性:在本线程内观察,所有操作都是有序的;在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序

CPU 的基本工作是执行存储的指令序列,即程序,程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程,为了提高性能,编译器和处理器会对指令重排,一般分为以下三种:

1
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令

现代 CPU 支持多级指令流水线,几乎所有的冯•诺伊曼型计算机的 CPU,其工作都可以分为 5 个阶段:取指令、指令译码、执行指令、访存取数和结果写回,可以称之为五级指令流水线。CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(每个线程不同的阶段),本质上流水线技术并不能缩短单条指令的执行时间,但变相地提高了指令地吞吐率

处理器在进行重排序时,必须要考虑指令之间的数据依赖性

  • 单线程环境也存在指令重排,由于存在依赖性,最终执行结果和代码顺序的结果一致
  • 多线程环境中线程交替执行,由于编译器优化重排,会获取其他线程处在不同阶段的指令同时执行

补充知识:

  • 指令周期是取出一条指令并执行这条指令的时间,一般由若干个机器周期组成
  • 机器周期也称为 CPU 周期,一条指令的执行过程划分为若干个阶段(如取指、译码、执行等),每一阶段完成一个基本操作,完成一个基本操作所需要的时间称为机器周期
  • 振荡周期指周期性信号作周期性重复变化的时间间隔

cache

缓存机制

缓存结构

在计算机系统中,CPU 高速缓存(CPU Cache,简称缓存)是用于减少处理器访问内存所需平均时间的部件;在存储体系中位于自顶向下的第二层,仅次于 CPU 寄存器;其容量远小于内存,但速度却可以接近处理器的频率

CPU 处理器速度远远大于在主内存中的,为了解决速度差异,在它们之间架设了多级缓存,如 L1、L2、L3 级别的缓存,这些缓存离 CPU 越近就越快,将频繁操作的数据缓存到这里,加快访问速度

从 CPU 到 大约需要的时钟周期
寄存器 1 cycle (4GHz 的 CPU 约为 0.25ns)
L1 3~4 cycle
L2 10~20 cycle
L3 40~45 cycle
内存 120~240 cycle
缓存使用

当处理器发出内存访问请求时,会先查看缓存内是否有请求数据,如果存在(命中),则不用访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器

缓存之所以有效,主要因为程序运行时对内存的访问呈现局部性(Locality)特征。既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality),有效利用这种局部性,缓存可以达到极高的命中率

伪共享

缓存以缓存行 cache line 为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long),在 CPU 从主存获取数据时,以 cache line 为单位加载,于是相邻的数据会一并加载到缓存中

缓存会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中,CPU 要保证数据的一致性,需要做到某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效,这就是伪共享

image-20220805105059038

解决方法:

  • padding:通过填充,让数据落在不同的 cache line 中

  • @Contended:原理参考 无锁 → Adder → 优化机制 → 伪共享

Linux 查看 CPU 缓存行:

  • 命令:cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size64
  • 内存地址格式:[高位组标记] [低位索引] [偏移量]

缓存一致

缓存一致性:当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一样

image-20220809122231511

MESI(Modified Exclusive Shared Or Invalid)是一种广泛使用的支持写回策略的缓存一致性协议,CPU 中每个缓存行(caceh line)使用 4 种状态进行标记(使用额外的两位 bit 表示):

  • M:被修改(Modified)

    该缓存行只被缓存在该 CPU 的缓存中,并且是被修改过的,与主存中的数据不一致 (dirty),该缓存行中的内存需要写回 (write back) 主存。该状态的数据再次被修改不会发送广播,因为其他核心的数据已经在第一次修改时失效一次

    当被写回主存之后,该缓存行的状态会变成独享 (exclusive) 状态

  • E:独享的(Exclusive)

    该缓存行只被缓存在该 CPU 的缓存中,是未被修改过的 (clear),与主存中数据一致,修改数据不需要通知其他 CPU 核心,该状态可以在任何时刻有其它 CPU 读取该内存时变成共享状态 (shared)

    当 CPU 修改该缓存行中内容时,该状态可以变成 Modified 状态

  • S:共享的(Shared)

    该状态意味着该缓存行可能被多个 CPU 缓存,并且各个缓存中的数据与主存数据一致,当 CPU 修改该缓存行中,会向其它 CPU 核心广播一个请求,使该缓存行变成无效状态 (Invalid),然后再更新当前 Cache 里的数据

  • I:无效的(Invalid)

    该缓存是无效的,可能有其它 CPU 修改了该缓存行

解决方法:各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,协议主要有 MSI、MESI 等

\1. E、S、M 状态的缓存行都可以满足 CPU 的读请求
\2. E 状态的缓存行,有写请求,会将状态改为 M,这时并不触发向主存的写
\3. E 状态的缓存行,必须监听该缓存行的读操作,如果有,要变为 S 状态

image-20220809122326086

\4. M 状态的缓存行,必须监听该缓存行的读操作,如果有,先将其它缓存(S 状态)中该缓存行变成 I 状态(即
\6. 的流程),写入主存,自己变为 S 状态
\5. S 状态的缓存行,有写请求,走 4. 的流程
\6. S 状态的缓存行,必须监听该缓存行的失效操作,如果有,自己变为 I 状态
\7. I 状态的缓存行,有读请求,必须从主存读取

image-20220809122407620


处理机制

单核 CPU 处理器会自动保证基本内存操作的原子性

多核 CPU 处理器,每个 CPU 处理器内维护了一块内存,每个内核内部维护着一块缓存,当多线程并发读写时,就会出现缓存数据不一致的情况。处理器提供:

  • 总线锁定:当处理器要操作共享变量时,在 BUS 总线上发出一个 LOCK 信号,其他处理器就无法操作这个共享变量,该操作会导致大量阻塞,从而增加系统的性能开销(平台级别的加锁
  • 缓存锁定:当处理器对缓存中的共享变量进行了操作,其他处理器有嗅探机制,将各自缓存中的该共享变量的失效,读取时会重新从主内存中读取最新的数据,基于 MESI 缓存一致性协议来实现

有如下两种情况处理器不会使用缓存锁定:

  • 当操作的数据跨多个缓存行,或没被缓存在处理器内部,则处理器会使用总线锁定

  • 有些处理器不支持缓存锁定,比如:Intel 486 和 Pentium 处理器也会调用总线锁定

总线机制:

  • 总线嗅探:每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存对应的内存地址的数据被修改,就将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行操作时,会重新从内存中把数据读取到处理器缓存中

  • 总线风暴:当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心(写传播),CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,不断的从主内存嗅探和 CAS 循环,无效的交互会导致总线带宽达到峰值;因此不要大量使用 volatile 关键字,使用 volatile、syschonized 都需要根据实际场景


volatile

同步机制

volatile 是 Java 虚拟机提供的轻量级的同步机制(三大特性)

  • 保证可见性
  • 不保证原子性
  • 保证有序性(禁止指令重排)

性能:volatile 修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小

synchronized 无法禁止指令重排和处理器优化,为什么可以保证有序性可见性

  • 加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的
  • 线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中(JMM 内存交互章节有讲)

指令重排

volatile 修饰的变量,可以禁用指令重排

指令重排实例:

  • example 1:

    1
    2
    3
    4
    5
    6
    public void mySort() {
    int x = 11; //语句1
    int y = 12; //语句2 谁先执行效果一样
    x = x + 5; //语句3
    y = x * x; //语句4
    }

    执行顺序是:1 2 3 4、2 1 3 4、1 3 2 4

    指令重排也有限制不会出现:4321,语句 4 需要依赖于 y 以及 x 的申明,因为存在数据依赖,无法首先执行

  • example 2:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    int num = 0;
    boolean ready = false;
    // 线程1 执行此方法
    public void actor1(I_Result r) {
    if(ready) {
    r.r1 = num + num;
    } else {
    r.r1 = 1;
    }
    }
    // 线程2 执行此方法
    public void actor2(I_Result r) {
    num = 2;
    ready = true;
    }

    情况一:线程 1 先执行,ready = false,结果为 r.r1 = 1

    情况二:线程 2 先执行 num = 2,但还没执行 ready = true,线程 1 执行,结果为 r.r1 = 1

    情况三:线程 2 先执行 ready = true,线程 1 执行,进入 if 分支结果为 r.r1 = 4

    情况四:线程 2 执行 ready = true,切换到线程 1,进入 if 分支为 r.r1 = 0,再切回线程 2 执行 num = 2,发生指令重排

底层原理

缓存一致

使用 volatile 修饰的共享变量,总线会开启 CPU 总线嗅探机制来解决 JMM 缓存一致性问题,也就是共享变量在多线程中可见性的问题,实现 MESI 缓存一致性协议

底层是通过汇编 lock 前缀指令,共享变量加了 lock 前缀指令就会进行缓存锁定,在线程修改完共享变量后写回主存,其他的 CPU 核心上运行的线程根据总线嗅探机制会修改其共享变量为失效状态,读取时会重新从主内存中读取最新的数据

lock 前缀指令就相当于内存屏障,Memory Barrier(Memory Fence)

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

内存屏障有三个作用:

  • 确保对内存的读-改-写操作原子执行
  • 阻止屏障两侧的指令重排序
  • 强制把缓存中的脏数据写回主内存,让缓存行中相应的数据失效

内存屏障

1. 如何保证可见性:
  • 写屏障(sfence,Store Barrier)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

    1
    2
    3
    4
    5
    public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 赋值带写屏障
    // 写屏障
    }
  • 读屏障(lfence,Load Barrier)保证在该屏障之后的,对共享变量的读取,从主存刷新变量值,加载的是主存中最新数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public void actor1(I_Result r) {
    // 读屏障
    // ready 是 volatile 读取值带读屏障
    if(ready) {
    r.r1 = num + num;
    } else {
    r.r1 = 1;
    }
    }
  • 全能屏障:mfence(modify/mix Barrier),兼具 sfence 和 lfence 的功能

如何保证有序性
  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
1
2
3
4
5
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
1
2
3
4
5
6
7
8
9
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}

image.png

还是那句话,不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

img

交互规则

对于 volatile 修饰的变量:

  • 线程对变量的 use 与 load、read 操作是相关联的,所以变量使用前必须先从主存加载
  • 线程对变量的 assign 与 store、write 操作是相关联的,所以变量使用后必须同步至主存
  • 线程 1 和线程 2 谁先对变量执行 read 操作,就会先进行 write 操作,防止指令重排

双端检锁

检锁机制

Double-Checked Locking:双端检锁机制

DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入 volatile 可以禁止指令重排

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;

public static Singleton getInstance() {
if(INSTANCE == null) { // t2,这里的判断不是线程安全的
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
// 这里是线程安全的判断,防止其他线程在当前线程等待锁的期间完成了初始化
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

不锁 INSTANCE 的原因:

  • INSTANCE 要重新赋值
  • INSTANCE 是 null,线程加锁之前需要获取对象的引用,设置对象头,null 没有引用

实现特点:

  • 懒惰初始化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 第一个 if 使用了 INSTANCE 变量,是在同步块之外,但在多线程环境下会产生问题

DCL问题

getInstance 方法对应的字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0: 	getstatic 		#2 		// Field INSTANCE:Ltest/Singleton;
3: ifnonnull 37
6: ldc #3 // class test/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Ltest/Singleton;
14: ifnonnull 27
17: new #3 // class test/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V 执行构造方法
24: putstatic #2 // Field INSTANCE:Ltest/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Ltest/Singleton;
40: areturn
  • 17 表示创建对象,将对象引用入栈
  • 20 表示复制一份对象引用,引用地址
  • 21 表示利用一个对象引用,调用构造方法初始化对象
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

步骤 21 和 24 之间不存在数据依赖关系,而且无论重排前后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的

  • 关键在于 0:getstatic 这行代码在 monitor 控制之外,可以越过 monitor 读取 INSTANCE 变量的值
  • 当其他线程访问 INSTANCE 不为 null 时,由于 INSTANCE 实例未必已初始化,那么 t2 拿到的是将是一个未初始化完毕的单例返回,这就造成了线程安全的问题


解决方法

指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性

引入 volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性:

1
private static volatile SingletonDemo INSTANCE = null;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;

public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) { // t2
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

字节码上看不出来 volatile 指令的效果

image.png

image.png

如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  • 可见性

    • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    • 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  • 有序性

    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  • 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

image.png

饿汉式:类加载就会导致该单实例对象被创建

懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 问题1:为什么加 final   防止方法被重写
// 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例 //反序列化也会创建一个对象,破换个单例,使用readResolve方法
public final class Singleton implements Serializable {
// 问题3:为什么设置为私有? 是否能防止反射创建新的实例? //防止其他对象创建,不能,反射获取构造器还是会获取
private Singleton() {}
// 问题4:这样初始化是否能保证单例对象创建时的线程安全?//
private static final Singleton INSTANCE = new Singleton();
// 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由 //
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return INSTANCE;
}
}
1
2
3
4
5
6
7
8
9
// 问题1:枚举单例是如何限制实例个数的 ,
// 问题2:枚举单例在创建时是否有并发问题 //静态成员变量,在类加载阶段完成,不会存在
// 问题3:枚举单例能否被反射破坏单例 //不能
// 问题4:枚举单例能否被反序列化破坏单例//默认实现序列化接口,但是枚举可以防止反序列化
// 问题5:枚举单例属于懒汉式还是饿汉式//饿汉式
// 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
enum Singleton {
INSTANCE;
}

实现3:

每次都要加锁

1
2
3
4
5
6
7
8
9
10
11
12
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
// 分析这里的线程安全, 并说明有什么缺点
public static synchronized Singleton getInstance() {
if( INSTANCE != null ){
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}

实现4:DCL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public final class Singleton {
private Singleton() { }

// 问题1:解释为什么要加 volatile ? 会发生重排序 赋值和创建对象被重排序
private static volatile Singleton INSTANCE = null;

// 问题2:对比实现3, 说出这样做的意义 //从性能保证效率
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗 //防止首次创建时,多个线程并发的问题
if (INSTANCE != null) { // t2
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}

当其他线程访问 INSTANCE 不为 null 时,由于 INSTANCE 实例未必已初始化,那么 t2 拿到的是将是一个未初始化完毕的单例返回,这就造成了线程安全的问题

(推荐的)实现5:

1
2
3
4
5
6
7
8
9
10
11
public final class Singleton {
private Singleton() { }
// 问题1:属于懒汉式还是饿汉式 懒汉式
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 问题2:在创建时是否有并发问题 JVM保证其安全性
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}

ha-be

happens-before 先行发生

Java 内存模型具备一些先天的“有序性”,即不需要通过任何同步手段(volatile、synchronized 等)就能够得到保证的安全,这个通常也称为 happens-before 原则,它是可见性与有序性的一套规则总结

不符合 happens-before 规则,JMM 并不能保证一个线程的可见性和有序性

  1. 程序次序规则 (Program Order Rule):一个线程内,逻辑上书写在前面的操作先行发生于书写在后面的操作 ,因为多个操作之间有先后依赖关系,则不允许对这些操作进行重排序

  2. 锁定规则 (Monitor Lock Rule):一个 unlock 操作先行发生于后面(时间的先后)对同一个锁的 lock 操作,所以线程解锁 m 之前对变量的写(解锁前会刷新到主内存中),对于接下来对 m 加锁的其它线程对该变量的读可见

  3. volatile 变量规则 (Volatile Variable Rule):对 volatile 变量的写操作先行发生于后面对这个变量的读

  4. 传递规则 (Transitivity):具有传递性,如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C

  5. 线程启动规则 (Thread Start Rule):Thread 对象的 start()方 法先行发生于此线程中的每一个操作

    1
    2
    static int x = 10;//线程 start 前对变量的写,对该线程开始后对该变量的读可见
    new Thread(()->{ System.out.println(x); },"t1").start();
  6. 线程中断规则 (Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生

  7. 线程终止规则 (Thread Termination Rule):线程中所有的操作都先行发生于线程的终止检测,可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行

  8. 对象终结规则(Finaizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始

设计模式

两阶段终止

终止模式之两阶段终止模式:停止标记用 volatile 是为了保证该变量在多个线程之间的可见性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class TwoPhaseTermination {
// 监控线程
private Thread monitor;
// 停止标记
private volatile boolean stop = false;;

// 启动监控线程
public void start() {
monitor = new Thread(() -> {
while (true) {
Thread thread = Thread.currentThread();
if (stop) {
System.out.println("后置处理");
break;
}
try {
Thread.sleep(1000);// 睡眠
System.out.println(thread.getName() + "执行监控记录");
} catch (InterruptedException e) {
System.out.println("被打断,退出睡眠");
}
}
});
monitor.start();
}

// 停止监控线程
public void stop() {
stop = true;
monitor.interrupt();// 让线程尽快退出Timed Waiting
}
}
// 测试
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
Thread.sleep(3500);
System.out.println("停止监控");
tpt.stop();
}

Balking

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MonitorService {
// 用来表示是否已经有线程已经在执行启动了
private volatile boolean starting = false;
public void start() {
System.out.println("尝试启动监控线程...");
synchronized (this) {
if (starting) {
return;
}
starting = true;
}
// 真正启动监控线程...
}
}

对比保护性暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待

例子:希望 doInit() 方法仅被调用一次,下面的实现出现的问题:

  • 当 t1 线程进入 init() 准备 doInit(),t2 线程进来,initialized 还为false,则 t2 就又初始化一次
  • volatile 适合一个线程写,其他线程读的情况,这个代码需要加锁
1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestVolatile {
volatile boolean initialized = false;

void init() {
if (initialized) {
return;
}
doInit();
initialized = true;
}
private void doInit() {
}
}

原理之(CPU)指令级并行

1. 名词

Clock Cycle Time 时钟周期时间

主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的Cycle Time 是 1s

例如,运行一条加法指令一般需要一个时钟周期时间

CPI 平均时钟周期数

有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数

IPC 即 CPI 的倒数

IPC(Instruction Per Clock Cycle)即 CPI 的倒数,表示每个时钟周期能够运行的指令数

CPU 执行时间

程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示

1
程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time 

2. 鱼罐头的故事

加工一条鱼需要 50 分钟,只能一条鱼、一条鱼顺序加工…

image.png

image.png

即使只有一个工人,最理想的情况是:他能够在 10 分钟内同时做好这 5 件事,因为对第一条鱼的真空装罐,不会

影响对第二条鱼的杀菌出锅…

3. 指令重排序优化

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?

可以想到指令还可以再划分成一个个更小的阶段,

例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段

image.png

术语参考:

  • instruction fetch (IF)
  • instruction decode (ID)
  • execute (EX)
  • memory access (MEM)
  • register write back (WB)

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位。

提示:

分阶段,分工是提升效率的关键!

指令重排的前提是,重排指令不能影响结果,例如

1
2
3
4
5
6
7
8
// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );

// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2

4. 支持流水线的处理器

现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理

器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一

条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了

指令地吞吐率。

提示:

奔腾四(Pentium 4)支持高达 35 级流水线,但由于功耗太高被废弃

img

5. SuperScalar 处理器

大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单

元等,这样可以把多条指令也可以做到并行获取、译码等,CPU 可以在一个时钟周期内,执行多于一条指令,IPC> 1

img

img