接上篇《JVM技术总结之五——JVM逃逸分析》
参考地址:
《java 中的锁 – 偏向锁、轻量级锁、自旋锁、重量级锁》
《彻底搞懂synchronized(从偏向锁到重量级锁)》
《synchronized实现原理》
在介绍 JVM 锁优化之前,首先明确几个概念,用于后续的介绍。
由于需要限制不同的程序之间的访问能力,防止他们获取别的程序的内存数据,或者获取外围设备的数据,CPU 划分出两个权限等级:用户态和内核态。
Java 程序运行时,在若干线程抢夺 synchronized
的锁时,只有一个线程抢夺成功,其他线程会进入阻塞状态,等待锁的释放。但 Java 线程是映射到操作系统的原生线程上的,阻塞与唤醒一个 Java 线程,需要操作系统的介入,也就是**用户态与内核态的切换。
线程状态的切换会消耗大量的资源,因为用户态、内核态都有自己专用的资源(内存空间、寄存器等),从用户态切换到内核态时,用户态需要向内核态传递很多信息,同时内核态又要保存好自己运行时所需的信息,用于状态切换回内核态之后正常的后续工作。
所以理解 Java 线程切换代价,是理解 Java 中各种锁的优缺点的基础之一**。在我们使用锁的时候,需要估算代码执行的时间以及线程状态切换是否频繁。如果代码执行时间较短,或者线程状态切换很频繁,那么比较未获取锁的线程挂起消耗的时间,以及获取锁线程的代码执行时间,前者比后者消耗时间更长,这样的同步策略是很糟糕的。
synchronized 会导致争用不到锁的线程进入阻塞状态,所以它是 Java 语言中的重量级同步操作。为了优化上述性能问题,Java 从 1.5 版本之后引入偏向锁与轻量锁(包括自旋锁),默认开启自旋锁。
注:轻量级锁与重量级锁:
- 轻量级锁:自旋锁、偏向锁、轻量锁
- 重量级锁:synchronized
markword 是所有 Java 对象数据结构的一部分,它是所有 Java 对象锁信息的表示,此处对其进行简要介绍。markword 是一个 32/64 bit 长度的数据,其中最后两位是锁状态标志位。
状态 | 标志位 | 存储内容 |
---|---|---|
未锁定偏向锁 | 01 | 对象 HashCode、对象分代年龄(偏向锁标志为 0 时) |
可偏向 | 01 | 偏向线程ID、偏向时间戳、对象分代年龄(偏向锁标志为 1 时) |
轻量级锁定 | 00 | 指向锁记录的指针 |
重量级锁定 | 10 | 执行重量级锁定的指针 |
GC 标志 | 11 | 空(不需要记录信息) |
上述基本内容说明完毕后,可以进行后续关于锁优化的说明。 对于 synchronized 这个关键字,可能之前大家有听过,他是一个重量级锁,开销很大,建议大家少用。但到了 JDK 1.6 之后,该关键字被进行了很多的优化,建议大家多使用。因为优化之后的 synchronized 关键字并非一开始就在对象上加了重量级锁,而是从偏向锁 -> 轻量级锁(自旋锁)-> 重量级锁逐步升级的过程。
偏向锁是 JDK 1.6 引入的一项锁优化,其中的“偏”是偏心的偏。偏向锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有其他线程来竞争该锁,也没有被其他线程获取,那么持有偏向锁的线程将永远不需要进行同步操作。
前面的介绍中可以了解到,每个对象都有锁信息,对象关于锁的信息是存到 markword 中的。当我们创建一个锁对象,并命名为 lockObject:
// 随便创建一个对象
Object lockObject = new Object();
synchronized(lockObject) {
// ......
}
上述代码我们可以分为主要的两步:创建对象、对象加锁。
第一步,创建对象:在我们创建这个 lockObject 对象时,该对象的 markword 关键数据如下:
bit fields | 锁标志位 | 是否偏向锁 |
---|---|---|
Hash | 01 | 0 |
表中数据可知,锁标志位为 01,说明当前锁状态为偏向锁;【是否偏向锁】的状态为 0,说明当前对象还没有被加上偏向锁。这里也说明了,所有对象在被创建了之后,都是可偏向的,但是刚刚被创建出来的时候,锁信息【是否偏向锁】的状态都为 0,即创建对象的偏向锁还没有生效。
第二步,对象加锁:当线程执行到临界区时,执行操作:
此时 markword 结构信息如下:
bit fields | 锁标志位 | 是否偏向锁 | |
---|---|---|---|
thread ID | epoch | 01 | 1 |
此时该对象偏向锁的【是否偏向锁】标志置为 1,说明偏向锁生效了,同时线程 ID 也存入了 markword 中。
该线程在之后的执行过程中,如果再次进入相同的同步代码段中,并不需要进行 synchronized 关键字通常需要做的加锁、解锁的操作,而是进行如下步骤:
所以如果大部分同步代码块都是由两个及以上线程竞争,那么偏向锁本身就是一种累赘,这种情况下我们可以在程序运行之前设置 JVM 参数 -XX:-UseBiasedLocking
将偏向锁默认功能关闭。
锁撤销升级为轻量锁后,锁对象的 markword 会进行相应的变化,线程中所有栈帧创建锁记录 LockRecord,修改所有与锁对象相关的栈帧信息。
修改后的锁对象的 markword 改为:
bit fields | 锁标志位 |
---|---|
指向 LockRecord 的指针 | 00 |
轻量级锁主要分为两种:自旋锁与自适应自旋锁。
自旋锁主要目的是为了避免用户线程与内核切换引起的消耗。如果持有锁的线程能在很短的时间内释放锁资源,那么等待锁释放的线程暂时不用做线程状态切换(即线程不用进入阻塞挂起状态),只需要让 CPU 等一等,也就是进入自旋状态,等待锁释放后立即获取锁,这样就避免了线程状态切换引起的消耗。
但是线程的自旋需要消耗 CPU,自旋状态下 CPU 是处于无效运行状态的。所以需要设定一个自旋等待的最长时间,如果在这段时间内一直获取不到锁,那么就进入阻塞状态。
在 JDK 1.5 版本下,自旋周期是定死的,1.6 版本下引入了自适应自旋锁,通常情况下认为一个线程上下文切换所需时间是一个比较好的值(默认情况下自旋次数为 10 次)。JVM 针对当前 CPU 负荷情况做了一定的优化,具体策略此处不细讲。如果线程自旋次数超过了这个值,自旋的方式就不适合了,这时候锁再次膨胀,升级为重量级锁。
自旋锁的优缺点:
注:轻量级锁也被称为非阻塞同步锁、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待。
参考地址:
《synchronized实现原理》
《深入理解Java并发之synchronized实现原理》
升级重量级锁完毕后,markword 部分数据为:
bit fields | 锁标志位 |
---|---|
指向 Mutex 的指针 | 10 |
前面说过,重量级锁性能消耗最大的地方在于用户态向内核态的转换。重量级锁也被称为互斥锁、悲观锁、阻塞同步锁,是依赖对象内部的 monitor 锁实现的,monitor 是依赖操作系统的 MutexLock,即互斥锁实现的。在 Java 虚拟机中,monitor 是由 ObjectMonitor 实现的,在 HotSpot 虚拟机源码中定义数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 记录持有当前 ObjectMonitor 对象的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁 block 状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor 中有两个队列 EntryList 与 WaitSet,用来保存 ObjectWaiter
对象列表,ObjectWaiter
对象用来封装每个等待该 monitor 的线程。owner 指针指向 ObjectMonitor 对象的线程。ObjectMonitor 对象的执行流程图如下:
wait()
方法,则该线程会放弃争取该 ObjectMonitor 的权利,进入 WaitSet 线程等待室中,等待被唤醒(通过notify() / notifyAll()
方法唤醒)