一、Monitor 锁简介
通常我们创建的 java 对象,它在内存中都是由两部分组成,一部分是由对象头,另外一部分是对象中的那些成员变量。
1.1 对象头
1.1.1 普通对象
以 32 位虚拟机为例,普通对象的对象头在 32 位的虚拟机里占用 64 位,即 8 个字节大小,其中 4 个字节是 Mark Word,它里面存储了很多的信息。另外 4 个字节存储 Klass Word,存储的是对象的类型,本质上是个指针,指向了这个对象所从属的 class。
32 位虚拟机的 Mark Word 结构如下
64 位虚拟机的 Mark Word 结构如下
不同的 State 表示 Mark Word 所存储的数据结构是不一样的。 我们先只考虑 Normal 状态下存储的数据结构信息。
1、hashCode:25:表示存储的 hashCode,占用 25 位。
2、age:4:表示垃圾回收时的分代年龄,占用 4 位。
3、biased_lock:0:表示是否为偏向锁。
4、01:表示是否为加锁状态。
1.1.2 数组对象
以 32 位虚拟机为例,数组对象的对象头在 32 位的虚拟机里占用 96 位,即 12 个字节大小,除了 Mark Word 和 Klass Word 以外,还有一个 array length 用于存储数组的长度,占用 4 个字节。
1.1.3 举例说明
以 int 类型为例,它占用 4 个字节。但是一个 Integer 对象,在 32 位虚拟机里面,一个对象头占用 8 个字节,还需要一个 value 值来存储 int 的整型,所以还需要 4 个字节,所以加起来就是 12 个字节。
所以在一些内存很敏感的场景下,建议用基本类型而不是用包装类型的原因,因为包装类型占用的空间比较大。
1.2 Monitor
1.2.1 工作原理
Monitor 被翻译为监视器或管程。每个 java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该 java 对象的 Mark Word 中就被设置指向 Monitor 对象的指针。
// obj 对象的 Mark Word 指向了自己的 Monitor 对象
// 每一个对象都有自己的 Monitor 对象
static Object obj = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (obj){
// todo
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (obj){
// todo
}
}
});
t1.start();
t2.start();
}
Monitor 结构如下:
1、刚开始 Monitor 中 Owner 为 null。
2、当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2, Monitor 中只能有一个 Owner。
3、在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList 中,并处于 BLOCKED 状态。
4、Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,不一定是先进来的会成为 Owner。
5、图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程。
1.2.2 注意
1、synchronized 必须是进入同一个对象的 monitor 才有上述的效果
2、不加 synchronized 的对象不会关联监视器,不遵从以上规则。
二、Synchronized 原理进阶
2.1 小故事
2.1.1 故事角色
1、老王 - JVM
2、小南 - 线程
3、小女 - 线程
4、房间 - 对象
5、房间门上 - 防盗锁 - Monitor
6、房间门上 - 小南书包 - 轻量级锁
7、房间门上 - 刻上小南大名 - 偏向锁
8、批量重刻名 - 一个类的偏向锁撤销到达 20 阈值
9、不能刻名字 - 批量撤销该类对象的偏向锁,设置该类不可偏向
2.1.2 故事情节
小南要使用房间保证计算不被其它人干扰(原子性),最初,他用的是防盗锁,当上下文切换时,锁住门。这样,即使他离开了,别人也进不了门,他的工作就是安全的。
但是,很多情况下没人跟他来竞争房间的使用权。小女是要用房间,但使用的时间上是错开的,小南白天用,小女晚上用。每次上锁太麻烦了,有没有更简单的办法呢?
小南和小女商量了一下,约定不锁门了,而是谁用房间,谁把自己的书包挂在门口,但他们的书包样式都一样,因此每次进门前得翻翻书包,看课本是谁的,如果是自己的,那么就可以进门,这样省的上锁解锁了。万一书包不是自己的,那么就在门外等,并通知对方下次用锁门的方式。
后来,小女回老家了,很长一段时间都不会用这个房间。小南每次还是挂书包,翻书包,虽然比锁门省事了,但仍然觉得麻烦。
于是,小南干脆在门上刻上了自己的名字:【小南专属房间,其它人勿用】,下次来用房间时,只要名字还在,那么说明没人打扰,还是可以安全地使用房间。如果这期间有其它人要用这个房间,那么由使用者将小南刻的名字擦掉,升级为挂书包的方式。
同学们都放假回老家了,小南就膨胀了,在 20 个房间刻上了自己的名字,想进哪个进哪个。后来他自己放假回老家了,这时小女回来了(她也要用这些房间),结果就是得一个个地擦掉小南刻的名字,升级为挂书包的方式。老王觉得这成本有点高,提出了一种批量重刻名的方法,他让小女不用挂书包了,可以直接在门上刻上自己的名字。
后来,刻名的现象越来越频繁,老王受不了了:算了,这些房间都不能刻名了,只能挂书包。
2.2 轻量级锁
2.2.1 使用场景
如果一个对象处于多线程的环境下并且需要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized,即当我们使用 synchronized 进行加锁时,优先使用的是轻量级锁进行加锁,如果轻量级锁失败了,才会去用重量级锁去加锁。
2.2.2 场景分析
假设有两个方法同步块,利用同一个对象加锁,代码如下:
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
先来回顾下,obj 对象由两部分组成,对象头和对象体,对象头又由两部分组成,一部分是 Mark Word,另一部分是 Klass Word。对象体里面存储的是成员变量。
1、首先在 Thread-0 的栈帧中创建锁记录(Lock Record)对象,一部分存储对象指针,用于存储锁住对象的内存地址;另一部分存储加锁对象的 Mark Word。如下图
2、让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录。此时 Mark Word 的状态位为 01,表示未加锁状态;而锁记录里面的状态位为 00,表示的是轻量级锁的状态。
3、如果 cas 替换成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下
如果 cas 失败,有两种情况:
1、如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程。
2、如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数。
4、当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一 。
5、当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头。
如果成功,则解锁成功。
如果失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。
2.3 锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时有一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
假设此时又有一个线程需要对 obj 进行加锁,代码如下
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
1、当 Thread-1 进行轻量级加锁时,发现 Thread-0 已经对该对象加了轻量级锁,如下图
2、这时 Thread-1 加轻量级锁失败,进入锁膨胀流程。即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,然后自己进入 Monitor 的 EntryList 并处于 BLOCKED 状态,如下图
3、当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,会发现恢复失败了。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 的线程。
2.4 自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
2.4.1 自旋成功
自旋重试成功的情况,如下
2.4.2 自旋失败
自旋重试失败的情况,如下
2.4.3 注意
1、自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
2、在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
3、Java 7 之后不能控制是否开启自旋功能。
2.5 偏向锁
2.5.1 偏向锁背景
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
为了解决这一问题,Java 6 中引入了偏向锁来做进一步优化,在第一次 CAS 时,将线程 ID 设置到对象的 Mark Word 头,之后如果发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
以下面的代码为例,模拟展示下轻量级锁和偏向锁的调用过程
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized( obj ) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized (obj) {
// 同步块 C
}
}
2.5.2 轻量级锁调用过程
第一次 synchronized 时给对象加上锁了,锁记录替换了对象的 mark word,当发生锁重入的时候,就会又产生一条锁记录,这条锁记录又要去尝试用锁记录替换了对象的 mark word,当然了第二次就失败了。
此时它知道这个锁是自己家的了,这条锁记录还是会被保留作为锁重入时的一个计数,虽然是自己家的了,但还是做了一次 CAS 。还是有一定的性能损耗的。
2.5.3 偏向锁调用过程
第一次 CAS 时,用 ThreadID 替换 Mark Word,后续发生锁重入时,就不会使用 CAS 去检查,直接看对象头的 mark word 的 ThreadID 就可以了。
2.5.4 偏向状态
先回忆一下对象头格式,如下图
1、一个对象创建时,如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0。
2、偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟。
3、如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值。
2.5.5 延迟性测试
首先创建一个 maven 工程,并引入相关的依赖,如下所示:
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.25</version>
</dependency>
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
测试代码如下所示:
@Slf4j
public class TestA {
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();
// 使用 ClassLayout 可以打印对象的头,true 表示只打印 2 进制的 Mark Word 头
log.debug( ClassLayout.parseInstance(d).toPrintable());
Thread.sleep(4000);
log.debug( ClassLayout.parseInstance(new Dog()).toPrintable());
}
}
class Dog{
}
打印的结果如下所示,
# 第一次打印
# 十六进制的 mark word
0x0000000000000001
# 转化为二进制为
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
# 第二次打印
# 十六进制的 mark work
0x0000000000000005
# 转换为二进制为
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
第一次打印偏向锁还没有生效,所以最后三位是 001,而 4 秒以后,偏向锁就会生效,所以最后三位是 101 。
偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟。如下图
2.5.6 偏向锁测试
上面的测试只是证明这个 d 对象可以支持偏向锁,但是还没有加锁,接下来加锁来测试下偏向锁,代码如下:
@Slf4j
public class TestA {
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();
// 此时我们是配置了偏向锁立即生效的命令了
// 加锁前打印对象的头
log.debug( ClassLayout.parseInstance(d).toPrintable());
synchronized (d){
// 加锁后打印对象头
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
// 解锁后打印对象头
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
}
class Dog{
}
打印结果如下
# 第一次打印
# 十六进制的 mark word
0x0000000000000005
# 转化为二进制为
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
# 第二次打印
# 十六进制的 mark work
0x0000000003373805
# 转换为二进制为
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
# 第三次打印
# 十六进制的 mark work
0x0000000003373805
# 转换为二进制为
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
第一次打印的最后三位是 101 ,证明启用了偏向锁。
第二次打印除了最后三位是 101,前面还多了很多的东西,这些东西是线程 ID。
第三次打印是释放完锁之后的打印,最后三位仍是 101,前面的线程 ID 也没有发生变化,这就证明发生了偏向,以后这个 d 对象就给你这个主线程用了,即 d 对象从属于主线程了。
2.5.7 偏向锁禁用
偏向锁的使用场景是冲突很少的情况,比如只有一个线程使用这些对象给对象加锁。但是如果应用程序的使用场景是多线程竞争使用锁对象,这个时候偏向锁就不合适了,可以通过 -XX:-UseBiasedLocking 参数来禁用偏向锁,代码如下:
@Slf4j
public class TestA {
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();
// 此时我们是配置了禁用偏向锁的命令了
// 加锁前打印对象的头
log.debug( ClassLayout.parseInstance(d).toPrintable());
synchronized (d){
// 加锁后打印对象头
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
// 解锁后打印对象头
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
}
class Dog{
}
# 第一次打印
# 十六进制的 mark word
0x0000000000000001
# 转化为二进制为
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
# 第二次打印
# 十六进制的 mark work
0x0000000002b8f288
# 转换为二进制为
00000000 00000000 00000000 00000000 00000001 01011100 01111001 10001000
# 第三次打印
# 十六进制的 mark work
0x0000000000000001
# 转换为二进制为
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
第一次打印最后三位是 001,此时处于正常状态。
第二次打印最后两位是 00,表示加的是轻量级锁。
第三次打印最后三位是 001,此时处于正常状态。
2.5.8 偏向锁撤销
2.5.8.1 hashCode 撤销
首先去掉配置的 JVM 参数,然后把延时为 0 的参数加上去。测试代码如下:
@Slf4j
public class TestA {
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();
d.hashCode();// 会禁用掉这个对象的偏向锁
// 加锁前打印对象的头
log.debug( ClassLayout.parseInstance(d).toPrintable());
synchronized (d){
// 加锁后打印对象头
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
// 解锁后打印对象头
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
}
class Dog{
}
测试结果如下所示
# 第一次打印
# 十六进制的 mark word
0x0000006438a39601
# 转化为二进制为
00000000 00000000 00000000 01100100 00111000 10100011 10010110 00000001
# 第二次打印
# 十六进制的 mark work
0x0000000002a1f688
# 转换为二进制为
00000000 00000000 00000000 00000000 00000010 10100001 11110110 10001000
# 第三次打印
# 十六进制的 mark work
0x0000006438a39601
# 转换为二进制为
00000000 00000000 00000000 01100100 00111000 10100011 10010110 00000001
第一次打印最后三位是 001,此时处于正常状态。
第二次打印最后两位是 00,表示加的是轻量级锁。
第三次打印最后三位是 001,此时处于正常状态。
为什么调用一次 hashCode 方法就会禁用掉偏向锁呢?是因为 mark word 里面没有足够的空间去存储 hashCode 了。即当一个可偏向的对象调用了自己的 hashCode 方法后,就会撤销这个对象的偏向状态。
2.5.8.2 其他线程使用撤销
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁,测试代码如下:
@Slf4j
public class TestB {
private static void test2() throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
synchronized (TestB.class) {
TestB.class.notify();
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (TestB.class) {
try {
TestB.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug( ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
log.debug( ClassLayout.parseInstance(d).toPrintable());
}, "t2");
t2.start();
}
}
class Dog{
}
2.5.8.3 调用 wait/notify 撤销
对象调用 wait/notify 方法时会将偏向锁升级为轻量级锁,测试代码如下:
@Slf4j
public class TestB {
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
log.debug( ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintable());
try {
d.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug( ClassLayout.parseInstance(d).toPrintable());
}
}, "t1");
t1.start();
new Thread(() -> {
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (d) {
log.debug("notify");
d.notify();
}
}, "t2").start();
}
}
class Dog{
}
2.5.9 批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID。
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。
测试代码如下:
@Slf4j
public class TestB {
public static void main(String[] args) throws InterruptedException {
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}
synchronized (list) {
list.notify();
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("===============> ");
for (int i = 0; i < 30; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}, "t2");
t2.start();
}
}
class Dog{
}
2.5.10 批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
测试代码如下:
@Slf4j
public class TestB {
static Thread t1,t2,t3;
public static void main(String[] args) throws InterruptedException {
Vector<Dog> list = new Vector<>();
int loopNumber = 39;
t1 = new Thread(() -> {
for (int i = 0; i < loopNumber; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}
LockSupport.unpark(t2);
}, "t1");
t1.start();
t2 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
LockSupport.unpark(t3);
}, "t2");
t2.start();
t3 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}, "t3");
t3.start();
t3.join();
log.debug(ClassLayout.parseInstance(new Dog()).toPrintable());
}
}
class Dog{
}
2.6 锁消除
测试代码如下,主要讲的是里面有两个方法都对静态变量进行 ++ 操作,不同的是 b 方法先创建了一个局部变量并进行了加锁,我们来分析下不加锁和加锁性能到底差了多少
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations=3)
@Measurement(iterations=5)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MyBenchmark {
static int x = 0;
@Benchmark
public void a() throws Exception {
x++;
}
@Benchmark
public void b() throws Exception {
Object o = new Object();
synchronized (o) {
x++;
}
}
}
运行结果如下:
我们发现这两个方法得分几乎一致,明明一个加锁了,肯定是有性能损耗的。
因为 Java 运行时有一个 JIT 即时编译器,他会对我们的字节码进行进一步优化,他发现这个 o 对象不会逃离方法的作用域,所以它会把 synchronized 关键字给优化掉,真正执行时是没有 synchronized 的,只是执行了里面的 ++ 操作。所以性能是非常相近的。
这种操作是有一个开关可以控制的,锁消除开关默认打开,命令为:-XX:-EliminateLocks,接下来我们关闭这个开关来测试下:
可以发现性能相差的还是蛮多的。
2.7 总结
synchronized 的加锁过程为:无锁 >>>> 偏向锁 >>>> 轻量级锁 >>>> 重量级锁。
synchronized 的锁分为以上四个阶段,会根据实际情况,对锁进行升级。应该注意的是,目前所只能升级,不能降级。
2.7.1 偏向锁
当锁第一次被一个线程获取时,优先进入偏向锁状态。偏向锁并非真的“加锁”,而是在对象头中做一个“偏向锁标记”,记录该锁属于哪个线程。如果后续没有其他线程竞争该锁,那么该锁不会有后续升级操作,减少了加锁带来的系统开销。
如果后续有其他线程竞争该锁,那么锁会真正的加锁,升级为轻量级锁。由于之前已经记录了该锁属于哪个线程,所以此时锁也是被记录的线程获取的。这种升级操作实际上属于延迟加锁,不必要不加锁,减少了加锁的系统开销,提高了运行效率。
2.7.2 轻量级锁
随着锁竞争的开始,锁将进入轻量级锁的状态,在初始状态是通过自旋锁实现的。自旋锁是循环不断地让线程尝试获取锁。优点在于当锁被释放,其他线程可以第一时间获取到锁。而自旋锁的缺点也在于会一直占用 CPU 资源。synchronized 对此也进行了优化,当自旋达到一定的时间或次数时,就不再自旋了,将转换为挂起等待。
同时,synchronized 内部也会统计当前锁对象有多少线程在竞争,如果锁竞争更加激烈,synchronized 就会从轻量级锁升级为重量级锁。
2.7.3 重量级锁
重量级锁是指使用内核提供的 mutex 锁。mutex 锁执行加锁操作时,会先进入内核态,在内核态判定当前锁是否已经被占用。如果该锁没有被占用,则加锁成功,并切换回用户态。如果该锁已经被占用,则加锁失败,线程阻塞等待,直到下一次唤醒。
2.7.4 锁消除
锁消除是 synchronized 锁的一种较保守的优化策略,通过编译器和 JVM 判断锁是否可以消除。这里的锁消除只会处理一些直接可以判断,完全不涉及线程安全问题的锁,比如在单线程环境下使用 StringBuffer 类中的方法。
2.7.5 锁粗化
这里有一个锁的粒度的概念,可以这么认为:在锁对象代码块中的代码越少则认为锁的粒度越细,反之则是越粗。实际开发中,使用细粒度的锁,往往是为了锁可以被其他线程及时获取。但有时,可能很长一段时间都没用其他线程来竞争这个锁。
因此,如果一段逻辑中出现多次加锁解锁,根据编译器和 JVM 的判断会自动对锁进行粗化。锁粗化是指将多个细粒度的锁合并为一个粗粒度的锁,可以在特定场景下提高程序的执行效率,减小系统开销。