小浣熊的100道Java核心面试题 03:synchronized的锁升级机制详解

欢迎关注,微信公众号/知乎/稀土掘金/小红书都叫 ------【浣熊say】

专注输出国央企招聘、Java开发、物联网和数字孪生相关优质内容,关注公众号有小福利哦~

小浣熊的100道Java核心面试题 03:synchronized的锁升级机制详解

实际上,锁升级机制在JDK 15后已经废弃了,本文所说的只是面试中常问的低版本中的synchronized的锁升级机制,有兴趣的需要自己去看最新的JDK源码来了解新的Synchronized的锁机制。

什么是synchronized?

在Java的并发编程当中,synchronzied无疑是最常用的关键字,用于保护代码块和方法在多现场场景下的,并发安全问题。在Java中,synchronized锁是基于对象实现的,通常的使用方式包括修饰同步方法和修饰同步代码块,如下图:

总体而言,synchronized关键字提供了一种简单而有效的方式来控制并发访问共享资源。但是,它也有一些限制,例如性能问题和潜在的死锁风险,在更复杂的并发场景中,可以考虑使用java.util.concurrent包中提供的更灵活的同步机制。

Synchronized原理详解------从一段Java代码说起

auto 复制代码
package com.tsinghualei.concurrent;

public class SynchronizedExample {
    private static final Object lock = new Object(); // 用于代码块的监视对象

    // 1. 修饰实例方法,使用当前对象作为锁
    public synchronized void synchronizedInstanceMethod() {
    }

    // 2. 修饰静态方法,使用当前类的.class作为锁
    public static synchronized void synchronizedStaticMethod() {
    }

    // 3. 修饰代码块,使用指定的监视对象作为锁
    public void synchronizedBlock() {
        synchronized (lock) {
            System.out.println("Synchronized Block - Start");
        }
    }
    
    public static void main(String[] args) {
        SynchronizedExample.synchronizedStaticMethod();
    }
}

这里我们给出了一段极其简单的代码,这段代码有三个方法,但是方法的内容都是空的,分别代表了synchronized关键字的三种使用方法,下面我们javap -v xxx.class命令将其字节码文件反编译,不知道怎么生成字节码文件的可以参考我的文章:Java大厦的基石------Java Class文件构成原创》,反编译结果如下面内容所示

修饰方法------synchronizedInstanceMethod,synchronizedStaticMethod

像上面这样使用synchronized修饰的普通方法可以注意到flags位的属性是ACC_SYNCHRONIZED,当 JVM 遇到带有设置了 ACC_SYNCHRONIZED 标志的方法时,它确保只有一个线程可以同时执行该方法。其他尝试访问相同方法的线程必须等待第一个线程释放锁。

synchronzied修饰静态方法也一样,也是在方法的字节码前面添加ACC_SYNCHRONIZED标志。

修饰代码块------synchronizedBlock

如上图所示,synchronized修饰的代码是在相对应的指令区间添加了monitorenter和monitorexit指令,JVM就是通过这两个指令来保证多线程状态下的同步的。

ACC_SYNCHRONIZED、monitorenter、monitorexit

下面的解释都是我去官网翻的介绍并用chatgpt翻译的结果

ACC_SYNCHRONIZED

方法级的synchronized是隐式执行的,作为方法调用和返回的一部分, 同步方法在运行时常量池的method_info结构中通过ACC_SYNCHRONIZED标志进行区分,该标志由方法调用指令检查。

当调用设置了ACC_SYNCHRONIZED的方法时,执行线程进入monitor,调用方法本身,并在方法调用正常完成或异常中断时退出monitor。

在执行线程拥有moniter的时间内,其他线程无法进入。如果在同步方法调用期间引发异常且同步方法未处理异常,则在将异常重新抛出同步方法之前,方法的监视器将自动退出。

对指令序列的同步通常用于编码Java编程语言的同步块。Java虚拟机提供了monitorenter和monitorexit指令来支持这样的语言结构。正确实现同步块需要由面向Java虚拟机的编译器协同工作。

总的来说就是,当JVM执行到有ACC_SYNCHRONIZED标志标记的方法的时候,JVM会自动在该方法的对应代码块前后添加monitorenter和monitorexit指令,通过monitor来完成同步操作。

monitorenter

Java中每个对象都与一个monitor相关联,只有对象在被线程持有的情况下,monitor才会被锁定。执行monitorenter的线程会尝试获取与对象相关联的monitor的所有权,具体如下:

  1. 如果与对象相关联的monitor的进入计数为零,则线程进入monitor并将其进入计数设置为一。此时,当前线程成为monitor的所有者。
  2. 如果线程已经拥有与对象相关联的monitor,则重新进入monitor,增加其进入计数。
  3. 如果另一个线程已经拥有与对象相关联的monitor,则线程将被阻塞,直到monitor的进入计数为零,然后再次尝试获取所有权。

monitorexit

执行monitorexit的线程必须是与对象引用的实例相关联的monitor的所有者。当monitorexit指令执行时,对象相关联的monitor的进入计数会减1。如果显示当前monitor进入计数的值为零,则线程退出monitor,并不再是其所有者,正在阻塞等待进入monitor的其他线程被允许尝试进入。

Monitor(监视器)

在Java中,Monitor(监视器)是一种用于实现线程同步和互斥的机制,每个Java对象都与一个Monitor相关联,Monitor的主要目的是确保在任何给定时间,只有一个线程能够执行与特定对象相关联的临界区代码。Monitor是通过对象头(Object Header)和内置锁(Intrinsic Lock)来实现的,在JVM中Monitor的具体实现是ObjectMonitor。

ObjectMonitor核心参数

JDK21的HotSpot源码:

下面是JDK 21的HotSpot源码中定义的ObjectMonitor,其具体包位置是:hotspot/share/runtime/objectMonitor.hpp,删除了代码中大部分内容,保留了一些关键的成员变量。

auto 复制代码
class ObjectMonitor : public CHeapObj<mtObjectMonitor> {

  static OopStorage* _oop_storage;         // 静态成员变量,用于存储对象的内存
  
  volatile markWord _header;               // 被监视对象的markword
  
  WeakHandle _object;                      // 被监视对象的弱引用指针
  
private:
  
  void* volatile _owner;                   // 拥有该监视器的线程的指针
  
  volatile uint64_t _previous_owner_tid;   // 先前拥有该监视器的线程的线程 ID
  
  ObjectMonitor* _next_om;                 // 下一个 ObjectMonitor* 的链接
  
  volatile intx _recursions;               // 进入线程计数,第一次进入时为 0
  
  ObjectWaiter* volatile _EntryList;       // 阻塞在进入或重新进入的线程链表

  ObjectWaiter* volatile _cxq;             // 最近到达并在进入时被阻塞的线程链表
  
  JavaThread* volatile _succ;              // 预定为继任者的线程 - 用于徒劳唤醒节流
  
  JavaThread* volatile _Responsible;       // 负责者线程,用于记录最后一次成功进入的线程
  
  volatile int _Spinner;                   // 用于退出->自旋器的优化
  
  volatile int _SpinDuration;              // 自旋的持续时间
  
  int _contentions;                        // 在 enter() 中的活动争用次数,由 is_busy() 使用
  
protected:
 
  ObjectWaiter* volatile _WaitSet;         // 阻塞在 monitor 上 wait() 的线程链表
   
  volatile int _waiters;                   // 等待的线程数
  
private:
 
  volatile int _WaitSetLock;               // 保护 Wait Queue 的简单自旋锁
};

下面详细说说上面的关键参数的意义:

  • _header: 每个对象都会关联一个monitor,而monitor中会存储这个对象头中的markword,用来标记锁升级信息等。
  • _owner: 如果当前对象被锁定了,那么_owner就会指向持有当前对象锁的线程指针,在C++中void*就是通用类型的指针。
  • _EntryList: 阻塞在获取当前对象锁的线程列表,当使用synchornized时候锁定对象如果已经被其它线程所持有,那么新的想获取该对象锁的线程就会被加入这个列表当中。
  • _cxq: _cxq 是"Contended eXit Queue" 的缩写, 表示"争用退出队列"。存储那些尝试获取锁却因为被其他线程占用而被阻塞的线程。它是一种用于管理竞争锁的机制,帮助控制和唤醒争用的线程,使它们在适当的时机有机会重新尝试获取锁。
  • _WaitSet:阻塞在monitor的wait()上的线程列表,在Java中调用Object.wait()就会将当前线程加入这个列表。

这里介绍的几个参数是了解Monitor机制的几个核心参数,源码中其它参数还有很多,感兴趣的可以直接去看源码。

ObjectMonitor核心机制

如上图所示的那样,monitor的核心机制其实不复杂,主要维护了一个EntrySet、WaitSet和一个线程的owner:

  • 当线程想获取对象锁的时候(也就是获取当前这个monitor),会进去_EntryList队列

  • 当某个线程获取到了当前这个monitor之后,_owner参数会被设置为当前线程的指针,同时计数器_recursions+1

  • 如果线程调用了wait()方法,则当前线程会进入_WaitSet列表,这个过程会释放monitor并且讲将_owner置为null,_recursions-1

  • 如果线程调用了notify/notifyAll()方法,则会唤醒_WaitSet方中的某个线程来尝试获取锁

  • 同步方法结束则会将_owner置为null,并释放monitor

上面就是ObjectorMonitor的核心原理,以上原理均为HotSpot源码中总结,由于源码实际上很长为了不影响体验就不贴源码了。

Java对象与monitor关联

理解这部分需要先了解Java对象布局和对象头的相关前置知识,可以参考前面的文章《Java对象的内存布局详解------超市薯片是怎么摆在货架上的?》(PS:稀土掘金屏蔽微信公众号链接,感兴趣的去微信公众号:ByteRaccoon,看原文)。

上面的图已经将关联关系展示得很清楚了,Java对象会有一个对象头,对象头中有MarkWord。当synchonized处于重量级锁的状态下的时候,其中的指针部分会指向HotSpot中C++定义的ObjectMonitor类。

Sychronzied的锁升级机制

在 JDK 1.6 之前,使用 synchronized 关键字需要依赖于底层操作系统的 Mutex Lock 实现,挂起线程和恢复线程都需要转入内核态来完成 ,也就是说阻塞或唤醒一个 Java 线程都需要系统去切换 CPU 状态,这种状态的切换需要消耗处理器时间。这也就是为什么 synchronized 属于重量级锁的原因,因为需要切换 CPU 状态导致效率低下,时间成本相对较高,特别是当同步代码的内容过于简单时,可能切换的时间还要比代码执行的时间长。

在 JDK 1.6 之后,引入了偏向锁与轻量锁来减小获取和释放锁所带来的性能消耗,也就是不再是一上来就需要切换 CPU 状态导致效率低下而是通过锁升级的方式逐步增大性能消耗,从而避免了一些无需使用重量级锁的情况的性能消耗问题。

锁升级可以分为四种状态:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,锁会随着线程的竞争情况逐渐升级,但是锁升级是不可逆的,只能升级不能降级。下面将详细介绍每个锁的状态。

无锁(Uncontended State

无锁状态其实就是不使用synchronized关键字的状态,Markword中的标志位为01,在一个对象的开始状态就是无锁状态。无锁状态下,对象并没有被真正上锁,因此也没有多余的内核态和用户态的切换带来的开销。

偏向锁(Biased Locking

当第一个线程访问同步块的时候,对象就会被标记为偏向锁状态,对象头部的Markword当中会存储持有锁的线程的ID。当其它线程尝试获取锁的时候,会先检查对象是否为偏向锁的状态,并验证持有锁的线程是否为当前线程。如果持有锁的线程为当前线程则直接获取锁,否则升级为轻量级锁。

为什么要引入偏向锁呢?因为实际上再大多数程序运行过程中锁都是被同一个线程锁持有,很少发生竞争,因此也就没有必要进行多次的锁的获取和释放过程,带来不必要的性能开销。所以,引入偏向锁的目的是为了解决只有一个线程访问同步代码块的时候进行不必要的锁获取和释放的过程,偏向锁标记的线程可以直接获取锁。

偏向锁升级过程:

当一个线程进入由synchronized关键字修饰的同步代码块时,JVM使用CAS(Compare and Swap)操作将当前线程的ID记录到作为锁的对象的Mark Word中的54bit的ThreadID字段中,同时修改偏向锁标志位为1,表示当前线程获得了该锁。此时,锁对象从无锁状态变为偏向锁状态。

当前线程再次访问该同步代码块时,JVM通过锁对象的对象头中的Mark Word判断ThreadID字段是否与当前线程的ID一致。如果一致,说明当前线程仍然持有该锁对象,可以直接进入同步代码块。偏向锁不会在线程执行完同步代码块后主动释放,因此线程可以一直访问同步代码块而无需重复加锁。

这种机制无需切换CPU状态,即不涉及操作系统的介入。偏向锁实际上就是在没有其他线程竞争的情况下,始终偏向于同一线程,该线程可以持续访问同步代码块而无需重复获取锁。因此,使用偏向锁几乎没有额外的开销,具有极高的性能。

偏向锁降级:

偏向锁在没有其他线程竞争时,持有偏向锁的线程不会主动释放。偏向锁的释放时机是在其他线程竞争该锁时,持有偏向锁的线程会被撤销,并释放该偏向锁。偏向锁的撤销需要等待到全局安全点,即在该时间点没有字节码正在执行。此外,根据持有偏向锁的线程是否执行完同步代码,偏向锁的撤销有两种情况:

情况一: 持有偏向锁的线程正在执行同步代码(尚未执行完)。此时,另一个线程抢占该锁,导致偏向锁被撤销,锁升级为轻量级锁。新竞争的线程会自旋等待获取该轻量级锁,而原持有偏向锁的线程继续执行其同步代码。

情况二: 持有偏向锁的线程已执行完同步代码(已退出同步代码块)。在这种情况下,另一个线程抢占该锁,导致偏向锁被撤销。此时,ThreadID会被置空,偏向锁位置被清零。根据持有偏向锁的线程是否再次竞争,有以下两种情况:

  • 如果持有偏向锁的线程不再竞争,那么偏向锁会重新偏向于新的线程,即新的线程成为持有偏向锁的线程。
  • 如果持有偏向锁的线程继续竞争,那么锁将升级为轻量级锁,通过CAS自旋抢占锁。

这种机制保证了在低竞争情况下,偏向锁的性能表现较好。

轻量级锁(Lightweight Locking

当2个线程争夺同一个锁时,对象升级为轻量级锁状态。轻量级锁使用CAS(Compare and Swap)操作,尝试将对象头部的锁记录指针替换为指向线程栈上的锁记录(Lock Record)。如果CAS成功,线程成功获取锁;否则,升级为重量级锁。

轻量级锁升级:

在线程A执行同步代码前,JVM在线程的栈帧中创建空间用于存储锁记录,即Lock Record,当线程A抢占锁对象时,JVM使用CAS操作将锁对象的对象头的Mark Word拷贝进线程A的锁记录Lock Record中(这个拷贝Mark Word的过程被称为Displaced Mark Word)。

同时,将Mark Word中指向线程栈中Lock Record的指针指向线程A的锁空间。如果CAS更新成功,表示线程A成功持有该对象锁,将对象锁的Mark Word的锁标志位更新为00。此时,线程A可以执行同步代码,而线程B则会自旋等待获取该轻量级锁,如果CAS更新失败,说明该锁被线程B抢占。

轻量级锁的撤销:

当有两个以上的线程同时竞争一个锁时,轻量级锁会被撤销并升级为重量级锁,这意味着不再通过自旋的方式等待获取锁,而是直接阻塞线程。

当持有轻量级锁的线程执行完同步代码时,同样会释放轻量级锁。这时,JVM会使用CAS操作将锁对象的Mark Word中指针指向的锁记录Lock Record重新替换回锁对象的Mark Word。

这种机制保证了在线程竞争激烈或同步代码执行完毕时,锁能够适应不同的情况进行升级或撤销,以提高并发性能。

重量级锁**(Heavyweight Locking)**

当轻量级锁竞争激烈,多个线程争夺同一个锁时,升级为重量级锁状态。在重量级锁状态下,对象的头部会指向一个Monitor对象,该Monitor对象负责管理锁的获取和释放。线程在进入同步块时,需要先获取Monitor对象,成功获取后执行同步块,执行完毕后释放Monitor对象。

锁优化

JDK 1.6及之后版本引入了自适应自旋锁、锁消除和锁粗化等锁优化策略,以进一步提升synchronized的性能。

自适应自旋锁

JDK 1.6之前已引入自旋锁,但它的缺点是在锁占用时间较长的情况下,线程一直占用CPU时间片,导致CPU资源浪费。为解决这个问题,引入了自适应自旋锁,它根据前一次在相同锁上的自旋时间以及锁的持有者状态来动态决定自旋的上限次数。JVM会根据线程在同一锁对象上的自旋等待情况来调整自旋的上限次数,减少额外的CPU开销。

锁消除:

锁消除是JVM在JIT编译期间进行的优化,通过逃逸分析来消除不可能存在共享资源竞争的锁。通过逃逸分析,JVM判断对象是否会逃逸,如果某个对象不会逃逸,即在堆上的对象不会被其他线程访问,就可以将其当作栈上的数据处理,认为该数据是线程私有的,从而省略同步加锁操作,实现锁消除。

锁粗化:

锁粗化是通过将加锁范围扩展到整个操作序列的外部,降低加锁解锁的频率来减少性能损耗。当存在一系列操作对同一个对象反复加锁和解锁,甚至在循环体中进行加锁操作时,即使没有线程竞争,频繁进行互斥同步操作也会导致性能损耗。为解决这个问题,引入锁粗化,将一系列操作的加锁解锁频率减低,提高性能。

总结

本文总结了JDK8中synchronized的锁升级机制,首先从字节码的角度分析了在字节码层面synchronized的实现原理,核心点是monitorenter和monitorexit两个指令。

Java中的每个对象都会关联一个monitor,monitor本身是一个管程的概念,用来管理Java对象在多线程中的同步问题,主要实现上是由一个EntrySet、一个WaitSet和一个_owner来进行管理。

最后,介绍了synchonized的锁升级机制,在JDK8中会从无锁->偏向锁->轻量级锁->重量级锁的流程进行升级,以提升并发效率。

更多优质内容

全平台一个名,微信公众号、知乎、稀土掘金、小红书都叫:浣熊say

欢迎大家关注我的公众号哇,后续专注输出Java开发、物联网和数字孪生相关优质内容~

PS:本文当中有些链接跳转不过去,是因为掘金禁止跳转微信公众号的链接,对连接内容感兴趣的,可以直接看我的微信公众号原文。

相关推荐
非鱼feiyu4 分钟前
自关联数据表查询优化实践:以 Django + 递归 CTE 构建树结构为例
数据库·后端·django
零日失眠者10 分钟前
这5个Python库一旦掌握就离不开
后端·python
幌才_loong13 分钟前
.NET8 × Redis 实战宝典:从配置到落地,搞定高并发缓存就这篇!
后端·.net
用户83562907805116 分钟前
如何使用 Python 从 Word 文档中批量提取表格数据
后端·python
l***370932 分钟前
spring 跨域CORS Filter
java·后端·spring
aiopencode43 分钟前
APP 公钥与 MD5 信息在工程中的价值 一次签名排查过程带来的经验总结
后端
ServBay1 小时前
Django 6.0 发布,新增原生任务队列与 CSP 支持
后端·python·django
用户2190326527351 小时前
Spring Boot 4.0 整合 RabbitMQ 注解方式使用指南
后端
PPPPickup2 小时前
easychat---创建,获取,获取详细,退群,解散,添加与移除群组
java·开发语言·后端·maven
回家路上绕了弯2 小时前
大表优化实战指南:从千万到亿级数据的性能蜕变
分布式·后端