[toc]
$\qquad$在 Java 提供 synchronized
的早期,JVM 会统一为所有用关键字修饰的代码块加上重量级的互斥锁。但是实际大部分情况并发抢占不是那么严重,用互斥锁显得有些繁重从而拖慢了性能。 在 JDK 1.6 中,开发组对 synchronized
进行了大幅度的优化,引入了诸多机制来提升同步代码块的性能。
比较重要的机制有 偏向锁
、轻量级锁
、自旋锁
、重量级锁
四种锁,以及锁消除
、锁粗化
两种策略。
关于 Lock Record
$\qquad$Lock Record 在 JVM 1.6 的锁优化中扮演了重要的角色,在偏向锁和轻量锁中都有用到。 每当线程试图进入同步代码块时,都会初始化一个 Lock Record 对象(这个 Lock Record 是扫描线程栈找到一个空闲的 Lock Record 得到的,如果线程栈中没有空闲的则再创建一些),并且将其指针指向锁对象。 每当线程离开同步代码块,便会将 Lock Record 释放掉。
$\qquad$不过在偏向锁和轻量锁中,Lock Record 对象的作用并不完全相同:在偏向锁中,Lock Record 仅仅用做重入计数;而在轻量锁中,Lock Record 还要负责保存锁对象原本的 Mark Word。
关于 Mark Word
$\qquad$在 JVM 中,任何对象在内存中除了自身属性数据外,还有一个「对象头」。对象头中分两部分:MarkWord
和 KlassWord
。
MarkWord 的格式非常复杂,在不同的情况下存储不同的数据,因此有不同的格式。与锁相关的信息都存储在对象的元信息,即对象的 MarkWord
中。
有些信息数据超过了 MarkWord 承载的范围,也会在 MarkWord 中存储指针,通过指针指向实际数据的位置。偏向锁需要存储的数据比较少,因此直接存储在 MarkWord 中。
偏向锁
偏向锁的特点
适用于单线程访问互斥代码段的场景。
例如在单线程中使用 ConcurrentHashMap,对于 ConccurrentHashMap 中的同步代码块来说,其实一直都是同一个线程抢占锁、释放锁,因此没必要每次都使用重量级锁,乃至使用轻量级锁都是多余的。
实际上在这种情况下,如果锁是可重入的,那即使抢占了锁的线程不再释放也是可以的,甚至可以避免多次获取、释放锁的开销从而提升性能。
偏向锁就是基于这样的设计思想而实现的。因此可以说偏向锁是一种可重入锁。当多次抢占锁当是同一个线程时,使用偏向锁可以提升性能。由于偏向锁假设每次执行同步代码块的都是同一个线程,从而没必要释放锁,因此当发现进入同步代码段的线程与当前持有偏向锁的线程不相同时,偏向锁不再适用,将膨胀为轻量锁。
偏向锁的原理
对象初次创建时,如果没有特别指定,则 MarkWord 低三位为 0b101,例如在 64 位 JVM 上其初始值为 0x0000_0005,即匿名偏向状态。匿名偏向状态是指后三位为 0b101,且线程 ID 的部分为0。注意即使是匿名偏向状态,也会保存 GC 代数等信息,因此不全是 0 。
当线程尝试获取偏向锁时,有四种可能:
1. 第一次获取锁,锁对象尚未使用过,MarkWord 此时为匿名偏向。线程会构造一个 MarkWord(通过各种位运算),该 MarkWord 中保存了当前线程的 ID,然后通过 CAS 操作将自身构建的 MarkWord 替换掉锁对象的初始 MarkWord。如果此时 CAS 操作失败,说明有其他线程在此期间抢先修改了锁对象的 MarkWord,线程多于一个,偏向锁不再适用,需要把偏向锁撤销,并膨胀为轻量锁。
再次获取锁,或者是重入获取锁,锁对象 MarkWord 内的线程 ID 与当前线程相同,则可以直接执行同步代码。
获取锁时发现锁对象 MarkWord 内 Epoch 已过期,相当于偏向锁已释放,则可以重新抢得锁使其偏向于自己。
获取锁时锁对象 MarkWord 内的线程 ID 不是当前线程且锁 Epoch 未过期,则说明有其他线程持有偏向锁,当前有一个以上的线程抢占,偏向锁已不再适用,因此需要膨胀为轻量锁。
注意
在偏向锁中,获取锁的 CAS 操作并不会在 Lock Record 中保留锁对象原先的 MarkWord。
因为偏向锁只是将自己的线程 ID 和 Epoch 存储在 MarkWord 中的前 25 位( 64 位为 56 位),后面的分代年龄保留在原地,因此不必单独保存原先的 MarkWord。
但是在计算过 HashCode 之后,哈希值占用了线程 ID 和 Epoch 的位置,之后再获取锁时必须要保留原 MarkWord,此时便不能再使用偏向锁了。
偏向锁的释放
$\qquad$偏向锁在释放时十分简单,只需要将本次进入同步代码块时初始化的 Lock Record 重置即可。 由于进入代码块时找的 Lock Record 是最近的空闲代码块,因此释放锁时也是寻找线程栈中最近的一个指向锁对象的 Lock Record 将其重置。
偏向锁的撤销
$\qquad$偏向锁的释放与撤销是两个不同的概念。释放操作仅在自身线程栈中,锁对象在其他线程看来依然属于其偏向的线程。 但是在多线程环境中,一个线程事实上不能无限期的占用锁。因此需要引入偏向锁的撤销机制,将偏向锁还原为匿名偏向的状态。这样当持有锁的线程结束退出之后,其他线程可以再次获取锁使其偏向自己;或者当多个线程交替获取锁时,可以顺利升级为轻量锁。
$\qquad$在 CAS 操作获取锁失败时,说明有多于一个线程使用过该锁。这时需要先把当前的偏向锁撤销。
撤销偏向锁需要等待一个全局安全点,此时所有线程都已挂起,然后检查当前持有偏向锁的线程。此时有两种可能:
1. 如果该线程已经退出了同步代码块,或者线程已经不存在了,则偏向锁可以安全撤销,变回无锁状态,然后再次偏向为当前线程。这个过程称为重偏向(rebias)
。
- 如果该线程还在同步代码块内,则需要将锁膨胀为轻量锁。
偏向锁的膨胀
所谓「膨胀」只是个逻辑上的概念。实际在代码中,只不过是偏向锁获取失败时,撤销锁并使用轻量锁重新获取锁而已。
批量重偏向与 Epoch
轻量锁
轻量锁的适用场景
- 适用于多个线程无抢占地先后执行互斥代码段的场景。即上一个线程释放锁一段时间之后,下一个线程才开始获取锁。在并发量较低时使用轻量锁可以提升性能。
- 当发现上一个线程还未释放锁,下一个线程已经开始获取锁,说明此时发生了抢占,将膨胀为重量锁。
但还有一个问题是, MarkWord 中原本就存储了对象的元信息,例如对象的哈希值,对象的 GC 存活次数等,如果在 MarkWord 中存储了偏向锁信息,那么原先的信息被挤到哪里去了?答案是那些信息在栈中创建单独的对象来保存,也就是 Lock Record。Lock Record 中会保留两部分信息,一部分是被转移过来的对象的 MarkWord,另一部分是指向该对象的指针。
轻量锁的原理
即使线程通过偏向锁的方式获取锁失败时,依然有可能锁是为锁定状态:偏向锁已撤销,或者锁对象已经计算了哈希值,不再支持偏向锁。
如果在启动 JVM 时禁用了偏向锁,那么线程会直接以轻量锁的方式获取锁。
1. 当第一次获取锁时,锁对象的 MarkWord 为 后三位为 0b001,因此会构建一个未锁定状态的 MarkWord (后三位值为 0b001) 保存于线程栈最近的一个 Lock Record 中。然后试图通过 CAS 操作将锁对象的 MarkWord 和 LockRecord 的地址交换,如果交换成功,那么 Lock Record 中会保存锁对象的 MarkWord,而锁对象的 MarkWord 中保存的是 LockRecord 的地址,相当于锁对象的 MarkWord 变成了指向 Lock Record 的指针。
2. 当第 N 次获取锁时,有可能是其他线程在竞争锁,或者同一个线程以重入的方式获取锁。 无论哪种方式,CAS 一定会失败,因为此时锁对象的 MarkWord 后三位不再是 0b001 ,而是指向某个线程栈中的 Lock Record 的指针(0b000)。此时便需要判断是否为同一个线程,如果是则说明是重入,否则说明发生了多线程竞争锁,需要膨胀为重量锁。
当同一个线程重入时,会在线程栈中创建一个新的 Lock Record ,但不用再 CAS 保存 MarkWord 了——此时 MarkWord 中保存的是同线程中另一个 Lock Record 的地址。
由于内存对齐的缘故,Lock Record 的地址一定是 4 的倍数,即二进制后两位一定是 00 。此时便可以判断 MarkWord 的后两位判断是否为指向了一个 Lock Record,即是否为轻量锁状态。
轻量锁的膨胀
当发现多个线程竞争同一个锁时,有三种可能: - 锁还没有分配,此时按轻量锁逻辑重新获得锁。如果失败了就需要膨胀。 - 同一个线程重入获得锁,此时创建 Lock Record 计数,并继续执行同步代码。不需要膨胀。 - 不同的线程同时竞争,如果等不到其他线程释放锁,那么便需要膨胀为重量锁。
重量锁
重量锁就是传统的互斥锁。偏向锁不需要额外的对象,轻量锁需要 Lock Record 以保存锁对象的 MarkWord,而重量级锁需要更复杂的机制——JVM 中称之为 ObjectMonitor。
ObjectMonitor 是一个基于 MESA 模型实现的一种管程。管程的特点是可以通过若干个条件变量实现等待。不过 synchronized 做了简化,只有一个条件变量,也就只有一个等待队列。
重量锁的原理
重量锁与轻量锁相比,是个十足的“大家伙”。其内部有多个队列用于存储不同状态的线程。
另外,Java 中的 wait()
, notify()
, notifyAll()
三个方法也是重量锁提供的机制。换句话说,如果线程要使用这三个方法,那么必须要膨胀为重量锁。
重量锁中有这样几个集合,线程经过封装后以 WaiterObject 的形式在几个集合中流转:
cxq: 只能用 CAS 操作更改头指针的方式修改的单链表结构。 用于存储试图获取锁,但是获取失败的线程。这些线程会使用
pthread_cond_wait
系统调用将自身挂起。 每次有新的线程试图获取锁失败时,会用头插法加入该链表;因此线程会遵循后入先出
的原则。EntryList: cxq 队列中有资格成为候选资源的线程会被移动到该队列中。
WaitSet: 当线程主动调用
Object::wait()
时,会进入该集合,挂起并释放掉锁。 这个集合就对应着管程模型中的等待队列。 当有线程调用notify()
或notifyAll()
时,会从该集合中将线程移动到 cxq 或 EntryList 中等待下一次获得锁并继续执行。
此外,ObjectMonitor 还有 owner 属性用于表示当前持有锁的线程, recursive 计数器用于保存同一线程的重入次数等。
获取重量锁
- 如果当前是无锁状态、锁重入、当前线程是之前持有轻量级锁的线程则只需更新一下 owner 和 重入计数即可继续执行同步代码。
- 如果已经有其他线程持有了锁,便先自旋尝试等待锁释放,这样做的目的是为了减少执行操作系统同步操作带来的开销。
- 如果还是没有获取到锁,就将自己作为头节点插入到 cxq 中,然后调用
pthread_cond_wait
将自己挂起。直到被其他线程释放锁时唤醒后再次尝试获取锁。 - 如果线程在获取到锁之后调用了
wait()
主动休眠,就会释放掉锁,然后该线程会进入 WaitSet 等待唤醒。进入 WaitSet 的线程也会调用pthread_cond_wait
将自己挂起。与 cxq 中的挂起不同的是,这些线程只有当其他线程调用notify()
,notifyAll()
时才会被唤醒。 - 如果线程调用了
notify()
,notifyAll()
方法,那么会从 WaitSet 中选取一个线程,放入 cxq 或 EntryList 中,使其可以参与后续的锁竞争。
当获取到锁之后需要将当前线程从 cxq 或 EntryList 移除。