文章目录
- 三 锁
- 3.1 偏向锁
- 3.1.1 为啥需要偏向锁?
- 3.1.2 原理
- 3.1.3 解释
- 3.1.4 案例
- 3.1.5 撤销与膨胀
- 3.1.5.1 撤销
- 3.1.5.2 膨胀
- 3.2 轻量级锁
- 3.2.1 为啥需要轻量级锁?
- 3.2.2 原理
- 3.2.3 案例
- 3.2.4 分类
- 3.2.5 膨胀
- 3.3 重量级锁
- 3.3.1 原理
- 3.3.2 对象监视器详细介绍
- 3.3.2.1 Cxq
- 3.3.2.2 EntryList
- 3.3.2.3 OnDeck Thread与Owner Thread
- 3.3.2.4 WaitSet
- 3.3.3 代码案例
- 3.4 总结
三 锁
锁.xmind
3.1 偏向锁
3.1.1 为啥需要偏向锁?
在实际场景中,如果一个同步块(或方法)没有多个线程竞争,而且总是由同一个线程多次重入获取锁,如果每次还有阻塞线程,唤醒CPU从用户态转为核心态,那么对于CPU是一种资源的浪费,为了解决这类问题,就引入了偏向锁的概念。
3.1.2 原理
- 如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态,此时Mark Word的结构变为偏向锁结构,锁对象的锁标志位(lock)被改为01,偏向标志位(biased_lock)被改为1,然后线程的ID记录在锁对象的Mark Word中(使用CAS操作完成)。
- 以后该线程获取锁时判断一下线程ID和标志位,就可以直接进入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。
3.1.3 解释
- 无竞争时,之前获得锁的线程再次获得锁时会判断偏向锁的线程ID是否指向自己。
- 如果是,那么该线程将不用再次获得锁,直接就可以进入同步块;如果未指向当前线程,当前线程就会采用CAS操作将Mark Word中的线程ID设置为当前线程ID,如果CAS操作成功,那么获取偏向锁成功,执行同步代码块,如果CAS操作失败,那么表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁。
3.1.4 案例
package com.shu.BiasedLocking;
import com.shu.Lock.ObjectLock;
import org.openjdk.jol.vm.VM;
import java.util.concurrent.CountDownLatch;
/**
* @description:
* @author: shu
* @createDate: 2022/11/15 14:09
* @version: 1.0
*/
public class BiasedLockingDemo {
static final int MAX_TREAD = 10;
static final int MAX_TURN = 1000;
CountDownLatch latch = new CountDownLatch(MAX_TREAD);
public static void main(String[] args) throws InterruptedException {
System.out.println("JVM信息:\n"+VM.current().details());
//JVM延迟偏向锁
Thread.sleep(5000);
ObjectLock lock = new ObjectLock();
System.out.println("抢占锁前, lock 的状态: ");
lock.printObjectStruct();
Thread.sleep(5000);
CountDownLatch latch = new CountDownLatch(1);
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (lock) {
lock.increase();
if (i == MAX_TURN / 2) {
System.out.println("占有锁, lock 的状态: ");
lock.printObjectStruct();
//读取字符串型输入,阻塞线程
// Print.consoleInput();
}
}
//每一次循环等待10ms
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
latch.countDown();
};
new Thread(runnable, "biased-demo-thread").start();
//等待加锁线程执行完成
latch.await();
System.out.println("释放锁后, lock 的状态:");
lock.printObjectStruct();
}
}
- 线程启动之前
- 线程启动中
05 10 28 26 (00000101 00010000 00101000 00100110) (640159749)
00 00 00 00 (00000000 00000000 00000000 00000000) (0)
转换一下小端序:
64 bit
(00000000 00000000 00000000 00000000 00100110 00101000 00010000 00000101)
我们参考之前MarkWord的偏向锁结构
解析字节
线程ID 54 bit 00000000 00000000 00000000 00000000 00100110 00101000 001000
epoch 2bit 00
未使用 1bit 0
分代年龄 4bit 0000
biased 1bit 1
locl 3bit 01
- 线程释放之后
注意
虽然抢锁的线程已经结束,但是ObjectLock实例的对象结构仍然记录了其之前的偏向线程ID,其锁状态还是偏向锁状态101
3.1.5 撤销与膨胀
JVM 安全点
OpenJDK官方定义如下:
- 安全点是在程序执行期间的所有GC Root已知并且所有堆对象的内容一致的点。
- 从全局的角度来看,所有线程必须在GC运行之前在安全点阻塞。 (作为一种特殊情况,运行JNI代码的线程可以继续运行,因为它们只使用句柄。但在安全点期间,它们必须阻塞而不是加载句柄的内容。)
- 从本地的角度来看,安全点是一个显着的点,它位于执行线程可能阻止GC的代码块中。 大多数调用点都能当做安全点。
- 在每个安全点都存在强大的不变量永远保持true不变,而在非安全点可能会被忽视。 编译的Java代码和C / C ++代码都在安全点之间进行了优化,但跨安全点时却不那么优化。 JIT编译器在每个安全点发出GC映射。 VM中的C / C ++代码使用程式化的基于宏的约定(例如,TRAPS)来标记潜在的安全点。
- 总的来说,安全点就是指,当线程运行到这类位置时,堆对象状态是确定一致的,JVM可以安全地进行操作,如GC,偏向锁解除等。
3.1.5.1 撤销
- 在一个安全点停止拥有锁的线程。
- 遍历线程的栈帧,检查是否存在锁记录。
- 如果存在锁记录,就需要清空锁记录,使其变成无锁状态,并修复锁记录指向的Mark Word,清除其线程ID。
- 将当前锁升级成轻量级锁。
- 唤醒当前线程。
3.1.5.2 膨胀
- 如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了。JVM检查原来持有该对象锁的占有线程是否依然存活,如果挂了,就可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程。
- 如果JVM检查到原来的线程依然存活,就进一步检查占有线程的调用堆栈是否通过锁记录持有偏向锁。如果存在锁记录,就表明原来的线程还在使用偏向锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀(INFLATING)为轻量级锁。
3.2 轻量级锁
引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS机制竞争锁减少重量级锁产生的性能损耗。重量级锁使用了操作系统底层的互斥锁(Mutex Lock),会导致线程在用户态和核心态之间频繁切换,从而带来较大的性能损耗。
3.2.1 为啥需要轻量级锁?
- 轻量锁存在的目的是尽可能不动用操作系统层面的互斥锁,因为其性能比较差。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁地阻塞和唤醒对CPU来说是一件负担很重的工作。
- 同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了轻量级锁。
- 轻量级锁是一种自旋锁,因为JVM本身就是一个应用,所以希望在应用层面上通过自旋解决线程同步问题。
3.2.2 原理
- 在抢锁线程进入临界区之前,如果内置锁(临界区的同步对象)没有被锁定,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指针指向锁对象。
3.2.3 案例
package com.shu.BiasedLocking;
import com.shu.Lock.ObjectLock;
import org.openjdk.jol.vm.VM;
import java.util.concurrent.CountDownLatch;
/**
* @description: 轻量级锁
* @author: shu
* @createDate: 2022/11/16 11:16
* @version: 1.0
*/
public class LightWeightLockingDemo {
static final int MAX_TURN = 1000;
public static void main(String[] args) throws InterruptedException {
System.out.println("JVM信息:\n"+VM.current().details());
//JVM延迟偏向锁
Thread.sleep(5000);
ObjectLock lock = new ObjectLock();
System.out.println("抢占锁前, lock 的状态: ");
lock.printObjectStruct();
Thread.sleep(5000);
CountDownLatch latch = new CountDownLatch(2);
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (lock) {
lock.increase();
if (i == 1) {
System.out.println("第一个线程占有锁, lock 的状态: ");
lock.printObjectStruct();
}
}
}
//循环完毕
latch.countDown();
//线程虽然释放锁,但是一直存在
for (int j = 0; ; j++) {
//每一次循环等待1ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
new Thread(runnable).start();
Thread.sleep(1000);
Runnable lightweightRunnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (lock) {
lock.increase();
if (i == MAX_TURN / 2) {
System.out.println("第二个线程占有锁, lock 的状态: ");
lock.printObjectStruct();
}
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
//循环完毕
latch.countDown();
};
new Thread(lightweightRunnable).start();
//等待加锁线程执行完成
latch.await();
Thread.sleep(2000); //等待2s
System.out.println("释放锁后, lock 的状态: ");
lock.printObjectStruct();
}
}
- 分析结果
偏向锁状态
无竞争锁状态
存在锁竞争状态
(10001000 11110010 00010111 00100110)
(00000000 00000000 00000000 00000000)
转换为小端序 00000000 00000000 00000000 00000000 00100110 00010111 11110010 10001000
参考MarkWord 轻量级锁结构
ptr_to_lock_record 62bit 00000000 00000000 00000000 00000000 00100110 00010111 11110010 100010
lock 2bit 00
释放锁之后
3.2.4 分类
普通自旋锁
- 所谓普通自旋锁,就是指当有线程来竞争锁时,抢锁线程会在原地循环等待,而不是被阻塞,直到那个占有锁的线程释放锁之后,这个抢锁线程才可以获得锁。
- 锁在原地循环等待的时候是会消耗CPU的,就相当于在执行一个什么也不干的空循环。所以轻量级锁适用于临界区代码耗时很短的场景,这样线程在原地等待很短的时间就能够获得锁了。默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin选项来进行更改。
自适应自旋锁
- 所谓自适应自旋锁,就是等待线程空循环的自旋次数并非是固定的,而是会动态地根据实际情况来改变自旋等待的次数,自旋次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
- 如果抢锁线程在同一个锁对象上之前成功获得过锁,JVM就会认为这次自旋很有可能再次成功,因此允许自旋等待持续相对更长的时间。
- 如果对于某个锁,抢锁线程很少成功获得过,那么JVM将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
JDK 1.6的轻量级锁使用的是普通自旋锁,且需要使用-XX:+UseSpinning选项手工开启。JDK 1.7后,轻量级锁使用自适应自旋锁,JVM启动时自动开启,且自旋时间由JVM自动控制。
3.2.5 膨胀
轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁(Mutex Lock)的概率,并不是要替代操作系统互斥锁。所以,在争用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现的重量级锁。
3.3 重量级锁
在JVM中,每个对象都关联一个监视器,这里的对象包含Object实例和Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证的线程即可进入临界区进行操作,没有拿到则需要阻塞等待。重量级锁通过监视器的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。
3.3.1 原理
- JVM中每个对象都会有一个监视器,监视器和对象一起创建、销毁。
- 监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。
- (1)同步。监视器所保护的临界区代码是互斥地执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还。
- (2)协作。监视器提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行。
ObjectMonitor:监视器是由C++类ObjectMonitor实现
//Monitor结构体
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
//线程的重入次数
_recursions = 0;
_object = NULL;
//标识拥有该Monitor的线程
_owner = NULL;
//等待线程组成的双向循环链表
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
//多线程竞争锁进入时的单向链表
cxq = NULL ;
FreeNext = NULL ;
//_owner从该双向循环链表中唤醒线程节点
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
3.3.2 对象监视器详细介绍
Cxq、EntryList、WaitSet这三个队列的说明如下:
(1)Cxq:竞争队列(Contention Queue),所有请求锁的线程首先被放在这个竞争队列中。(2)EntryList:Cxq中那些有资格成为候选资源的线程被移动到EntryList中。
(3)WaitSet:某个拥有ObjectMonitor的线程在调用Object.wait()方法之后将被阻塞,然后该线程将被放置在WaitSet链表中。
3.3.2.1 Cxq
- Cxq并不是一个真正的队列,只是一个虚拟队列,原因在于Cxq是由Node及其next指针逻辑构成的,并不存在一个队列的数据结构。每次新加入Node会在Cxq的队头进行,通过CAS改变第一个节点的指针为新增节点,同时设置新增节点的next指向后续节点;从Cxq取得元素时,会从队尾获取。显然,Cxq结构是一个无锁结构。
- 因为只有Owner线程才能从队尾取元素,即线程出列操作无争用,当然也就避免了CAS的ABA问题。
3.3.2.2 EntryList
EntryList与Cxq在逻辑上都属于等待队列。Cxq会被线程并发访问,为了降低对Cxq队尾的争用,而建立EntryList。在Owner线程释放锁时,JVM会从Cxq中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为OnDeck Thread(Ready Thread)。EntryList中的线程作为候选竞争线程而存在。
3.3.2.3 OnDeck Thread与Owner Thread
JVM不直接把锁传递给Owner Thread,而是把锁竞争的权利交给OnDeck Thread,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大地提升系统的吞吐量,在JVM中,也把这种选择行为称为“竞争切换”。
OnDeck Thread获取到锁资源后会变为Owner Thread。无法获得锁的OnDeck Thread则会依然留在EntryList中,考虑到公平性,OnDeck Thread在EntryList中的位置不发生变化(依然在队头)。在OnDeck Thread成为Owner的过程中,还有一个不公平的事情,就是后来的新抢锁线程可能直接通过CAS自旋成为Owner而抢到锁。
3.3.2.4 WaitSet
如果Owner线程被Object.wait()方法阻塞,就转移到WaitSet队列中,直到某个时刻通过Object.notify()或者Object.notifyAll()唤醒,该线程就会重新进入EntryList中。
3.3.3 代码案例
package com.shu.BiasedLocking;
import com.shu.Lock.ObjectLock;
import org.openjdk.jol.vm.VM;
import java.util.concurrent.CountDownLatch;
/**
* @description: 重量级锁
* @author: shu
* @createDate: 2022/11/16 14:08
* @version: 1.0
*/
public class HeavyWeightLockingDemo {
static final int MAX_TURN = 1000;
public static void main(String[] args) throws InterruptedException {
System.out.println("JVM信息:\n"+VM.current().details());
//JVM延迟偏向锁
Thread.sleep(5000);
ObjectLock counter = new ObjectLock();
System.out.println("抢占锁前, counter 的状态: ");
counter.printObjectStruct();
Thread.sleep(5000);
CountDownLatch latch = new CountDownLatch(3);
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (counter) {
counter.increase();
if (i == 0) {
System.out.println("第一个线程占有锁, counter 的状态: ");
counter.printObjectStruct();
}
}
}
//循环完毕
latch.countDown();
//线程虽然释放锁,但是一直存在
for (int j = 0; ; j++) {
//每一次循环等待1ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
new Thread(runnable).start();
Thread.sleep(1000); //等待2s
Runnable lightweightRunnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (counter) {
counter.increase();
if (i == 0) {
System.out.println("占有锁, counter 的状态: ");
counter.printObjectStruct();
}
//每一次循环等待10ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
//循环完毕
latch.countDown();
};
new Thread(lightweightRunnable, "抢锁线程1").start();
Thread.sleep(100); //等待2s
new Thread(lightweightRunnable, "抢锁线程2").start();
//等待加锁线程执行完成
latch.await();
Thread.sleep(2000); //等待2s
System.out.println("释放锁后, counter 的状态: ");
counter.printObjectStruct();
}
}
偏向锁状态
偏向锁状态
轻量级锁状态
重量级锁状态
无锁状态
3.4 总结
- 线程抢锁时,JVM首先检测内置锁对象Mark Word中的biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态。
- 在内置锁对象确认为可偏向状态之后,JVM检查Mark Word中的线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码。
- 如果Mark Word中的线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中的线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。
- 如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁。
- JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。
- 如果JVM的CAS替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁。