讲义整理-JMM 和底层实现原理

18 篇文章 0 订阅
12 篇文章 0 订阅


JMM 和底层实现原理

JMM 基础-计算机原理

Java 内存模型即 Java Memory Model,简称 JMM。JMM 定义了 Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM 是整个计算机虚拟模型,所以 JMM是隶属于 JVM 的。Java1.5 版本对其进行了重构,现在的 Java 仍沿用了 Java1.5的版本。Jmm 遇到的问题与现代计算机中遇到的问题是差不多的。

物理计算机中的并发问题,物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。

根据《Jeff Dean 在 Google 全体工程大会的报告》我们可以看到
在这里插入图片描述
计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的。
(以下案例仅做说明,并不代表真实情况。)

如果从内存中读取 1M 的 int 型数据由CPU 进行累加,耗时要多久?
做个简单的计算,1M 的数据,Java 里 int 型为 32 位,4 个字节,共有1024*1024/4 = 262144 个整数 ,则 CPU 计算耗时:262144 0.6 = 157 286 纳秒,而我们知道从内存读取 1M 数据需要 250000 纳秒,两者虽然有差距(当然这个差距并不小,十万纳秒的时间足够 CPU 执行将近二十万条指令了),但是还在一个数量级上。但是,没有任何缓存机制的情况下,意味着每个数都需要从内存中读取,这样加上 CPU 读取一次内存需要 100 纳秒,262144 个整数从内存读取到 CPU 加上计算时间一共需要 262144100+250000 = 26 464 400 纳秒,这就存在着数量级上的差异了。

而且现实情况中绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个 I/O 操作是基本上是无法消除的(无法仅靠寄存器来完成所有运算任务)。早期计算机中cpu 和内存的速度是差不多的,但在现代计算机中,cpu 的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

在这里插入图片描述
在这里插入图片描述
在计算机系统中,寄存器划是 L0 级缓存,接着依次是 L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0 寄存器是 L1 一级缓存的缓存,L1 是 L2 的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集。

在这里插入图片描述
在现代 CPU 上,一般来说 L0, L1,L2,L3 都集成在 CPU 内部,而 L1 还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2 缓存,然后一个 CPU 的多个核心共享最后一层 CPU 缓存 L3

物理内存模型带来的问题

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。

现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。

在这里插入图片描述
在这里插入图片描述
处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终可能得到 x=y=0的结果。

处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(步骤 A1,B1),然后从内存中读取另一个共享变量(步骤 A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(步骤 A3,B3)。当以这种时序执行时,程序就可以得到 x=y=0 的结果。

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是 A2→A1。

如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?
为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly 及 Dragon Protocol 等。

伪共享

前面我们已经知道,CPU 中有好几级高速缓存。但是 CPU 缓存系统中是以缓存行(cache line)为单位存储的。目前主流的 CPU Cache 的 Cache Line 大小都是 64Bytes。Cache Line 可以简单的理解为 CPU Cache 中的最小缓存单位,今天的CPU 不再是按字节访问内存,而是以 64 字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存。

一个缓存行可以存储多个变量(存满当前缓存行的字节数);而 CPU 对缓存的修改又是以缓存行为最小单位的,在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。

为了避免伪共享,我们可以使用数据填充的方式来避免,即单个数据填充满一个 CacheLine。这本质是一种空间换时间的做法。但是这种方式在 Java7 以后可能失效。

Java8 中已经提供了官方的解决方案,Java8 中新增了一个注解
@sun.misc.Contended。
比如 JDK 的 ConcurrentHashMap 中就有使用

在这里插入图片描述
加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在 jvm 启动时设置-XX:-RestrictContended 才会生效。
测试代码,参见 cn.enjoyedu.ch9. FalseSharing 一个类中,只有一个 long 类型的变量:
在这里插入图片描述
定义一个 VolatileLong 类型的数组,然后让多个线程同时并发访问这个数组,这时可以想到,在多个线程同时处理数据时,数组中的多个 VolatileLong 对象可能存在同一个缓存行中。
在这里插入图片描述
运行后,可以得到运行时间
在这里插入图片描述
花费了 39 秒多。
我们改用进行了缓存行填充的变量
在这里插入图片描述
花费了 8.1 秒,如果任意注释上下填充行的任何一行,时间表现不稳定,从8 秒到 20 秒都有,但是还是比不填充要快。具体原因目前未知。
再次改用注解标识的变量,同时加入参数-XX:-RestrictContended
在这里插入图片描述
花费了 7.7 秒。
由上述的实验结果表明,伪共享确实会影响应用的性能。

Java 内存模型(JMM)

从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
在这里插入图片描述
在这里插入图片描述

Java 内存模型带来的问题

可见性问题

在这里插入图片描述
左边 CPU 中运行的线程从主存中拷贝共享对象 obj 到它的 CPU 缓存,把对象 obj 的 count 变量改为 2。但这个变更对运行在右边 CPU 中的线程不可见,因为这个更改还没有 flush 到主存中。

在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。但是什么时候最新的值会被刷新至主内存中是不太确定,一般来说会很快,但具体时间不知。

要解决共享对象可见性这个问题,我们可以使用 volatile 关键字或者是加锁。

竞争问题

线程 A 和线程 B 共享一个对象 obj。假设线程 A 从主存读取 Obj.count 变量到自己的 CPU 缓存,同时,线程 B 也读取了 Obj.count 变量到它的 CPU 缓存,并且这两个线程都对 Obj.count 做了加 1 操作。此时,Obj.count 加 1 操作被执行了两次,不过都在不同的 CPU 缓存中。
在这里插入图片描述
如果这两个加 1 操作是串行执行的,那么 Obj.count 变量便会在原始值上加2,最终主存中的 Obj.count 的值会是 3。然而图中两个加 1 操作是并行的,不管是线程 A 还是线程 B 先 flush 计算结果到主存,最终主存中的 Obj.count 只会增加 1 次变成 2,尽管一共有两次加 1 操作。 要解决上面的问题我们可以使用 java synchronized 代码块

重排序

重排序类型

除了共享内存和工作内存带来的问题,还存在重排序的问题:在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分 3 种类型。

  • 1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

数据依赖性

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列 3 种类型,上面3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
在这里插入图片描述
在这里插入图片描述
很明显,A 和 C 存在数据依赖,B 和 C 也存在数据依赖,而 A 和 B 之间不存在数据依赖,如果重排序了 A 和 C 或者 B 和 C 的执行顺序,程序的执行结果就会被改变。

很明显,不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个 as-if-serial的概念。

as-if-serial

as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。

A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。
在这里插入图片描述
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、runtime 和处理器可以让我们感觉到:单线程程序看起来是按程序的顺序来执行的。asif-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

控制依赖性

在这里插入图片描述
上述代码中,flag 变量是个标记,用来标识变量 a 是否已被写入,在 use 方法中变量 i 的赋值依赖 if (flag)的判断,这里就叫控制依赖,如果发生了重排序,结果就不对了。
考察代码,我们可以看见,

操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。操作 3 和操作 4 则存在所谓控制依赖关系

在程序中,当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。猜测执行实质上对操作 3 和 4 做了重排序,问题在于这时候,a 的值还没被线程 A赋值。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因)。

但是对多线程来说就完全不同了:这里假设有两个线程 A 和 B,A 首先执行init ()方法,随后 B 线程接着执行 use ()方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入呢?答案是:不一定能看到。

让我们先来看看,当操作 1 和操作 2 重排序,操作 3 和操作 4 重排序时,可能会产生什么效果?操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。

此时,变量 a 还没有被线程 A 写入,这时就会发生错误!

所以在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

内存屏障

Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。

  1. 保证特定操作的执行顺序。
  2. 影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和 CPU 能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条 Memory Barrier 会告诉编译器和 CPU:不管什么指令都不能和这条Memory Barrier 指令重排序。

Memory Barrier 所做的另外一件事是强制刷出各种 CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此,任何 CPU 上的线程都能读取到这些数据的最新版本。

JMM 把内存屏障指令分为 4 类

在这里插入图片描述
StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他 3 个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

临界区

在这里插入图片描述
JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得多线程在这两个时间点按某种顺序执行。

临界区内的代码则可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。虽然线程 A 在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

回想一下,为啥线程安全的单例模式中一般的双重检查不能保证真正的线程安全?

happens-before

在 Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before 的概念来阐述操作之间的内存可见性。对应 Java 程序员来说,理解 happens-before 是理解 JMM 的关键。

JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before 关系本质上和 as-if-serial 语义是一回事。as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变。

定义

用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before 关系 。

两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)

加深理解

上面的定义看起来很矛盾,其实它是站在不同的角度来说的。

  • 1)站在 Java 程序员的角度来说:JMM 保证,如果一个操作 happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 2)站在编译器和处理器的角度来说:JMM 允许,两个操作之间存在happens-before 关系,不要求 Java 平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序是允许的。

回顾我们前面存在数据依赖性的代码:
在这里插入图片描述
站在我们 Java 程序员的角度:
在这里插入图片描述
但是仔细考察,2、3 是必需的,而 1 并不是必需的,因此 JMM 对这三个happens-before 关系的处理就分为两类:

  1. 会改变程序执行结果的重排序
  2. 不会改变程序执行结果的重排序

JMM 对这两种不同性质的重排序,采用了不同的策略,如下:

  1. 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序;
  2. 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求。于是,站在我们程序员的角度,看起来这个三个操作满足了 happens-before关系,而站在编译器和处理器的角度,进行了重排序,而排序后的执行结果,也是满足 happens-before 关系的。
    在这里插入图片描述

Happens-Before 规则

JMM 为我们提供了以下的 Happens-Before 规则:

  1. 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  3. volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。4)传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  4. start()规则:如果线程 A 执行操作 ThreadB.start()(启动线程 B),那么 A线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作。
  5. join()规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。
    7 . 线程中断规则:对线程 interrupt 方法的调用 happens-before 于被中断线程的代码检测到中断事件的发生。

volatile 详解

volatile 特性

可以把对 volatile 变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步
在这里插入图片描述
所以 volatile 变量自身具有下列特性

可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile变量最后的写入。
原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++这种复合操作不具有原子性。

volatile 的内存语义

内存语义:可以简单理解为 volatile,synchronize,atomic,lock 之类的在 JVM中的内存方面实现原则。
volatile 写的内存语义如下:
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile 读的内存语义如下:
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

所以对于代码
在这里插入图片描述
如果我们将 flag 变量以 volatile 关键字修饰,那么实际上:线程 A 在写 flag变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值都被刷新到主内存中。

在这里插入图片描述
在读 flag 变量后,本地内存 B 包含的值已经被置为无效。此时,线程 B 必须从主内存中读取共享变量。线程 B 的读取操作将导致本地内存 B 与主内存中的共享变量的值变成一致。

在这里插入图片描述
如果我们把 volatile 写和 volatile 读两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。

为何 volatile 不是线程安全的

在这里插入图片描述

volatile 内存语义的实现

volatile 重排序规则表
在这里插入图片描述
总结起来就是:
当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。

当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。

当第一个操作是 volatile 写,第二个操作是 volatile

volatile 的内存屏障
在 Java 中对于 volatile 修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序问题。

volatile 写
在这里插入图片描述
storestore 屏障:对于这样的语句 store1; storestore; store2,在 store2 及后续写入操作执行前,保证 store1 的写入操作对其它处理器可见。(也就是说如果出现 storestore 屏障,那么 store1 指令一定会在 store2 之前执行,CPU 不会 store1与 store2 进行重排序)
storeload 屏障:对于这样的语句 store1; storeload; load2,在 load2 及后续所有读取操作执行前,保证 store1 的写入对所有处理器可见。(也就是说如果出现storeload 屏障,那么 store1 指令一定会在 load2 之前执行,CPU 不会对 store1 与load2 进行重排序)

volatile 读
在这里插入图片描述
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。在每个 volatile 读操
作的后面插入一个 loadstore 屏障。

  • loadload 屏障:对于这样的语句 load1; loadload; load2,在 load2 及后续读取操作要读取的数据被访问前,保证 load1 要读取的数据被读取完毕。(也就是说,如果出现 loadload 屏障,那么 load1 指令一定会在 load2 之前执行,CPU不会对 load1 与 load2 进行重排序)
  • loadstore 屏障:对于这样的语句 load1; loadstore; store2,在 store2 及后续写入操作被刷出前,保证 load1 要读取的数据被读取完毕。(也就是说,如果出现 loadstore 屏障,那么 load1 指令一定会在 store2 之前执行,CPU 不会对load1 与 store2 进行重排序)

volatile 的实现原理

通过对 OpenJDK 中的 unsafe.cpp 源码的分析,会发现被 volatile 关键字修饰的变量会存在一个“lock:”的前缀。

Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。

同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他 CPU 里缓存了该地址的数据无效。

在具体的执行上,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。在 Lock 锁住总线的时候,其他

final 的内存语

在构造线程的类时,我们有种方式就是让类中所有的成员变量都不可变,利用的就是 final 关键字,那么这个 final 为何可以做到呢?重排序这种优化动作对构造方法,一样也是存在的。这就说明,一个成员变量加了 final 关键字后,JMM 一定是做了相关处理的。

final 的两个重排序规则

对应 final 域,编译器和处理器需要遵守两个重排序规则。我们以代码cn.enjoyedu.ch9.semantics. FinalMemory 来说明
在这里插入图片描述
我们假设一个线程 A 执行 writer 方法,随后另一个线程 B 执行 reader 方法。

  • 1、在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
    看 write()方法,只包含一行代码 obj = new FinalMemory();。这一行代码包含两个步骤:
    构造一个 FinalMemory 类型的对象。
    把这个对象的引用赋值给引用变量 obj。
    假设线程 B 读对象引用(FinalMemory object = obj)与读对象的成员域之间(int a = object.i;int b = object.j)没有重排序,下面的图是一种可能的执行时序:
    在这里插入图片描述

从上面可能的时序图中我们可以看到,写普通域被编译器重排序到了构造函数之外,读线程 B 错误的读取了普通变量 i 初始化之前的值。而写 final 域的操作,被写 final 域的重排序规则“限制”到了构造函数之内,读线程 B 正确读取了 final变量初始化之后的值。
总结:写 final 域的重排序规则可以确保在对象引用为任意线程可见之前,对象的 final 域已经被正常的初始化了,而普通域不具有这样的保证。

  • 2、初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序
    在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作。编译器会在读 final 域操作的前面插入一个 LoadLoad屏障。
    reader()方法包含 3 个步骤:
    • 初次读引用变量 obj
    • 初次读引用变量 obj 指向对象的普通域 i
    • 初次读引用变量 obj 指向对象的 final 域 j

我们假设写线程 A 没有发生任何重排序,则下图是一种可能的时序:
在这里插入图片描述
读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被线程 A 写入,所以上面的是一个错误的读取操作。但是读 final 域的重排序规则把读对象 final 域的操作“限定”在读对象引用之后,该 final 域已经被 A 线程初始化了,是一个正确的读取操作。

总结:读 final 域的重排序规则可以确保在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。

final 域为引用类型

我们以代码 cn.enjoyedu.ch9.semantics. FinalRefMemory 来说明
在这里插入图片描述
在上面的代码中,final 域是一个引用类型,它引用了一个 int 类型的数组,对于引用类型,写 final 域的重排序规则对编译器和处理器增加了一下的约束:在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

我们假设线程 A 先执行 write0 操作,执行完后线程 B 执行 write1 操作,执行完后线程 C 执行 reader 操作,下图是一种可能的执行时

在这里插入图片描述

  • 1 是对 final 域的写入,
  • 2 是对这个 final 域引用的对象的成员域的写入,
  • 3 是把被构造的对象的引用赋值给某个引用变量。
    这里除了前面提到的 1 不能和 3重排序外,2 和 3 也不能重排序。

JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看得到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。

如果想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线程 C 之间需要使用同步(lock 或 volatile)来确保内存可见性。

final 引用不能从构造函数内逃逸

写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中逃逸。
我们以 cn.enjoyedu.ch9.semantics. FinalEscape 为例来说明
在这里插入图片描述
假设一个线程 A 执行 writer()方法,另一个线程 B 执行 reader()方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且在程序中操作 2 排在操作 1 后面,执行 read()方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重排序。
在这里插入图片描述
因此在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的 final 域可能还没有被初始化。

final 语义的实现

会要求编译器在 final 域的写之后,构造函数 return 之前插入一个 StoreStore障屏。
读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个LoadLoad 屏障

锁的内存语义

当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
在这里插入图片描述
如果我们回顾第一章的 VolatileCase,我们知道,为了让子线程可以及时看到 ready 变量的修改,我们需要将 ready 变量以 volatile 来修饰。
在这里插入图片描述
但是,当我们将程序做如下改造
在这里插入图片描述
我们可以看见子线程同样可以中止,为何?我们观察 System.out.println 的实现,
在这里插入图片描述
结合前面锁的内存语义,我们可以知道,当进入 synchronized 语句块时,子线程会被强制从主内存中读取共享变量,其中就包括了 ready 变量,所以子线程同样中止了。

synchronized 的实现原理

Synchronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter 和 MonitorExit 指令来实现。

对同步块,MonitorEnter 指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象 Monitor 的所有权,即尝试获得该对象的锁,而monitorExit 指令则插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须有对应的 MonitorExit。

对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令monitorenter 和 monitorexit 来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED 标示符。

JVM 就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。

synchronized 使用的锁是存放在 Java 对象头里面,
在这里插入图片描述
具体位置是对象头里面的 MarkWord,MarkWord 里默认数据是存储对象的HashCode 等信息,
在这里插入图片描述
但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式
在这里插入图片描述

了解各种锁

自旋锁

原理
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

但是线程自旋是需要消耗 CPU 的,说白了就是让 CPU 在做无用功,线程不能一直占用 CPU 自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗!

但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cup的线程又不能获取到 cpu,造成 cpu 的浪费。

自旋锁时间阈值
自旋锁的目的是为了占着 CPU 的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋次数很重要

JVM 对于自旋次数的选择,jdk1.5 默认为 10 次,在 1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。

JDK1.6 中-XX:+UseSpinning 开启自旋锁; JDK1.7 后,去掉此参数,由 jvm 控制;

锁的状态

一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。

偏向锁

引入背景:大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的 CAS 操作。

偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,减少加锁/解锁的一些 CAS 操作(比如等待队列的一些 CAS 操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM 会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

偏向锁获取过程:

  • 步骤 1、 访问 Mark Word 中偏向锁的标识是否设置成 1,锁标志位是否为01,确认为可偏向状态。
  • 步骤 2、 如果为可偏向状态,则测试线程 ID 是否指向当前线程,如果是,进入步骤 5,否则进入步骤 3。
  • 步骤 3、 如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞争成功,则将 Mark Word 中线程 ID 设置为当前线程 ID,然后执行 5;如果竞争失败,执行 4。
  • 步骤 4、 如果 CAS 获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致 stop the word)
  • 步骤 5、 执行同步代码。
    在这里插入图片描述

偏向锁的释放:
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁的适用场景
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致 stop the word 操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致 stw,导致性能下降,这种情况下应当禁用。

jvm 开启/关闭偏向锁

  • 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 关闭偏向锁:-XX:-UseBiasedLocking

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;

轻量级锁的加锁过程:
在代码进入同步块的时候,如果同步对象锁状态为无锁状态且不允许进行偏向(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word 的拷贝,官方称之为 Displaced Mark Word。

拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 LockRecord 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更新成功,则执行步骤 4,否则执行步骤 5。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 MarkWord 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态

如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,当竞争线程尝试占用轻量级锁失败多次之后,轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
在这里插入图片描述

不同锁的比较

在这里插入图片描述

JDK 对锁的更多优化措

逃逸分析

如果证明一个对象不会逃逸方法外或者线程外,则可针对此变量进行优化:同步消除 synchronization Elimination,如果一个对象不会逃逸出线程,则对此变量的同步措施可消除。

锁消除和粗化

  • 锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可能发生共享数据竞争,则会去掉这些锁。
  • 锁粗化:将临近的代码块用同一个锁合并起来。
  • 消除无意义的锁获取和释放,可以提高程序运行性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值