Java synchronized轻量级锁的核心原理详解
作者:小小茶花女 时间:2022-10-13 23:28:33
问题:
什么是自旋锁?
说一下 synchronized 底层实现原理?
多线程中 synchronized 锁升级的原理是什么?
1. 轻量级锁的原理
引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS机制竞争锁减少重量级锁产生的性能损耗。重量级锁使用了操作系统底层的互斥锁(Mutex Lock),会导致线程在用户态和核心态之间频繁切换,从而带来较大的性能损耗。
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以 使用轻量级锁来优化。
轻量锁存在的目的是尽可能不动用操作系统层面的互斥锁,因为其性能比较差。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁地阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了轻量级锁。轻量级锁是一种自旋锁,因为JVM本身就是一个应用,所以希望在应用层面上通过自旋解决线程同步问题。
public class Main {
static final Object obj = new Object();
public static void main(String[] args) {
Thread thread = new Thread(()->{
method1();
});
thread.start();
}
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
}
轻量级锁的执行过程:
在抢锁线程进入临界区之前,如果内置锁没有被锁定,JVM首先将在抢锁线程的栈帧中建立一个锁记录(Lock Record
),用于存储对象Mark Word的拷贝,
然后抢锁线程将使用CAS自旋操作,尝试将内置锁对象头的Mark Word
的ptr_to_lock_record
(锁记录指针)更新为抢锁线程栈帧中锁记录的地址,如果这个更新执行成功了,这个线程就拥有了这个对象锁。然后JVM将Mark Word中的lock标记位改为00(轻量级锁标志),即表示该对象处于轻量级锁状态。抢锁成功之后,JVM会将Mark Word中原来的锁对象信息(如哈希码等)保存在抢锁线程锁记录的Displaced Mark Word
(可以理解为放错地方的Mark Word)字段中,再将抢锁线程中锁记录的owner指针指向锁对象。
锁记录是线程私有的,每个线程都有自己的一份锁记录,在创建完锁记录后,会将内置锁对象的Mark Word复制到锁记录的Displaced Mark Word
字段。这是为什么呢?因为内置锁对象的MarkWord的结构会有所变化,Mark Word将会出现一个指向锁记录的指针,而不再存着无锁状态下的锁对象哈希码等信息,所以必须将这些信息暂存起来,供后面在锁释放时使用。
(1) 在抢锁线程进入临界区之前,如果内置锁没有被锁定,JVM首先将在抢锁线程的栈帧中建立一个锁记录(Lock Record
),每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的mark word
(2) 抢锁线程将使用CAS自旋操作,尝试将内置锁对象头的mark word的ptr_to_lock_record(
锁记录指针)更新为抢锁线程栈帧中锁记录的地址,如果这个更新执行成功了,这个线程就拥有了这个对象锁。然后jvm将mark word中的lock标记位改为00,即表示该对象处于轻量级锁状态。
抢锁成功之后,jvm会将mark word中原来的锁对象信息(如哈希码等)保存在抢锁线程锁记录的Displaced Mark Word字段中,再将抢锁线程中锁记录的owner指针指向锁对象。
64位的mark word结构如表所示:
在轻量级锁抢占成功之后,锁记录和对象头的状态如图所示:
锁记录是线程私有的,每个线程都有自己的一份锁记录,在创建完锁记录后,会将内置锁对象的Mark Word复制到锁记录的Displaced Mark Word字段。这是为什么呢?因为内置锁对象的mark word的结构会有所变化,mark word将会出现一个指向锁记录的指针,而不再存着无锁状态下的锁对象哈希码等信息,所以必须将这些信息暂存起来,供后面在锁释放时使用。
(3) 如果 cas 失败,有两种情况:
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程 ;
如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数;
(4) 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
(5) 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 mark word的值恢复给对象头
成功,则解锁成功失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
2. 轻量级锁的分类
轻量级锁主要有两种:普通自旋锁和自适应自旋锁。
1、普通自旋锁
所谓普通自旋锁,就是指当有线程来竞争锁时,抢锁线程会在原地循环等待,而不是被阻塞,直到那个占有锁的线程释放锁之后,这个抢锁线程才可以获得锁。
说明:
锁在原地循环等待的时候是会消耗CPU的,就相当于在执行一个什么也不干的空循环。所以轻量级锁适用于临界区代码耗时很短的场景,这样线程在原地等待很短的时间就能够获得锁了。默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin
选项来进行更改。
2、自适应自旋锁
所谓自适应自旋锁,就是等待线程空循环的自旋次数并非是固定的,而是会动态地根据实际情况来改变自旋等待的次数,自旋次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。自适应自旋锁的大概原理是:
如果抢锁线程在同一个锁对象上之前成功获得过锁,jvm就会认为这次自旋很有可能再次成功,因此允许自旋等待持续相对更长的时间。
如果对于某个锁,抢锁线程很少成功获得过,那么jvm将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
自适应自旋解决的是“锁竞争时间不确定”的问题。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定。总的思想是:根据上一次自旋的时间与结果调整下一次自旋的时间。
JDK 1.6的轻量级锁使用的是普通自旋锁,且需要使用-XX:+UseSpinning
选项手工开启。
JDK 1.7后,轻量级锁使用自适应自旋锁,JVM
启动时自动开启,且自旋时间由JVM
自动控制。
轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待。
3. 轻量级锁的膨胀
轻量级锁的问题在哪里呢?
虽然大部分临界区代码的执行时间都是很短的,但是也会存在执行得很慢的临界区代码。临界区代码执行耗时较长,在其执行期间,其他线程都在原地自旋等待,会空消耗CPU。因此,如果竞争这个同步锁的线程很多,就会有多个线程在原地等待继续空循环消耗CPU(空自旋),这会带来很大的性能损耗。
轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁的概率,并不是要替代操作系统互斥锁。所以,在争用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现的重量级锁。
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有 竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
(1) 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
这时 Thread-1 加轻量级锁失败,进入锁膨胀流程,即为锁对象申请 Monitor 锁,让锁对象指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
当 Thread-0 退出同步块解锁时,使用 cas 将mark word的值恢复给对象头,失败。这时会进入重量级解锁 流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。
来源:https://hengheng.blog.csdn.net/article/details/123185235