【12】详细聊一聊Synchronized关键字的实现原理

synchronized底层实现原理

synchronized 是 JVM 的内置锁,基于 Monitor 机制实现。每一个对象都有一个与之关联的监视器 (Monitor),这个监视器充当了一种互斥锁的角色。当一个线程想要访问某个对象的 synchronized 代码块,首先需要获取该对象的 Monitor。如果该 Monitor 已经被其他线程持有,则当前线程将会被阻塞,直至 Monitor 变为可用状态。当线程完成 synchronized 块的代码执行后,它会释放 Monitor,并把 Monitor 返还给对象池,这样其他线程才能获取 Monitor 并进入 synchronized 代码块。现在,让我们一起深入理解 Monitor 是什么,以及它的工作机制。

什么是Monitor

在并发编程中,监视器(Monitor)是Java虚拟机(JVM)的内置同步机制,旨在实现线程之间的互斥访问和协调。每个Java对象都有一个与之关联的Monitor。这个Monitor的实现是在JVM的内部完成的,它采用了一些底层的同步原语,用以实现线程间的等待和唤醒机制,这也是为什么等待(wait)和通知(notify)方法是属于Object类的原因。这两个方法实际上是通过操纵与对象关联的Monitor,以完成线程的等待和唤醒操作,从而实现线程之间的同步。

在实现线程同步时,Monitor 确实利用了 JVM 的内存交互操作,包括 lock(锁定)和 unlock(解锁)指令。当一个线程试图获取某个对象的 Monitor 锁时,它会执行 lock 指令来尝试获取该锁。如果这个锁已经被其他线程占有,那么当前线程将会被阻塞,直至锁变得可用。当一个线程持有了 Monitor 锁并且已完成对临界区资源的操作后,它将会执行 unlock 指令来释放该锁,从而使得其他线程有机会获取该锁并执行相应的临界区代码。如下图一所示,

图一
图一

在jdk1.6后引入了偏向锁,意思就是如果该同步块没有被其他线程占用,JVM会将对象头中的标记字段设置为偏向锁,并将线程ID记录在对象头中,这个过程是通过CAS。值得注意的是,当升级到重量级锁时,才会引入Monitor的概念。

Monitor在Java虚拟机中使用了MESA精简模型来实现线程的等待和唤醒操作。那什么是MESA模型。

MESA模型

MESA模型是一种用于实现线程同步的模型,它提供了一种机制来实现线程之间的协作和通信。MESA模型提供了两个基本操作:wait和signal(在Java中对应为wait和notify/notifyAll),如图二所示。

图二
图二

和我们Java中用到的不一样,java中锁的变量只有一个。

Monitor机制在Java中的实现

通过上边了解到,Monitor机制提供了wait和notify,notiryAll方法,他们之间协作如下图。

图三
图三

图解释:

  • cxq (Contention Queue):是一个 栈结构先进后出队列。当一个线程尝试获取已经被其他线程占用的Monitor时,如果尝试失败,这个线程会被加入到cxq中。
  • EntryList:当锁释放,会从这个队列选出来第一个线程并执行cas操作尝试获取锁。
  • WaitSet:FIFO(先进先出)。当一个线程调用了对象的wait()方法后,会被加入到这个队列中。

cxq,EntryList和WaitSet他们之间是怎么协作的?

  • 线程通过cas争抢锁,cas争抢锁失败会进入到cxq队列中,放到cxq的头部。cas成功就会获得锁。

  • 当锁释放会先从entryList中获取第一个线程让它cas操作,如果cas成功就获得锁。cas失败(也就是说entryList第一个线程cas时候,恰好有另外一个线程执行了cas并且成功了)。

  • 如果entryList中没有,会将cxq的全部线程一次性的放到entryList中,然后重新执行上一步操作。

  • 线程调用了wait操作,会将线程放到waitSet队列的尾部。

  • 当其他线程执行了notifyAll时候,会重新执行第一步,也就是把所有的线程取出来然后开始cas操作尝试获取锁,获取失败的就放到cxq中。

下边用一个简单的例子去说明:

有A,B,C,D四个线程。

  1. A线程执行枪锁操作成功获得锁。
  2. B线程执行,C线程执行,B,C都枪锁失败进入cxq队列。cxq队列[C,B],EntryList和waitSet队列为空。
  3. A执行wait操作,释放锁,并进入到waitSet队列。cxq[C,B],EntryList[],WaisSet[A]。
  4. A释放锁后,先判断EntryList队列是否为空,如果为空,会将cxq队列平移到EntryList队列。cxq[],EntryList[C,B],waitSet[A]。
  5. 从EntryList头部获取一个线程进行cas操作,C线程枪锁成功,C现在获得锁。现在cxq[],EntryList[B],waitSet[A]。
  6. 线程C执行notifyAll操作,会把waitSet所有的等待线程取出来挨个cas尝试获取锁,失败的放入到cxq。cxq[A],EntryList[B],waitSet[]。
  7. D开始执行,枪锁失败,进入到cxq队列。cxq[D,A],EntryList[B],waitSet[A]。
  8. 线程C执行完毕,释放锁,从EntryList获取一个线程并执行,现在B出队列获取锁,cxq[D,A],EntryList[],waitSet[]。。
  9. 当B运行完毕,由于EntryList为空,会从cxq中获取并移动到EntryList,执行完之后列表编程cxq[],entryList[D,A],waitSet[]。

我们用代码去演示。

代码源码:

public static void main(String[] args) {
  User user = new User();
  Thread A = new Thread(() -> {
   synchronized (user) {
    System.out.println(Thread.currentThread().getName() + "运行");
    ThreadUtil.sleep(3000L);
    System.out.println(Thread.currentThread().getName() + "调用wait");
    try {
     user.wait();
    } catch (InterruptedException e) {
     e.printStackTrace();
    }
    System.out.println(Thread.currentThread().getName() + "又继续运行");
   }
}, "线程A");

Thread D = new Thread(() -> {
  synchronized (user) {
   System.out.println(Thread.currentThread().getName() + "运行");
  }
 }, "线程D");

 Thread B = new Thread(() -> {
  synchronized (user) {
    System.out.println(Thread.currentThread().getName() + "运行");
    user.notifyAll();
    D.start();
   }
  }, "线程B");
  Thread C = new Thread(() -> {
   synchronized (user) {
    System.out.println(Thread.currentThread().getName() + "运行");
    user.notifyAll();
    D.start();
   }
  }, "线程C");
  A.start();
  ThreadUtil.sleep(1000L);
  B.start();
  C.start();
 }

运行结果如下:

线程A运行 线程A调用wait 线程C运行 线程B运行 线程D运行 线程A又继续运行

运行流程

运行流程
运行流程

锁的升级

在JDK 1.5之前,synchronized关键字对应的是重量级锁,其涉及到操作系统对线程的调度,带来较大的开销。线程尝试获取一个已经被其他线程持有的重量级锁时,它会进入阻塞状态,直到锁被释放。这种阻塞涉及用户态和核心态的切换,消耗大量资源。然而,实际上,线程持有锁的时间大多数情况下是相当短暂的,那么将线程挂起就显得效率不高,存在优化的空间。

JDK 1.6以后,Java引入了锁的升级过程,即:无锁-->偏向锁-->轻量级锁(自旋锁)-->重量级锁。这种优化过程避免了一开始就采用重量级锁,而是根据实际情况动态地升级锁的级别,能够有效地降低资源消耗和提高并发性能。

「Java中对象的内存布局:」

普通对象在内存中分为三块区域:对象头、实例数据和对齐填充数据。对象头包括Mark Word(8字节)和类型指针(开启压缩指针时为4字节,不开启时为8字节)。实例数据就是对象的成员变量。对齐填充数据用于保证对象的大小为8字节的倍数,将对象所占字节数补到能被8整除。

内存分布
内存分布

经典面试题,一个Object空对象占几个字节:

默认开启压缩指针的情况下,64位机器:

Object o = new Object();(开启指针压缩)在内存中占了 8(markWord)+4(classPointer)+4(padding)=16字节

64位对象头mark work分布:

mark work分布
mark work分布

可以利用工具来查看锁的内存分布:

添加Maven

<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
<scope>provided</scope>

使用方法:

在使用之前,设置JVM参数,禁止延迟偏向,HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。

//禁止延迟偏向
-XX:BiasedLockingStartupDelay=0
public static void main(String[] args) {
 Object o = new Object();
 synchronized (o){
  System.out.println(ClassLayout.parseInstance(o).toPrintable());
 }
}
内存分布
内存分布

前两行显示的是Mark Word,它占用8字节。第三行显示的是类型指针(Class Pointer),它指向对象所属类的元数据。由于JVM开启了指针压缩,所以类型指针占用4字节。第四行显示的是对齐填充数据,它用于保证对象大小为8字节的倍数。在这种情况下,由于对象头占用12字节,所以需要额外的4字节对齐填充数据来使整个对象占用16字节。

我们重点要看的就是我红框标记的那三位,那是锁的状态。

无锁状态

正如上图所示那样,001表示无锁状态。没有线程去获得锁。

偏向锁

没有竞争的情况下,偏向锁会偏向于第一个访问锁的线程,让这个线程以后每次访问这个锁时都不需要进行同步。在第一次获取偏向锁的线程进入同步块时,它会使用CAS操作尝试将对象头中的Mark Word更新为包含线程ID和偏向时间戳的值。

public static void main(String[] args) {
 Object o = new Object();
 synchronized (o){
  System.out.println(ClassLayout.parseInstance(o).toPrintable());
 }
 ThreadUtil.sleep(1000L);
 System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

我们看下这个的偏向锁的分布:

偏向锁
偏向锁

图分析,红框标注的101是偏向锁,这时候发现持有锁的时候和释放锁之后,两个内存分布是一样的,这是因为,在偏向锁释放后,对象的锁标志位仍然保持偏向锁的状态,锁记录中的线程ID也不会被清空,偏向锁的设计思想就是预计下一次还会有同一个线程再次获得锁,所以为了减少不必要的CAS操作(比较和交换),在没有其他线程尝试获取锁的情况下,会保持为偏向锁状态,以提高性能。只有当其他线程试图获取这个锁时,偏向锁才会升级为轻量级锁或者重量级锁。

「那么下一个线程再来获取偏向锁,会发生什么?」

当另一个线程尝试获取偏向锁时,会发生偏向锁的撤销,也称为锁撤销。具体过程如下:

  1. 首先,需要检查当前持有偏向锁的线程是否存活,这一步需要通过暂停该线程来完成。
  2. 如果持有偏向锁的线程仍然存活,并持有该锁,那么偏向锁会被撤销,并且升级为轻量级锁。
  3. 如果持有偏向锁的线程已经不再存活,或者持有偏向锁的线程并没有在使用这个锁,那么偏向锁会被撤销。在撤销后,锁会被设置为无锁状态,此时其他线程可以尝试获取锁。
  4. 如果在撤销偏向锁的过程中,有多个线程尝试获取锁,那么锁可能会直接升级为重量级锁。

「偏向锁调用hashCode会发生什么?」

在分析这个之前,需要先回顾上图【mark word分布】。

当一个对象调用原生的hashCode方法(来自Object的,未被重写过的)后,该对象将无法进入偏向锁状态,起步就会是轻量级锁。如果hashCode方法的调用是在对象已经处于偏向锁状态时调用,它的偏向状态会被立即撤销。在这种情况下,锁会升级为重量级锁。

这是因为偏向锁在线程获取偏向锁时,会用Thread ID和epoch值覆盖identity hash code所在的位置。如果一个对象的hashCode方法已经被调用过一次之后,这个对象还能被设置偏向锁么?答案是不能。因为如果可以的话,那Mark Word中的identity hash code必然会被偏向线程Id给覆盖,这就会造成同一个对象前后两次调用hashCode方法得到的结果不一致。

轻量级锁会在锁记录中保存hashCode。

重量级锁会在Monitor中记录hashCode。

「偏向锁调用wait/notify会发生什么?」

由上边说到的synchronized底层实现原理知道,wait,notify,是Monitor提供的,像偏向锁,轻量级锁这些都是cas操作的不会用到Monitor,重量级锁才会用到Monitor,所以当调用wait/notify的时候就会升级到重量级锁。

轻量级锁

轻量级锁主要用于线程交替执行同步块的场景,这种场景下,线程没有真正的竞争,也就是有两个线程,一个线程获得了锁,另一个线程在自旋,如果这时候第三个线程过来枪锁,那就产生了真正的竞争了也就升级锁。

「轻量级锁的工作流程」

  1. 线程A获取了锁,并且锁的状态是偏向状态。
  2. 线程B尝试获取锁,发现锁的Mark word中的线程id和自己的不一样,并且线程还活着没释放锁。
  3. 撤销偏向锁,暂停拥有偏向锁的线程A,升级为轻量级锁。
  4. 线程B会在自己的线程栈中创建Lock Record的空间,然后将锁的Mark Word复制到LockRecord中。
  5. LockRecord里边有一个字段叫做Owner,将Owner赋值成锁地址。
  6. 线程B开始CAS操作,将锁的mark Word转换成Lock Record的地址。
  7. 如果失败,锁升级为重量级锁。
  8. 如果成功,开始自旋操作,监听线程A是否释放了锁,默认自旋十次。在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。
重量级锁

当升级到到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了,需要获取到 Monitor 对象,线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

「锁能降级嘛」

全局安全点是一个在所有线程都停止执行常规代码并执行特定任务的点,比如垃圾收集或线程栈调整。在全局安全点,JVM有可能会尝试降级锁。

降级锁的过程主要包括以下几个步骤:

  1. 恢复锁对象的MarkWord对象头。这是因为在升级为重量级锁的过程中,对象的MarkWord被改变了,所以在降级时需要恢复到原来的状态。
  2. 重置ObjectMonitor对象。ObjectMonitor是用于管理锁的一个对象,重置它的目的是为了准备将锁降级为轻量级锁或偏向锁。
  3. 将ObjectMonitor对象放入全局空闲列表。这是为了让这个ObjectMonitor对象可以在后续被其他需要使用锁的线程使用。

「为什么调用Object的wait/notify/notifyAll方法,需要加synchronized锁?」

调用Object的wait/notify/notifyAll方法需要加synchronized锁,是因为这些方法都会操作锁对象。在synchronized底层,JVM使用了一个叫做Monitor的数据结构来实现锁的功能。 当一个线程调用wait方法时,它会释放锁对象并进入Monitor的WaitSet队列等待。当另一个线程调用notify或notifyAll方法时,它会唤醒WaitSet队列中的一个或多个线程,这些线程会重新竞争锁对象。 由于wait/notify/notifyAll方法都会操作锁对象,所以在调用这些方法之前,需要先获取锁对象。加synchronized锁可以让我们获取到锁对象。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值