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),从而写出高效、安全的并发代码。
