java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java Synchronized关键字

深入了解Java中Synchronized关键字的实现原理

作者:西瓜西瓜大西瓜

synchronized是JVM的内置锁,基于Monitor机制实现,每一个对象都有一个与之关联的监视器 (Monitor),这个监视器充当了一种互斥锁的角色,本文就详细聊一聊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,EntryList和WaitSet他们之间是怎么协作的?

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

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

我们用代码去演示。

代码源码:

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分布

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

添加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操作(比较和交换),在没有其他线程尝试获取锁的情况下,会保持为偏向锁状态,以提高性能。只有当其他线程试图获取这个锁时,偏向锁才会升级为轻量级锁或者重量级锁。

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

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

「偏向锁调用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的时候就会升级到重量级锁。

轻量级锁

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

「轻量级锁的工作流程」

重量级锁

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

「锁能降级嘛」

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

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

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

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

以上就是深入了解Java中Synchronized关键字的实现原理的详细内容,更多关于Java Synchronized关键字的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文