线程安全与锁优化
版本说明:本文主要基于 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 具有的可重入、阻塞其他求锁者的特性。但它还具有三个额外的特点,支持某些场景下的任务调度需求:
- 等待可中断。Lock 接口有实现类可以实现试锁,超时试锁等功能,各种接口都有 interruptibly 版本。这样 synchronized中,其他求锁线程傻等的情况可以避免。
- 公平锁。公平锁指的是按照求锁顺序来分配锁(求锁也是有顺序的,fifo 天然就是公平的)。默认的锁(synchronized 和 ReentrantLock 的默认构造函数)是非公平的,随机给予锁,这样性能更好。synchronized 本身并不内置公平锁,AQS 的非公平锁通过允许插队(新来的线程可以直接尝试 CAS 获取锁,不用排队),来减少 cpu 时间片花在调度/cpu上下文切换上的开销,来获得更高的吞吐。非公平锁的吞吐会更好,而公平锁可避免线程饥饿。ReentrantLock 默认使用非公平锁。
- 绑定多个条件。在 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
四大核心原则:
-
操作的本质:当我们调用
ThreadLocal.get()或ThreadLocal.set()时,实际上是在操作当前线程内部的 ThreadLocalMap。ThreadLocal 对象本身只是一个"访问入口",真正的数据存储在各个线程的隐藏 Map 中。 -
ThreadLocal 应该是 static 变量:ThreadLocal 应该声明为 static 变量,作为类级别的全局唯一实例。原因有三:
- 避免内存浪费:如果作为成员变量,每个对象实例都会创建新的 ThreadLocal,导致每个线程的 ThreadLocalMap 中会有多个 Entry,浪费内存且违背线程本地存储的设计初衷。我们的初衷是每个线程有一个变量的副本,而不是多个副本。
- 防止对象无法回收:如果 ThreadLocal 是成员变量,开发者会习惯性地通过
对象实例.threadLocal.get()来访问 ThreadLocal。这种使用方式会导致对象实例被外部持有引用,进而无法被 GC 回收。而如果 ThreadLocal 是 static 变量,访问方式是类.threadLocal.get(),不会持有对象实例的引用,避免了对象级别的内存泄漏。 - Class 生命周期更长:相比之下,Class 对象通常不需要频繁回收,其生命周期与应用程序相当,因此 static 成员是可以接受的。static 声明确保全局只有一个 ThreadLocal 实例,每个线程的 Map 中只有一个对应的 Entry,既节省内存又避免了对象无法回收的问题。
-
线程生命周期决定数据生命周期:即使我们不主动调用
remove(),当线程销毁时(如普通线程执行完毕),该线程的 ThreadLocalMap 也会随之销毁,所有 value 自动被回收。这就是为什么在非线程池场景下,ThreadLocal 的内存泄漏问题不那么严重。 -
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 | |
一个 ThreadLocal 变量的"副本"实际上是分散存储在多个线程的 ThreadLocalMap 中的。每个线程的 ThreadLocalMap 中都有一个 Entry,以同一个 ThreadLocal 对象为 key,但 value 是各自独立的。
静态变量场景的引用关系:
1 | |
为什么 key 使用弱引用?
因为线程内部的 ThreadLocalMap 是隐式容器,由线程自己管理。如果 key 使用强引用,那么只要线程存活(如线程池场景),ThreadLocal 对象就永远无法被回收。使用弱引用后,当 ThreadLocal 对象没有外部强引用时(如静态变量被置为 null),它可以被 GC 回收,Entry 的 key 变为 null,后续的 get/set/remove 操作会自动清理这些过期的 Entry。
弱引用的特性:在垃圾回收器线程扫描内存区域时,一旦发现只具有弱引用的对象,不管当前内存空间是否足够,都会回收它的内存。
内存泄漏的发生机制:
ThreadLocal 的内存泄漏实际上是一个条件链,任何一个环节被破坏都可能导致泄漏:
- ThreadLocal 对象被回收:当 ThreadLocal 对象没有强引用时(如静态变量被置为 null),它会被 GC 回收
- Entry 的 key 变为 null:ThreadLocalMap 中对应的 Entry 的 key(弱引用)失效
- value 仍被强引用:但 value 仍被 Entry 强引用,无法被回收(value 不是被 key 引用,而是被 Entry 引用)
- 自动清理机制:后续的 get/set/remove 操作会触发
expungeStaleEntry(),清理 key 为 null 的 Entry - 泄漏发生:如果线程长期存活(如线程池)且不再调用 get/set/remove,这些 Entry 永远不会被清理
两种泄漏场景:
-
ThreadLocal 对象泄漏:
- ThreadLocal 通常被声明为静态变量,便于全局访问
- 如果静态变量的生命周期管理不当,会导致方法区泄漏
- 这与 ThreadLocalMap 的弱引用机制无关,是静态变量本身的泄漏
-
value 泄漏:
- value 被 ThreadLocalMap 的 Entry 强引用
- 只要 ThreadLocal 对象被回收(key 变为 null)且触发了 get/set/remove 操作,Entry 就会被自动清理
- value 的泄漏本质上是由 ThreadLocal 对象无法被回收或清理机制未被触发导致的
最佳实践:
- 手动清理:使用完 ThreadLocal 后立即调用
remove(),这是最可靠的方式 - 避免静态变量泄漏:谨慎管理 ThreadLocal 静态变量的生命周期
- 触发自动清理:在长期运行的线程中,定期调用 get/set/remove 操作,让内部的
expungeStaleEntry()清理过期 Entry - 线程池场景:特别注意在任务执行完毕后清理 ThreadLocal(在使用线程池时,线程不会被销毁,必须手动清理)
1 | |
对象头
需要参考:
在 32 位虚拟机里:
1 | |

我们大致上认为一个对象应该分为 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 Word 和 Klass 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 | |
可以发现:偏向锁的 Mark Word 中根本没有 hashCode 的位置!54 位被线程 ID 占用,剩余的位用于 epoch、age 和标志位。
这意味着偏向锁与 hashCode 是互斥的:
- 如果对象从未调用过
hashCode():对象可以正常进入偏向锁状态,Mark Word 存储线程 ID - 如果对象已经调用过
hashCode():- 无锁状态下,hashCode 已经被计算并存储在 Mark Word 中
- 此时如果尝试加偏向锁,偏向锁会被直接跳过,直接进入轻量级锁
- 因为没有地方同时存储 hashCode 和线程 ID
- 如果对象已经处于偏向锁状态,此时调用
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(),所以这个限制通常不会造成问题
实践建议:
- 避免对锁对象调用 hashCode():如果一个对象主要用作锁,不要调用它的
hashCode()或将其放入HashMap/HashSet - 理解性能影响:如果你的锁对象频繁调用
hashCode(),偏向锁优化将完全失效 - 这也是偏向锁被废弃的原因之一:现代应用中,对象的使用模式越来越复杂,偏向锁的假设(单线程访问、不调用 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
关键理解:
-
Mark Word 是"指针容器"而非"数据容器":在轻量级锁和重量级锁状态下,Mark Word 不再直接存储 hashCode 等原始信息,而是存储指向其他数据结构的指针。
-
Monitor 是独立的数据结构:ObjectMonitor 是 JVM 在 C++ 层面实现的对象,它独立于 Java 对象存在。当锁膨胀为重量级锁时,JVM 会创建(或复用)一个 ObjectMonitor 对象,并将其地址写入 Mark Word。
-
原始信息的"流转":
- 无锁/偏向锁:原始信息直接在 Mark Word 中
- 轻量级锁:原始信息被拷贝到线程栈帧的 Lock Record 中
- 重量级锁:原始信息被保存到 Monitor 的
_header字段中
-
解锁时的恢复:无论是轻量级锁还是重量级锁,解锁时都需要将原始的 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 这个中间层?
- 跨平台抽象:不同操作系统的阻塞原语不同(Linux 用 futex/pthread,Windows 用 Event),Parker 提供统一接口
- 性能优化:Parker 可以实现"先自旋再阻塞"的策略,减少不必要的系统调用
- 与 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 | |
即使 methodA 和 methodB 的业务逻辑完全独立,它们也会互相阻塞,因为竞争的是同一个对象的 monitor。正确做法是为不相关的临界区使用不同的锁对象。这个问题同样适用于 ReentrantLock——锁的粒度由锁对象/Lock 实例的数量决定,一个锁对象 = 一把锁 = 同一时刻只能一个线程持有。
锁优化


所有的锁优化其实是 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 | 操作系统内核 | 系统调用、内核调度器 | 用户态/内核态切换 |
这种设计体现了"能在用户态解决的问题,就不要下沉到内核态"的优化原则:
-
偏向锁(纯用户态,零开销):假设锁总是被同一个线程获取,直接在 Mark Word 中记录线程 ID,后续加锁只需比较线程 ID,连 CAS 都省了。这是最乐观的假设,完全在 Java 对象层面解决,不涉及任何 JVM 外部机制。
-
轻量级锁(用户态,低开销):当出现竞争时,退而求其次,使用 CAS + 自旋的方式在用户态解决。Lock Record 存储在线程栈帧中,仍然是 Java 层面的数据结构。虽然 CAS 需要 CPU 提供原子指令支持,但不涉及操作系统调用,仍在用户态完成。
-
重量级锁(内核态,高开销):当竞争激烈、自旋无法快速获取锁时,才不得不"下沉"到操作系统层面,使用 Mutex/Futex 等同步原语。此时 Mark Word 指向 ObjectMonitor,而 ObjectMonitor 内部会调用操作系统的阻塞/唤醒机制,触发用户态/内核态切换。
与绿色线程的类比深化:
| 设计理念 | 绿色线程 | 轻量级锁 |
|---|---|---|
| 核心思想 | 用户态调度替代内核态调度 | 用户态同步替代内核态同步 |
| 实现方式 | M:N 模型,多个用户态线程映射到少量内核线程 | CAS + 自旋,在用户态完成锁的获取和释放 |
| 优势 | 避免内核态切换开销,支持大量轻量级并发 | 避免系统调用开销,支持低竞争场景的高效同步 |
| 局限性 | 无法利用多核并行(除非有内核线程支撑) | 无法处理高竞争场景(必须膨胀为重量级锁) |
| 典型实现 | Go goroutine、Erlang process、Java 虚拟线程 | JVM 偏向锁、轻量级锁 |
但有一个关键区别:绿色线程可以完全替代内核线程(如 Go 的 goroutine 在大多数场景下足够),而轻量级锁不能完全替代重量级锁——当竞争激烈时,自旋会浪费大量 CPU,必须膨胀为重量级锁让线程阻塞等待。
为什么锁只能升级不能降级?
这也解释了为什么锁只能升级不能降级:一旦发现竞争激烈到需要重量级锁,说明这个锁的使用场景确实存在高并发竞争,降级回轻量级锁反而会因为频繁的 CAS 失败和自旋浪费更多 CPU 资源。这就像一个服务发现单机处理不了流量后扩容到集群,即使流量下降也不会立即缩容——因为流量模式已经证明了需要更高的处理能力。
总结:锁优化的本质是"就近原则"
1 | |
离问题发生地越近的解决方案,开销越小。 这与计算机体系结构中的"局部性原理"一脉相承: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) 空循环。实际的实现涉及以下几个层面:
- 字节码层面:
monitorenter指令在获取锁失败时,会进入 JVM 运行时的自旋逻辑 - JVM 运行时层面:在
ObjectMonitor::enter()方法中实现自旋逻辑 - CPU 指令层面:使用
PAUSE指令(x86)或类似指令来优化自旋
1 | |
PAUSE 指令的作用:
| 作用 | 说明 |
|---|---|
| 降低 CPU 功耗 | 告诉 CPU 当前处于自旋等待状态,可以降低时钟频率 |
| 减少总线竞争 | 避免频繁的缓存行失效(cache line invalidation) |
| 提高超线程效率 | 让出执行资源给同一物理核心上的其他超线程 |
| 避免流水线惩罚 | 防止 CPU 错误预测分支导致的流水线刷新 |
自旋次数的控制
固定自旋(JDK 6 之前):
循环的次数通常不会很多,默认是 10 次。这个次数可以通过 -XX:PreBlockSpin 参数调整。
1 | |
自适应自旋(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 | |
这也是为什么 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 | |
在这个例子中,StringBuffer sb 是一个局部变量,不会逃逸出 concatString 方法,更不可能被其他线程访问。因此,JIT 编译器可以安全地消除 append() 方法内部的同步操作。
优化后的等效代码:
1 | |
场景2:方法内部的同步块
1 | |
由于 lock 对象是方法内部创建的局部变量,每次方法调用都会创建新的对象,不可能被其他线程访问,因此这个同步块可以被完全消除。
ReentrantLock 能被消除吗?
根据 CMU 的研究论文,ReentrantLock 也可以被锁消除优化。JIT 编译器的逃逸分析不仅适用于 synchronized,也适用于 java.util.concurrent 包中的锁。
1 | |
如果 lock 对象不逃逸,JIT 编译器可以消除 lock() 和 unlock() 的调用。
但需要注意:
| 锁类型 | 消除难度 | 原因 |
|---|---|---|
| synchronized | 较容易 | JVM 内置支持,字节码层面可识别 |
| ReentrantLock | 较难 | 需要识别 lock()/unlock() 的调用模式 |
| 分布式锁 | 不可能 | 涉及外部系统,无法通过逃逸分析判断 |
如何验证锁消除是否生效?
1 | |
锁粗化(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:连续的同步方法调用
1 | |
锁粗化的权衡
图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 | |
最佳实践:
- 信任 JVM 的优化:在大多数情况下,JVM 的锁粗化决策是合理的
- 避免过度优化:不要为了"帮助" JVM 而手动粗化锁,这可能适得其反
- 关注热点代码:只有被 JIT 编译的热点代码才会进行锁粗化优化
- 监控锁竞争:使用
jstack、async-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 | |
在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为"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 要放在栈帧中?
- 生命周期自动管理:栈帧随方法调用创建、随方法返回销毁,Lock Record 也随之自动管理,无需额外的内存分配和回收
- 线程私有:每个线程有自己的栈,Lock Record 天然是线程私有的,无需同步
- 支持锁重入:每次进入 synchronized 块都会创建新的 Lock Record,通过栈的 LIFO 特性天然支持锁重入的计数和恢复
- 快速访问:栈帧在 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 中删除,启动参数无效 |
废弃偏向锁的主要原因:
- 实现复杂度高:偏向锁的撤销(revocation)需要在安全点(safepoint)进行,涉及复杂的栈遍历和对象头修改
- 维护成本大:偏向锁的代码与 HotSpot 的其他子系统(如 GC、栈遍历)深度耦合,增加了代码维护难度
- 收益递减:现代应用中,无竞争的同步场景越来越少,偏向锁的优化收益不再显著
- 替代方案成熟:轻量级锁的 CAS 操作在现代 CPU 上已经足够高效
在日常的 JVM 调优中,很多团队为了避免偏向锁撤销带来的性能抖动,也会主动关闭偏向锁。
无锁和轻量级锁的差别是:
- 无锁是自旋修改同步资源:无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
- 轻量级锁是自旋抢锁而不是阻塞抢锁:是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
如果硬要对比:
- 无锁更像是直接使用
AtomicInteger.compareAndSet()进行乐观更新,失败就重试,不涉及任何锁的概念 - 轻量级锁更像是 AQS 中
tryAcquire()的非阻塞尝试部分——通过 CAS 竞争锁,失败后自旋重试,但不会立即阻塞线程
两者的本质区别在于:无锁是对数据的 CAS 操作(修改共享变量本身),而轻量级锁是对锁状态的 CAS 操作(竞争 Mark Word 的所有权)。






