Java并发编程之synchronized锁升级机制深入解析
一、引言
在Java并发编程中,synchronized关键字是最基本的同步机制之一。从JDK 1.6开始,为了提升锁的性能,Java虚拟机对synchronized进行了重大优化,引入了锁升级机制,将锁分为偏向锁、轻量级锁和重量级锁三个级别。这种优化使得synchronized在不同并发场景下能够自动选择最适合的锁实现,从而在保证线程安全的同时,最大化地提升程序性能。
本文将深入解析synchronized的锁升级机制,包括各类型锁的实现原理、升级过程以及在实际应用中的性能特点。
二、synchronized的基本原理
在了解锁升级机制之前,我们需要先回顾一下synchronized的基本原理。
2.1 synchronized的使用方式
synchronized可以用于以下三种场景:
- 修饰实例方法:锁是当前实例对象
- 修饰静态方法:锁是当前类的Class对象
- 修饰代码块:锁是括号里指定的对象
// 1. 修饰实例方法
synchronized void instanceMethod() {
// 同步代码
}
// 2. 修饰静态方法
static synchronized void staticMethod() {
// 同步代码
}
// 3. 修饰代码块
void blockMethod() {
synchronized (this) {
// 同步代码
}
}2.2 对象头与Mark Word
要理解synchronized的锁升级机制,首先需要了解Java对象的内存布局。在HotSpot虚拟机中,Java对象的内存布局分为三部分:
- 对象头(Header):存储对象的元数据信息,如哈希码、GC分代年龄、锁状态等
- 实例数据(Instance Data):存储对象的实际成员变量值
- 对齐填充(Padding):用于保证对象大小是8字节的整数倍
其中,对象头是实现synchronized的关键,它包含两部分信息:
- Mark Word:存储对象的哈希码、GC分代年龄、锁状态标志等
- 类型指针:指向对象所属类的元数据指针
Mark Word的结构会根据对象的锁状态发生变化,不同的锁状态对应不同的Mark Word结构:
| 锁状态 | 25bit | 4bit | 1bit | 2bit | 1bit | 描述 |
|---|---|---|---|---|---|---|
| 无锁 | 对象哈希码 | 对象分代年龄 | 0 | 01 | 0 | 无锁状态 |
| 偏向锁 | 线程ID(23bit) | epoch(2bit) | 1 | 01 | 0 | 偏向锁状态 |
| 轻量级锁 | 指向栈中锁记录的指针 | 对象分代年龄 | 0 | 00 | 0 | 轻量级锁状态 |
| 重量级锁 | 指向重量级锁(Monitor)的指针 | 对象分代年龄 | 0 | 10 | 0 | 重量级锁状态 |
| GC标记 | 空 | 对象分代年龄 | 0 | 11 | 0 | GC标记状态 |
三、偏向锁(Biased Locking)
3.1 偏向锁的设计初衷
偏向锁是JDK 1.6引入的一种锁优化机制,其设计初衷是为了减少无竞争场景下的锁开销。在大多数应用中,锁不仅不存在竞争,而且总是由同一个线程多次获取。在这种情况下,使用传统的轻量级锁或重量级锁会带来不必要的性能开销。
3.2 偏向锁的实现原理
偏向锁的核心思想是:当一个线程第一次获取锁时,虚拟机将对象头中的Mark Word设置为偏向模式,并记录获取锁的线程ID。之后该线程再次获取锁时,无需进行CAS操作,只需检查Mark Word中的线程ID是否与当前线程ID一致即可。
3.3 偏向锁的获取过程
- 线程尝试获取锁时,检查对象头的Mark Word是否处于无锁状态(01)
- 如果是无锁状态,使用CAS操作将Mark Word设置为偏向模式,记录当前线程ID
- 如果CAS成功,线程获得偏向锁,执行同步代码
- 如果CAS失败,说明有竞争,偏向锁会被撤销并升级为轻量级锁
3.4 偏向锁的撤销过程
当有其他线程尝试获取同一把锁时,偏向锁会被撤销,撤销过程需要停顿持有锁的线程,并将锁升级为轻量级锁或重量级锁。
撤销过程如下:
- 暂停持有偏向锁的线程
- 检查持有锁的线程是否仍然存活
- 如果线程已经死亡,将对象头设置为无锁状态
- 如果线程仍然存活,将锁升级为轻量级锁或重量级锁
- 恢复线程执行
3.5 偏向锁的适用场景
偏向锁适用于只有一个线程多次获取锁的场景,例如单线程环境下的同步操作。在这种情况下,偏向锁可以大幅减少锁开销,提升程序性能。
四、轻量级锁(Lightweight Locking)
4.1 轻量级锁的设计初衷
轻量级锁是JDK 1.6引入的另一种锁优化机制,其设计初衷是为了减少多线程竞争但竞争不激烈场景下的锁开销。在这种场景下,线程之间的竞争是间歇性的,使用重量级锁会带来较大的性能开销。
4.2 轻量级锁的实现原理
轻量级锁的核心思想是:通过CAS操作来尝试获取锁,而无需进入操作系统的内核态。
4.3 轻量级锁的获取过程
- 线程尝试获取锁时,虚拟机在当前线程的栈帧中创建一个**锁记录(Lock Record)**对象
- 将锁记录的
obj字段指向锁对象,并将对象头中的Mark Word复制到锁记录中 - 使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针
- 如果CAS成功,线程获得轻量级锁,执行同步代码
- 如果CAS失败,说明有竞争,虚拟机将自旋尝试获取锁,如果自旋失败,锁会升级为重量级锁
4.4 轻量级锁的释放过程
- 线程释放锁时,使用CAS操作将锁记录中的Mark Word(即对象头的原始值)恢复到对象头中
- 如果CAS成功,轻量级锁释放成功
- 如果CAS失败,说明有其他线程尝试获取锁,需要唤醒等待线程并将锁升级为重量级锁
4.5 自旋锁(Spin Lock)
轻量级锁在竞争时会使用自旋锁来尝试获取锁。自旋锁的核心思想是:当线程尝试获取锁失败时,不立即挂起线程,而是循环尝试获取锁,直到成功或达到自旋次数上限。
自旋锁的优点是避免了线程上下文切换的开销,缺点是会消耗CPU资源。因此,自旋锁适用于锁持有时间短、竞争不激烈的场景。
JDK 1.6引入了自适应自旋锁,虚拟机可以根据前一次自旋获取锁的情况,动态调整自旋次数。
4.6 轻量级锁的适用场景
轻量级锁适用于多线程竞争但竞争不激烈的场景,例如两个线程交替获取同一把锁的场景。在这种情况下,轻量级锁可以避免重量级锁的上下文切换开销,提升程序性能。
五、重量级锁(Heavyweight Locking)
5.1 重量级锁的实现原理
重量级锁是最传统的锁实现方式,它依赖于**操作系统的互斥量(Mutex)**来实现线程同步。当多个线程竞争同一把锁时,未获取到锁的线程会被阻塞并挂起,等待获取到锁的线程释放锁后被唤醒。
5.2 重量级锁的获取与释放过程
- 线程尝试获取锁时,检查对象头的Mark Word是否处于无锁状态
- 如果是无锁状态,使用CAS操作将Mark Word设置为指向重量级锁(Monitor)的指针
- 如果CAS成功,线程获得重量级锁,执行同步代码
- 如果CAS失败,说明有其他线程持有锁,当前线程会被阻塞并加入等待队列
- 线程释放锁时,唤醒等待队列中的线程,让它们重新尝试获取锁
5.3 Monitor对象
在Java虚拟机中,每个对象都关联一个Monitor对象(也称为管程或监视器)。Monitor对象是重量级锁的核心,它包含以下几个关键部分:
- Entry Set:等待获取锁的线程队列
- Owner:当前持有锁的线程
- Wait Set:调用
wait()方法等待的线程队列
Monitor对象的实现依赖于操作系统的互斥量机制,因此重量级锁的获取和释放都需要进行用户态与内核态的切换,这是重量级锁性能较低的主要原因。
5.4 重量级锁的适用场景
重量级锁适用于多线程激烈竞争的场景,例如多个线程同时访问共享资源的场景。在这种情况下,虽然重量级锁的开销较大,但它可以确保线程安全和公平性。
六、锁升级的完整过程
现在,我们来总结一下synchronized锁升级的完整过程:
- 初始状态:对象处于无锁状态(Mark Word为01)
- 偏向锁:当第一个线程获取锁时,锁升级为偏向锁,Mark Word记录线程ID(101)
- 轻量级锁:当有其他线程尝试获取同一把锁时,偏向锁被撤销,锁升级为轻量级锁,Mark Word指向线程栈中的锁记录(00)
- 重量级锁:当轻量级锁的CAS操作失败或自旋达到上限时,锁升级为重量级锁,Mark Word指向Monitor对象(10)
锁升级的过程是不可逆的,即一旦锁升级为轻量级锁或重量级锁,就不会再降级回偏向锁或无锁状态。
七、锁升级的性能特点
不同类型的锁具有不同的性能特点:
| 锁类型 | 适用场景 | 性能特点 | 开销 |
|---|---|---|---|
| 偏向锁 | 单线程多次获取锁 | 几乎无开销 | 极低 |
| 轻量级锁 | 多线程交替获取锁 | 自旋开销 | 较低 |
| 重量级锁 | 多线程激烈竞争 | 上下文切换开销 | 较高 |
八、锁升级的源码分析
为了更深入地理解锁升级机制,我们来看一下OpenJDK中的相关源码实现。
8.1 偏向锁的源码分析
偏向锁的获取过程主要在markOop.cpp文件的try_set_mark()方法中实现:
bool markOopDesc::try_set_mark(markOop new_mark) {
// CAS操作设置Mark Word
return ( Atomic::cmpxchg_ptr(new_mark,
(void**)this,
(void*)mark()) == mark() );
}8.2 轻量级锁的源码分析
轻量级锁的获取过程主要在objectMonitor.cpp文件的TryLock()方法中实现:
bool ObjectMonitor::TryLock() {
Thread *Self = Thread::current();
void *own = _owner;
if (own == NULL) {
// 对象未被锁定,尝试获取锁
if (Atomic::cmpxchg_ptr(Self, &_owner, NULL) == NULL) {
// 获取锁成功
return true;
}
} else if (own == Self) {
// 重入锁
_recursions++;
return true;
}
// 获取锁失败
return false;
}8.3 重量级锁的源码分析
重量级锁的获取过程主要在objectMonitor.cpp文件的enter()方法中实现:
void ObjectMonitor::enter(TRAPS) {
Thread *Self = THREAD;
// 尝试快速获取锁
if (TryLock() == true) {
return;
}
// 获取锁失败,进入慢速路径
EnterI(THREAD);
}EnterI()方法会将线程加入等待队列并挂起线程:
void ObjectMonitor::EnterI(TRAPS) {
Thread *Self = THREAD;
// 将线程加入等待队列
Self->_ParkEvent->reset();
AddWaiter(Self);
// 挂起线程
Self->_ParkEvent->park();
// 线程被唤醒后,重新尝试获取锁
// ...
}九、锁升级的实际应用与优化建议
9.1 锁升级的实际应用
在实际应用中,我们应该根据具体的并发场景选择合适的锁策略:
- 单线程场景:使用偏向锁可以获得最佳性能
- 低并发场景:使用轻量级锁可以避免上下文切换开销
- 高并发场景:使用重量级锁可以确保线程安全和公平性
9.2 优化建议
- 减少锁持有时间:尽量缩短同步代码块的长度,减少线程持有锁的时间
- 减小锁粒度:将一个大锁拆分为多个小锁,减少锁竞争
- 使用无锁数据结构:在合适的场景下,使用
Atomic类等无锁数据结构代替synchronized - 避免锁嵌套:尽量避免锁的嵌套使用,减少死锁风险和锁竞争
- 合理使用volatile:在不需要原子性操作的场景下,使用
volatile关键字代替synchronized
十、总结
synchronized的锁升级机制是JDK 1.6引入的一项重要优化,它通过将锁分为偏向锁、轻量级锁和重量级锁三个级别,在不同的并发场景下自动选择最适合的锁实现,从而在保证线程安全的同时,最大化地提升程序性能。
- 偏向锁:适用于单线程多次获取锁的场景,几乎无锁开销
- 轻量级锁:适用于多线程交替获取锁的场景,避免上下文切换开销
- 重量级锁:适用于多线程激烈竞争的场景,确保线程安全和公平性
理解synchronized的锁升级机制对于编写高效的并发程序至关重要。在实际应用中,我们应该根据具体的并发场景选择合适的锁策略,并遵循相关的优化建议,以提升程序的并发性能。