Java锁
原理学习
Java 中的锁机制经历了从重型到轻量,从单一到多元的发展历程。要深入理解它们,不能只背诵概念,必须结合 JVM 内存模型 (JMM)、对象头 (Mark Word) 以及 AQS (AbstractQueuedSynchronizer) 的底层原理。
以下是对 Java 各类锁的深度解析,涵盖 JVM 层面的锁优化、JUC 显式锁以及分布式环境下的锁策略。
一、 宏观分类:锁的特性视角
在深入具体实现之前,我们需要建立一个清晰的分类体系。这些术语描述的是锁的特性或设计思想,而非具体的类。
| 锁分类 | 描述 | 代表实现 | 适用场景 |
|---|---|---|---|
| 乐观锁 | 假设没有冲突,操作时检测数据是否被修改 (CAS)。 | AtomicInteger, 数据库版本号 |
读多写少,竞争不激烈 |
| 悲观锁 | 假设总有冲突,操作前先锁定。 | synchronized, ReentrantLock |
写多读少,竞争激烈 |
| 可重入锁 | 允许同一个线程多次获取同一把锁,防止死锁。 | synchronized, ReentrantLock |
递归调用,父子类方法调用 |
| 公平锁 | 严格按照请求顺序获取锁 (FIFO)。 | ReentrantLock(true) |
需要防止线程饥饿 |
| 非公平锁 | 允许插队,吞吐量更高,但可能导致饥饿。 | synchronized, ReentrantLock(false) |
追求高性能 (默认) |
| 共享/独占锁 | 锁是只能被一个线程持有,还是可以被多个线程共享。 | ReentrantReadWriteLock |
读写分离场景 |
二、什么是可重入?
在并发编程中,可重入性 是指:
如果一个线程已经持有了某个锁,当它试图再次获取这把相同的锁时,可以直接成功,而不会被自己阻塞。
1 | class Counter { |
- 非重入锁的简单实现: 锁只检查
isLocked状态,不关心是哪个线程锁定的。 - 重入锁的实现(如
ReentrantLock): 锁不仅检查isLocked状态,还会检查当前持有锁的线程 ID 是否是自己。如果是自己,则简单地增加一个重入计数器,而不是阻塞。
三、 JVM 内置锁:Synchronized (关键字)
在 Java 6 之前,synchronized 被称为“重量级锁”,因为它依赖于操作系统的 Mutex Lock,涉及用户态和内核态的切换,开销极大。但在 Java 6 之后,JVM 引入了锁升级 (Lock Escalation) 机制,使其性能大幅提升。

1. 锁升级过程
锁的状态保存在 对象头(Object Header) 的 Mark Word 中。
- 偏向锁 (Biased Lock):
- 原理: 假设只有一个线程在访问。当线程第一次访问同步块时,CAS 修改 Mark Word 记录线程 ID。后续该线程进入无需同步。
- 现状: 在 JDK 15+ 中,偏向锁默认已禁用(
UseBiasedLocking),因为在现代高并发应用中,偏向锁撤销的开销往往大于收益。
- 轻量级锁 (Lightweight Lock):
- 原理: 当有第二个线程竞争偏向锁时,升级为轻量级锁。线程在栈帧中创建 Lock Record,通过 CAS 尝试将 Mark Word 替换为指向 Lock Record 的指针。
- 自旋 (Spin): 如果 CAS 失败,线程不会立即阻塞,而是自旋(空循环)等待,期望持有锁的线程很快释放。
- 重量级锁 (Heavyweight Lock):
- 原理: 自旋超过一定次数(自适应自旋)或竞争加剧,锁膨胀为重量级锁。此时 Mark Word 指向 ObjectMonitor,未获得锁的线程进入阻塞队列 (
EntryList),挂起并等待操作系统唤醒。
- 原理: 自旋超过一定次数(自适应自旋)或竞争加剧,锁膨胀为重量级锁。此时 Mark Word 指向 ObjectMonitor,未获得锁的线程进入阻塞队列 (
2. 最佳实践
- 不要过早优化: 现在的
synchronized性能非常强劲,且语法简洁,不易出错(自动释放)。在非极高并发场景下,它是首选。
四、 JUC 显式锁:java.util.concurrent.locks
JUC 锁的核心基石是 AQS (AbstractQueuedSynchronizer)。AQS 使用一个 volatile int state 变量表示同步状态,并维护一个 FIFO 的双向队列(CLH 变体)来管理等待线程。
1. ReentrantLock (可重入锁)
比 synchronized 更加灵活,提供了 :
tryLock()(尝试获取,不等待)lockInterruptibly()(可中断) 等功能。
1 | import java.util.concurrent.locks.ReentrantLock; |
2. ReentrantReadWriteLock (读写锁)
适用于读多写少的场景。
- 读锁 (共享锁): 多个线程可以同时持有读锁。
- 写锁 (独占锁): 写锁被持有时,所有读锁和其他写锁都被阻塞。
- 锁降级: 允许持有写锁的线程获取读锁,然后释放写锁,从而降级为读锁。
- 公平性与饥饿问题:
ReentrantReadWriteLock默认是非公平的,它可能会导致写饥饿(Writer Starvation)写饥饿: 当有大量的读线程持续请求读锁时,如果新的读请求不断插队,写线程可能会长时间无法获取写锁,导致写入操作长时间得不到执行。
可以通过构造函数
new ReentrantReadWriteLock(true)创建公平锁来缓解饥饿问题,但公平锁会引入额外的开销,降低整体吞吐量。因此,在大多数高性能应用中,通常使用默认的非公平模式。
1 | import java.util.concurrent.locks.ReentrantReadWriteLock; |
3. StampedLock (JDK 8+ 高性能读写锁)
它是 ReentrantReadWriteLock 的加强版。引入了 乐观读 (Optimistic Read) 策略,在读操作期间不阻塞写操作,极大地提高了吞吐量。
注意: StampedLock 不可重入,且不支持 Condition,使用稍复杂。
StampedLock 是 Java 8 在 java.util.concurrent.locks 包中引入的一种新的、更高性能的锁机制,旨在作为 ReentrantReadWriteLock 的替代品,尤其是在读操作远多于写操作的场景中。
它通过返回一个被称为 “Stamp”(时间戳/标记) 的整数值来管理锁状态,这个 Stamp 是锁状态的凭证。
🎯 StampedLock 的三大模式
StampedLock 提供了三种模式,允许开发者在性能和安全性之间进行权衡:
| 模式 | 方法 | 特性 |
|---|---|---|
| 1. 乐观读(Optimistic Read) | tryOptimisticRead() |
非阻塞。不获取锁,线程可以自由读取数据。它假设在读取期间,没有其他线程会写入数据。 |
| 2. 悲观读(Pessimistic Read) | readLock() |
阻塞。与 ReentrantReadWriteLock 的读锁类似,是共享锁。 |
| 3. 独占写(Exclusive Write) | writeLock() |
阻塞。与 ReentrantReadWriteLock 的写锁类似,是排他锁。 |
💡 核心机制:乐观读与验证
StampedLock 的高性能主要来源于其乐观读机制。
1. 乐观读取 (tryOptimisticRead())
- 获取 Stamp: 线程调用
long stamp = lock.tryOptimisticRead();获取一个当前的锁状态 Stamp。 - 读取数据: 线程在不持有任何锁的情况下,直接读取共享变量。
- 验证 Stamp: 读取完成后,线程调用
lock.validate(stamp)来验证 Stamp 是否有效。
- 如果验证成功 (
validate(stamp)返回true): 表明在线程读取数据的整个过程中,没有其他线程获得写锁进行修改。读取的数据是有效的,操作完成。 - 如果验证失败 (
validate(stamp)返回false): 表明在读取期间,有其他线程获取了写锁并可能修改了数据。此时,乐观读取失败,线程必须**退化(Fallback)**到悲观读取模式重新尝试。
2. 悲观读 (readLock())
如果乐观读取失败,或者操作本身需要更强的安全性保证,线程必须退化到悲观读模式:
- 调用
long stamp = lock.readLock();获取悲观读锁。 - 在
try...finally中执行读取操作。 - 在
finally中调用lock.unlockRead(stamp);释放读锁。
3. 独占写 (writeLock())
写操作是排他的,它会阻塞所有读锁和写锁。
- 调用
long stamp = lock.writeLock();获取写锁。 - 在
try...finally中执行写入操作。 - 在
finally中调用lock.unlockWrite(stamp);释放写锁。
🆚 StampedLock 与 ReentrantReadWriteLock 的主要区别
| 特性 | StampedLock | ReentrantReadWriteLock |
|---|---|---|
| 读模式 | 三种:乐观读、悲观读(悲观读可降级自写锁) | 一种:悲观读 |
| 写锁重入性 | 不可重入。持有写锁的线程不能再次获取写锁。 | 可重入。持有写锁的线程可以再次获取写锁。 |
| 性能 | 更高。乐观读几乎没有同步开销,写锁优化了缓存一致性协议。 | 较低。读写都需要通过 AQS 队列机制。 |
| 中断支持 | 支持。提供了 try...Lock() 系列方法,如 tryReadLock() 和 tryWriteLock()。 |
支持。通过 lockInterruptibly() 实现。 |
⚠️ 注意事项
由于 StampedLock 引入了乐观读和不可重入的写锁,它在使用上比 ReentrantReadWriteLock 更复杂,需要注意以下几点:
- 写锁不可重入: 如果一个线程在持有写锁的情况下再次尝试获取写锁,它将阻塞自身,导致死锁。
- 必须使用 Stamp: 在释放锁(
unlockRead(stamp)或unlockWrite(stamp))时,必须传入获取锁时返回的 Stamp 值。 - 乐观读的失败处理: 如果乐观读验证失败,必须退化到悲观锁模式重新执行,否则可能读取到不一致的数据。
总而言之,StampedLock 通过乐观读提供了极高的读取并发性,是 Java 并发工具箱中针对读多写少场景的有力武器。
1 | import java.util.concurrent.locks.StampedLock; |
五、分布式锁 (架构视角)
在微服务架构(Spring Cloud)中,JVM 内部的锁只能控制单个实例的并发。跨服务的资源互斥必须使用分布式锁。
1. Redis 分布式锁 (Redisson)
这是 Spring 生态中最主流的方案。不要自己手写 setnx,因为很难处理好锁续期(WatchDog 机制)和原子性问题。
Redisson 架构流:
代码段
1 | sequenceDiagram |
- 优点: 性能极高,实现简单。
- 缺点: 在 Redis 集群的主从切换(Failover)间隙,可能出现锁丢失(Redlock 算法虽然解决了这个问题,但争议较大且复杂,通常建议接受 CP 模型的 Zookeeper 或 强一致性数据库锁)。
2. Zookeeper 分布式锁 (Curator)
- 原理: 利用临时顺序节点(Ephemeral Sequential Nodes)。
- 优点: CP 模型,强一致性,可靠性高于 Redis。
- 缺点: 性能略逊于 Redis,频繁创建删除节点对 ZK 集群压力大。
六、无锁和有锁的区别?
| 无锁 | 有锁锁 | |
|---|---|---|
| 行为模式 | 自旋 | 挂起 |
| 成本 | 用户态 | 内核态 |
- 无锁是指利用 CAS 操作,进行自旋,在用户态完成,此时 CPU 占用率会上升。
- 有锁是指一旦 CAS 失败,决定阻塞,会调用 LockSupport. park()将线程挂起,腾出 CPU,从用户态切换到内核态。
由于 CPU 的调度需要操作系统参与,因此需要切换到内核态
七、JVM 锁和 JUC 锁
synchronized 的底层实现与 AQS 毫无关系。
- AQS (
AbstractQueuedSynchronizer):是 Java 层面(JDK源码)实现的一套框架,代码就在java.util.concurrent包下,你能直接看到它的源码。 synchronized:是 JVM 层面(C++源码)实现的关键字,它的逻辑深埋在 HotSpot 虚拟机的源码里(ObjectMonitor),Java 层面看不见。
实现原理:
- AQS 是 Java 代码 + CAS + LockSupport;
- Synchronized 是 C++ 代码 (ObjectMonitor) + 操作系统 Mutex 。
- 队列:AQS 有 Java 的
Node队列;Synchronized有 C++ 的_EntryList和_WaitSet. - Monitor:Synchronized 确实是基于 Object Header 里的 Monitor 实现的,这个 Monitor 是操作系统级别的互斥锁,所以被称为“重量级锁”。
synchronized vs Lock+Condition 对比
| 特性 | synchronized + wait/notify | Lock + Condition |
|---|---|---|
| 等待队列 | 1个(无法区分等待原因) | 多个(notEmpty、notFull) |
| 唤醒精度 | notify() 随机唤醒 | signal() 精确唤醒目标队列 |
| 性能 | 需要用 notifyAll()(唤醒所有) |
只唤醒需要的线程 |
| 可读性 | 较差 | 更清晰(语义明确) |
关键差异
建议:在生产者-消费者场景中,优先使用 Lock + Condition,更高效且语义清晰
八、专家建议:如何在 Spring 中选择锁?
作为架构师,我遵循以下决策树:
单体应用 / 纯内存操作:
- 首选
synchronized(简单,JVM 优化好)。 - 如果需要超时机制或非阻塞获取:用
ReentrantLock。 - 如果是极端的读多写少(如缓存读取):用
StampedLock。
- 首选
分布式环境 / 关键资源互斥:
- 高性能、容忍极低概率的锁失效: 使用 Redis (Redisson)。这是 90% 互联网业务的选择。
- 金融级强一致性: 使用 Zookeeper 或 数据库悲观锁 (
SELECT ... FOR UPDATE,注意性能瓶颈)。
避免死锁 (Deadlock):
- 始终按照固定的顺序获取锁。
- 使用
tryLock配合超时机制,永远不要无限等待。 - 减小锁的粒度(只锁核心代码块,不要锁整个方法)。
下一步
我可以为你演示 “如何使用 Spring AOP + Redisson 自定义一个
@DistributedLock注解”,让你在业务代码中只需一行注解就能实现安全的分布式锁。你需要这个实战示例吗?
这是一个非常棒的后续话题。结合我们刚才讨论的 AQS 共享模式(Shared Mode) 和 Java 17 的重构,CountDownLatch 是理解 AQS 如何实现“共享锁”机制的最佳样本。
它的核心不再是“争抢锁”,而是“等待门闩打开”。
源码总结
CountDownLatch
1. 核心概念:倒计时门闩
CountDownLatch 就像一个发令枪或者大坝闸门。
- 初始状态: 闸门关闭,上面写着一个数字
N(计数器)。 - 动作:
await():调用这个方法的线程会被阻塞,堵在闸门前等待。countDown():每调用一次,闸门上的数字减 1。
- 触发: 当数字减到 0 时,闸门瞬间打开,所有堵在前面的线程同时被释放(唤醒)。
2. AQS 深度解析:它是如何利用 AQS 的?
结合你刚才关心的 Java 17 AQS 底层,CountDownLatch 的实现非常巧妙。它本质上是一个 共享锁(Shared Lock) 的特例。
A. state 的含义
在 ReentrantLock 中,state=1 代表锁被占用;但在 CountDownLatch 中:
state > 0:代表锁被“占用”(门闩还关着),线程需要等待。state == 0:代表锁“空闲”(门闩开了),所有线程都可以通过。
B. 核心方法重写 (内部类 Sync)
这是 AQS 共享模式的经典实现。
- tryAcquireShared(int arg) (用于 await())
这个方法的逻辑反直觉:它不是去“抢”某个资源,而是判断“能不能通过”。
Java
1 | protected int tryAcquireShared(int acquires) { |
- Java 17 上下文: 当返回
-1时,AQS 会把当前线程包装成Node(SHARED模式),状态设为WAITING,然后 Park 挂起。
- tryReleaseShared(int arg) (用于 countDown())
这是一个 CAS 操作,负责减少计数。
Java
1 | protected boolean tryReleaseShared(int releases) { |
- 关键点: 只有最后一个调用
countDown()把state减为 0 的线程,它的tryReleaseShared才会返回true。 - 后续动作: AQS 收到
true后,会调用doReleaseShared(),唤醒队列头部的线程。
3. 唤醒风暴:从 JDK 8 的 PROPAGATE 到 JDK 17 的优化
这正是我们刚才讨论的“状态消失”的实际应用场景。
场景: 此时队列里有 10 个线程在 await()。
当计数器归零时,AQS 需要唤醒这 10 个线程。这是一个**连锁唤醒(Propagation)**的过程。
唤醒流程(简化版):
最后一个 countDown 线程:把
state变为 0,唤醒队列头节点(Head 后面的第一个 Node A)。Node A 醒来:
- 检查
tryAcquireShared-> 成功(因为 state 已经是 0 了)。 - 关键动作: 它在自己醒来后,会检查“我是不是共享模式?如果是,而且资源还有剩余(state=0 在这里被视为一种无限资源),那我不仅自己要醒,还要接着唤醒我的后继节点(Node B)。”
- 检查
Node B 醒来:
- 检查
tryAcquireShared-> 成功。 - 继续唤醒 Node C…
- 检查
JDK 8 vs JDK 17 的区别:
- JDK 8:在这个传递过程中,为了防止某些极端并发下的信号丢失,专门设计了
PROPAGATE(-3) 状态来标记“需要继续传播”。 - JDK 17:重写了
acquireShared的逻辑,去掉了PROPAGATE状态。只要tryAcquireShared返回正数(代表成功且还有资源),就无条件尝试唤醒下一个节点。逻辑更直接,不再依赖节点状态位的标记。
4. 局限性:一次性用品
CountDownLatch 最大的特点(也是缺点)是不可重置。
一旦 state 减到 0,它就永远是 0 了。这扇门打开后就关不上了。
如果你需要一个可以循环使用的计数器(比如:人满发车,车走了下一波人继续排队),你需要使用 CyclicBarrier 或 Phaser。
5. 实战代码示例
1 | // 模拟场景:等待 3 个微服务启动完成后,主程序才开始对外服务 |
总结
- 本质: 基于 AQS 共享锁实现。
- State: 只要
state > 0就代表锁被抢光了(阻塞),state == 0代表锁无限供应(通行)。 - 唤醒: 利用 AQS 的共享传播机制,像推倒多米诺骨牌一样,一个接一个唤醒所有等待线程。
需要我对比一下 CyclicBarrier 吗? 它的底层实现不直接依赖 AQS,而是基于 ReentrantLock 和 Condition,逻辑完全不同。
面试题 / Checklist
synchronized的锁升级过程(偏向→轻量→重量)触发条件分别是什么?- Mark Word 里与锁相关的信息有哪些?锁膨胀后对象头指向什么?
- 自旋锁的收益/代价是什么?什么时候会“越自旋越慢”?
ReentrantLock相比synchronized多了哪些能力(可中断/可超时/公平/Condition)?ReentrantReadWriteLock可能出现写饥饿的原因与缓解方式?StampedLock的乐观读如何工作?为什么不可重入/不支持 Condition?- 无锁(CAS)与有锁(park/阻塞)在成本模型上的差异是什么?
- 分布式锁三要素:互斥、可重入/续期、释放正确性;常见失效场景有哪些?