Appearance
锁机制详解
一、Java锁的基础:synchronized的底层实现
synchronized
是Java中最基本的同步机制,其底层依赖JVM的 Monitor(监视器) 和 对象头(Mark Word) 实现。要理解synchronized
,必须先搞清楚这两个核心结构。
1.1 对象头(Mark Word):锁状态的存储载体
在JVM中,每个对象都有一个对象头(Object Header),用于存储对象的元数据(如哈希码、GC年龄、锁状态等)。其中,Mark Word是对象头的核心部分(占32位或64位,取决于JVM位数),其结构会根据锁状态动态变化。
以64位JVM为例,Mark Word的默认结构(无锁状态)如下:
位偏移 | 含义 |
---|---|
0-2 | 锁状态标志(001:无锁) |
3-4 | GC年龄 |
5 | 是否偏向锁(0:未偏向) |
6-11 | 未使用 |
12-63 | 对象哈希码(HashCode) |
当对象被加锁时,Mark Word的结构会发生变化,以存储锁的状态信息(如偏向线程ID、轻量级锁指针、重量级锁Monitor地址等)。不同锁状态对应的Mark Word结构如下:
锁状态 | 锁标志位 | Mark Word存储内容 |
---|---|---|
无锁 | 001 | 哈希码、GC年龄、是否偏向锁(0) |
偏向锁 | 101 | 偏向线程ID、偏向时间戳、GC年龄、是否偏向锁(1) |
轻量级锁 | 000 | 指向栈帧中锁记录(Lock Record)的指针 |
重量级锁 | 010 | 指向Monitor对象的指针 |
GC标记 | 111 | 无意义(GC回收时使用) |
1.2 Monitor:重量级锁的实现核心
Monitor(监视器) 是JVM中的同步工具,每个对象都关联一个Monitor(通过对象头的Mark Word指向)。Monitor的结构如下(逻辑模型):
c
typedef struct Monitor {
Object* owner; // 当前持有锁的线程
ThreadList* entryList; // 等待获取锁的线程队列(阻塞队列)
ThreadList* waitSet; // 调用wait()后等待的线程队列
int recursiveCount; // 重入次数(可重入锁的实现)
} Monitor;
当线程尝试获取synchronized
锁时,JVM会执行以下步骤:
- 尝试获取Monitor:如果Monitor的
owner
为null
,则当前线程将owner
设为自己,并将recursiveCount
设为1,成功获取锁。 - 重入处理:如果当前线程已经是Monitor的
owner
,则recursiveCount
加1(可重入性)。 - 阻塞等待:如果Monitor的
owner
是其他线程,则当前线程进入entryList
队列,进入阻塞状态(BLOCKED),等待被唤醒。
当线程释放锁时(退出synchronized
块或方法),recursiveCount
减1,若减至0,则将owner
设为null
,并唤醒entryList
中的一个线程(公平性取决于JVM实现,默认非公平)。
1.3 synchronized的三种使用方式与Monitor关联
synchronized
可以修饰方法、代码块,其底层都通过Monitor实现,但关联的对象不同:
- 修饰实例方法:Monitor关联当前对象(
this
)的对象头。 - 修饰静态方法:Monitor关联当前类的
Class
对象(存储在方法区)的对象头。 - 修饰代码块:Monitor关联
synchronized(lockObj)
中的lockObj
的对象头。
二、JVM的锁升级:从偏向锁到重量级锁
JDK 1.6之前,synchronized
是重量级锁(直接关联Monitor),性能较差。JDK 1.6引入了分层锁机制(偏向锁→轻量级锁→重量级锁),根据竞争强度动态调整锁的类型,优化性能。
2.1 偏向锁(Biased Locking):单线程优化
核心思想:当一个线程第一次获取锁时,将对象头的Mark Word设置为偏向模式(锁标志位101),记录该线程的ID。之后该线程再次获取锁时,无需进行CAS操作,直接判断Mark Word中的线程ID是否为当前线程,即可快速获取锁。
实现细节:
偏向锁的获取:
- 线程第一次访问
synchronized
块时,JVM检查对象头的Mark Word是否为无锁状态(001)且未偏向(是否偏向锁位为0)。 - 如果是,通过CAS操作将Mark Word的是否偏向锁位设为1,线程ID设为当前线程ID,偏向时间戳设为当前时间。
- 后续该线程再次进入
synchronized
块时,直接比较Mark Word中的线程ID,若匹配则无需任何操作,快速获取锁。
- 线程第一次访问
偏向锁的撤销:
- 当有其他线程尝试获取锁时,偏向锁会被撤销(升级为轻量级锁)。撤销过程需要暂停拥有偏向锁的线程,并检查该线程是否还在执行
synchronized
块:- 如果线程已退出,则将对象头恢复为无锁状态。
- 如果线程仍在执行,则将对象头升级为轻量级锁(锁标志位000),并让该线程重新获取轻量级锁。
- 当有其他线程尝试获取锁时,偏向锁会被撤销(升级为轻量级锁)。撤销过程需要暂停拥有偏向锁的线程,并检查该线程是否还在执行
适用场景:
- 单线程场景(如单线程循环执行同步代码):偏向锁几乎没有开销(仅第一次CAS),性能最优。
- 避免场景:多线程竞争频繁的场景(偏向锁会频繁撤销,反而增加开销)。可以通过
-XX:-UseBiasedLocking
禁用偏向锁。
2.2 轻量级锁(Lightweight Locking):低竞争优化
核心思想:当线程竞争不激烈时(如线程交替执行同步代码),使用**自旋锁(Spin Lock)**替代重量级锁的阻塞,减少上下文切换开销。
实现细节:
轻量级锁的获取:
- 线程进入
synchronized
块时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record),用于存储对象头的Mark Word副本(称为displaced Mark Word
)。 - 线程通过CAS操作将对象头的Mark Word替换为指向锁记录的指针(锁标志位设为000)。
- 如果CAS成功,线程获取轻量级锁;如果失败(说明有其他线程竞争),则进入自旋等待(循环尝试CAS)。
- 线程进入
轻量级锁的自旋:
- 自旋的目的是避免线程阻塞(上下文切换开销大),适用于锁持有时间短的场景。
- JVM采用自适应自旋(Adaptive Spinning):根据之前的自旋结果调整自旋次数(如上次自旋成功,则增加自旋次数;上次失败,则减少或取消自旋)。
轻量级锁的升级:
- 如果自旋次数超过阈值(默认10次,可通过
-XX:PreSpin
调整),或有多个线程竞争(如第三个线程尝试获取锁),轻量级锁会升级为重量级锁(锁标志位设为010,指向Monitor对象)。此时,自旋的线程会进入Monitor的entryList
队列,进入阻塞状态。
- 如果自旋次数超过阈值(默认10次,可通过
适用场景:
- 低竞争场景(如线程交替执行同步代码):轻量级锁的自旋开销远小于重量级锁的阻塞开销。
- 避免场景:高竞争场景(自旋会浪费CPU资源,此时应直接使用重量级锁)。
2.3 重量级锁(Heavyweight Locking):高竞争兜底
核心思想:当线程竞争激烈时(如多个线程同时争夺锁),使用Monitor的阻塞机制,确保线程安全,但开销最大(涉及上下文切换)。
实现细节:
- 重量级锁的获取与释放依赖Monitor的
entryList
和waitSet
队列:- 线程获取锁失败时,进入
entryList
队列,状态变为BLOCKED
。 - 线程调用
wait()
方法时,释放锁(将owner
设为null
,recursiveCount
减至0),进入waitSet
队列,状态变为WAITING
。 - 线程调用
notify()
/notifyAll()
方法时,唤醒waitSet
中的一个/所有线程,这些线程会进入entryList
队列,重新竞争锁。
- 线程获取锁失败时,进入
适用场景:
- 高竞争场景(如多个线程同时访问共享资源):重量级锁的阻塞机制可以避免CPU资源浪费,但性能最差。
2.4 锁升级的整体流程
JVM的锁升级是不可逆的(除了偏向锁可以被撤销),流程如下:
无锁状态(001)
↓(第一次获取锁,单线程)
偏向锁(101)
↓(其他线程竞争,撤销偏向锁)
轻量级锁(000)
↓(自旋失败/多线程竞争)
重量级锁(010)
三、并发包的核心:AQS框架
java.util.concurrent.locks
包中的锁(如ReentrantLock
、CountDownLatch
、Semaphore
)均基于AQS(AbstractQueuedSynchronizer,抽象队列同步器)实现。AQS是JUC的核心框架,其设计思想是将同步状态(State)与等待队列(CLH队列)分离,提供通用的同步机制。
3.1 AQS的核心结构
AQS的核心由两部分组成:
同步状态(State):
- 用
volatile int state
表示,用于存储同步状态(如ReentrantLock
的重入次数、Semaphore
的许可数)。 - 状态的修改必须通过CAS操作(
compareAndSetState()
),确保原子性。
- 用
等待队列(CLH队列):
- 是一个双向链表,每个节点代表一个等待锁的线程(
Node
对象)。 - 节点的状态(
waitStatus
)包括:CANCELLED
(取消)、SIGNAL
(等待唤醒)、CONDITION
(等待条件)、PROPAGATE
(共享模式传播)。 - 队列的头节点(
head
)是当前持有锁的线程,尾节点(tail
)是最后一个等待的线程。
- 是一个双向链表,每个节点代表一个等待锁的线程(
3.2 AQS的两种模式
AQS支持独占模式(Exclusive Mode)和共享模式(Shared Mode),分别对应不同的同步场景:
- 独占模式:同一时间只有一个线程可以获取锁(如
ReentrantLock
)。 - 共享模式:同一时间多个线程可以获取锁(如
Semaphore
、CountDownLatch
)。
独占模式的实现(以ReentrantLock
为例):
获取锁(
lock()
):- 调用
tryAcquire(int arg)
方法(由子类实现),尝试修改状态(如state
从0变为1)。 - 如果
tryAcquire
成功,当前线程获取锁。 - 如果
tryAcquire
失败,将当前线程封装为Node
,加入CLH队列的尾部(通过CAS操作),然后进入阻塞状态(LockSupport.park()
)。
- 调用
释放锁(
unlock()
):- 调用
tryRelease(int arg)
方法(由子类实现),修改状态(如state
减1)。 - 如果
tryRelease
成功,唤醒CLH队列中的头节点的下一个节点(Node.signal()
),该节点的线程会重新尝试获取锁。
- 调用
共享模式的实现(以Semaphore
为例):
获取许可(
acquire()
):- 调用
tryAcquireShared(int arg)
方法(由子类实现),尝试修改状态(如state
减arg
)。 - 如果
tryAcquireShared
返回值≥0(成功),当前线程获取许可。 - 如果返回值<0(失败),将当前线程封装为
Node
,加入CLH队列的尾部,然后进入阻塞状态。
- 调用
释放许可(
release()
):- 调用
tryReleaseShared(int arg)
方法(由子类实现),修改状态(如state
加arg
)。 - 如果
tryReleaseShared
成功,唤醒CLH队列中的头节点的下一个节点,该节点的线程会重新尝试获取许可,并将唤醒操作传播给后续节点(共享模式的特性)。
- 调用
3.3 AQS的子类实现:以ReentrantLock
为例
ReentrantLock
是AQS的典型子类,实现了可重入的独占锁,支持公平锁和非公平锁。
公平锁与非公平锁的区别:
- 非公平锁(默认):线程尝试获取锁时,先通过CAS修改状态(
state
从0变为1),如果成功则直接获取锁;如果失败,再加入CLH队列。这种方式允许线程“插队”,提高性能,但可能导致线程饥饿(某些线程长期无法获取锁)。 - 公平锁:线程尝试获取锁时,先检查CLH队列是否有等待的线程,如果有,则直接加入队列;如果没有,再通过CAS修改状态。这种方式保证线程按等待顺序获取锁,公平性好,但性能略低(因为需要维护队列顺序)。
ReentrantLock
的tryAcquire
实现(非公平锁):
java
protected boolean tryAcquire(int arg) {
if (arg != 1) throw new IllegalArgumentException();
// 非公平锁:先尝试CAS修改state(插队)
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
// 重入处理:当前线程已持有锁,state加1
Thread current = Thread.currentThread();
if (getExclusiveOwnerThread() == current) {
int nextState = getState() + arg;
if (nextState < 0) throw new Error("Maximum lock count exceeded");
setState(nextState);
return true;
}
// 失败,返回false
return false;
}
3.4 AQS与synchronized
的区别
特性 | synchronized | ReentrantLock (AQS实现) |
---|---|---|
实现层面 | JVM层面(Monitor) | JDK层面(AQS框架) |
锁类型 | 可重入、独占 | 可重入、独占(支持公平/非公平) |
锁升级 | 偏向锁→轻量级锁→重量级锁 | 无(直接使用AQS的队列机制) |
手动释放 | 自动(退出同步块/方法) | 手动(必须调用unlock() ,否则死锁) |
中断支持 | 不支持(线程阻塞时无法中断) | 支持(lockInterruptibly() ) |
超时获取 | 不支持 | 支持(tryLock(long timeout, TimeUnit unit) ) |
公平性 | 非公平(默认) | 支持公平/非公平(可配置) |
条件变量 | 支持(wait() /notify() ) | 支持(Condition 接口,更灵活) |
四、锁的内存语义:可见性与有序性
锁的核心作用是保证共享变量的原子性,但同时也通过内存屏障(Memory Barrier)保证了可见性和有序性。
4.1 synchronized
的内存语义
根据JMM(Java内存模型),synchronized
的进入和退出会插入以下内存屏障:
- 进入同步块:插入LoadLoad、LoadStore、StoreStore屏障,禁止重排序(确保同步块内的代码不会被重排序到同步块外)。
- 退出同步块:插入StoreLoad屏障,强制将缓存中的数据刷新到主内存(确保其他线程能看到当前线程修改的共享变量)。
简单来说:
- 线程A进入
synchronized
块修改共享变量x
,修改后的数据会被刷新到主内存。 - 线程B进入同一个
synchronized
块时,会从主内存读取x
的最新值(可见性)。 - 同步块内的代码顺序不会被重排序(有序性)。
4.2 volatile
与synchronized
的内存语义对比
volatile
:仅保证可见性和有序性(禁止重排序),但不保证原子性(如i++
操作不是原子的)。synchronized
:保证原子性、可见性、有序性(全能,但开销更大)。
五、锁的优化策略:从JVM到应用层
为了提高锁的性能,JVM和应用层都有一系列优化策略,以下是常见的几种:
5.1 锁消除(Lock Elimination)
核心思想:JIT编译器通过逃逸分析(Escape Analysis),识别出不会被其他线程访问的共享变量,从而消除不必要的锁。
例如,以下代码中的synchronized
块可以被消除:
java
public String concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // sb不会逃逸到方法外
sb.append(a);
sb.append(b);
return sb.toString();
}
StringBuffer
的append()
方法是synchronized
的,但sb
是方法内的局部变量,不会被其他线程访问(未逃逸),因此JIT编译器会消除append()
方法中的锁,优化为StringBuilder
的非同步操作。
5.2 锁粗化(Lock Coarsening)
核心思想:将多个连续的锁操作合并为一个大的锁操作,减少锁的开销(如CAS操作、上下文切换)。
例如,以下代码中的循环内的synchronized
块会被粗化为循环外的一个锁:
java
for (int i = 0; i < 1000; i++) {
synchronized (lock) { // 连续的小锁
// 业务逻辑
}
}
JIT编译器会将其优化为:
java
synchronized (lock) { // 合并为一个大锁
for (int i = 0; i < 1000; i++) {
// 业务逻辑
}
}
5.3 自旋锁(Spin Lock)
核心思想:线程获取锁失败时,不立即阻塞,而是循环尝试获取锁(自旋),避免上下文切换开销。
自旋锁适用于锁持有时间短的场景(如轻量级锁的自旋)。JVM采用自适应自旋,根据之前的自旋结果调整自旋次数(如上次自旋成功,则增加自旋次数;上次失败,则减少或取消自旋)。
5.4 锁分离(Lock Splitting)
核心思想:将一个大的锁拆分为多个小的锁,减少锁的竞争范围。
例如,ConcurrentHashMap
的**分段锁(Segment)**机制:将哈希表分为多个Segment,每个Segment对应一个锁,不同Segment的操作可以并行执行,提高并发性能。
5.5 读写锁(ReadWriteLock)
核心思想:将读操作和写操作分离,允许多个读线程同时访问(读共享),但写线程独占(写独占),适用于读多写少的场景。
ReentrantReadWriteLock
是读写锁的实现,其特点:
- 读锁(共享锁):多个线程可以同时获取,不会阻塞其他读线程,但会阻塞写线程。
- 写锁(独占锁):一个线程获取后,会阻塞所有读线程和写线程。
- 锁降级:写线程可以降级为读线程(先获取写锁,再获取读锁,最后释放写锁),避免写线程释放锁后其他线程修改数据。
六、高级话题:死锁与锁的公平性
6.1 死锁的分析与避免
死锁是指两个或多个线程互相等待对方释放锁,导致无限阻塞的状态。死锁的四个必要条件:
- 互斥条件:资源只能被一个线程持有。
- 请求与保持条件:线程持有一个资源的同时,请求另一个资源。
- 不可剥夺条件:资源不能被强制剥夺。
- 循环等待条件:线程之间形成循环等待链(如线程A等待线程B的锁,线程B等待线程A的锁)。
死锁的排查:
- 使用
jstack
命令:查看线程栈信息,寻找BLOCKED
状态的线程,以及它们等待的锁。 - 使用
jconsole
或VisualVM
:可视化工具,查看线程状态和锁信息。
死锁的避免:
- 顺序加锁:线程获取多个锁时,按固定的顺序(如从小到大)获取,避免循环等待。
- 超时加锁:使用
tryLock(long timeout, TimeUnit unit)
方法,超时后放弃获取锁,避免无限等待。 - 释放锁:确保锁的释放操作在
finally
块中,避免异常导致锁未释放。 - 使用并发工具:如
CountDownLatch
、Semaphore
等,替代手动加锁。
6.2 锁的公平性:公平锁与非公平锁
公平锁:线程按等待顺序获取锁(先到先得),公平性好,但性能略低(因为需要维护队列顺序)。 非公平锁:线程尝试获取锁时,先插队(直接尝试CAS修改状态),如果失败再加入队列,性能好,但可能导致线程饥饿。
选择建议:
- 高并发、低延迟:选择非公平锁(如
ReentrantLock
的默认模式),提高吞吐量。 - 需要公平性:选择公平锁(如
ReentrantLock(true)
),避免线程饥饿。
七、总结:JVM锁的设计思想
JVM中的锁机制(synchronized
)和并发包中的锁(ReentrantLock
等),其设计思想都是分层优化和适应不同场景:
- 分层锁:偏向锁(单线程)→轻量级锁(低竞争)→重量级锁(高竞争),根据竞争强度动态调整,优化性能。
- AQS框架:将同步状态与等待队列分离,提供通用的同步机制,支持各种同步工具(如锁、信号量、计数器)。
- 锁的优化:通过锁消除、锁粗化、自旋锁等策略,减少锁的开销,提高并发性能。
作为高级开发人员,需要深入理解锁的底层实现(如Mark Word、Monitor、AQS),掌握锁的优化策略(如读写锁、锁分离),并能根据场景选择合适的锁(如synchronized
vs ReentrantLock
),从而写出高效、安全的并发代码。