浅析: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开始,根据竞争的残酷程度,一步步演化,最终不得不求助操作系统内核。这种设计思想本身就是一种极致的性能优化艺术。

相关推荐
用户2018792831674 小时前
SystemClock.elapsedRealtime() 和 System.currentTimeMillis()
android
低调小一4 小时前
深入理解 Android targetSdkVersion:从 Google Play 政策到依赖冲突
android
皆过客,揽星河4 小时前
Linux上安装MySQL8详细教程
android·linux·hadoop·mysql·linux安装mysql·数据库安装·详细教程
catchadmin5 小时前
开发 PHP 扩展新途径 通过 FrankenPHP 用 Go 语言编写 PHP 扩展
android·golang·php
花城飞猪6 小时前
Android系统框架知识系列(二十):专题延伸:JVM vs ART/Dalvik - Android运行时演进深度解析
android·jvm·dalvik
用户2018792831676 小时前
故事:老王的图书馆HashMap vs 小张的现代科技SparseArray
android
用户2018792831676 小时前
故事:两个图书馆的比喻ArrayMap
android
用户2018792831676 小时前
SparseArray、SparseIntArray 和 SparseLongArray 的差异
android
2501_916013747 小时前
App 上架全流程指南,iOS App 上架步骤、App Store 应用发布流程、uni-app 打包上传与审核要点详解
android·ios·小程序·https·uni-app·iphone·webview