JAVA锁优化和膨胀过程
共 3053字,需浏览 7分钟
·
2021-05-28 04:04
HotSpot虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁削除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
自旋锁
自选锁其实就是在拿锁时发现已经有线程拿了锁,自己如果去拿会阻塞自己,这个时候会选择进行一次循环尝试。也就是不停循环看是否能等到上个线程自己释放锁。这个问题是基于一个现实考量的:很多拿了锁的线程会很快释放锁。因为一般敏感的操作不会很多。当然这个是一个不能完全确定的情况,只能说总体上是一种优化。
举个例子就好比一个人要上厕所发现厕所里面有人,他可以:
1,等一小会。
2,跑去另外的地方上厕所。
等一小会不一定能等到前一个人出来,不过如果跑去别的厕所的花费的时间肯定比等一小会结果前一个人出来了长。
当然等完了结果那个人没出来还是要跑去别的地方上厕所这是最慢的。
然后是基于这种做法的一个优化:自适应自旋锁。
也就是说,第一次设置最多自旋10次,结果在自旋的过程中成功获得了锁,那么下一次就可以设置成最多自旋20次。
道理是:一个锁如果能够在自旋的过程中被释放说明很有可能下一次也会发生这种事。
那么就更要给这个锁某种“便利”方便其不阻塞的锁(毕竟快了很多)。
同样如果多次尝试的结果是完全不能自旋等到其释放锁,那么就说明很有可能这个临界区里面的操作比较耗时间。就减小自旋的次数,因为其可能性太小了。
锁粗化
原则上为了提高运行效率,锁的范围应该尽量小,减少同步的代码,但是这不是绝对的原则,试想有一个循环,循环里面是一些敏感操作,有的人就在循环里面写上了synchronized关键字。这样确实没错不过效率也许会很低,因为其频繁地拿锁释放锁。要知道锁的取得(假如只考虑重量级MutexLock)是需要操作系统调用的,从用户态进入内核态,开销很大。于是针对这种情况也许虚拟机发现了之后会适当扩大加锁的范围(所以叫锁粗化)以避免频繁的拿锁释放锁的过程。
比如像这样的代码:
synchronized{
做一些事情
}
synchronized{
做另外一些事情
}
1
2
3
4
5
6
就会被粗化成:
synchronized{
做一些事情
做另外一些事情
}
1
2
3
4
锁消除
通过逃逸分析发现其实根本就没有别的线程产生竞争的可能(别的线程没有临界量的引用),或者同步块内进行的是原子操作,而“自作多情”地给自己加上了锁。有可能虚拟机会直接去掉这个锁。
偏向锁
在大多数的情况下,锁不仅不存在多线程的竞争,而且总是由同一个线程获得。因此为了让线程获得锁的代价更低引入了偏向锁的概念。偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。偏向锁可以通过 -XX:+UseBiasedLocking开启或者关闭
偏向锁的获取
偏向锁的获取过程非常简单,当一个线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,表示哪个线程获得了偏向锁,结合前面分析的Mark Word来分析一下偏向锁的获取逻辑
1 首先获取目标对象的Mark Word,根据锁的标识为epoch去判断当前是否处于可偏向的状态
2 如果为可偏向状态,则通过CAS操作将自己的线程ID写入到MarkWord,如果CAS操作成功,则表示当前线程成功获取到偏向锁,继续执行同步代码块
3 如果是已偏向状态,先检测MarkWord中存储的threadID和当前访问的线程的threadID是否相等,如果相等,表示当前线程已经获得了偏向锁,则不需要再获得锁直接执行同步代码;如果不相等,则证明当前锁偏向于其他线程,需要撤销偏向锁。
CAS:表示自旋锁,由于线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说性能开销很大。同时,很多对象锁的锁定状态指会持续很短的时间,因此引入了自旋锁,所谓自旋就是一个无意义的死循环,在循环体内不断的重试竞争锁。当然,自旋的次数会有限制,超出指定的限制会升级到阻塞锁。
偏向锁的撤销
当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,撤销偏向锁的过程需要等待一个全局安全点(所有工作线程都停止字节码的执行)。
1 首先,暂停拥有偏向锁的线程,然后检查偏向锁的线程是否为存活状态
2 如果线程已经死了,直接把对象头设置为无锁状态
3 如果还活着,当达到全局安全点时获得偏向锁的线程会被挂起,接着偏向锁升级为轻量级锁,然后唤醒被阻塞在全局安全点的线程继续往下执行同步代码
轻量级锁
前面我们知道,当存在超过一个线程在竞争同一个同步代码块时,会发生偏向锁的撤销。偏向锁撤销以后对象会可能会处于两种状态
一种是不可偏向的无锁状态,简单来说就是已经获得偏向锁的线程已经退出了同步代码块,那么这个时候会撤销偏向锁,并升级为轻量级锁
一种是不可偏向的已锁状态,简单来说就是已经获得偏向锁的线程正在执行同步代码块,那么这个时候会升级到轻量级锁并且被原持有锁的线程获得锁
那么升级到轻量级锁以后的加锁过程和解锁过程是怎么样的呢?
轻量级锁加锁
1 JVM会先在当前线程的栈帧中创建用于存储锁记录的空间(LockRecord)
2 将对象头中的Mark Word复制到锁记录中,称为Displaced Mark Word.
3 线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针
4 如果替换成功,表示当前线程获得轻量级锁,如果失败,表示存在其他线程竞争锁,那么当前线程会尝试使用CAS来获取锁, 当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁
轻量级锁解锁
尝试CAS操作将所记录中的Mark Word替换回到对象头中
如果成功,表示没有竞争发生
如果失败,表示当前锁存在竞争,锁会膨胀成重量级锁
重量级锁
重量级锁依赖对象内部的monitor锁来实现,而monitor又依赖操作系统的MutexLock(互斥锁)
大家如果对MutexLock有兴趣,可以抽时间去了解,假设Mutex变量的值为1,表示互斥锁空闲,这个时候某个线程调用lock可以获得锁,而Mutex的值为0表示互斥锁已经被其他线程获得,其他线程调用lock只能挂起等待
为什么重量级锁的开销比较大呢?
原因是当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的
锁的膨胀过程
首先简单说下先偏向锁、轻量级锁、重量级锁三者各自的应用场景:
偏向锁: 只有一个线程进入临界区;
轻量级锁: 多个线程交替进入临界区;
重量级锁: 多个线程同时进入临界区。
首先它们的关系是:最高效的是偏向锁,尽量使用偏向锁,如果不能(发生了竞争)就膨胀为轻量级锁,最后是重量级锁