共计 5249 个字符,预计需要花费 14 分钟才能阅读完成。
前面两篇文章我介绍了一下
- 看完你就应该能明白的悲观锁和乐观锁
- 看完你就明白的锁系列之自旋锁
看完你就会知道,线程如果锁住了某个资源,致使其他线程无法访问的这种锁被称为悲观锁,相反,线程不锁住资源的锁被称为乐观锁,而自旋锁是基于 CAS 机制实现的,CAS 又是乐观锁的一种实现,那么对于锁来说,多个线程同步访问某个资源的流程细节是否一样呢?换句话说,在多线程同步访问某个资源时,锁的状态会如何变化呢?本篇文章来探讨一下。
锁状态的分类
Java 语言专门针对 synchronized
关键字设置了四种状态,它们分别是:无锁、偏向锁、轻量级锁和重量级锁,但是在了解这些锁之前还需要先了解一下 Java 对象头和 Monitor。
Java 对象头
我们知道 synchronized 是悲观锁,在操作同步之前需要给资源加锁,这把锁就是对象头里面的,而 Java 对象头又是什么呢?我们以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段)
和 Klass Pointer(类型指针)
。
Mark Word:默认存储对象的 HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以 Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了 64bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的
用中文翻译过来就是
- 无状态也就是
无锁
的时候,对象头开辟 25bit 的空间用来存储对象的 hashcode,4bit 用于存放分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为 01 偏向锁
中划分更细,还是开辟 25bit 的空间,其中 23bit 用来存放线程 ID,2bit 用来存放 epoch,4bit 存放分代年龄,1bit 存放是否偏向锁标识,0 表示无锁,1 表示偏向锁,锁的标识位还是 01轻量级锁
中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为 00重量级锁
中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为 11GC 标记
开辟 30bit 的内存空间却没有占用,2bit 空间存放锁标志位为 11。
其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1bit 区分了这是无锁状态还是偏向锁状态。
关于为什么这么分配的内存,我们可以从 OpenJDK
中的 markOop.hpp 类中的枚举窥出端倪
来解释一下
- age_bits 就是我们说的分代回收的标识,占用 4 字节
- lock_bits 是锁的标志位,占用 2 个字节
- biased_lock_bits 是是否偏向锁的标识,占用 1 个字节
- max_hash_bits 是针对无锁计算的 hashcode 占用字节数量,如果是 32 位虚拟机,就是 32 – 4 – 2 -1 = 25 byte,如果是 64 位虚拟机,64 – 4 – 2 – 1 = 57 byte,但是会有 25 字节未使用,所以 64 位的 hashcode 占用 31 byte
- hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取 31,否则取真实的字节数
- cms_bits 我觉得应该是不是 64 位虚拟机就占用 0 byte,是 64 位就占用 1byte
- epoch_bits 就是 epoch 所占用的字节大小,2 字节。
Synchronized 锁
synchronized
用的锁是存在 Java 对象头里的。
JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1,相应地,在执行 monitorexit 指令时会将锁计数器减 1,当计数器被减到 0 时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
Monitor
Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 重量级锁
。
Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了 偏向锁
和轻量级锁
:锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
所以锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6 中默认是开启偏向锁和轻量级锁的,我们也可以通过 -XX:-UseBiasedLocking=false 来禁用偏向锁。
锁的分类及其解释
无锁
无锁状态
,无锁即没有对资源进行锁定,所有的线程都可以对同一个资源进行访问,但是只有一个线程能够成功修改资源。
无锁的特点就是在循环内进行修改操作,线程会不断的尝试修改共享资源,直到能够成功修改资源并退出,在此过程中没有出现冲突的发生,这很像我们在之前文章中介绍的 CAS 实现,CAS 的原理和应用就是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
Hotspot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
可以从对象头的分配中看到,偏向锁要比无锁多了 线程 ID
和 epoch
,当一个线程访问同步代码块并获取锁时,会在对象头和栈帧的记录中存储线程的 ID,等到下一次线程在进入和退出同步代码块时就不需要进行 CAS
操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程 ID,判断的标志当然是根据锁的标志位来判断的。
偏向锁的获取过程
- 访问 Mark Word 中偏向锁的标志是否设置成 1,锁的标志位是否是 01 — 确认为可偏向状态。
- 如果确认为可偏向状态,判断当前线程 id 和 对象头中存储的线程 ID 是否一致,如果一致的话,则执行步骤 5,如果不一致,进入步骤 3
- 如果当前线程 ID 与对象头中存储的线程 ID 不一致的话,则通过 CAS 操作来竞争获取锁。如果竞争成功,则将 Mark Word 中的线程 ID 修改为当前线程 ID,然后执行步骤 5,如果不一致,则执行步骤 4
- 如果 CAS 获取偏向锁失败,则表示有竞争(CAS 获取偏向锁失败则表明至少有其他线程曾经获取过偏向锁,因为线程不会主动释放偏向锁)。当到达全局安全点(SafePoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动状态,则将对象头置为
无锁状态 (标志位为 01)
,然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁
的状态(标志位为00
),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。 - 执行同步代码
偏向锁的释放过程
偏向锁的释放过程可以参考上述的步骤 4,偏向锁在遇到其他线程竞争锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为01
)或轻量级锁(标志位为00
)的状态。
关闭偏向锁
偏向锁在 Java 6 和 Java 7 里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
关于 epoch
真正理解 epoch 的概念比较复杂,这里简单理解,就是 epoch 的值可以作为一种检测偏向锁有效性的 时间戳
轻量级锁
轻量级锁
是指当前锁是偏向锁的时候,被另外的线程所访问,那么偏向锁就会升级为 轻量级锁
,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
加锁过程
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为 01 状态,是否为偏向锁为 0),虚拟机首先将在当前线程的栈帧中建立一个名为 锁记录(Lock Record)
的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 复制到锁记录中。
拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为 00,表示此对象处于轻量级锁定状态。
如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为 10,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
重量级锁
重量级锁也就是通常说 synchronized 的对象锁,锁标识位为 10,其中指针指向的是 monitor 对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
上图简单描述多线程获取锁的过程,当多个线程同时访问一段同步代码时,首先会进入 Entry Set 当线程获取到对象的 monitor 后进入 The Owner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器 count 加 1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor (锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。
由此看来,monitor 对象存在于每个 Java 对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象 Object 中的原因。(部分来源于网络)
下面为自己做个宣传,欢迎关注公众号 Java 建设者,号主是 Java 技术栈,热爱技术,喜欢阅读,热衷于分享和总结,希望能把每一篇好文章分享给成长道路上的你。关注公众号回复 002 领取为你特意准备的大礼包,你一定会喜欢并收藏的。
文章参考:
不可不说的 Java“锁”事
白话 Synchronized
https://gist.github.com/artur…
https://blog.csdn.net/zhoufan…
Synchronized 锁性能优化偏向锁轻量级锁升级 多线程中篇(五)
https://juejin.im/post/5bfe6d…
https://zhuanlan.zhihu.com/p/…
http://citeseerx.ist.psu.edu/…
https://blogs.oracle.com/dave…
(
本文由博客一文多发平台 OpenWrite 发布!