浅析:Synchronized的锁升级机制

一、核心思想:为什么需要"锁升级"?

想象一下,你去一个几乎没人的公共卫生间:

  1. 你进去后,会特意把每个隔间都锁上吗?不会,你只需要找一个没人的隔间,象征性地关上门(甚至不关) 就行了。这很高效。
  2. 但如果人多了,开始排队了,你就得从里面把门锁上,防止别人误入。
  3. 再极端点,如果因为谁占坑太久引发了冲突,大家就得吵吵嚷嚷,甚至需要管理员(操作系统)来协调谁先谁后。

Java的锁也是这个道理。它认为大部分情况下,多线程竞争是不激烈的 。如果一开始就用最重量级的、需要调用操作系统功能的锁(像吵架找管理员),成本太高了。所以,JVM设计了一套聪明的策略:从低到高,逐步升级,根据竞争的激烈程度来选择合适的锁。

这套策略中的锁状态,就记录在Java对象的"身份证"里------对象头(Object Header)


二、锁的"身份证":对象头(Object Header)

每一个Java对象(包括new Object())在堆内存中都有两部分:

  1. 对象头(Header) :存储对象的元数据,包括锁信息、GC年龄、类型指针等。
  2. 实例数据(Instance Data) :对象各个字段的值。
  3. 对齐填充(Padding) :为了内存对齐,提高访问效率。

我们的焦点是对象头(Mark Word) 。在32位和64位JVM中,它的长度分别是32bit和64bit。它就像一个多功能瑞士军刀,在不同的状态下,表示不同的含义。下图是32位JVM下的Mark Word布局(64位类似,只是空间更大):

无锁状态下的Mark Word结构:

text

scss 复制代码
|-------------------------------------------------------|--------------------|
|                 25 bits (unused)                     |   4 bits   |1 bit | 1 bit       |
|                                                       | (age)      | (biased) | (lock) |
|-------------------------------------------------------|--------------------|
|               identity_hashcode                      | 0          | 0     | 01         |
|-------------------------------------------------------|--------------------|
  • identity_hashcode: 对象的哈希码
  • age: 分代回收的年龄
  • biased_lock: 偏向锁标识位,0表示未开启偏向锁
  • lock: 锁标志位,01代表无锁状态

关键在于,当锁状态变化时,整个Mark Word的内容和意义都会发生改变!

锁的升级过程就是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 。锁标志位(lock)和偏向锁位(biased_lock)共同决定了当前处于何种状态。

锁状态 偏向锁位(biased_lock) 锁标志位(lock) 组合值
无锁 0 01 001
偏向锁 1 01 101
轻量级锁 (不关心这个位) 00 00
重量级锁 (不关心这个位) 10 10
GC标记 (不关心这个位) 11 11

三、逐层深入锁的升级过程

1. 偏向锁 (Biased Locking)

核心思想 :假设锁始终只由一个线程获得,不存在竞争。那么只需要在Mark Word里记录一下这个线程的ID,以后这个线程再来加锁,看一眼是自己的ID就直接通行,连一次CAS操作都省了,代价极低。

适用场景:真正无竞争或几乎无竞争的环境。

工作原理(代码视角)

我们写一段简单的同步代码:

java

csharp 复制代码
public class LockExample {
    private final Object lock = new Object();
    private int count = 0;

    public void increment() {
        synchronized (lock) { // 第一次加锁,lock对象处于无锁状态
            count++;
        }
    }
}

底层发生了什么(以第一次加锁为例)

  1. 检查状态 :线程A进入synchronized块,发现lock对象的Mark Word中锁标志是01(无锁/偏向锁),且偏向锁位是0(未偏向)。

  2. CAS抢偏 :线程A发起一次CAS(Compare-And-Swap)操作,试图将Mark Word的前23位(32位JVM)替换成自己的线程ID,同时将偏向锁位设为1,锁标志位仍为01

    • CAS是一个CPU原子指令,可以理解为"我认为M的值应该是A,如果是,我就把它改成B;否则就不修改"。这个过程是硬件保证的,非常高效。
  3. 成功:如果CAS成功,Mark Word现在就变成了:

    text

    lua 复制代码
    |-------------------------------------------------------|--------------------|
    |        Thread A's ID (54 bits in 64bit JVM)          |  Epoch | age | 1 | 01 |
    |-------------------------------------------------------|--------------------|

    现在,这个锁就偏向了线程A

  4. 后续加锁 :以后只要线程A再次进入这个synchronized块,它只需要做一步:

    • 检查Mark Word里的线程ID是不是自己。
    • 发现是,ok,直接通行!零次CAS操作,性能极高。

偏向锁的撤销

如果另一个线程B也来尝试加锁(发生了竞争),它检查Mark Word:

  • 发现线程ID不是自己,而是线程A。

  • 这表明存在竞争了,偏向锁需要撤销(Revoke Bias)

  • 撤销过程需要等待一个全局安全点(JVM停顿所有线程的时刻),然后检查原持有偏向锁的线程A是否还活着或者还在同步块内。

    • 如果线程A已经不活了或者不在同步块了,可以将锁对象重置为无锁状态(01) ,然后线程B再尝试重新竞争,此时可能会直接升级为轻量级锁。
    • 如果线程A还在同步块内,说明竞争已经发生,直接将锁升级为轻量级锁

注意 :Java 15以后,默认已经禁用了偏向锁(-XX:-UseBiasedLocking)。因为维护偏向锁和撤销带来的开销,在现代多核高并发应用中,往往弊大于利。但理解它对于掌握锁的完整演进至关重要。

2. 轻量级锁 (Lightweight Locking)

核心思想:当锁有轻微的竞争时(比如两个线程交替执行),避免直接动用操作系统的重量级锁。它采用一种"自旋"的方式在用户态尝试获取锁。

适用场景 :线程竞争是交替执行的,不存在同一时刻多个线程激烈竞争的情况。

工作原理(加锁)

  1. 当发生竞争导致偏向锁撤销,或一个无锁对象首次遇到竞争时,JVM会准备升级到轻量级锁。

  2. 在当前线程的栈帧(Stack Frame) 中,开辟一块名为锁记录(Lock Record) 的空间。

  3. 将对象当前的Mark Word复制 到线程的锁记录中,官方称这个拷贝为Displaced Mark Word

  4. 线程使用CAS操作 ,尝试将对象头的Mark Word替换为指向其栈中锁记录的指针 。如果成功,锁标志位变为00

    • 此时对象头的Mark Word结构:

      text

      lua 复制代码
      |-------------------------------------------------------|
      |          ptr_to_lock_record (62 bits)           | 00 |
      |-------------------------------------------------------|
  5. 如果第4步的CAS操作失败了怎么办?这意味着至少有两个线程同时在竞争这个锁。

    • 失败线程会自旋(Spin) ------ 即在一个空循环里不停地重试CAS操作,期望持有锁的线程很快会释放锁。
    • 自适应自旋:JVM会根据上次自旋是否成功来智能决定这次自旋的次数。如果上次成功了,这次就多自旋一会儿;如果上次失败了,这次可能干脆不自旋了。

工作原理(解锁)

  1. 持有轻量级锁的线程执行完同步代码块后,使用CAS操作,想用之前拷贝的Displaced Mark Word去替换回对象头的Mark Word。
  2. 如果替换成功,整个同步过程就完成了,锁恢复到无锁状态(01)。
  3. 如果替换失败,说明在持有锁期间,已经有其他线程来竞争导致锁升级了 (比如自旋失败,升级为了重量级锁)。此时在解锁时,需要唤醒被挂起的线程,正式进入重量级锁的解锁流程。

3. 重量级锁 (Heavyweight Locking)

核心思想 :当竞争非常激烈,线程自旋一定次数后还是无法获取锁(浪费CPU),就升级为最重量级的锁。这需要依赖操作系统内核的互斥量(Mutex) 来实现。线程会从用户态陷入内核态,由操作系统负责线程的调度和阻塞/唤醒。

适用场景:高并发、线程持有锁时间较长、竞争激烈的场景。

工作原理

  1. 当轻量级锁自旋失败后,锁就会升级。

  2. JVM会向操作系统申请一个互斥量(Mutex) ,同时在对象头的Mark Word中,用一个指针指向这个互斥量和相关的监视器(Monitor) 结构。锁标志位变为10

    • 此时对象头的Mark Word结构:

      text

      lua 复制代码
      |-------------------------------------------------------|
      |          ptr_to_heavyweight_monitor (62 bits)   | 10 |
      |-------------------------------------------------------|
  3. 这个Monitor (在JVM里由ObjectMonitor类实现,C++代码)是重量级锁的核心。它内部维护着几个关键队列:

    • _EntryList:等待获取锁的线程队列(竞争失败后进来的)。
    • _WaitSet:调用了wait()方法而等待的线程队列。
  4. 当一个线程(比如线程C)尝试获取重量级锁失败后,它会被操作系统挂起(Park) ,然后放入_EntryList中,等待被唤醒。这个过程涉及从用户态到内核态的切换,成本非常高。

  5. 当持有锁的线程释放锁时,它会去_EntryList中唤醒一个线程(比如线程D)。这个被唤醒的线程D会尝试重新获取锁。注意:唤醒操作同样涉及内核态切换

ObjectMonitor的核心源码概念(hotspot源码):

cpp

arduino 复制代码
// 位于 hotspot/src/share/vm/runtime/objectMonitor.hpp
class ObjectMonitor {
    ...
    void* volatile _owner;          // 指向当前持有锁的线程( owner == null 表示未锁定)
    volatile jlong _count;          // 重入次数
    volatile int _waiters;          // 等待的线程数
    protected:
    ObjectWaiter* volatile _EntryList; // 等待获取锁的线程列表(竞争失败)
    ObjectWaiter* volatile _WaitSet;   // 调用了 wait() 的线程列表
    ...
    // 获取锁
    void ATTR ObjectMonitor::enter(TRAPS);
    // 释放锁
    void ATTR ObjectMonitor::exit(TRAPS);
};

synchronizedmonitorentermonitorexit指令,在重量级锁模式下,最终就会调用到ObjectMonitorenterexit方法。


四、总结与全景图

现在,让我们把整个过程串联起来,形成一张完整的锁升级全景图:

Diagram

Code

css 复制代码
flowchart TD
    A[无锁状态 (001)] -->|第一个线程访问| B[尝试偏向锁]
    B -->|CAS成功| C[偏向锁状态 (101)]
    C -->|同一线程再次访问| C["✅ 直接进入 (零成本)"]
    C -->|其他线程访问<br>发生竞争| D[撤销偏向锁]
    
    D -->|原线程已退出| A
    D -->|原线程未退出| E[升级为轻量级锁]
    
    A -->|有竞争| E
    
    E[轻量级锁状态 (00)] -->|线程CAS将Mark Word指向栈中锁记录| F[成功]
    F -->|执行同步代码| G[解锁:CAS还原Mark Word]
    G -->|成功| A
    G -->|失败| H[升级为重量级锁]
    
    E -->|CAS失败| I[自旋重试]
    I -->|自旋成功| F
    I -->|自旋失败| H
    
    H[重量级锁状态 (10)] -->|Mark Word指向Monitor| J[线程进入_EntryList]
    J -->|被OS挂起| J
    J -->|被owner线程唤醒| K[竞争锁]
    K -->|成功| L[成为owner]
    L -->|执行同步代码| M[解锁并唤醒_EntryList线程]
    M --> H

五、如何观察和验证?

  1. 使用JOL工具(Java Object Layout)

    这是一个开源库,可以让你直接查看一个对象在内存中的布局,包括Mark Word。

    java

    less 复制代码
    // 添加依赖:org.openjdk.jol:jol-core
    import org.openjdk.jol.vm.VM;
    import org.openjdk.jol.info.ClassLayout;
    
    Object obj = new Object();
    System.out.println(VM.current().details()); // 输出VM详情
    System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 输出对象内存布局

    通过这个工具,你可以清晰地看到加锁前后Mark Word的变化。

  2. 看汇编字节码

    使用javap -c YourClass反编译你的class文件,可以看到monitorentermonitorexit指令。但请注意,这只是字节码层面的指令,具体的锁升级逻辑是在JVM执行这些指令时(C++代码)实现的。

希望这次超详细的讲解,能让你对Java锁的底层实现有一个深刻而清晰的认识。它从一个简单的CAS开始,根据竞争的残酷程度,一步步演化,最终不得不求助操作系统内核。这种设计思想本身就是一种极致的性能优化艺术。

相关推荐
消失的旧时光-19436 分钟前
kmp需要技能
android·设计模式·kotlin
帅得不敢出门1 小时前
Linux服务器编译android报no space left on device导致失败的定位解决
android·linux·服务器
雨白2 小时前
协程间的通信管道 —— Kotlin Channel 详解
android·kotlin
TimeFine3 小时前
kotlin协程 容易被忽视的CompletableDeferred
android
czhc11400756635 小时前
Linux1023 mysql 修改密码等
android·mysql·adb
GOATLong5 小时前
MySQL内置函数
android·数据库·c++·vscode·mysql
onthewaying7 小时前
Android SurfaceTexture 深度解析
android·opengl
茄子凉心7 小时前
Android Bluetooth 蓝牙通信
android·蓝牙通信·bluetooth通信
00后程序员张8 小时前
iOS 26 App 运行状况全面解析 多工具协同监控与调试实战指南
android·ios·小程序·https·uni-app·iphone·webview
2501_916007479 小时前
iOS 混淆实战,多工具组合完成 IPA 混淆、加固与发布治理(iOS混淆|IPA加固|无源码混淆|App 防反编译)
android·ios·小程序·https·uni-app·iphone·webview