版本说明:本文主要基于 JDK 6 ~ JDK 14 的 HotSpot 虚拟机实现。需要注意的是,从 JDK 15 开始,偏向锁已被默认关闭并标记为废弃(JEP 374),在 JDK 18 中相关代码已被移除。如果你使用的是 JDK 15+,文中关于偏向锁的内容仅作为历史参考。

线程安全

什么是线程安全

“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。”

相对的线程安全,可以分成五个等级:

线程安全的分类

不可变

不可变的数据,都是线程安全的。不可变的对象引用,加上所有field都是不可变的。如果有得选,尽量连方法都是final的。

绝对线程安全

Vector 不是绝对线程安全的。它也会出现并发修改时 Out of Range 的异常(注意,不是 ConcurrentModification 的异常)。

相对线程安全

需要保证对这个对象的单独操作是线程安全的,在调用的时候不需要加上额外的保障措施。对于特定顺序的连续操作,就需要额外的同步来保证调用的正确性了。

Vector 是相对线程安全的。

线程兼容

可以通过特殊手段做到线程安全的普通类,绝大部分类都属于线程兼容的。

线程对立

线程对立,是不管调用端是否采取了同步措施,都无法在多线程环境中使用的代码。常见的线程对立的操作还有 suspend(),resume(), System.setIn(),System.setOut()和System.runFinalizerOnExit()。

线程安全的实现

互斥同步(Mutual Exclusion & Synchronization)

这是最常见(也是我们在考虑并发问题的时候,首先应该考虑的万能解决方案,也是《Java并发编程实践》和《Thinking in Java 》中最推荐的做法。)的保障并发正确性的手段。同步(Synchronization)是指协调多个线程的执行,保证共享数据在同一时刻只被一条(或使用信号量时多条)线程访问。互斥(Mutual Exclusion)是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量都是实现互斥的常见方式。互斥是因,同步是果;互斥是方法,同步是目的。这两个概念源自操作系统理论(Dijkstra 1965 年提出信号量时引入),同样出现在 OS 层面。同步的终极目标是将并发的乱序转化为类似无并发时的有序。

在 Java 里面,最基本的互斥手段就是 synchronized 关键字。它经过编译后,会转化为 monitorenter 和 monitorexit 这两个字节码指令(bytecode instructions)。在执行这两个字节码指令之前,需要先将锁对象的引用压入操作数栈,指令会从栈顶获取这个引用来确定加锁/解锁的对象。这个引用不是一个普通对象实例,就是一个 Class 对象(对于 synchronized 静态方法)。

根据虚拟机规范,在执行 monitorenter 指令时,首先尝试获取对象的锁(实际上就是去用线程信息写 markword)。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,那么把锁的计数器加1。相应地,在执行 monitorexit 时,会对计数器减1,当计数器为0时,锁就被释放了。从某种意义上来讲,这种设计可以在分布式场景下用 Redis 实现。如果获取锁失败了,那么就会进入阻塞状态,直到对象锁被释放为止。虚拟机规范对 monitorenter 和 monitorexit 两条指令的行为描述中,有两点是需要特别注意的。首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死(阻塞)的情况。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入对于映射到操作系统原生进程的实现,不管是阻塞还是唤醒线程,都需要操作系统的调用帮忙,也就会牵涉到用户态转变入核心态的问题(系统控制权从用户空间转入内核空间)。这种切换需要消耗很多 CPU 时间。这也是为什么它是昂贵的原因,时间是最昂贵的。对于很多简单的getter()、setter()操作,花在状态切换上的时间,甚至会多过用户代码执行的时间。甚至可以认为,这样的状态切换需要使用很多的汇编指令代码,以至于要使用很多的 cpu 时钟周期。因此synchronized本身是一种重量级(Heavyweight)操作。JVM(注意,不是Java语言) 本身可能会对重量锁进行优化,使用自旋来避免频繁地切入核心态之中(自旋难道就不浪费CPU 时间了吗?)。

J.U.C包里专门提供了Reentrantlock来实现同步。它同样具有 synchronized 具有的可重入、阻塞其他求锁者的特性。但它还具有三个额外的特点,支持某些场景下的任务调度需求:

  1. 等待可中断。Lock 接口有实现类可以实现试锁,超时试锁等功能,各种接口都有 interruptibly 版本。这样 synchronized中,其他求锁线程傻等的情况可以避免。
  2. 公平锁。公平锁指的是按照求锁顺序来分配锁(求锁也是有顺序的,fifo 天然就是公平的)。默认的锁(synchronized 和 ReentrantLock 的默认构造函数)是非公平的,随机给予锁,这样性能更好。synchronized 本身并不内置公平锁,AQS 的非公平锁通过允许插队(新来的线程可以直接尝试 CAS 获取锁,不用排队),来减少 cpu 时间片花在调度/cpu上下文切换上的开销,来获得更高的吞吐。非公平锁的吞吐会更好,而公平锁可避免线程饥饿。ReentrantLock 默认使用非公平锁。
  3. 绑定多个条件。在 synchronized 的时代,多个 condition 就意味着多层 synchronized。

synchronized 的性能屡屡被 JVM 的实现者改进,因此还是优先要使用synchronized(《TIJ》、《Java 并发实践》和《深入理解 Java 虚拟机》到此达到了同一结论)。

非阻塞同步(Non-Blocking Synchronization)

也就是我们常说的乐观策略。不需要加锁,也就不需要负担线程状态切换的代价。但代价是,如果真的发生了冲突,乐观操作需要付出的代价就是补偿(compensation)。最常见的补偿,应该就是不断重试(又要引入自旋了)。乐观锁的核心基石,实际上是 CAS(CompareAndSet或者 CompareAndSwap),这两个操作必须是原子化操作,这就要求现代的处理器提供这样的指令原语(instruction primitive)。JVM 虚拟机里,专门通过 Unsafe 包来向上层提供这种原语的语义。

CAS操作有一个很讨厌的 ABA 问题。虽然 ABA 问题本身在大部分情况下不会引起问题,但J.U.C还是提供了一个 AtomicStampedReference操作来避免这个问题(所以说,带版本的原子值才是最安全的)。在大多数情况下,进入互斥同步,还比用这些鸡肋功能要高效。所有自旋都满足如下规律:低度竞争自旋优于真正的 mutex 互斥锁定,高度竞争自旋会浪费 cpu-低度竞争适合线程活跃等待,高度竞争适合线程阻塞等待。

无同步方案

可重入代码(Reentrant Code)

也叫纯代码(Pure Code)。在它执行的任意时刻中断它,转而去执行另一段代码,再切换上下文回来以后,不会发生任何错误。所有可重入的代码都是线程安全的,但并非所有线程安全的代码都是可重入的

可重入代码的特征:

  • 不依赖任何非常量的全局变量或静态变量
  • 不修改自身的代码
  • 不调用不可重入的函数
  • 所有数据都通过参数传递或使用局部变量(栈封闭)

这类似于函数式编程里的纯函数,函数的行为完全由输入参数决定,结果可预测,不依赖也不修改外部状态。这也是为什么函数式编程在高并发下是安全的,它们天然满足栈封闁的标准。

线程本地存储(Thread Local Storage)

ThreadLocal 工作机制图解:

图1:引用关系与原则1(操作的本质)

graph TB
    subgraph "方法区"
        TL[ThreadLocal 静态变量<br/>static ThreadLocal]
        style TL fill:#e1f5ff
    end
    
    subgraph "Thread-1 内部"
        T1[Thread-1]
        TLM1[ThreadLocalMap]
        E1[Entry]
        V1[value: data1]
        T1 -->|强引用| TLM1
        TLM1 -->|强引用| E1
        E1 -.->|key弱引用| TL
        E1 -->|强引用| V1
        style T1 fill:#fff4e6
        style TLM1 fill:#f0f0f0
        style E1 fill:#e8f5e9
        style V1 fill:#c8e6c9
    end
    
    subgraph "Thread-2 内部"
        T2[Thread-2]
        TLM2[ThreadLocalMap]
        E2[Entry]
        V2[value: data2]
        T2 -->|强引用| TLM2
        TLM2 -->|强引用| E2
        E2 -.->|key弱引用| TL
        E2 -->|强引用| V2
        style T2 fill:#fff4e6
        style TLM2 fill:#f0f0f0
        style E2 fill:#e8f5e9
        style V2 fill:#c8e6c9
    end
    
    subgraph "Thread-3 内部"
        T3[Thread-3]
        TLM3[ThreadLocalMap]
        E3[Entry]
        V3[value: data3]
        T3 -->|强引用| TLM3
        TLM3 -->|强引用| E3
        E3 -.->|key弱引用| TL
        E3 -->|强引用| V3
        style T3 fill:#fff4e6
        style TLM3 fill:#f0f0f0
        style E3 fill:#e8f5e9
        style V3 fill:#c8e6c9
    end
    
    TL -.->|"get()/set() 操作<br/>实际访问当前线程的 Map"| TLM1
    TL -.->|"get()/set() 操作<br/>实际访问当前线程的 Map"| TLM2
    TL -.->|"get()/set() 操作<br/>实际访问当前线程的 Map"| TLM3
    
    note1["原则1: get/set 操作的是各线程内部的隐藏 Map<br/>ThreadLocal 对象只是访问入口,真正的数据在各线程的 Map 中"]
    
    style note1 fill:#fff9c4

图2:原则2(为什么 ThreadLocal 应该是 static 变量)

graph TB
    subgraph "错误做法:ThreadLocal 作为成员变量"
        direction TB
        
        subgraph "对象实例1"
            O1[MyService 实例1]
            TL1[ThreadLocal 实例1]
            O1 -->|持有| TL1
            style O1 fill:#ffcdd2
            style TL1 fill:#ffcdd2
        end
        
        subgraph "对象实例2"
            O2[MyService 实例2]
            TL2[ThreadLocal 实例2]
            O2 -->|持有| TL2
            style O2 fill:#ffcdd2
            style TL2 fill:#ffcdd2
        end
        
        subgraph "对象实例3"
            O3[MyService 实例3]
            TL3[ThreadLocal 实例3]
            O3 -->|持有| TL3
            style O3 fill:#ffcdd2
            style TL3 fill:#ffcdd2
        end
        
        subgraph "Thread-1 的 Map"
            E1A[Entry: key=TL1]
            E1B[Entry: key=TL2]
            E1C[Entry: key=TL3]
            style E1A fill:#fff9c4
            style E1B fill:#fff9c4
            style E1C fill:#fff9c4
        end
        
        problem["问题:<br/>1. 每个对象实例都创建新的 ThreadLocal<br/>2. 线程的 Map 中会有多个 Entry<br/>3. 浪费内存,违背线程本地存储的设计初衷"]
        style problem fill:#ffebee
    end
    
    subgraph "正确做法:ThreadLocal 作为 static 变量"
        direction TB
        
        subgraph "类级别"
            TLS[static ThreadLocal<br/>全局唯一实例]
            style TLS fill:#c8e6c9
        end
        
        subgraph "Thread-1 的 Map"
            E2[Entry: key=TLS<br/>唯一的 Entry]
            E2 -.->|key弱引用| TLS
            style E2 fill:#e8f5e9
        end
        
        subgraph "Thread-2 的 Map"
            E3[Entry: key=TLS<br/>唯一的 Entry]
            E3 -.->|key弱引用| TLS
            style E3 fill:#e8f5e9
        end
        
        benefit["优势:<br/>1. 全局只有一个 ThreadLocal 实例<br/>2. 每个线程的 Map 中只有一个对应的 Entry<br/>3. 节省内存,符合设计初衷"]
        style benefit fill:#e8f5e9
    end
    
    note2["原则2: ThreadLocal 应该声明为 static 变量<br/>作为类级别的全局唯一实例,避免每个对象实例都创建新的 ThreadLocal"]
    
    style note2 fill:#fff9c4

图3:原则3(线程生命周期决定数据生命周期)

graph TB
    subgraph "场景:普通线程执行完毕"
        direction TB
        
        subgraph "执行前"
            T1A[Thread-1 运行中]
            TLM1A[ThreadLocalMap]
            E1A[Entry]
            V1A[value: data1]
            T1A -->|强引用| TLM1A
            TLM1A -->|强引用| E1A
            E1A -->|强引用| V1A
            style T1A fill:#fff4e6
            style TLM1A fill:#f0f0f0
            style E1A fill:#e8f5e9
            style V1A fill:#c8e6c9
        end
        
        arrow1[" ⬇️ 线程执行完毕 ⬇️ "]
        style arrow1 fill:#ffebee
        
        subgraph "执行后"
            destroyed["Thread-1 被销毁<br/>ThreadLocalMap 被销毁<br/>Entry 被销毁<br/>value 被 GC 回收"]
            style destroyed fill:#ffcdd2
        end
        
        执行前 --> arrow1
        arrow1 --> 执行后
    end
    
    note3["原则3: 线程销毁时,ThreadLocalMap 随之销毁,value 自动清理<br/>这就是为什么在非线程池场景下,内存泄漏问题不严重"]
    
    style note3 fill:#fff9c4

图4:原则4(ThreadLocal 对象的生命周期影响所有线程)

graph TB
    subgraph "场景:ThreadLocal 静态变量被置为 null"
        direction TB
        
        subgraph "置为 null 前"
            TLA[ThreadLocal 静态变量]
            E1A[Thread-1 的 Entry]
            E2A[Thread-2 的 Entry]
            E3A[Thread-3 的 Entry]
            V1A[value: data1]
            V2A[value: data2]
            V3A[value: data3]
            E1A -.->|key弱引用| TLA
            E2A -.->|key弱引用| TLA
            E3A -.->|key弱引用| TLA
            E1A -->|强引用| V1A
            E2A -->|强引用| V2A
            E3A -->|强引用| V3A
            style TLA fill:#e1f5ff
            style E1A fill:#e8f5e9
            style E2A fill:#e8f5e9
            style E3A fill:#e8f5e9
            style V1A fill:#c8e6c9
            style V2A fill:#c8e6c9
            style V3A fill:#c8e6c9
        end
        
        arrow2[" ⬇️ ThreadLocal = null + GC ⬇️ "]
        style arrow2 fill:#ffebee
        
        subgraph "置为 null 后"
            TLB[ThreadLocal 对象被 GC 回收]
            E1B[Thread-1 的 Entry<br/>key=null]
            E2B[Thread-2 的 Entry<br/>key=null]
            E3B[Thread-3 的 Entry<br/>key=null]
            V1B[value: data1<br/>待清理]
            V2B[value: data2<br/>待清理]
            V3B[value: data3<br/>待清理]
            E1B -->|强引用| V1B
            E2B -->|强引用| V2B
            E3B -->|强引用| V3B
            style TLB fill:#ffcdd2
            style E1B fill:#fff9c4
            style E2B fill:#fff9c4
            style E3B fill:#fff9c4
            style V1B fill:#ffebee
            style V2B fill:#ffebee
            style V3B fill:#ffebee
        end
        
        arrow3[" ⬇️ 任意线程调用 get/set/remove ⬇️ "]
        style arrow3 fill:#ffebee
        
        subgraph "自动清理"
            cleaned["expungeStaleEntry 触发<br/>key=null 的 Entry 被清理<br/>value 被 GC 回收"]
            style cleaned fill:#c8e6c9
        end
        
        置为null前 --> arrow2
        arrow2 --> 置为null后
        置为nullnull后 --> arrow3
        arrow3 --> 自动清理
    end
    
    note4["原则4: ThreadLocal 被置为 null 后,所有 Entry 的 key 失效<br/>后续任意线程的 get/set/remove 操作会自动清理这些过期 Entry"]
    
    style note4 fill:#fff9c4

四大核心原则:

  1. 操作的本质:当我们调用 ThreadLocal.get()ThreadLocal.set() 时,实际上是在操作当前线程内部的 ThreadLocalMap。ThreadLocal 对象本身只是一个"访问入口",真正的数据存储在各个线程的隐藏 Map 中。

  2. ThreadLocal 应该是 static 变量:ThreadLocal 应该声明为 static 变量,作为类级别的全局唯一实例。原因有三:

    • 避免内存浪费:如果作为成员变量,每个对象实例都会创建新的 ThreadLocal,导致每个线程的 ThreadLocalMap 中会有多个 Entry,浪费内存且违背线程本地存储的设计初衷。我们的初衷是每个线程有一个变量的副本,而不是多个副本
    • 防止对象无法回收:如果 ThreadLocal 是成员变量,开发者会习惯性地通过 对象实例.threadLocal.get() 来访问 ThreadLocal。这种使用方式会导致对象实例被外部持有引用,进而无法被 GC 回收。而如果 ThreadLocal 是 static 变量,访问方式是 类.threadLocal.get(),不会持有对象实例的引用,避免了对象级别的内存泄漏。
    • Class 生命周期更长:相比之下,Class 对象通常不需要频繁回收,其生命周期与应用程序相当,因此 static 成员是可以接受的。static 声明确保全局只有一个 ThreadLocal 实例,每个线程的 Map 中只有一个对应的 Entry,既节省内存又避免了对象无法回收的问题。
  3. 线程生命周期决定数据生命周期:即使我们不主动调用 remove(),当线程销毁时(如普通线程执行完毕),该线程的 ThreadLocalMap 也会随之销毁,所有 value 自动被回收。这就是为什么在非线程池场景下,ThreadLocal 的内存泄漏问题不那么严重。

  4. ThreadLocal 对象的生命周期影响所有线程:如果我们将 ThreadLocal 静态变量置为 null(去除强引用),那么所有线程的 ThreadLocalMap 中对应的 Entry 的 key 都会失效(弱引用被回收)。即使线程什么都不做,只要后续有任何 get/set/remove 操作触发,这些 key 为 null 的 Entry 就会被自动清理,value 随之消失。

设计理念:为什么不用 Map<Thread, Value>

很多人初次设计线程本地存储时,会想到用一个全局的 Map<Thread, Value> 来存储每个线程的数据。但这种设计有致命缺陷:Thread 对象会被 Map 强引用,导致线程无法被 JVM 回收,造成严重的内存泄漏。

ThreadLocal 采用了相反的设计:让 Thread 持有 Map,而不是让 Map 持有 Thread。每个 Thread 内部都有一个 ThreadLocalMap,用于存储该线程的所有线程本地变量。这样设计的好处是:

  • 线程销毁时,ThreadLocalMap 随之销毁,数据自动清理
  • ThreadLocal 对象可以被显式管理(如声明为静态变量)
  • 线程内部的存储容器是隐式的,由线程自己管理

引用关系结构:

1
2
3
4
5
Thread -> ThreadLocalMap -> Entry[]

Entry extends WeakReference<ThreadLocal<?>>
- key: ThreadLocal 对象(弱引用)
- value: 实际存储的值(强引用)

一个 ThreadLocal 变量的"副本"实际上是分散存储在多个线程的 ThreadLocalMap 中的。每个线程的 ThreadLocalMap 中都有一个 Entry,以同一个 ThreadLocal 对象为 key,但 value 是各自独立的。

静态变量场景的引用关系:

1
2
方法区静态变量 -> 强引用 ThreadLocal 对象
ThreadLocalMap.Entry -> 弱引用 ThreadLocal 对象

为什么 key 使用弱引用?

因为线程内部的 ThreadLocalMap 是隐式容器,由线程自己管理。如果 key 使用强引用,那么只要线程存活(如线程池场景),ThreadLocal 对象就永远无法被回收。使用弱引用后,当 ThreadLocal 对象没有外部强引用时(如静态变量被置为 null),它可以被 GC 回收,Entry 的 key 变为 null,后续的 get/set/remove 操作会自动清理这些过期的 Entry。

弱引用的特性:在垃圾回收器线程扫描内存区域时,一旦发现只具有弱引用的对象,不管当前内存空间是否足够,都会回收它的内存。

内存泄漏的发生机制:

ThreadLocal 的内存泄漏实际上是一个条件链,任何一个环节被破坏都可能导致泄漏:

  1. ThreadLocal 对象被回收:当 ThreadLocal 对象没有强引用时(如静态变量被置为 null),它会被 GC 回收
  2. Entry 的 key 变为 null:ThreadLocalMap 中对应的 Entry 的 key(弱引用)失效
  3. value 仍被强引用:但 value 仍被 Entry 强引用,无法被回收(value 不是被 key 引用,而是被 Entry 引用
  4. 自动清理机制:后续的 get/set/remove 操作会触发 expungeStaleEntry(),清理 key 为 null 的 Entry
  5. 泄漏发生:如果线程长期存活(如线程池)且不再调用 get/set/remove,这些 Entry 永远不会被清理

两种泄漏场景:

  1. ThreadLocal 对象泄漏

    • ThreadLocal 通常被声明为静态变量,便于全局访问
    • 如果静态变量的生命周期管理不当,会导致方法区泄漏
    • 这与 ThreadLocalMap 的弱引用机制无关,是静态变量本身的泄漏
  2. value 泄漏

    • value 被 ThreadLocalMap 的 Entry 强引用
    • 只要 ThreadLocal 对象被回收(key 变为 null)且触发了 get/set/remove 操作,Entry 就会被自动清理
    • value 的泄漏本质上是由 ThreadLocal 对象无法被回收或清理机制未被触发导致的

最佳实践:

  1. 手动清理:使用完 ThreadLocal 后立即调用 remove(),这是最可靠的方式
  2. 避免静态变量泄漏:谨慎管理 ThreadLocal 静态变量的生命周期
  3. 触发自动清理:在长期运行的线程中,定期调用 get/set/remove 操作,让内部的 expungeStaleEntry() 清理过期 Entry
  4. 线程池场景:特别注意在任务执行完毕后清理 ThreadLocal(在使用线程池时,线程不会被销毁,必须手动清理)
1
2
3
4
5
6
7
8
9
10
private static final ThreadLocal<SomeObject> threadLocal = new ThreadLocal<>();

public void doSomething() {
try {
threadLocal.set(new SomeObject());
// 业务逻辑
} finally {
threadLocal.remove(); // 确保清理,避免内存泄漏
}
}

对象头

需要参考:

  1. What is in Java object header?
  2. 并发编程的艺术(3):深入理解Synchronized的原理

在 32 位虚拟机里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
普通对象
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
数组对象
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
标记字是
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30(lock_record指针) | 00 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30(monitor指针) | 10 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|
在64位虚拟机里
|--------------------------------------------------------------|
| Object Header (128 bits) |
|-------------------------------|------------------------------|
| Mark Word (64 bits) | Klass Word (64 bits) |
|-------------------------------|------------------------------|
标记字是
|------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------|--------------------|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01 | Normal |
|------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | Biased |
|------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | 00 | Lightweight Locked |
|------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | 10 | Heavyweight Locked |
|------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|------------------------------------------------------------------|--------------------|

monitor的结构

我们大致上认为一个对象应该分为 object header 和 object body,然后再把 header 分为 Mark Word 和 Klass Pointer。

为什么叫 Mark Word?

  • Mark = 标记/标志,因为这个字段用于存储对象的各种运行时标记信息
  • Word = 机器字长(32 位或 64 位),表示它占用一个机器字的空间
  • 存储内容(根据锁状态动态变化):
    • 无锁状态:对象的 hashCode、GC 分代年龄(age)、锁标志位
    • 偏向锁状态:偏向线程 ID、epoch、GC 分代年龄、锁标志位
    • 轻量级锁状态:指向栈中 Lock Record 的指针
    • 重量级锁状态:指向 Monitor 对象的指针
    • GC 标记状态:用于垃圾回收

为什么叫 Klass Word(类型字)?

  • Klass 是 HotSpot 虚拟机中对 Java 类元数据的 C++ 表示(注意不是 Class,而是 Klass,这是 HotSpot 源码中的命名约定)
  • Word = 机器字长(32 位或 64 位),表示它占用一个机器字的空间
  • 本质上是一个指针,指向方法区中该对象所属类的元数据(Klass 对象)
  • 存储内容:指向方法区中 InstanceKlass(普通对象)或 ArrayKlass(数组对象)的指针
  • 作用:JVM 通过这个指针确定对象是哪个类的实例,从而找到类的方法表、字段布局等信息

术语说明:在不同的技术文档中,你可能会看到 Klass WordKlass Pointer 两种叫法,它们指的是同一个东西。Klass Word 强调它占用一个机器字的空间(与 Mark Word 对应),Klass Pointer 强调它的本质是一个指针。本文统一使用 Klass Word 以与对象头结构图保持一致。

为什么放在对象头?

  • 这两个信息是 JVM 管理对象的核心元数据,每个对象都必须携带
  • Mark Word 支持 synchronized 锁机制和 GC
  • Klass Word 支持方法调用(虚方法表查找)和类型检查(instanceof)

两个 Word 的结构特性差异:

在不同的 JVM 实现中(32 位 vs 64 位),这两个 Word 都是固定的机器字长(32 bits 或 64 bits),但它们的结构可变性完全不同:

字段 大小 结构是否可变 说明
Mark Word 32/64 bits 可变 根据锁状态动态变化,像 C 语言的 union,同一块内存在不同状态下存储不同含义的数据
Klass Word 32/64 bits 固定 始终是一个指向方法区类元数据的指针,结构从不改变

这种设计体现了不同的职责:

  • Klass Word 是静态的:对象的类型在创建后就确定了,永远不会改变,所以 Klass Word 只需要存储一个固定的指针
  • Mark Word 是动态的:对象的运行时状态(锁状态、GC 年龄、hashCode 等)会随着程序执行而变化,所以 Mark Word 需要能够"变形"来适应不同的场景

锁升级时原始信息去哪了?

Mark Word 的各种状态是互斥的,锁升级时原始信息(hashCode、age 等)会被转移保存

锁状态 原始信息存储位置
无锁 直接存储在 Mark Word 中
偏向锁 hashCode 不存储(见下文详解),age 存储在 Mark Word 中
轻量级锁 被拷贝到线程栈帧中的 Lock Record(Displaced Mark Word)
重量级锁 被保存在 ObjectMonitor 对象的 _header 字段中

偏向锁状态下 hashCode 存在哪里?——答案是:不存在!

这是一个非常重要但容易被忽略的细节。观察 64 位 JVM 下偏向锁的 Mark Word 结构:

1
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 |

可以发现:偏向锁的 Mark Word 中根本没有 hashCode 的位置!54 位被线程 ID 占用,剩余的位用于 epoch、age 和标志位。

这意味着偏向锁与 hashCode 是互斥的:

  1. 如果对象从未调用过 hashCode():对象可以正常进入偏向锁状态,Mark Word 存储线程 ID
  2. 如果对象已经调用过 hashCode()
    • 无锁状态下,hashCode 已经被计算并存储在 Mark Word 中
    • 此时如果尝试加偏向锁,偏向锁会被直接跳过,直接进入轻量级锁
    • 因为没有地方同时存储 hashCode 和线程 ID
  3. 如果对象已经处于偏向锁状态,此时调用 hashCode()
    • 偏向锁会被立即撤销
    • 对象膨胀为重量级锁(因为需要一个地方存储 hashCode)
    • hashCode 被存储在 ObjectMonitor 的 _header 字段中

图:hashCode 与锁状态的互斥关系

graph TB
    subgraph "场景1:先加锁,后调用 hashCode()"
        B1["对象处于偏向锁状态<br/>Mark Word: thread_id | epoch | age | 1 | 01"]
        B2["调用 obj.hashCode()"]
        B3["偏向锁被撤销<br/>膨胀为重量级锁"]
        B4["hashCode 存储在<br/>ObjectMonitor._header 中"]
        B1 --> B2 --> B3 --> B4
        style B1 fill:#fff9c4
        style B3 fill:#ffcdd2
        style B4 fill:#e1bee7
    end
    
    subgraph "场景2:先调用 hashCode(),后加锁"
        A1["新对象(无锁状态)<br/>Mark Word: unused | 0 | 0 | age | 0 | 01"]
        A2["调用 obj.hashCode()"]
        A3["hashCode 被计算并存储<br/>Mark Word: unused | hashCode | age | 0 | 01"]
        A4["尝试 synchronized(obj)"]
        A5["跳过偏向锁<br/>直接进入轻量级锁"]
        A6["hashCode 被保存到<br/>Lock Record 的 Displaced Mark Word"]
        A1 --> A2 --> A3 --> A4 --> A5 --> A6
        style A1 fill:#c8e6c9
        style A3 fill:#c8e6c9
        style A5 fill:#bbdefb
        style A6 fill:#e3f2fd
    end
    
    note1["关键结论:<br/>1. 偏向锁的 Mark Word 没有空间存储 hashCode<br/>2. 调用 hashCode() 会导致偏向锁失效<br/>3. 这也是为什么偏向锁在某些场景下性能反而更差"]
    style note1 fill:#fff9c4

为什么这样设计?

这是一个空间与性能的权衡

  • Mark Word 只有 64 位(32 位 JVM 更少),空间非常有限
  • 偏向锁的设计目标是"零开销",需要存储 54 位的线程 ID 来快速判断是否是同一个线程
  • hashCode 需要 31 位,如果同时存储两者,剩余空间不足以存储其他必要信息
  • 在实际应用中,大多数作为锁对象的实例(如 private final Object lock = new Object()从不调用 hashCode(),所以这个限制通常不会造成问题

实践建议:

  1. 避免对锁对象调用 hashCode():如果一个对象主要用作锁,不要调用它的 hashCode() 或将其放入 HashMap/HashSet
  2. 理解性能影响:如果你的锁对象频繁调用 hashCode(),偏向锁优化将完全失效
  3. 这也是偏向锁被废弃的原因之一:现代应用中,对象的使用模式越来越复杂,偏向锁的假设(单线程访问、不调用 hashCode)越来越难以满足

这也解释了为什么轻量级锁解锁时必须用 CAS 把 Displaced Mark Word 写回——就是为了恢复原始的 Mark Word 信息。如果 CAS 失败,说明锁已膨胀,原始信息已转移到 ObjectMonitor 中。

Mark Word 本身在对象生命周期里面表现得像 union 一样可变,是让研究 synchronized 的人最头痛的。

锁的三重对象链接

通常我们可以看到 thread 会维护 lock record/monitor record;monitor 会维护两种 set 和 owner(aqs 原理的原型),似乎可以被看成操作系统的 mutext lock 在 jvm 里的句柄;object 本身使用一个 object header。

Mark Word 与 Monitor 的关系

核心要点:Mark Word 只保存 Monitor 的引用(指针),而不保存 Monitor 的具体信息。

虽然 Mark Word 是一个多变的数据结构(根据锁状态动态变化),但在重量级锁状态下,它会保存指向 Monitor 对象的指针。而真正的锁管理信息——包括 Owner(当前持有锁的线程)EntryList(阻塞等待锁的线程队列)WaitSet(调用 wait() 后等待的线程集合)——都存储在 Monitor 这个独立的数据结构中。

Monitor(ObjectMonitor)的核心字段:

字段 类型 说明
_header markOop 保存对象原始的 Mark Word(用于锁释放时恢复)
_owner void* 指向当前持有锁的线程
_EntryList ObjectWaiter* 阻塞在 synchronized 入口处的线程链表
_WaitSet ObjectWaiter* 调用 wait() 后进入等待状态的线程集合
_recursions intptr_t 锁的重入次数
_count volatile intptr_t 等待获取锁的线程数

图1:锁对象、Mark Word 与 Monitor 的引用关系

graph TB
    subgraph "Java 对象"
        OBJ[Object]
        subgraph "Object Header"
            MW[Mark Word<br/>64 bits]
            KP[Klass Pointer]
        end
        BODY[Object Body<br/>实例数据]
        OBJ --> MW
        OBJ --> KP
        OBJ --> BODY
    end
    
    subgraph "Monitor 对象(ObjectMonitor)"
        MON[ObjectMonitor]
        HEADER["_header<br/>原始 Mark Word 备份"]
        OWNER["_owner<br/>当前持锁线程"]
        ENTRY["_EntryList<br/>阻塞等待队列"]
        WAIT["_WaitSet<br/>wait() 等待集合"]
        REC["_recursions<br/>重入次数"]
        MON --> HEADER
        MON --> OWNER
        MON --> ENTRY
        MON --> WAIT
        MON --> REC
    end
    
    subgraph "线程"
        T1[Thread-1<br/>持有锁]
        T2[Thread-2<br/>阻塞等待]
        T3[Thread-3<br/>wait 等待]
    end
    
    MW -->|"重量级锁状态<br/>ptr_to_heavyweight_monitor"| MON
    OWNER -->|指向| T1
    ENTRY -->|包含| T2
    WAIT -->|包含| T3
    
    style MW fill:#e1f5ff
    style MON fill:#fff4e6
    style HEADER fill:#e8f5e9
    style OWNER fill:#ffcdd2
    style ENTRY fill:#fff9c4
    style WAIT fill:#e1bee7

图2:不同锁状态下 Mark Word 的内容变化

graph TB
    subgraph "无锁状态 (01)"
        MW1["Mark Word<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>unused:25 | hashcode:31 | unused:1 | age:4 | 0 | 01<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>直接存储 hashCode 和 GC 年龄"]
        style MW1 fill:#c8e6c9
    end
    
    subgraph "偏向锁状态 (01)"
        MW2["Mark Word<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>thread:54 | epoch:2 | unused:1 | age:4 | 1 | 01<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>存储偏向线程 ID,无需 Monitor"]
        style MW2 fill:#fff9c4
    end
    
    subgraph "轻量级锁状态 (00)"
        MW3["Mark Word<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>ptr_to_lock_record:62 | 00<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>指向线程栈帧中的 Lock Record<br/>原始 Mark Word 保存在 Lock Record 中"]
        LR["Lock Record<br/>(线程栈帧中)<br/>━━━━━━━━━━━━<br/>Displaced Mark Word<br/>(原始 Mark Word 备份)"]
        MW3 -.->|指向| LR
        style MW3 fill:#bbdefb
        style LR fill:#e3f2fd
    end
    
    subgraph "重量级锁状态 (10)"
        MW4["Mark Word<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>ptr_to_heavyweight_monitor:62 | 10<br/>━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br/>只存储 Monitor 指针!<br/>所有锁信息都在 Monitor 中"]
        MON4["ObjectMonitor<br/>━━━━━━━━━━━━━━━━━━━━<br/>_header: 原始 Mark Word<br/>_owner: 持锁线程<br/>_EntryList: 阻塞队列<br/>_WaitSet: 等待集合<br/>_recursions: 重入次数"]
        MW4 -.->|指向| MON4
        style MW4 fill:#ffcdd2
        style MON4 fill:#ffebee
    end
    
    MW1 -->|"第一次加锁<br/>(无竞争)"| MW2
    MW2 -->|"出现竞争<br/>撤销偏向"| MW3
    MW3 -->|"竞争激烈<br/>锁膨胀"| MW4

图3:重量级锁下 Monitor 的工作机制

sequenceDiagram
    participant T1 as Thread-1
    participant OBJ as 锁对象
    participant MW as Mark Word
    participant MON as ObjectMonitor
    participant T2 as Thread-2
    participant T3 as Thread-3
    
    Note over OBJ,MON: 初始状态:无锁
    
    T1->>OBJ: synchronized(obj)
    OBJ->>MW: 检查锁状态
    MW->>MON: 锁膨胀,创建 Monitor
    Note over MW: Mark Word 变为<br/>ptr_to_monitor | 10
    MON->>MON: _owner = Thread-1
    Note over T1,MON: Thread-1 成功获取锁
    
    T2->>OBJ: synchronized(obj)
    OBJ->>MW: 检查锁状态
    MW->>MON: 获取 Monitor 引用
    MON->>MON: 检查 _owner != null
    MON->>MON: Thread-2 加入 _EntryList
    Note over T2: Thread-2 阻塞等待
    
    T1->>MON: obj.wait()
    MON->>MON: Thread-1 移入 _WaitSet
    MON->>MON: _owner = null
    MON->>MON: 从 _EntryList 唤醒 Thread-2
    MON->>MON: _owner = Thread-2
    Note over T2,MON: Thread-2 获取锁
    
    T3->>MON: obj.notify()
    MON->>MON: 从 _WaitSet 移出 Thread-1
    MON->>MON: Thread-1 加入 _EntryList
    Note over T1: Thread-1 等待重新竞争锁

图4:锁状态转换与数据存储位置

graph LR
    subgraph "锁状态"
        UNLOCK[无锁<br/>01]
        BIASED[偏向锁<br/>01]
        LIGHT[轻量级锁<br/>00]
        HEAVY[重量级锁<br/>10]
    end
    
    subgraph "原始信息存储位置"
        IN_MW["在 Mark Word 中"]
        IN_LR["在 Lock Record 中<br/>(线程栈帧)"]
        IN_MON["在 Monitor 的<br/>_header 字段中"]
    end
    
    subgraph "锁管理信息"
        NO_LOCK["无需管理"]
        THREAD_ID["线程 ID 在 Mark Word"]
        LR_PTR["Lock Record 指针在 Mark Word"]
        MON_ALL["Owner/EntryList/WaitSet<br/>全部在 Monitor 中"]
    end
    
    UNLOCK -->|hashCode/age| IN_MW
    UNLOCK -->|锁信息| NO_LOCK
    
    BIASED -->|hashCode/age| IN_MW
    BIASED -->|锁信息| THREAD_ID
    
    LIGHT -->|hashCode/age| IN_LR
    LIGHT -->|锁信息| LR_PTR
    
    HEAVY -->|hashCode/age| IN_MON
    HEAVY -->|锁信息| MON_ALL
    
    UNLOCK -->|首次加锁| BIASED
    BIASED -->|竞争| LIGHT
    LIGHT -->|膨胀| HEAVY
    
    style UNLOCK fill:#c8e6c9
    style BIASED fill:#fff9c4
    style LIGHT fill:#bbdefb
    style HEAVY fill:#ffcdd2
    style IN_MW fill:#e8f5e9
    style IN_LR fill:#e3f2fd
    style IN_MON fill:#ffebee

关键理解:

  1. Mark Word 是"指针容器"而非"数据容器":在轻量级锁和重量级锁状态下,Mark Word 不再直接存储 hashCode 等原始信息,而是存储指向其他数据结构的指针。

  2. Monitor 是独立的数据结构:ObjectMonitor 是 JVM 在 C++ 层面实现的对象,它独立于 Java 对象存在。当锁膨胀为重量级锁时,JVM 会创建(或复用)一个 ObjectMonitor 对象,并将其地址写入 Mark Word。

  3. 原始信息的"流转"

    • 无锁/偏向锁:原始信息直接在 Mark Word 中
    • 轻量级锁:原始信息被拷贝到线程栈帧的 Lock Record 中
    • 重量级锁:原始信息被保存到 Monitor 的 _header 字段中
  4. 解锁时的恢复:无论是轻量级锁还是重量级锁,解锁时都需要将原始的 Mark Word 恢复回去。这就是为什么轻量级锁解锁时需要 CAS 操作——它要把 Lock Record 中保存的 Displaced Mark Word 写回对象头。

Monitor 与操作系统同步原语的关系

核心问题:ObjectMonitor 是如何实现线程阻塞和唤醒的?

前面我们知道,重量级锁的核心是 ObjectMonitor,它管理着 Owner、EntryList、WaitSet 等数据结构。但 ObjectMonitor 本身只是一个 JVM 层面的 C++ 对象,它无法直接让线程阻塞或唤醒——这些操作必须依赖操作系统提供的同步原语。

图1:从 synchronized 到操作系统的完整调用链

graph TB
    subgraph "Java 层"
        SYNC["synchronized(obj)"]
        style SYNC fill:#c8e6c9
    end
    
    subgraph "JVM 层(HotSpot C++)"
        INTERP["字节码解释器<br/>monitorenter/monitorexit"]
        OBJMON["ObjectMonitor<br/>━━━━━━━━━━━━━━━━━━━━<br/>enter() / exit()<br/>wait() / notify()"]
        PARKER["Parker<br/>━━━━━━━━━━━━━━━━━━━━<br/>park() / unpark()<br/>每个线程一个 Parker 实例"]
        
        INTERP --> OBJMON
        OBJMON -->|"竞争失败<br/>需要阻塞"| PARKER
        
        style INTERP fill:#fff9c4
        style OBJMON fill:#ffcc80
        style PARKER fill:#ffab91
    end
    
    subgraph "操作系统层"
        subgraph "Linux"
            FUTEX["futex()<br/>━━━━━━━━━━━━━━━━━━━━<br/>Fast Userspace Mutex<br/>用户态/内核态混合"]
            PTHREAD_L["pthread_mutex_t<br/>pthread_cond_t"]
            FUTEX --> PTHREAD_L
            style FUTEX fill:#e1bee7
            style PTHREAD_L fill:#ce93d8
        end
        
        subgraph "macOS/BSD"
            PTHREAD_M["pthread_mutex_t<br/>pthread_cond_t"]
            style PTHREAD_M fill:#ce93d8
        end
        
        subgraph "Windows"
            CRITICAL["CRITICAL_SECTION<br/>WaitForSingleObject"]
            style CRITICAL fill:#90caf9
        end
    end
    
    PARKER -->|"Linux"| FUTEX
    PARKER -->|"macOS"| PTHREAD_M
    PARKER -->|"Windows"| CRITICAL
    
    note1["关键洞察:<br/>ObjectMonitor 不直接调用 OS 原语<br/>而是通过 Parker 这个中间层<br/>Parker 封装了跨平台的阻塞/唤醒逻辑"]
    style note1 fill:#e1f5ff

图2:ObjectMonitor 内部的同步机制

graph TB
    subgraph "ObjectMonitor 内部结构"
        direction TB
        
        subgraph "数据字段"
            OWNER["_owner<br/>当前持锁线程"]
            ENTRY["_EntryList<br/>阻塞等待队列"]
            WAIT["_WaitSet<br/>wait() 等待集合"]
            CXQUEUE["_cxq<br/>竞争队列(新来的线程)"]
            style OWNER fill:#ffcdd2
            style ENTRY fill:#fff9c4
            style WAIT fill:#e1bee7
            style CXQUEUE fill:#b2dfdb
        end
        
        subgraph "同步原语(平台相关)"
            MUTEX["底层 Mutex<br/>━━━━━━━━━━━━━━━━━━━━<br/>保护 ObjectMonitor 自身<br/>的数据结构一致性"]
            EVENT["Park/Unpark 事件<br/>━━━━━━━━━━━━━━━━━━━━<br/>用于线程阻塞/唤醒"]
            style MUTEX fill:#ffcc80
            style EVENT fill:#ffab91
        end
    end
    
    subgraph "线程状态转换"
        T_RUN["RUNNABLE<br/>运行中"]
        T_BLOCK["BLOCKED<br/>阻塞等待锁"]
        T_WAIT["WAITING<br/>wait() 等待"]
        
        T_RUN -->|"获取锁失败<br/>park()"| T_BLOCK
        T_BLOCK -->|"获取锁成功<br/>unpark()"| T_RUN
        T_RUN -->|"调用 wait()<br/>park()"| T_WAIT
        T_WAIT -->|"被 notify()<br/>unpark()"| T_BLOCK
        
        style T_RUN fill:#c8e6c9
        style T_BLOCK fill:#ffcdd2
        style T_WAIT fill:#e1bee7
    end
    
    MUTEX -.->|"保护"| OWNER
    MUTEX -.->|"保护"| ENTRY
    MUTEX -.->|"保护"| WAIT
    EVENT -.->|"阻塞/唤醒"| T_BLOCK
    EVENT -.->|"阻塞/唤醒"| T_WAIT

ObjectMonitor 与 OS 原语的对应关系:

ObjectMonitor 操作 对应的 OS 原语 说明
enter() 获取锁失败 pthread_mutex_lock()futex(FUTEX_WAIT) 线程阻塞,进入 EntryList
exit() 释放锁 pthread_mutex_unlock()futex(FUTEX_WAKE) 唤醒 EntryList 中的线程
wait() pthread_cond_wait() 释放锁,进入 WaitSet,阻塞
notify() pthread_cond_signal() 从 WaitSet 移动一个线程到 EntryList
notifyAll() pthread_cond_broadcast() 移动 WaitSet 中所有线程到 EntryList

图3:Linux 上的 Futex 优化机制

graph TB
    subgraph "Futex 的设计理念"
        direction TB
        
        subgraph "无竞争路径(Fast Path)"
            FAST["用户态 CAS 操作<br/>━━━━━━━━━━━━━━━━━━━━<br/>直接修改 futex word<br/>不进入内核<br/>开销:≈ 几十个 CPU 周期"]
            style FAST fill:#c8e6c9
        end
        
        subgraph "有竞争路径(Slow Path)"
            SLOW["futex() 系统调用<br/>━━━━━━━━━━━━━━━━━━━━<br/>进入内核<br/>线程阻塞在内核等待队列<br/>开销:≈ 几千个 CPU 周期"]
            style SLOW fill:#ffcdd2
        end
    end
    
    subgraph "与 JVM 锁的对应"
        LIGHT_LOCK["轻量级锁<br/>━━━━━━━━━━━━━━━━━━━━<br/>类似 Futex Fast Path<br/>用户态 CAS + 自旋"]
        HEAVY_LOCK["重量级锁<br/>━━━━━━━━━━━━━━━━━━━━<br/>类似 Futex Slow Path<br/>进入内核阻塞"]
        
        style LIGHT_LOCK fill:#fff9c4
        style HEAVY_LOCK fill:#ffcc80
    end
    
    FAST -.->|"设计理念相同"| LIGHT_LOCK
    SLOW -.->|"设计理念相同"| HEAVY_LOCK
    
    note1["Futex = Fast Userspace Mutex<br/>核心思想:无竞争时在用户态解决<br/>有竞争时才进入内核<br/>这与 JVM 锁优化的思想一致!"]
    style note1 fill:#e1f5ff

图4:完整的锁获取流程(从 Java 到内核)

sequenceDiagram
    participant Java as Java 代码
    participant JVM as JVM (HotSpot)
    participant Monitor as ObjectMonitor
    participant Parker as Parker
    participant OS as 操作系统内核
    
    Java->>JVM: synchronized(obj)
    JVM->>JVM: 检查 Mark Word
    
    alt 偏向锁/轻量级锁成功
        JVM->>Java: 获取锁成功(用户态完成)
    else 需要重量级锁
        JVM->>Monitor: 获取 ObjectMonitor
        Monitor->>Monitor: 尝试 CAS 设置 _owner
        
        alt CAS 成功
            Monitor->>Java: 获取锁成功
        else CAS 失败(有竞争)
            Monitor->>Monitor: 自旋尝试
            
            alt 自旋成功
                Monitor->>Java: 获取锁成功
            else 自旋失败
                Monitor->>Parker: 调用 park()
                Parker->>OS: futex(FUTEX_WAIT) / pthread_mutex_lock()
                Note over OS: 线程阻塞在内核
                OS-->>Parker: 被唤醒
                Parker-->>Monitor: park() 返回
                Monitor->>Monitor: 重新竞争锁
            end
        end
    end

为什么需要 Parker 这个中间层?

  1. 跨平台抽象:不同操作系统的阻塞原语不同(Linux 用 futex/pthread,Windows 用 Event),Parker 提供统一接口
  2. 性能优化:Parker 可以实现"先自旋再阻塞"的策略,减少不必要的系统调用
  3. 与 LockSupport 对接:Java 层的 LockSupport.park()/unpark() 最终调用的就是 Parker

关键理解:两层 Mutex 的区别

很多人容易混淆的一点是:ObjectMonitor 内部有一个 Mutex,操作系统也有 Mutex,它们是什么关系?

层级 Mutex 用途 说明
ObjectMonitor 内部 Mutex 保护 ObjectMonitor 自身的数据结构 确保多线程并发修改 _owner_EntryList 等字段时的一致性
OS Mutex(通过 Parker) 实现线程的阻塞和唤醒 当线程需要等待时,真正让 CPU 不再调度该线程

简单来说:

  • ObjectMonitor 的 Mutex 是为了保护"锁的元数据"
  • OS 的 Mutex/Futex 是为了实现"线程的阻塞"

这就像一个银行:

  • 银行内部的保险柜锁(ObjectMonitor Mutex)保护的是"谁在排队、谁在办业务"的记录
  • 银行大门的锁(OS Mutex)决定的是"顾客能不能进来"

锁对象复用陷阱: 由于一个对象在任意时刻只能被一个线程锁定(monitor 只有一个 owner),如果在不同的业务逻辑中复用同一个锁对象,会导致本来毫无关联的代码互相阻塞。例如:

1
2
3
4
5
6
7
8
9
10
11
public class BadExample {
private final Object lock = new Object(); // 只有一个锁对象

public void methodA() {
synchronized (lock) { /* 业务逻辑 A */ }
}

public void methodB() {
synchronized (lock) { /* 业务逻辑 B,与 A 完全无关 */ }
}
}

即使 methodAmethodB 的业务逻辑完全独立,它们也会互相阻塞,因为竞争的是同一个对象的 monitor。正确做法是为不相关的临界区使用不同的锁对象。这个问题同样适用于 ReentrantLock——锁的粒度由锁对象/Lock 实例的数量决定,一个锁对象 = 一把锁 = 同一时刻只能一个线程持有。

锁优化

synchronized原理
锁对象的变化流程

所有的锁优化其实是 synchronized 优化。

锁优化的设计哲学:用户态锁 vs 内核态锁

核心思想:锁越轻,离底层 Mutex 机制越远,越能在 Java/JVM 内部的数据结构中解决;涉及的 JVM 外部/底层机制越少,开销就越小。

这个设计思想与**绿色线程(Green Thread)vs 内核线程(Kernel Thread)**的设计理念高度相似:

对比维度 绿色线程 内核线程 轻量级锁 重量级锁
调度/管理者 用户态运行时(如 JVM、Go runtime) 操作系统内核 JVM(CAS + 自旋) 操作系统(Mutex)
切换开销 极低(不涉及内核) 高(用户态/内核态切换) 极低(用户态 CAS) 高(系统调用)
数据结构位置 用户空间 内核空间 Java 栈帧(Lock Record) 内核 Mutex + ObjectMonitor
适用场景 大量轻量级并发 需要真正并行 低竞争同步 高竞争同步

可以说,偏向锁和轻量级锁就是"用户态锁",而重量级锁是"内核态锁"。

图:锁机制的分层架构——从用户态到内核态的渐进式下沉

graph TB
    subgraph "用户态(User Space)"
        subgraph "纯 Java 对象层"
            BIAS["偏向锁<br/>━━━━━━━━━━━━━━━━━━━━<br/>Mark Word 存储线程 ID<br/>后续加锁:仅比较线程 ID<br/>开销:≈ 0"]
            style BIAS fill:#c8e6c9
        end
        
        subgraph "Java 栈 + CAS 层"
            LIGHT["轻量级锁<br/>━━━━━━━━━━━━━━━━━━━━<br/>Mark Word → Lock Record<br/>Lock Record 在线程栈帧中<br/>开销:CAS + 自旋"]
            style LIGHT fill:#fff9c4
        end
        
        subgraph "JVM 运行时层"
            MONITOR["ObjectMonitor<br/>━━━━━━━━━━━━━━━━━━━━<br/>JVM C++ 对象<br/>管理 Owner/EntryList/WaitSet"]
            style MONITOR fill:#ffcc80
        end
    end
    
    subgraph "内核态(Kernel Space)"
        MUTEX["OS Mutex / Futex<br/>━━━━━━━━━━━━━━━━━━━━<br/>操作系统同步原语<br/>线程阻塞/唤醒<br/>开销:用户态/内核态切换"]
        style MUTEX fill:#ffcdd2
    end
    
    BIAS -->|"出现竞争<br/>撤销偏向"| LIGHT
    LIGHT -->|"竞争激烈<br/>自旋失败"| MONITOR
    MONITOR -->|"阻塞线程<br/>系统调用"| MUTEX
    
    note1["越往下,离 Java 越远,离 OS 越近<br/>开销越大,但能处理的竞争越激烈"]
    style note1 fill:#e1f5ff

为什么这种分层设计是高效的?

观察 Mark Word 在不同锁状态下的内容变化,可以发现一个清晰的局部性原则

锁状态 数据存储位置 依赖层级 涉及的外部机制 性能开销
无锁 Mark Word 直接存储 hashCode、age 纯 Java 对象 无额外开销
偏向锁 Mark Word 存储线程 ID 纯 Java 对象 无(仅首次 CAS) ≈ 0
轻量级锁 Mark Word → Lock Record(线程栈帧) Java 栈 + CPU CAS 指令 CPU 原子指令 用户态自旋
重量级锁 Mark Word → ObjectMonitor → OS Mutex 操作系统内核 系统调用、内核调度器 用户态/内核态切换

这种设计体现了"能在用户态解决的问题,就不要下沉到内核态"的优化原则:

  1. 偏向锁(纯用户态,零开销):假设锁总是被同一个线程获取,直接在 Mark Word 中记录线程 ID,后续加锁只需比较线程 ID,连 CAS 都省了。这是最乐观的假设,完全在 Java 对象层面解决,不涉及任何 JVM 外部机制

  2. 轻量级锁(用户态,低开销):当出现竞争时,退而求其次,使用 CAS + 自旋的方式在用户态解决。Lock Record 存储在线程栈帧中,仍然是 Java 层面的数据结构。虽然 CAS 需要 CPU 提供原子指令支持,但不涉及操作系统调用,仍在用户态完成。

  3. 重量级锁(内核态,高开销):当竞争激烈、自旋无法快速获取锁时,才不得不"下沉"到操作系统层面,使用 Mutex/Futex 等同步原语。此时 Mark Word 指向 ObjectMonitor,而 ObjectMonitor 内部会调用操作系统的阻塞/唤醒机制,触发用户态/内核态切换

与绿色线程的类比深化:

设计理念 绿色线程 轻量级锁
核心思想 用户态调度替代内核态调度 用户态同步替代内核态同步
实现方式 M:N 模型,多个用户态线程映射到少量内核线程 CAS + 自旋,在用户态完成锁的获取和释放
优势 避免内核态切换开销,支持大量轻量级并发 避免系统调用开销,支持低竞争场景的高效同步
局限性 无法利用多核并行(除非有内核线程支撑) 无法处理高竞争场景(必须膨胀为重量级锁)
典型实现 Go goroutine、Erlang process、Java 虚拟线程 JVM 偏向锁、轻量级锁

但有一个关键区别:绿色线程可以完全替代内核线程(如 Go 的 goroutine 在大多数场景下足够),而轻量级锁不能完全替代重量级锁——当竞争激烈时,自旋会浪费大量 CPU,必须膨胀为重量级锁让线程阻塞等待。

为什么锁只能升级不能降级?

这也解释了为什么锁只能升级不能降级:一旦发现竞争激烈到需要重量级锁,说明这个锁的使用场景确实存在高并发竞争,降级回轻量级锁反而会因为频繁的 CAS 失败和自旋浪费更多 CPU 资源。这就像一个服务发现单机处理不了流量后扩容到集群,即使流量下降也不会立即缩容——因为流量模式已经证明了需要更高的处理能力。

总结:锁优化的本质是"就近原则"

1
2
3
问题能在 Java 对象层解决 → 偏向锁(Mark Word 存线程 ID
问题能在 Java 栈层解决 → 轻量级锁(Lock Record + CAS
问题必须在 OS 层解决 → 重量级锁(ObjectMonitor + Mutex

离问题发生地越近的解决方案,开销越小。 这与计算机体系结构中的"局部性原理"一脉相承:CPU 缓存比内存快,内存比磁盘快,本地调用比远程调用快——同样,用户态同步比内核态同步快。

自旋锁(Spinning Lock)

为什么需要自旋锁?

一个已经拥有 CPU 执行时间的线程,在求锁的时候,如果直接被阻塞(进入内核态等待),会带来显著的性能开销:

操作 开销 说明
用户态/内核态切换 ≈ 1000+ CPU 周期 需要保存/恢复寄存器、切换特权级别
线程上下文切换 ≈ 5000+ CPU 周期 需要保存/恢复线程状态、刷新 TLB
CAS 操作 ≈ 10-100 CPU 周期 仅需原子指令,无需内核介入

如果锁的持有时间很短(如简单的 getter/setter),线程阻塞后很快就会被唤醒,那么阻塞和唤醒的开销可能远超过实际执行临界区代码的开销。这就是自旋锁存在的意义:用 CPU 空转换取避免线程切换的开销

自旋锁的实现原理

自旋锁的核心思想是:当线程获取锁失败时,不立即阻塞,而是执行一个忙循环(busy waiting),不断尝试获取锁。

图1:自旋锁 vs 阻塞锁的执行流程对比

graph TB
    subgraph "阻塞锁(传统方式)"
        direction TB
        B1["线程尝试获取锁"]
        B2{"获取成功?"}
        B3["进入临界区执行"]
        B4["释放锁"]
        B5["阻塞线程<br/>━━━━━━━━━━━━━━━━━━━━<br/>1. 保存线程上下文<br/>2. 用户态→内核态<br/>3. 加入等待队列<br/>4. 调度其他线程"]
        B6["被唤醒<br/>━━━━━━━━━━━━━━━━━━━━<br/>1. 内核态→用户态<br/>2. 恢复线程上下文<br/>3. 重新调度"]
        
        B1 --> B2
        B2 -->|是| B3
        B2 -->|否| B5
        B5 -->|锁释放| B6
        B6 --> B1
        B3 --> B4
        
        style B5 fill:#ffcdd2
        style B6 fill:#ffcdd2
    end
    
    subgraph "自旋锁"
        direction TB
        S1["线程尝试获取锁"]
        S2{"获取成功?"}
        S3["进入临界区执行"]
        S4["释放锁"]
        S5["自旋等待<br/>━━━━━━━━━━━━━━━━━━━━<br/>while (!tryLock()) {<br/>    // 空循环<br/>    // 或执行 PAUSE 指令<br/>}"]
        
        S1 --> S2
        S2 -->|是| S3
        S2 -->|否| S5
        S5 -->|继续尝试| S2
        S3 --> S4
        
        style S5 fill:#fff9c4
    end

忙循环是怎么实现的?

在 HotSpot JVM 中,自旋锁的实现并不是简单的 while(true) 空循环。实际的实现涉及以下几个层面:

  1. 字节码层面monitorenter 指令在获取锁失败时,会进入 JVM 运行时的自旋逻辑
  2. JVM 运行时层面:在 ObjectMonitor::enter() 方法中实现自旋逻辑
  3. CPU 指令层面:使用 PAUSE 指令(x86)或类似指令来优化自旋
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// HotSpot 中自旋锁的简化伪代码
void ObjectMonitor::enter(TRAPS) {
// 快速路径:尝试 CAS 获取锁
if (Atomic::cmpxchg(Self, &_owner, NULL) == NULL) {
return; // 获取成功
}

// 自旋尝试
int spinCount = Knob_SpinLimit; // 自旋次数限制
while (spinCount-- > 0) {
if (_owner == NULL) {
if (Atomic::cmpxchg(Self, &_owner, NULL) == NULL) {
return; // 自旋期间获取成功
}
}
SpinPause(); // 执行 PAUSE 指令,降低 CPU 功耗和总线竞争
}

// 自旋失败,进入阻塞
EnterI(THREAD);
}

PAUSE 指令的作用:

作用 说明
降低 CPU 功耗 告诉 CPU 当前处于自旋等待状态,可以降低时钟频率
减少总线竞争 避免频繁的缓存行失效(cache line invalidation)
提高超线程效率 让出执行资源给同一物理核心上的其他超线程
避免流水线惩罚 防止 CPU 错误预测分支导致的流水线刷新

自旋次数的控制

固定自旋(JDK 6 之前):

循环的次数通常不会很多,默认是 10 次。这个次数可以通过 -XX:PreBlockSpin 参数调整。

1
2
# 设置自旋次数为 20
java -XX:PreBlockSpin=20 MyApplication

自适应自旋(Adaptive Spinning,JDK 6+):

固定自旋次数的问题在于:不同的锁、不同的场景,最优的自旋次数是不同的。JDK 6 引入了自适应自旋锁,让 JVM 根据运行时统计信息动态调整自旋次数:

graph TB
    subgraph "自适应自旋的决策逻辑"
        direction TB
        
        HISTORY["历史统计信息<br/>━━━━━━━━━━━━━━━━━━━━<br/>• 上次自旋是否成功<br/>• 锁的持有者状态<br/>• 自旋成功率"]
        
        DECIDE{"决策"}
        
        INCREASE["增加自旋次数<br/>━━━━━━━━━━━━━━━━━━━━<br/>上次自旋成功获取锁<br/>说明锁竞争不激烈<br/>值得多自旋几次"]
        
        DECREASE["减少自旋次数<br/>━━━━━━━━━━━━━━━━━━━━<br/>上次自旋失败<br/>说明锁竞争激烈<br/>自旋浪费 CPU"]
        
        SKIP["跳过自旋<br/>━━━━━━━━━━━━━━━━━━━━<br/>自旋从未成功过<br/>直接阻塞更高效"]
        
        HISTORY --> DECIDE
        DECIDE -->|"自旋成功率高"| INCREASE
        DECIDE -->|"自旋成功率低"| DECREASE
        DECIDE -->|"自旋从未成功"| SKIP
        
        style INCREASE fill:#c8e6c9
        style DECREASE fill:#fff9c4
        style SKIP fill:#ffcdd2
    end

自适应自旋的核心思想是:让 JVM 自己学习最优的自旋策略。如果对于某个锁,自旋经常能成功获取,那么下次就多自旋几次;如果自旋很少成功,那么下次就少自旋甚至直接阻塞。

自旋锁的适用场景

图2:自旋锁 vs 阻塞锁的性能对比

graph LR
    subgraph "锁持有时间短 + 竞争不激烈"
        SHORT["临界区执行时间 < 线程切换时间"]
        SPIN_WIN["✅ 自旋锁胜出<br/>━━━━━━━━━━━━━━━━━━━━<br/>自旋几次就能获取锁<br/>避免了线程切换开销"]
        SHORT --> SPIN_WIN
        style SPIN_WIN fill:#c8e6c9
    end
    
    subgraph "锁持有时间长 + 竞争激烈"
        LONG["临界区执行时间 > 线程切换时间"]
        BLOCK_WIN["✅ 阻塞锁胜出<br/>━━━━━━━━━━━━━━━━━━━━<br/>自旋浪费大量 CPU<br/>不如让出 CPU 给其他线程"]
        LONG --> BLOCK_WIN
        style BLOCK_WIN fill:#c8e6c9
    end
场景 推荐策略 原因
CAS 操作(如 AtomicInteger.incrementAndGet() 自旋 操作极快(几个 CPU 周期),自旋几乎总能成功
简单 getter/setter 自旋 临界区代码很短,锁持有时间极短
数据库操作、网络 I/O 阻塞 操作耗时长,自旋会浪费大量 CPU
高并发竞争 阻塞 多个线程同时自旋会导致 CPU 资源浪费

自旋锁的本质权衡:

1
2
3
4
5
自旋锁的收益 = 避免的线程切换开销
自旋锁的成本 = 自旋期间浪费的 CPU 时间

当 收益 > 成本 时,自旋锁是划算的
当 收益 < 成本 时,阻塞锁更高效

这也是为什么 JVM 会结合使用自旋和阻塞:先自旋一定次数,如果还获取不到锁,再阻塞。这种策略在大多数场景下都能取得较好的平衡。

锁消除(Lock Elimination)

什么是锁消除?

锁消除是 JIT 编译器的一项优化技术:如果 JVM 通过**逃逸分析(Escape Analysis)**发现某个锁对象不可能被其他线程访问,那么这个锁就是"多余的",可以被安全地消除。

逃逸分析的核心问题:对象会不会"逃逸"出当前作用域?

逃逸类型 说明 示例
不逃逸 对象只在方法内部使用,不会被外部引用 方法内的局部变量
方法逃逸 对象被作为参数传递或作为返回值 return new Object()
线程逃逸 对象可能被其他线程访问 赋值给静态变量、实例变量

只有"不逃逸"的对象上的锁才能被消除。

锁消除的工作原理

图1:锁消除的决策流程

graph TB
    subgraph "JIT 编译时的逃逸分析"
        CODE["synchronized (lockObj) {<br/>    // 临界区代码<br/>}"]
        
        ANALYZE["逃逸分析<br/>━━━━━━━━━━━━━━━━━━━━<br/>分析 lockObj 的引用链<br/>判断是否可能被其他线程访问"]
        
        ESCAPE{"lockObj 是否逃逸?"}
        
        ELIMINATE["锁消除<br/>━━━━━━━━━━━━━━━━━━━━<br/>移除 monitorenter/monitorexit<br/>直接执行临界区代码"]
        
        KEEP["保留锁<br/>━━━━━━━━━━━━━━━━━━━━<br/>正常执行同步逻辑"]
        
        CODE --> ANALYZE
        ANALYZE --> ESCAPE
        ESCAPE -->|"不逃逸"| ELIMINATE
        ESCAPE -->|"可能逃逸"| KEEP
        
        style ELIMINATE fill:#c8e6c9
        style KEEP fill:#fff9c4
    end

锁消除的典型场景

场景1:StringBuffer/StringBuilder 的同步

1
2
3
4
5
6
7
8
public String concatString(String s1, String s2, String s3) {
// StringBuffer 是线程安全的,内部方法都有 synchronized
StringBuffer sb = new StringBuffer();
sb.append(s1); // synchronized
sb.append(s2); // synchronized
sb.append(s3); // synchronized
return sb.toString();
}

在这个例子中,StringBuffer sb 是一个局部变量,不会逃逸出 concatString 方法,更不可能被其他线程访问。因此,JIT 编译器可以安全地消除 append() 方法内部的同步操作。

优化后的等效代码:

1
2
3
4
5
6
7
8
public String concatString(String s1, String s2, String s3) {
// 锁被消除,等效于使用 StringBuilder
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

场景2:方法内部的同步块

1
2
3
4
5
6
public void doSomething() {
Object lock = new Object(); // 局部变量,不逃逸
synchronized (lock) {
// 临界区代码
}
}

由于 lock 对象是方法内部创建的局部变量,每次方法调用都会创建新的对象,不可能被其他线程访问,因此这个同步块可以被完全消除。

ReentrantLock 能被消除吗?

根据 CMU 的研究论文ReentrantLock 也可以被锁消除优化。JIT 编译器的逃逸分析不仅适用于 synchronized,也适用于 java.util.concurrent 包中的锁。

1
2
3
4
5
6
7
8
9
public void processWithLock() {
ReentrantLock lock = new ReentrantLock(); // 局部变量
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock();
}
}

如果 lock 对象不逃逸,JIT 编译器可以消除 lock()unlock() 的调用。

但需要注意:

锁类型 消除难度 原因
synchronized 较容易 JVM 内置支持,字节码层面可识别
ReentrantLock 较难 需要识别 lock()/unlock() 的调用模式
分布式锁 不可能 涉及外部系统,无法通过逃逸分析判断

如何验证锁消除是否生效?

1
2
3
4
5
6
7
8
9
10
11
# 开启逃逸分析(JDK 6u23+ 默认开启)
-XX:+DoEscapeAnalysis

# 开启锁消除(默认开启)
-XX:+EliminateLocks

# 打印逃逸分析结果
-XX:+PrintEscapeAnalysis

# 打印锁消除信息
-XX:+PrintEliminateLocks

锁粗化(Lock Coarsening)

什么是锁粗化?

锁粗化是 JIT 编译器的另一项优化技术:如果 JVM 检测到一系列连续的加锁/解锁操作都是针对同一个锁对象,那么可以将这些操作合并为一次更大范围的加锁/解锁,从而减少锁操作的开销。

图1:锁粗化的优化过程

graph TB
    subgraph "优化前:频繁加锁/解锁"
        BEFORE["synchronized (lock) { op1(); }<br/>synchronized (lock) { op2(); }<br/>synchronized (lock) { op3(); }"]
        
        COST1["开销分析<br/>━━━━━━━━━━━━━━━━━━━━<br/>• 3 次 monitorenter<br/>• 3 次 monitorexit<br/>• 3 次 CAS 操作<br/>• 可能的锁膨胀"]
        
        BEFORE --> COST1
        style COST1 fill:#ffcdd2
    end
    
    subgraph "优化后:一次大锁"
        AFTER["synchronized (lock) {<br/>    op1();<br/>    op2();<br/>    op3();<br/>}"]
        
        COST2["开销分析<br/>━━━━━━━━━━━━━━━━━━━━<br/>• 1 次 monitorenter<br/>• 1 次 monitorexit<br/>• 1 次 CAS 操作<br/>• 锁持有时间略长"]
        
        AFTER --> COST2
        style COST2 fill:#c8e6c9
    end
    
    BEFORE -->|"JIT 锁粗化"| AFTER

锁粗化的典型场景

场景1:循环内的同步

1
2
3
4
5
6
7
8
9
10
11
12
13
// 优化前:每次循环都加锁/解锁
for (int i = 0; i < 1000; i++) {
synchronized (lock) {
list.add(i);
}
}

// 优化后:整个循环只加锁一次
synchronized (lock) {
for (int i = 0; i < 1000; i++) {
list.add(i);
}
}

场景2:连续的同步方法调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 优化前:每个方法调用都有独立的同步
public void process() {
sb.append("Hello"); // synchronized
sb.append(" "); // synchronized
sb.append("World"); // synchronized
sb.append("!"); // synchronized
}

// 优化后:合并为一次同步
public void process() {
synchronized (sb) {
sb.append("Hello");
sb.append(" ");
sb.append("World");
sb.append("!");
}
}

锁粗化的权衡

图2:锁粗化的利弊分析

graph TB
    subgraph "锁粗化的收益"
        BENEFIT1["减少锁操作次数<br/>━━━━━━━━━━━━━━━━━━━━<br/>N 次加锁/解锁 → 1 次<br/>减少 CAS 开销"]
        BENEFIT2["减少锁状态转换<br/>━━━━━━━━━━━━━━━━━━━━<br/>避免频繁的偏向锁撤销<br/>避免轻量级锁膨胀"]
        BENEFIT3["提高缓存命中率<br/>━━━━━━━━━━━━━━━━━━━━<br/>减少 Mark Word 的修改<br/>减少缓存行失效"]
        
        style BENEFIT1 fill:#c8e6c9
        style BENEFIT2 fill:#c8e6c9
        style BENEFIT3 fill:#c8e6c9
    end
    
    subgraph "锁粗化的代价"
        COST1["锁持有时间变长<br/>━━━━━━━━━━━━━━━━━━━━<br/>其他线程等待时间增加<br/>可能降低并发度"]
        COST2["临界区变大<br/>━━━━━━━━━━━━━━━━━━━━<br/>包含了原本不需要同步的代码<br/>可能引入不必要的串行化"]
        
        style COST1 fill:#ffcdd2
        style COST2 fill:#ffcdd2
    end
场景 是否适合锁粗化 原因
连续的短临界区 ✅ 适合 锁操作开销 > 临界区执行时间
循环内的同步 ✅ 适合 避免大量重复的加锁/解锁
包含 I/O 操作的临界区 ❌ 不适合 锁持有时间过长,严重影响并发
高并发竞争场景 ❌ 不适合 锁粗化会加剧竞争

锁粗化 vs 锁细化

锁粗化和锁细化(Lock Splitting/Striping)是两个相反的优化方向:

graph LR
    subgraph "锁粗化"
        COARSE["多个小锁 → 一个大锁<br/>━━━━━━━━━━━━━━━━━━━━<br/>减少锁操作开销<br/>适合低竞争场景"]
        style COARSE fill:#e1f5ff
    end
    
    subgraph "锁细化"
        FINE["一个大锁 → 多个小锁<br/>━━━━━━━━━━━━━━━━━━━━<br/>减少锁竞争<br/>适合高并发场景"]
        style FINE fill:#fff9c4
    end
    
    COARSE <-->|"相反的优化方向"| FINE

JVM 自动进行锁粗化,但锁细化需要程序员手动设计。 典型的锁细化例子包括:

  • ConcurrentHashMap 的分段锁(JDK 7)/ CAS + synchronized(JDK 8+)
  • LongAdder 的分散热点
  • 读写锁分离(ReentrantReadWriteLock

如何控制锁粗化?

1
2
3
4
5
# 开启锁粗化(默认开启)
-XX:+EliminateNestedLocks

# 关闭锁粗化(用于调试)
-XX:-EliminateNestedLocks

最佳实践:

  1. 信任 JVM 的优化:在大多数情况下,JVM 的锁粗化决策是合理的
  2. 避免过度优化:不要为了"帮助" JVM 而手动粗化锁,这可能适得其反
  3. 关注热点代码:只有被 JIT 编译的热点代码才会进行锁粗化优化
  4. 监控锁竞争:使用 jstackasync-profiler 等工具监控锁竞争情况,判断是否需要手动调整锁粒度

轻量级锁(Lightweight Lock)

轻量级锁本身是 JDK 1.6 以后才加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的(Mutex等于重量锁,在不同的场景下又称 Mutext Lock、fat lock。可以认为OS的系统调用提供了并发机制-线程,就会必然提供互斥量机制。)。它不是用来代替重量级锁的,用意是在多线程竞争不激烈的情况下,减少重量级锁的使用,来减少性能消耗。

我们已经知道,对象头(Object Header)分成两个部分(不算Padding的话),“Mark Word” 与 Klass Point。Mark Word 的大小取决于虚拟机的版本,分别是32bits 和 64bits(总之总是字长对齐)。数组对象的 Klass Point 还有一个额外的部分存储数组长度。轻量级锁和偏向锁的关键是“Mark Word”。

“Mark Word”被设计成一个非固定的数据结构,以便在极小的空间内存储尽量多的信息。因此,它的内存布局是可变的。要动态地理解对象的数据结构,可以采用 jol 工具:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
Student stu=new Student();
System.out.println("=====加锁之前======");
System.out.println(ClassLayout.parseInstance(stu).toPrintable());
synchronized (stu){
System.out.println("=====加锁之后======");
System.out.println(ClassLayout.parseInstance(stu).toPrintable());
}
}

在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为"01"),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储所对象目前的 Mark Word的拷贝(实际上被命名为 Displaced Markd Word)。也就是说,试图求锁的线程局部栈帧可能是不一样的。

线程栈帧与 Lock Record 的结构

要理解轻量级锁的工作原理,首先需要理解线程栈帧的结构以及 Lock Record 在其中的位置。

线程栈帧(Stack Frame)的基本结构:

每个线程在执行方法时,JVM 会为该方法创建一个栈帧(Stack Frame),栈帧是方法执行的基本单位。栈帧包含以下几个核心部分:

组成部分 说明
局部变量表(Local Variable Table) 存储方法参数和方法内定义的局部变量
操作数栈(Operand Stack) 用于执行字节码指令时的临时数据存储
动态链接(Dynamic Linking) 指向运行时常量池中该方法的引用
方法返回地址(Return Address) 方法正常退出或异常退出后的返回位置
Lock Record(锁记录) 仅在进入 synchronized 块时创建,用于轻量级锁的实现

图:线程栈帧结构与 Lock Record 的位置

graph TB
    subgraph "线程栈(Thread Stack)"
        direction TB
        
        subgraph "栈帧3(当前方法 - synchronized 块内)"
            LVT3["局部变量表<br/>Local Variable Table"]
            OS3["操作数栈<br/>Operand Stack"]
            DL3["动态链接<br/>Dynamic Linking"]
            RA3["方法返回地址<br/>Return Address"]
            
            subgraph "Lock Record(锁记录)"
                DMW["Displaced Mark Word<br/>━━━━━━━━━━━━━━━━━━━━<br/>对象原始 Mark Word 的备份<br/>(hashCode、age 等信息)"]
                OBJ_REF["owner 指针<br/>━━━━━━━━━━━━━━━━━━━━<br/>指向被锁定的对象"]
            end
            
            style DMW fill:#e8f5e9
            style OBJ_REF fill:#fff9c4
        end
        
        subgraph "栈帧2(调用方法)"
            LVT2["局部变量表"]
            OS2["操作数栈"]
            DL2["动态链接"]
            RA2["方法返回地址"]
            style LVT2 fill:#f5f5f5
            style OS2 fill:#f5f5f5
            style DL2 fill:#f5f5f5
            style RA2 fill:#f5f5f5
        end
        
        subgraph "栈帧1(main 方法)"
            LVT1["局部变量表"]
            OS1["操作数栈"]
            DL1["动态链接"]
            RA1["方法返回地址"]
            style LVT1 fill:#f5f5f5
            style OS1 fill:#f5f5f5
            style DL1 fill:#f5f5f5
            style RA1 fill:#f5f5f5
        end
    end
    
    subgraph "堆(Heap)"
        OBJ["锁对象<br/>━━━━━━━━━━━━━━━━━━━━<br/>Object Header:<br/>Mark Word | Klass Word<br/>━━━━━━━━━━━━━━━━━━━━<br/>Object Body"]
        style OBJ fill:#e1f5ff
    end
    
    OBJ_REF -->|"指向"| OBJ
    OBJ -->|"Mark Word 存储<br/>Lock Record 指针"| DMW
    
    note1["Lock Record 是栈帧的一部分<br/>仅在进入 synchronized 块时动态创建<br/>退出时销毁"]
    style note1 fill:#fff9c4

Lock Record 的内部结构:

Lock Record 是轻量级锁实现的核心数据结构,它包含两个关键字段:

字段 类型 说明
Displaced Mark Word markOop 对象原始 Mark Word 的备份。当获取轻量级锁时,对象的 Mark Word 会被替换为指向 Lock Record 的指针,原始信息(hashCode、GC age 等)就保存在这里。解锁时需要将其恢复回对象头。
owner(对象指针) oop 指向被锁定对象的指针。用于标识这个 Lock Record 锁定的是哪个对象,也用于 GC 时的对象引用追踪。

图:轻量级锁加锁过程中 Lock Record 与对象的交互

sequenceDiagram
    participant Thread as 线程栈帧
    participant LR as Lock Record
    participant OBJ as 锁对象
    participant MW as Mark Word
    
    Note over Thread,MW: 1. 进入 synchronized 块
    
    Thread->>LR: 在栈帧中创建 Lock Record
    Thread->>LR: 初始化 owner 指向锁对象
    
    Note over Thread,MW: 2. 备份原始 Mark Word
    
    OBJ->>MW: 读取当前 Mark Word
    MW->>LR: 拷贝到 Displaced Mark Word
    Note over LR: Displaced Mark Word =<br/>hashCode | age | 01
    
    Note over Thread,MW: 3. CAS 尝试获取锁
    
    Thread->>MW: CAS(原始值, Lock Record 指针)
    
    alt CAS 成功
        MW->>MW: Mark Word = ptr_to_LR | 00
        Note over Thread,MW: 获取轻量级锁成功!
    else CAS 失败
        alt Mark Word 已指向当前线程的 Lock Record
            Note over Thread,MW: 锁重入,直接进入
        else Mark Word 指向其他线程
            Note over Thread,MW: 竞争失败,锁膨胀为重量级锁
        end
    end

图:Lock Record 在锁重入场景下的表现

graph TB
    subgraph "线程栈(同一线程多次进入 synchronized)"
        direction TB
        
        subgraph "栈帧 - 第三次进入 synchronized"
            LR3["Lock Record 3<br/>━━━━━━━━━━━━━━━━━━━━<br/>Displaced Mark Word: null<br/>owner: → 锁对象"]
            style LR3 fill:#ffcdd2
        end
        
        subgraph "栈帧 - 第二次进入 synchronized"
            LR2["Lock Record 2<br/>━━━━━━━━━━━━━━━━━━━━<br/>Displaced Mark Word: null<br/>owner: → 锁对象"]
            style LR2 fill:#fff9c4
        end
        
        subgraph "栈帧 - 第一次进入 synchronized"
            LR1["Lock Record 1<br/>━━━━━━━━━━━━━━━━━━━━<br/>Displaced Mark Word: 原始值<br/>owner: → 锁对象"]
            style LR1 fill:#c8e6c9
        end
    end
    
    subgraph "堆"
        OBJ["锁对象<br/>━━━━━━━━━━━━━━━━━━━━<br/>Mark Word:<br/>ptr_to_LR1 | 00"]
        style OBJ fill:#e1f5ff
    end
    
    LR1 -->|"owner"| OBJ
    LR2 -->|"owner"| OBJ
    LR3 -->|"owner"| OBJ
    OBJ -->|"Mark Word 指向"| LR1
    
    note1["锁重入时:<br/>1. 每次进入都创建新的 Lock Record<br/>2. 只有第一个 LR 保存原始 Mark Word<br/>3. 后续 LR 的 Displaced Mark Word 为 null<br/>4. 解锁时按栈顺序逐个弹出 LR<br/>5. 遇到非 null 的 Displaced Mark Word 时恢复"]
    style note1 fill:#fff9c4

为什么 Lock Record 要放在栈帧中?

  1. 生命周期自动管理:栈帧随方法调用创建、随方法返回销毁,Lock Record 也随之自动管理,无需额外的内存分配和回收
  2. 线程私有:每个线程有自己的栈,Lock Record 天然是线程私有的,无需同步
  3. 支持锁重入:每次进入 synchronized 块都会创建新的 Lock Record,通过栈的 LIFO 特性天然支持锁重入的计数和恢复
  4. 快速访问:栈帧在 CPU 缓存中的命中率高,访问速度快

然后虚拟机试图使用 CAS 操作尝试将对象的Mark Word 更新为指向 Lock Record 的指针(注意是整个Mark Word)。如果更新成功了,那么线程就拥有了该对象的锁,并且 Mark Word 的**锁标志位(Markword的最后两位)**转变为“00”。如果这个更新失败了,虚拟机首先会检查对象的 Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word也就变成指向重量级锁的指针(也就是说,不再指向 Lock Record)。

轻量级锁的解锁过程,也必须借助 CAS 操作,把 Displaced Mark Word 的值写到 Mark Word 上。如果替换完成,同步结束。如果替换失败,证明有其他线程尝试获取过该锁,说明在持有轻量级锁期间发生了锁膨胀。此时需要按重量级锁的方式释放锁并唤醒被挂起的线程。这确实是一个"不对称"的操作——加锁时使用轻量级锁,但解锁时需要按重量级锁处理。这种设计是合理的:锁膨胀发生在持锁期间,持锁线程在解锁时必须负责处理膨胀后的状态。

轻量级锁在发生竞争时,依然会出现锁膨胀,而且还加上了CAS的开销,反而比直接使用重量级锁更慢。使用偏向锁只能根据一种经验假定,“绝大部分锁,在同步周期内是不存在竞争的”。

从这个过程我们可以看出来,mark word里并不是存了线程号,而是直接把mark word指向了目标线程的栈帧,轻量级锁和重量级锁的差别就在于底层是不是会触发 Mutex。

偏向锁(Biased Lock)

偏向锁也是 JDK 1.6 中引入的一项锁优化。它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁在无竞争的情况下使用 CAS操作去消除同步使用的互斥量,偏向锁就是在无竞争的情况下,把整个同步过程都消除掉,连 CAS 都不做了。

偏向锁的偏,是偏心的。这个锁会偏向于第一个获得它的线程,如果接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。从这点来看,偏向锁导致同步消除了,等同于锁消除了。但锁消除并不等同于偏向锁,可能有JIT自己去掉同步代码的优化

当对象在第一次被线程锁定的时候,虚拟机会把标志位设置为“01”(至此标志位已经被用尽了)。同时使用 CAS 模式(因为此时还不能保证没有竞争)试图把线程 ID 写入 Mark Word中(此处就真的写入线程号了)。如果CAS成功,那么以后再进入同步块,都不需要执行任何同步操作。

如果这个时候发生锁竞争,则会发生撤销偏向(Revoke Bias),对象会短暂回到未锁定状态,然后进入轻量级锁的竞争阶段。注意:偏向锁撤销后是先升级到轻量级锁,而不是直接膨胀为重量级锁。只有在轻量级锁竞争失败(CAS 自旋超过阈值)时,才会进一步膨胀为重量级锁。偏向锁是默认打开的,很多推荐的JVM配置都关掉它,因为多线程竞争很激烈的情况下,偏向锁的假定往往会失效(轻量级锁实际上也会失效)。所以可以用 -XX:-UseBiasedLocking 来关闭偏向锁。

所以锁的变化过程就是无锁->偏向锁->轻量级锁->重量级锁。

偏向锁在 JVM 内部的实现实在太复杂了,从 Java 15 开始要逐步 deprecated。偏向锁的废弃历程如下:

JDK 版本 偏向锁状态 说明
JDK 6 ~ JDK 14 默认开启 需要 -XX:-UseBiasedLocking 显式关闭
JDK 15 默认关闭 JEP 374 将其标记为废弃,需要 -XX:+UseBiasedLocking 显式开启
JDK 18+ 已移除 相关代码已从 HotSpot 中删除,启动参数无效

废弃偏向锁的主要原因:

  1. 实现复杂度高:偏向锁的撤销(revocation)需要在安全点(safepoint)进行,涉及复杂的栈遍历和对象头修改
  2. 维护成本大:偏向锁的代码与 HotSpot 的其他子系统(如 GC、栈遍历)深度耦合,增加了代码维护难度
  3. 收益递减:现代应用中,无竞争的同步场景越来越少,偏向锁的优化收益不再显著
  4. 替代方案成熟:轻量级锁的 CAS 操作在现代 CPU 上已经足够高效

在日常的 JVM 调优中,很多团队为了避免偏向锁撤销带来的性能抖动,也会主动关闭偏向锁。

无锁和轻量级锁的差别是:

  • 无锁是自旋修改同步资源:无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
  • 轻量级锁是自旋抢锁而不是阻塞抢锁:是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

如果硬要对比:

  • 无锁更像是直接使用 AtomicInteger.compareAndSet() 进行乐观更新,失败就重试,不涉及任何锁的概念
  • 轻量级锁更像是 AQS 中 tryAcquire() 的非阻塞尝试部分——通过 CAS 竞争锁,失败后自旋重试,但不会立即阻塞线程

两者的本质区别在于:无锁是对数据的 CAS 操作(修改共享变量本身),而轻量级锁是对锁状态的 CAS 操作(竞争 Mark Word 的所有权)。