引用文章https://www.cnblogs.com/twoheads/p/10148598.html
1. Java 锁的优化策略 和 膨胀过程
1.1 自旋锁
自选锁其实就是在拿锁时发现已经有线程拿了锁,自己如果去拿会阻塞自己,这个时候会选择进行一次忙循环尝试。也就是不停循环看是否能等到上个线程自己释放锁。这个问题是基于一个现实考量的:很多拿了锁的线程会很快释放锁。因为一般敏感的操作不会很多。当然这个是一个不能完全确定的情况,只能说总体上是一种优化。
举个例子就好比一个人要上厕所发现厕所里面有人,他可以:1,等一小会。2,跑去另外的地方上厕所。等一小会不一定能等到前一个人出来,不过如果跑去别的厕所的花费的时间肯定比等一小会结果前一个人出来了长。当然等完了结果那个人没出来还是要跑去别的地方上厕所这是最慢的。
从轻量级锁获取的流程中我们知道**,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态**,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
1.2 锁粗化(Lock Coarsening)
锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。要知道锁的取得(假如只考虑重量级MutexLock)是需要操作系统调用的,从用户态进入内核态,开销很大。于是针对这种情况也许虚拟机发现了之后会适当扩大加锁的范围(所以叫锁粗化)以避免频繁的拿锁释放锁的过程。举个例子:
package com.paddx.test.string;
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
1.3 锁消除(Lock Elimination)
消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:
package com.paddx.test.concurrent;
public class SynchronizedTest02 {
public static void main(String[] args) {
SynchronizedTest02 test02 = new SynchronizedTest02();
//启动预热
for (int i = 0; i < 10000; i++) {
i++;
}
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
test02.append("abc", "def");
}
System.out.println("Time=" + (System.currentTimeMillis() - start));
}
public void append(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
}
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。下面是我本地执行的结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WF1em4IK-1615514210955)(https://i.loli.net/2021/03/12/fWxlZ9zTNiqutcF.png)]
为了尽量减少其他因素的影响,这里禁用了偏向锁(-XX:-UseBiasedLocking)。通过上面程序,可以看出消除锁以后性能还是有比较大提升的。
1.4 锁膨胀
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。
锁的状态保存在对象的头文件中,以32位的JDK为例:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f3Yx1uPv-1615514210960)(https://i.loli.net/2021/03/12/364o95wcZVTQnhY.png)]
偏向锁和轻量级锁,这两个锁既是一种优化策略,也是一种膨胀过程所以一起说。首先它们的关系是:最高效的是偏向锁,尽量使用偏向锁,如果不能(发生了竞争)就膨胀为轻量级锁,这样优化的效率不如原来高不过还是一种优化(对比重量级锁而言)。所以整个过程是尽可能地优化。
1.4.1 偏向锁
举个例子来说明偏向锁:一个仓库管理员管着钥匙,偏向锁,偏向锁就是仓库的锁头,而管理人员就是这个进程,只有找老王拿到钥匙才能够进行同步代码块的执行.此时,老张(一个线程)每一次都是老王去借,仓库管理员于是就认识了老王,直接和他说,“行,你直接拿就是不用填表格了我记得你”。此时老王就将锁头(Mark Word)上标注偏向锁标志位为"1",记录偏向线程的ID.这样偏向线程就一直持有着锁,直到竞争发生才释放锁。
下一次有线程尝试获取锁的时候,首先检查这个对象头的MarkWord是不是储存着这个线程的ID。如果是,那么直接进去而不需要任何别的操作。如果不是,那么分为两种情况。
- 对象的偏向锁标志位为0(当前不是偏向锁),说明已经发生了竞争,已经膨胀为轻量级锁,这时使用CAS操作尝试获得锁
- 偏向锁标志位为1,说明还是偏向锁不过请求的线程不是原来那个了。也就是说当前的锁可能有人获取且未释放,也有可能刚刚的老张已经释放锁了,但还偏上他。这时只需要使用CAS尝试把对象头偏向锁从原来那个线程指向目前求锁的线程。
那问题来了,此时进行CAS会不会失败呢? 当然是会的了。
1.4.1.1 加锁
偏向锁加锁发生在偏向线程第一次进入同步块时,CAS原子操作尝试更新对象的Mark Word(偏向锁标志位为"1",记录偏向线程的ID)。
1.4.1.2 撤销偏向锁
当有另一个线程来竞争锁的时候,就不能再使用偏向锁了,要膨胀为轻量级锁。
竞争线程尝试CAS更新对象头失败,会等待到全局安全点(此时不会执行任何代码)撤销偏向锁。
撤销偏向锁,会将锁置于无锁状态,即偏向锁位为0,锁标志位为01
1.4.2 轻量级锁
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
轻量锁与偏向锁不同的是:
- 轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
- 每次进入退出同步块都需要CAS更新对象头
- 争夺轻量级锁失败时,自旋尝试抢占锁
可以看到轻量锁适合在竞争情况下使用,其自旋锁可以保证响应速度快,但自旋操作会占用CPU,所以一些计算时间长的操作不适合使用轻量级锁。
1.4.2.1 轻量级锁的加锁过程
(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图2.1所示。

(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4NQ3RzzX-1615514210964)(https://i.loli.net/2021/03/12/AoqcpBQCmUM5kzO.png)]
(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
1.4.2.2 轻量级锁的解锁过程:
(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
(2)如果CAS操作成功,整个同步过程就完成了。
(3)若CAS操作失败则是出现了竞争,锁已膨胀为重量级锁了,此时需要释放锁(持有重量级锁线程的指针位为"0",锁标识位为"10")并唤醒重量锁的线程。
1.5 偏向锁、轻量级锁和重量级锁对比
| 锁类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁、解锁不需要额外资源消耗,效率较高 | 如果线程间存在锁竞争,会带来额外的解锁消耗 | 适用只有一个线程访问同步块的情景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序响应速度 | 如果获取锁失败,会进入自旋消耗cpu | 针对锁占用时间短,对响应时间比较敏感的情况 |
| 重量级锁 | 线程竞争不使用自旋,不消耗cpu | 线程会被阻塞,影响响应时间 | 锁占用时间较长,对吞吐量要求较高 |
本文详细介绍了Java锁的优化策略,包括自旋锁、锁粗化、锁消除和锁膨胀的过程。锁膨胀涉及偏向锁、轻量级锁和重量级锁,其中偏向锁适用于无竞争场景,轻量级锁适用于线程交替执行同步块的情况,而重量级锁则在多线程竞争时使用。文章还讨论了锁的状态转换和加锁、解锁的具体步骤。
9万+

被折叠的 条评论
为什么被折叠?



