8. JMM 和底层实现原理
8.1 线程间的通信与同步
线程之间的通信
线程的通信是指线程之间以何种机制来交换信息. 在编程中, 线程之间的通信机制有两种, 共享内存和消息传递.
在共享内存的并发模型里, 线程之间共享程序的公共状态, 线程之间通过写 - 读内存中的公共状态来隐式进行通信, 典型的共享内存通信方式就是通过共享对象进行通信.
在消息传递的并发模型里, 线程之间没有公共状态, 线程之间必须通过明确的发送消息来显式进行通信, 在 java 中典型的消息传递方式就是 wait()和 notify().
线程之间的同步
同步是指程序用于控制不同线程之间操作发生相对顺序的机制.
在共享内存并发模型里, 同步是显式进行的. 程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行.
在消息传递的并发模型里, 由于消息的发送必须在消息的接收之前, 因此同步是隐式进行的.
注意到, Java 的并发采用的是共享内存模型, 接下来将会主要进行介绍.
8.2 Java 内存模型(JMM)
JMM
Java 的内存模型如下图所示:
每个 Java 线程拥有对应的工作内存, 工作内寸通过 Save 和 Load 操作和主内存进行数据交互.
在 JVM 内部, Java 内存模型把内存分成了两部分: 线程栈区和堆区
JVM 中运行的每个线程都拥有自己的线程栈, 线程栈包含了当前线程执行的方法调用相关信息, 我们也把它称作调用栈. 随着代码的不断执行, 调用栈会不断变化.
线程栈还包含了当前方法的所有局部变量信息. 一个线程只能读取自己的线程栈, 也就是说, 线程中的本地变量对其它线程是不可见的. 即使两个线程执行的是同一段代码, 它们也会各自在自己的线程栈中创建局部变量, 因此, 每个线程中的局部变量都会有自己的版本.
堆中的对象可以被多线程共享, 如果一个线程获得一个对象的应用, 它便可访问这个对象的成员变量. 如果两个线程同时调用了同一个对象的同一个方法, 那么这两个线程便可同时访问这个对象的成员变量, 但是对于局部变量, 每个线程都会拷贝一份到自己的线程栈中.
JMM 带来的问题
上述介绍的 JMM 也带来了一些问题:
1. 共享对象对各个线程的可见性
在一个线程中修改了共享数据后, 如何保证对另外一个线程可见?
2. 共享对象的竞争现象
对于同一个共享数据, 如何保证两个线程正确的修改?
指令重排
除了之前提到的 JMM 中存在的两个问题之外, 指令重排也可能对程序的正确性产生影响.
如上图所示, JVM 中为了提高指令执行的效率, 可能在不改变运行结果的情况下, 重排部分指令的顺序达到并发执行的效果.
单线程下, 这种指令重排序会遵从以下两个规则:
1. 数据依赖性
在下面三种情况, 数据存在依赖关系, 指令重排必须保证这种情况下的正确性.
2. 控制依赖性
对于控制依赖性, 比如下面的例子, b 的值依赖于 a 的状态, 这种情况下, 指令重排也会保证这种关系的正确性.
- if (a == 1){
- b = 2;
- }
as-if-serial 语义: 不管怎么重排序 (编译器和处理器为了提高并行度),(单线程) 程序的执行结果不会改变. 在 as-if-serial 语义下, 编译器和处理器不会对存在数据依赖关系的操作做重排序, 因为这种重排序会改变执行结果.
但是在指令重排并不保证并发执行的正确性, 因此可能带来比较严重的问题, 比如下面的例子中, use()通过判断 flag 是否为 true, 来获取初始化完成的信息. 但是由于指令重排, 可能拿到错误的 a 的值.
在并发情况下, 为了解决重排序带来的问题, 引入了内存屏障来阻止重排序:
8.3 Happens-Before
定义
用 happens-before 的概念来阐述操作之间的内存可见性. 在 JMM 中, 如果一个操作执行的结果需要对另一个操作可见, 那么这两个操作之间必须要存在 happens-before 关系 .
两个操作之间具有 happens-before 关系, 并不意味着前一个操作必须要在后一个操作之前执行! happens-before 仅仅要求前一个操作 (执行的结果) 对后一个操作可见, 且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second).
对于 happens-before, 可以从下面两个方面去理解:
对用户来讲: 如果一个操作 happens-before 另一个操作, 那么第一个操作的执行结果将对第二个操作可见, 而且第一个操作的执行顺序排在第二个操作之前.
对编译器和处理器来说: 两个操作之间存在 happens-before 关系, 并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行. 如果重排序之后的执行结果, 与按 happens-before 关系来执行的结果一致, 那么这种重排序是允许的.
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.
5)start()规则: 如果线程 A 执行操作 ThreadB.start()(启动线程 B), 那么 A 线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作.
6)join()规则: 如果线程 A 执行操作 ThreadB.join()并成功返回, 那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回.
7 )线程中断规则: 对线程 interrupt 方法的调用 happens-before 于被中断线程的代码检测到中断事件的发生.
8.4 volatile 的内存语义
volatile 变量自身具有下列特性:
可见性. 对一个 volatile 变量的读, 总是能看到 (任意线程) 对这个 volatile 变量最后的写入.
原子性: 对任意单个 volatile 变量的读 / 写具有原子性, 但类似于 volatile++ 这种复合操作不具有原子性.
具体来看, 可以把对 volatile 变量的单个读 / 写, 看成是使用同一个锁对这些单个读 / 写操作做了同步. 如下面的例子所示:
等价于:
volatile 写与读
volatile 写的内存语义如下: 当写一个 volatile 变量时, JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存.
volatile 读的内存语义如下: 当读一个 volatile 变量时, JMM 会把该线程对应的本地内存置为无效. 线程接下来将从主内存中读取共享变量.
volatile 内存语义的实现
JMM 通过内存屏障插入策略, 来实现 volatile 的读写语义.
在每个 volatile 写操作的前面插入一个 StoreStore 屏障. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障.
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障.
volatile 的底层实现原理:
有 volatile 变量修饰的共享变量进行写操作的时候会使用 CPU 提供的 Lock 前缀指令.
将当前处理器缓存行的数据写回到系统内存
这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效.
volatile 语义进一步:
保证变量对所有线程可见: 注意由于一条字节 ma
8.5 锁的内存语义
当线程释放锁时, JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中.
当线程获取锁时, JMM 会把该线程对应的本地内存置为无效. 从而使得被监视器保护的临界区代码必须从主内存中读取共享变量.
8.5 final 的内存语义
编译器和处理器要遵守两个重排序规则:
在构造函数内对一个 final 域的写入, 与随后把这个被构造对象的引用赋值给一个引用变量, 这两个操作之间不能重排序.
初次读一个包含 final 域的对象的引用, 与随后初次读这个 final 域, 这两个操作之间不能重排序
final 域为引用类型
增加了如下规则: 在构造函数内对一个 final 引用的对象的成员域的写入, 与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量, 这两个操作之间不能重排序.
final 语义在处理器中的实现
会要求编译器在 final 域的写之后, 构造函数 return 之前插入一个 StoreStore 障屏.
读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个 LoadLoad 屏障.
8.6 Synchronized 的实现原理
synchronized 底层如何实现? 什么是锁的升级, 降级?
这是一个非常常见的面试题, 标准回答如下:
synchronized 代码块是由一对 monitorenter/monitorexit 指令实现的, Monitor 对象是同步的基本实现单元.
在 Java 6 之前, Monitor 的实现完全是依靠操作系统内部的互斥锁, 因为需要进行用户态到内核态的切换, 所以同步操作是一个无差别的重量级操作.
现代的(Oracle)JDK 中, JVM 进行了大量改进, 提供了三种不同的 Monitor 实现, 也就是常说的三种不同的锁: 偏斜锁(Biased Locking), 轻量级锁和重量级锁, 大大改进了其性能.
所谓锁的升级, 降级, 就是 JVM 优化 synchronized 运行的机制, 当 JVM 检测到不同的竞争状况时, 会自动切换到适合的锁实现, 这种切换就是锁的升级, 降级.
当没有竞争出现时, 默认会使用偏斜锁. JVM 会利用 CAS 操作(compare and swap), 在对象头上的 Mark Word 部分设置线程 ID, 以表示这个对象偏向于当前线程, 所以并不涉及真正的互斥锁. 这样做的假设是基于在很多应用场景中, 大部分对象生命周期中最多会被一个线程锁定, 使用偏斜锁可以降低无竞争开销.
如果有另外的线程试图锁定某个已经被偏斜过的对象, JVM 就需要撤销 (revoke) 偏斜锁, 并切换到轻量级锁实现. 轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁, 如果重试成功, 就使用普通的轻量级锁; 否则, 进一步升级为重量级锁.
Synchronized 原理详细总结
1. monitor 和对象头
Java 对象头和 monitor 是实现 synchronized 的基础.
synchronized 用的锁是存在 Java 对象头里的. JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步, 任何对象都有一个 monitor 与之关联, 当且一个 monitor 被持有后, 它将处于锁定状态. 在实现时, 使用到了 monitorenter 和 monitorexit 指令, monitorenter 指令是在编译后插入到同步代码块的开始位置, 而 monitorexit 是插入到方法结束处和异常处.
Java 对象头
synchronized 用的锁是存在 Java 对象头里的, Hotspot 虚拟机的对象头主要包括两部分数据: Mark Word(标记字段),Klass Pointer(类型指针).
Klass Point 是是对象指向它的类元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例.
Mark Word 用于存储对象自身的运行时数据, 它是实现轻量级锁和偏向锁的关键, 其中储存的数据, 如哈希码(HashCode),GC 分代年龄, 锁状态标志, 线程持有的锁, 偏向线程 ID, 偏向时间戳等等.
Java 对象头一般占有两个机器码(在 32 位虚拟机中, 1 个机器码等于 4 字节, 也就是 32bit), 但是如果对象是数组类型, 则需要三个机器码, 因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小, 但是无法从数组的元数据来确认数组的大小, 所以用一块来记录数组长度. 下图是 Java 对象头的存储结构(32 位虚拟机):
对象头信息是与对象自身定义的数据无关的额外存储成本, 但是考虑到虚拟机的空间效率, Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据, 它会根据对象的状态复用自己的存储空间, 也就是说, Mark Word 会随着程序的运行发生变化, 变化状态如下(32 位虚拟机):
Monitor
Monitor 可以把它理解为一个同步工具, 也可以描述为一种同步机制, 它通常被描述为一个对象.
与一切皆对象一样, 所有的 Java 对象是天生的 Monitor, 每一个 Java 对象都有成为 Monitor 的潜质, 因为在 Java 的设计中 , 每一个 Java 对象自生成后就自带一种看不见的锁, 它叫做内部锁或者 Monitor 锁.
Monitor 是线程私有的数据结构, 每一个线程都有一个可用 monitor record 列表, 同时还有一个全局的可用列表. 每一个被锁住的对象都会和一个 monitor 关联(对象头的 MarkWord 中的 LockWord 指向 monitor 的起始地址), 同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识, 表示该锁被这个线程占用. 其结构如下:
Owner: 初始时为 NULL 表示当前没有任何线程拥有该 monitor record, 当线程成功拥有该锁后保存线程唯一标识, 当锁被释放时又设置为 NULL;
EntryQ: 关联一个系统互斥锁(semaphore), 阻塞所有试图锁住 monitor record 失败的线程.
RcThis: 表示 block 或 waiting 在该 monitor record 上的所有线程的个数.
Nest: 用来实现重入锁的计数.
HashCode: 保存从对象头拷贝过来的 HashCode 值(可能还包含 GC age).
Candidate: 用来避免不必要的阻塞或等待线程唤醒, 因为每一次只有一个线程能够成功拥有锁, 如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程, 会引起不必要的上下文切换 (从阻塞到就绪然后因为竞争锁失败又被阻塞) 从而导致性能严重下降. Candidate 只有两种可能的值 0 表示没有需要唤醒的线程 1 表示要唤醒一个继任线程来竞争锁.
2. 偏向锁 & 轻量级锁 & 重量级锁
Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗, 引入了 "偏向锁" 和 "轻量级锁": 锁一共有 4 种状态, 级别从低到高依次是: 无锁状态, 偏向锁状态, 轻量级锁状态和重量级锁状态.
偏向锁
HotSpot 的作者经过研究发现, 大多数情况下, 锁不仅不存在多线程竞争, 而且总是由同一线程多次获得. 偏向锁是为了在只有一个线程执行同步块时提高性能.
当一个线程访问同步块并获取锁时, 会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID, 以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁, 只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁.
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径, 因为轻量级锁的获取及释放依赖多次 CAS 原子指令, 而偏向锁只需要检查是否为偏向锁, 锁标识为以及 ThreadID 即可.
获取锁的流程:
检测 Mark Word 是否为可偏向状态, 即偏向锁的标识是否设置成 1, 锁标志位是否为 01-- 确认为可偏向状态.
若为可偏向状态, 则测试线程 ID 是否为当前线程 ID, 如果是, 则执行步骤(5), 否则执行步骤(3);
如果线程 ID 不为当前线程 ID, 则通过 CAS 操作竞争锁, 竞争成功, 则将 Mark Word 的线程 ID 替换为当前线程 ID, 否则执行线程(4);
通过 CAS 竞争锁失败, 证明当前存在多线程竞争情况, 当到达全局安全点(这个时间点是上没有正在执行的代码), 获得偏向锁的线程被挂起, 偏向锁升级为轻量级锁, 然后被阻塞在安全点的线程继续往下执行同步代码块;
执行同步代码块
释放锁
偏向锁的释放采用了一种只有竞争才会释放锁的机制, 线程是不会主动去释放偏向锁, 需要等待其他线程来竞争. 偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码). 其步骤如下:
暂停拥有偏向锁的线程, 判断锁对象是否还处于被锁定状态;
撤销偏向锁, 恢复到无锁状态 (01) 或者轻量级锁的状态;
关闭偏向锁
偏向锁在 Java 6 和 Java 7 里是默认启用的. 由于偏向锁是为了在只有一个线程执行同步块时提高性能, 如果你确定应用程序里所有的锁通常情况下处于竞争状态, 可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false, 那么程序默认会进入轻量级锁状态.
轻量级锁
轻量级锁是为了在线程近乎交替执行同步块时提高性能.
加锁过程
在代码进入同步块的时候, 如果同步对象锁状态为无锁状态 (锁标志位为 "01" 状态, 是否为偏向锁为 "0"), 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record) 的空间, 用于存储锁对象目前的 Mark Word 的拷贝, 官方称之为 Displaced Mark Word. 这时候线程堆栈与对象头的状态如下图所示.
拷贝对象头中的 Mark Word 复制到锁记录中.
拷贝成功后, 虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针, 并将 Lock record 里的 owner 指针指向 object mark Word. 如果更新成功, 则执行步骤(4), 否则执行步骤(5).
如果这个更新动作成功了, 那么这个线程就拥有了该对象的锁, 并且对象 Mark Word 的锁标志位设置为 "00", 即表示此对象处于轻量级锁定状态, 这时候线程堆栈与对象头的状态如下图所示.
如果这个更新操作失败了, 虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧, 如果是就说明当前线程已经拥有了这个对象的锁, 那就可以直接进入同步块继续执行. 否则说明多个线程竞争锁, 若当前只有一个等待线程, 则可通过自旋稍微等待一下, 可能另一个线程很快就会释放锁. 但是当自旋超过一定的次数, 或者一个线程在持有锁, 一个在自旋, 又有第三个来访时, 轻量级锁膨胀为重量级锁, 重量级锁使除了拥有锁的线程以外的线程都阻塞, 防止 CPU 空转, 锁标志的状态值变为 "10",Mark Word 中存储的就是指向重量级锁 (互斥量) 的指针, 后面等待锁的线程也要进入阻塞状态.
解锁过程
通过 CAS 操作尝试把线程中复制的 Displaced Mark Word 对象替换当前的 Mark Word.
如果替换成功, 整个同步过程就完成了.
如果替换失败, 说明有其他线程尝试过获取该锁(此时锁已膨胀), 那就要在释放锁的同时, 唤醒被挂起的线程.
自旋锁
1. 基于乐观情况下推荐使用, 即锁竞争不强, 锁等待时间不长的情况下推荐使用
2. 单 CPU 无效, 因为基于 cas 的轮询会占用 CPU, 导致无法做线程切换
3. 轮询不产生上下文切换, 如果可估计到睡眠的时间很长, 用互斥锁更好
重量级锁
如上轻量级锁的加锁过程见轻量级锁的步骤(5), 轻量级锁所适应的场景是线程近乎交替执行同步块的情况, 如果存在同一时间访问同一锁的情况, 就会导致轻量级锁膨胀为重量级锁. Mark Word 的锁标记位更新为 10,Mark Word 指向互斥量(重量级锁).
Synchronized 的重量级锁是通过对象内部的一个叫做监视器锁 (monitor) 来实现的, 监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的. 而操作系统实现线程之间的切换需要从用户态转换到核心态, 这个成本非常高, 状态之间的转换需要相对比较长的时间, 这就是为什么 Synchronized 效率低的原因.
3. 三种锁的切换
一个对象刚开始实例化的时候, 没有任何线程来访问它的时候. 它是可偏向的, 意味着, 它现在认为只可能有一个线程来访问它, 所以当第一个线程来访问它的时候, 它会偏向这个线程, 此时, 对象持有偏向锁. 偏向第一个线程, 这个线程在修改对象头成为偏向锁的时候使用 CAS 操作, 并将对象头中的 ThreadID 改成自己的 ID, 之后再次访问这个对象时, 只需要对比 ID, 不需要再使用 CAS 在进行操作.
一旦有第二个线程访问这个对象, 因为偏向锁不会主动释放, 所以第二个线程可以看到对象时偏向状态, 这时表明在这个对象上已经存在竞争了. 检查原来持有该对象锁的线程是否依然存活, 如果挂了, 则可以将对象变为无锁状态, 然后重新偏向新的线程. 如果原来的线程依然存活, 则马上执行那个线程的操作栈, 检查该对象的使用情况, 如果仍然需要持有偏向锁, 则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的), 此时轻量级锁由原持有偏向锁的线程持有, 继续执行其同步代码, 而正在竞争的线程会进入自旋等待获得该轻量级锁.
轻量级锁认为竞争存在, 但是竞争的程度很轻, 一般两个线程对于同一个锁的操作都会错开, 或者说稍微等待一下(自旋), 另一个线程就会释放锁. 但是当自旋超过一定的次数, 或者一个线程在持有锁, 一个在自旋, 又有第三个来访时, 轻量级锁膨胀为重量级锁, 重量级锁使除了拥有锁的线程以外的线程都阻塞, 防止 CPU 空转.
偏向所锁, 轻量级锁都是乐观锁, 重量级锁是悲观锁.
悲观锁
总是假设最坏的情况, 每次去拿数据的时候都认为别人会修改, 所以每次在拿数据的时候都会上锁, 这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用, 其它线程阻塞, 用完后再把资源转让给其它线程). 传统的关系型数据库里边就用到了很多这种锁机制, 比如行锁, 表锁等, 读锁, 写锁等, 都是在做操作之前先上锁. Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现.
乐观锁
总是假设最好的情况, 每次去拿数据的时候都认为别人不会修改, 所以不会上锁, 但是在更新的时候会判断一下在此期间别人有没有去更新这个数据, 可以使用版本号机制和 CAS 算法实现. 乐观锁适用于多读的应用类型, 这样可以提高吞吐量, 像数据库提供的类似于 write_condition 机制, 其实都是提供的乐观锁. 在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的.
乐观锁适用于写比较少的情况下(多读场景), 即冲突真的很少发生的时候, 这样可以省去了锁的开销, 加大了系统的整个吞吐量. 但如果是多写的情况, 一般会经常产生冲突, 这就会导致上层应用会不断的进行 retry, 这样反倒是降低了性能, 所以一般多写的场景下用悲观锁就比较合适.
参考链接:
https://zhuanlan.zhihu.com/p/29866981
来源: https://www.cnblogs.com/way2backend/p/12117047.html