锁升级概述
锁对象,对象头里面的内容,使用不同的锁,就修改对象头中锁对应的位置信息
为什么需要进行锁升级呢?这主要是为了减少线程间的竞争和同步的开销,提高并发性能。下面分别解释这三种锁状态及其作用:
-
偏向锁(Biased Locking):
- 偏向锁是 JVM 在 JDK 1.6 中引入的一种锁优化,它的目标是消除无竞争情况下的同步开销。
- 当一个线程首次访问同步代码块或方法时,JVM 会将对象头的锁标志位设为偏向模式,并使用线程 ID 记录当前访问的线程。后续该线程再次访问时,只需检查对象头的线程 ID 是否与当前线程 ID 一致,如果一致则无需再进行任何同步操作,直接执行代码。
- 偏向锁可以提高无竞争情况下的性能,因为它避免了不必要的锁获取和释放操作。
-
轻量级锁(Lightweight Locking):
- 当多个线程竞争偏向锁时,偏向锁会被撤销,升级为轻量级锁。
- 轻量级锁通过自旋(Spinning)的方式尝试获取锁,而不是立即阻塞线程。自旋意味着线程会在一个循环中尝试获取锁,而不是直接放弃 CPU 时间片。
- 如果锁很快被释放(即锁持有者很快执行完同步代码块),那么自旋的线程可能很快就能够获得锁,避免了线程切换的开销。
- 轻量级锁适用于线程间竞争不激烈的情况,可以减少线程阻塞和唤醒的开销。
-
重量级锁(Heavyweight Locking):
- 当轻量级锁自旋失败,或者竞争过于激烈时,锁会升级为重量级锁。
- 重量级锁是传统意义上的互斥锁,它会阻塞未获取到锁的线程,直到锁被释放。
- 重量级锁的开销较大,因为它涉及到线程阻塞和唤醒,以及操作系统的调度开销。
- 但是,在竞争激烈的场景下,重量级锁能够确保线程安全地访问共享资源。
锁的膨胀升级过程
我们说过了对象头的内容,接下来可以说说我们的锁内部是如何升级上锁的了。从无锁到重量级锁的一个升级过程,我们来边画图,边详细看一下。
无锁状态:
开始时应该这样的,线程A和线程B要去争抢锁对象,但还未开始争抢,锁对象的对象头是无锁的状态也就是25bit位存的hashCode,4bit位存的对象的分代年龄,1bit位记录是否为偏向锁,2bit位记录状态,优先看最后2bit位,是01,所以说我们的对象可能无锁或者偏向锁状态的,继续前移一个位置,有1bit专门记录是否为偏向锁的,1代表是偏向锁,0代表无锁,刚刚开始的时候一定是一个无锁的状态,这个不需要多做解释,系统不同内部bit位存的东西可能有略微差异,但关键信息是一致的。
偏向锁:
这时线程开始占有锁对象,比如线程A得到了锁对象。
就会变成这样的,线程A拿到锁对象,将我们的偏向锁标志位改为1,并且将原有的hashCode的位置变为23bit位存放线程A的线程ID(用CAS算法存储的线程A的ID),2bit位存epoch,偏向锁是永远不会被释放的。
接下来,线程B也开始运行,线程B也希望得到这把锁啊,于是线程B会检查23bit位存的是不是自己的线程ID,因为被线程A已经持有了,锁的23bit位一定不是线程B的线程ID了
然后线程B也会不甘示弱啊,会尝试修改一次23bit位的对象头存储,如果说这时恰好线程A释放了锁,可以修改成功,然后线程B就可以持有该偏向锁了。如果修改失败,开始升级锁。自己无法修改,线程B只能找“大哥”了,线程B会通知虚拟机撤销偏向锁,然后虚拟机会撤销偏向锁,并告知线程A到达安全点进行等待。线程A到达了安全点,会再次判断线程是否已经退出了同步块,如果退出了,将23bit位置空,这时锁不需要升级,线程B可以直接进行使用了,还是将23bit的null改为线程B的线程ID就可以了。
轻量级锁:
如果线程B没有拿到锁,我们就会升级到轻量级锁,首先会在线程A和线程B都开辟一块LockRecord空间,然后把锁对象头复制一份到自己的LockRecord空间下,并且开辟一块owner空间留作执行锁使用,并且锁对象的前30bit位合并,等待线程A和线程B来修改指向自己的线程,假如线程A修改成功,则锁对象头的前30bit位会存线程A的LockRecord的内存地址,并且线程A的owner也会存一份锁对象的内存地址,形成一个双向指向的形式。而线程B修改失败,则进入一个自旋状态,就是持续来修改锁对象。
重量级锁:
如果说线程B多次自旋以后还是迟迟没有拿到锁,他会继续上告,告知虚拟机,我多次自旋还是没有拿到锁,这时我们的线程B会由用户态切换到内核态,申请一个互斥量,并且将锁对象的前30bit指向我们的互斥量地址,并且进入睡眠状态,然后我们的线程A继续运行知道完成时,当线程A想要释放锁资源时,发现原来锁的前30bit位并不是指向自己了,这时线程A释放锁,并且去唤醒那些处于睡眠状态的线程,锁升级到重量级锁。
轻量级锁 -> 重量级锁
当锁升级为轻量级锁之后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁.
- 将 MonitorObject 中的 _owner设置成 A线程;
- 将前三十位的 mark word 设置为 Monitor 对象地址,锁标志位改为10
- 将B线程阻塞放到 ContentionList 队列;
JVM 每次从Waiting Queue 的尾部取出一个线程放到OnDeck作为候选者,但是如果并发比较高,Waiting Queue会被大量线程执行CAS操作,为了降低对尾部元素的竞争,将Waiting Queue 拆分成ContentionList 和 EntryList 二个队列, JVM将一部分线程移到EntryList 作为准备进OnDeck的预备线程。另外说明几点:
所有请求锁的线程首先被放在ContentionList这个竞争队列中;
Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck;
当前已经获取到所资源的线程被称为 Owner;
处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的);
作为Owner 的A 线程执行过程中,可能调用wait 释放锁,这个时候A线程进入 Wait Set , 等待被唤醒。
这是 synchronized 在 JDK 6之前的实现原理。