【Java 多线程】5 - 7 深入了解监视器锁
§5-7 深入了解监视器锁
5-7.1 synchronized
关键字原理
synchronized
关键字所使用的锁称为监视器锁(monitor),作为一个修饰符,它可用于修饰方法和代码块。
下文内容来自:
要想了解 synchronized
的原理,得先了解 Java 对象头。
5-7.2 Java 对象头与 Monitor 对象
在 JVM 中,对象在内存中的存储布局可分为三个区域:
- 实例数据:存储类的属性数据信息,含父类的属性信息,这部分内存按 4 字节对齐;
- 填充数据:由于虚拟机要求对象起始地址必须是 8 字节的整数倍,这部分数据并不是必须存在的,仅仅是为了字节对齐;
- 对象头:在 Hotspot 虚拟机中,对象头又分为两部分,一个是 Mark Word(标记字段),一个是 Class Pointer(类型指针);若是数组,则还会有数组长度(数组也当作对象处理);
(一)对象头
对象头的 Mark Word 主要存储了对象的运行时数据,Mark Word 也存储了对象和锁的有关信息。
当对象使用 synchronized
关键字当作同步锁时,和锁的一系列相关操作都与 Mark Word 有关。JDK 1.6 版本对 synchronized
关键字做了优化,引入了轻量级锁和偏向锁。Mark Word 在不同锁状态下的存储内容不相同。以 32 位 JVM 中对象头的存储内容为例,如下图所示:
可以看到,Mark Word 使用 2 bits 存储锁标记状态。
我们先讨论优化前的 synchronized
同步锁,也就是重量级锁。优化后的偏向锁和轻量级锁稍后讨论。
(二)Monitor 对象
可以看到,为重量级锁时,对象头存储了一个 Monitor 对象的指针。这时就必须先了解什么是 Monitor
对象。
Monitor
对象称为管程或监视器锁。在 Java 中,每一个对象实例都会关联一个 Monitor
对象,因此这也被称为对象锁。这个 Monitor
对象既可以和关联对象一起创建和销毁,也可以在线程试图获取锁对象时生成。当 Monitor
对象由线程持有时,它便处于锁定状态。
在 Hotspot 虚拟机中,Monitor
是由 ObjectMonitor
实现的,这是一个 C++ 的实现类,主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0; // 线程重入次数
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 调用wait方法后的线程会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList
FreeNext = NULL ;
_EntryList = NULL ; // 没有抢到锁的线程会被放到这个队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor
主要有五个部分:
_owner
:监视器锁的持有者线程,初始值为NULL
,表示无任何线程持有锁,当一个线程成功获取监视器锁后会保存线程 ID 标识,当锁被释放时,该值又会被重置为NULL
;_WaitSet
:调用了监视器wait
方法后的线程会加入到这个队列;_cxq
:阻塞队列,线程被唤醒后根据决策判读线程放入_cxq
还是_EntryList
;_EntryList
:没有抢到锁的线程会放到这个队列中;_count
:用于记录线程获取锁的次数,成功获取锁时该值 + 1,释放锁时该值 - 1;
若线程获取到对象的监视器,监视器的 _owner
会设为持有者线程的 ID,同时 _count
加 1;若调用对象的 wait
方法,线程会释放持有的监视器锁,并将 _owner
重置为 NULL
,且 _count
减 1,此时线程会进入到 _WaitSet
队列中等待唤醒。
_WaitSet
, _cxq
和 _EntrySet
都是链表结构的队列,存放封装了线程的 ObjectWaiter
对象。这里需要深入虚拟机查看相关源码才能理解这些队列的作用,这里仅简单地介绍这些队列间的关系:
在多条线程竞争监视器锁时,所有没有抢到监视器锁的线程都会封装成一个 ObjectWaiter
对象并加入到 _EntryList
队列中。当一个进程获取到监视器锁时,调用 wait
方法,线程也会被封装成 ObjectWaiter
对象并加入到 _WaitSet
队列中。当调用锁对象的 notify
方法后,会根据不同的情况决定将 _WaitSet
中的线程放到 _cxq
还是 _EntryList
队列中。等到持有锁的线程释放锁后,又会根据条件执行 _EntryList
中的线程,或者将 _cxq
中的线程移到 _EntryList
中执行。
可见,位于 _WaitSet
中的线程处于 WAITING
状态,等待唤醒;位于 _EntryList
中的线程处于 BLOCKED
状态,等待锁释放。而 _cxq
队列是一个临时队列,最终线程还是要被转移到 _WaitSet
或 _EntryList
中。
(三)同步代码块实现原理
先编写一个简单的类,类中有一个方法,方法中含有一个简单的同步代码块。
package com.multithreading;
public class SyncBlock {
private static int i = 0;
public void add() {
synchronized (this) {
i++;
}
}
}
编译后,使用 javap
命令对字节码文件进行反编译,通过命令 javap -v -c -l com.multithreading.SyncBlock
得到:
{
public com.multithreading.SyncBlock();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public void add();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter //同步代码块的入口
4: getstatic #7 // Field i:I
7: iconst_1
8: iadd
9: putstatic #7 // Field i:I
12: aload_1
13: monitorexit //同步代码块的出口
14: goto 22
17: astore_2
18: aload_1
19: monitorexit //同步代码块的出口
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
}
可以看到,monitorenter
对应的就是同步代码块的入口,monitorexit
对应的就是同步代码块的出口。而第 4, 7, 8, 9 条指令对应的就是 i++
的指令(可见其并不具备原子性)。当执行到 monitorenter
指令时,线程会尝试获取对象的监视器锁,获取该锁的所有权。
当该对象的监视器计数器 _count
为 0 时,线程会获取对象的监视器锁,_count
加 1,并将 _owner
设置为持有者线程的 ID。持有者线程可以重入该锁,对应地,每重入一次,计数器 _count
都会加 1。其他线程执行 monitorenter
指令时,由于监视器锁已被该线程持有,这些线程会进入 _EntryList
等待锁释放。
执行了多少次 monitorenter
就要对应有多少次 monitorexit
。也就是说,多次重入锁的持有者线程必须对应执行相应重入次数的释放锁操作。每执行一次 monitorexit
指令,_count
都会减 1,当 _count
达到零时,该对象的锁被释放,_owner
重置为 0。此时其他线程才能有机会获取该对象的监视器锁。
(四)同步方法实现原理
同样地,先编写一个简单的类,类中含有一个执行简单操作的同步方法。
package com.multithreading;
public class SyncMethod {
private static int i = 0;
public synchronized void add() {
i++;
}
}
同样地,使用 javap
指令,反编译字节码文件,得到:
{
public com.multithreading.SyncMethod();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public synchronized void add();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED //同步方法具有 ACC_SYNCHRONIZED 标记
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field i:I
3: iconst_1
4: iadd
5: putstatic #7 // Field i:I
8: return
LineNumberTable:
line 7: 0
line 8: 8
}
可以看到,同步方法中不再有 monitorenter
和 monitorexit
指令,取而代之的是,方法具有 ACC_SYNCHRONIZED
标记,标记方法为同步方法。
由于同步方法全方法都是同步代码,因此不需要再通过 monitorenter
和 monitorexit
指令标记同步代码的出入口。当线程执行到同步方法时,会判断该方法是否具有 ACC_SYNCHRONIZED
标志。如果有该标志,则说明该方法为同步方法,线程将会以同样的方式获取对象的监视器锁,此处不再赘述。
5-7.3 重量级锁的性能问题
在 Linux 操作系统中,应用程序运行在用户空间中,进入用户空间运行状态就是所谓的用户态。在用户态中可能会涉及到 I/O 调用,这时就需要进入内核运行,此时进程进入内核运行态,简称内核态。
- 内核:本质上也是一种软件,控制计算机的硬件资源,并为上层应用程序提供运行环境;
- 用户空间:上层应用程序活动的空间,应用程序必须运行在依托于内核所提供的资源,包括 CPU 资源、存储资源、I/O 资源;
- 系统调用:为了使得上层应用程序能够访问内核控制的资源,内核必须为上层应用程序提供这些接口,称为系统调用;
前文提到,使用 synchronized
加锁是一种重量级锁的加锁方式,在 ObjectMonitor.cpp
(点击此处跳转)会涉及到 Atomic::cmpxchg_ptr
, Atomic::inc_ptr
等内核函数。执行同步代码块,没有竞争到监视器锁的线程会由 park()
挂起,而竞争到锁的线程会由 unpark()
唤醒。这时候就会涉及到内核态与用户态之间的转换,这种转换会消耗大量的系统资源。若程序中存在大量的锁竞争,则会频繁引起内核态与用户态之间的切换,严重影响程序性能,这也是 synchronized
效率低的原因。
因此,自 JDK 1.6 起,引入了偏向锁和轻量级锁优化 synchronized
。
5-7.4 锁的不同状态
JDK 1.6 引入了偏向锁和轻量级锁后,synchronized
就具有四种不同状态:无锁状态、偏向锁、轻量级锁和重量级锁。这四种锁状态依次表示锁从低级锁向高级锁升级的过程。锁会根据线程竞争的激烈程度进行升级,锁升级的过程是单向不可逆的。一旦升级为重量级锁,锁就不能够降级。
接下来先来了解不同的锁状态。
(一)偏向锁
研究发现:大多数情况下不仅不存在线程竞争关系,反而大多数情况是同一条线程多次反复获取同一把锁。
因此,为了减少同一条线程反复获取同一把锁的代价,引入了偏向锁的概念。
核心思想:若一个线程获取了锁,则锁进入偏向模式,此时 Mark Word 结构转变为偏向锁(第 30 位偏向锁标识位改为 1),并且记录该偏向线程的 ID,以及偏向时间戳。当线程再次请求锁时,无需做任何同步操作,即可获取锁。这样省去了大量反复申请锁的操作,提升程序性能。
偏向锁在线程竞争较少的情况下具有很好的优化效果,这种情况下同一条线程反复获得同一把锁的可能性很高。但是,若线程竞争成都较为激烈,偏向锁就出现问题。面对这种情况,偏向锁会升级为轻量级锁。
(二)轻量级锁
优化依据:对于大部分的锁,在整个同步生命周期内都不存在竞争。
当偏向锁升级为轻量级锁时,Mark Word 结构也会随之发生变化。JVM 会利用 CAS 算法尝试修改对象原本的 Mark Word,若成功修改,则表示锁升级成功;否则,修改失败,意味着锁升级失败。
锁升级成功时,对象的 Mark Word 会修改为 Lock Record 的指针。
适用场景:线程交替执行同步代码块的场合。若存在同一时间访问同一锁的情况,轻量级锁就会失效,进而膨胀为重量级锁。
(三)自旋锁
轻量级锁升级失败后,虚拟机为了防止线程真实地在操作系统层面上挂起,会进行自旋锁的优化手段,
自旋锁的条件:自旋锁基于在大多数情况下,持有者线程持有锁的时间不会太长。
基于这种假设,这时若直接将线程在操作系统层面上挂起,操作系统执行线程切换需要从用户态转换到核心态,时间成本相对较高。因此,虚拟机会假设在不久的将来,当前线程就能够获取锁,会让当前线程执行几个空循环(自旋),不断尝试获取锁。一般而言,空循环不会执行太多次。因为过多次的循环意味着等待时间过长,而过长的等待时间也就意味着空循环可能显著占用 CPU 资源,造成资源浪费。基于这种考虑,自旋时间不会太长,若经过若干次循环后,还是未能够获取锁,这时就会将自旋锁升级为重量级锁。
自旋锁也是一种轻量级的锁机制。
5-7.5 锁升级过程
了解了 JDK 1.6 所引入的这些新的锁机制后,接下来来看看 synchronized
是如何一步步升级锁的。
以 32 位 JVM 为例:
- 对象未被当成锁时,该对象就是一个普通的对象,对象头的 Mark Work 分别记录对象的
hashCode
(25位)、GC 分代年龄(4 位)、一个偏向锁标志位(1 位,此时为 0)以及锁标志位(2 位,此时为 01); - 当对象被当做同步锁并有一条线程 A 竞争到了这把锁,锁标志位不变,Mark Word 中对象的
hashCode
修改为持有者线程 ID 和 偏向时间戳,偏向锁标志位修改为 1; - 此时锁处于偏向模式,这时若有线程 B 竞争这把锁,JVM 会采用 CAS 算法尝试修改对象原本的 Mark Word。注意到线程 A 一般不会自动释放锁,但若成功抢到锁,则会将锁的偏向线程 ID 修改为线程 B 的 ID,由线程 B 执行同步代码;
- 但若线程 B 竞争偏向锁失败,意味着线程竞争较为激烈,这时锁会升级为轻量级锁。JVM 会在当前线程的线程栈中开辟一块单独的空间,保存指向对象锁 Mark Word 的指针,同时会在对象锁的 Mark Word 中保存指向这片空间的指针。这两个保存操作均以 CAS 算法执行。若保存成功,则锁升级成功,并将对象的 Mark Word 中锁标志位修改为 00;否则,修改失败,锁升级失败,意味着线程竞争过于激烈;
- 若线程 B 竞争轻量级锁失败,这时会采用自旋锁机制,让当前线程执行几轮空循环,不断地尝试获取锁。自 JDK 1.7 开始,自旋锁默认启用,自旋次数由 JVM 决定。若枪锁成功,则会执行同步代码;否则,锁将升级为重量级锁;
- 若竞争自旋锁失败,则锁会升级为重量级锁,锁标志位修改为 10。这种情况下,未抢到锁的线程都将会被阻塞。
CAS 算法在上一节中已有提及,此处不再赘述。
5-7.6 小结
- 使用重量级锁的同步代码块,代码块入口会有
monitorenter
指令,代码块出口会有monitorexit
指令;同步方法会有ACC_SYNCHRONIZED
方法标记; - 对象的监视器锁由
ObjectMonitor
类实现,这是一个 C++ 实现类。其中,_owner
记录持有者线程 ID,_count
表示重入次数,_WaitSet
,_cxq
和_EntryList
都是列表结构的队列,存储封装了线程的ObjectWaiter
对象。前者存储调用了wait
方法进入等待状态(WAITING
)的线程,_cxq
是一个临时队列,最终由决策判读线程放入_WaitSet
还是_EntryList
中;_EntryList
存储进入阻塞状态(BLOCKED
)的线程,没有抢到锁的线程都会位于这个队列中; - JDK 1.6 引入了多种锁机制优化
synchronized
重量级锁,以减少内核态与用户态转换所带来的性能消耗; - 同步锁会逐步升级,升级过程单向不可逆,锁的升级根据线程的竞争激烈程度决定,一旦升级为重量级锁则不能降级;
- 对象在未当成锁时,是一个普通对象;一旦被某个线程视为锁并持有时,对象升级为偏向锁,对象的 Mark Word 记录偏向线程 ID 和时间戳;
- 存在另一条线程竞争偏向锁时,若竞争成功,则修改对象的偏向线程 ID;若失败,则升级为轻量级锁,JVM 使用 CAS 算法,在当前线程的线程栈中开辟一块独立空间,保存指向对象锁的 Mark Word 指针,同时会在对象锁的 Mark Word 保存指向这片空间的指针;
- 若竞争轻量级锁失败,则采用自旋锁机制,让当前线程执行几轮空循环,等待锁释放,避免由于内核态和用户态之间的切换导致性能下降、时间成本增高;若成功抢到锁,则执行同步代码;否则,则将锁升级为重量级锁;
synchronized
关键字的用法十分简单,但其原理却十分复杂,不容易弄清。有关内容仍需进一步查阅相关资料,才能够进一步了解其原理。