synchronized 与 ReentrantLock:Java 锁机制原理与实现对比
synchronized 与 ReentrantLock:Java 锁机制原理与实现对比
目录
- 为什么有两种锁
- synchronized 的本质
- JVM 锁优化
- ReentrantLock 的设计
- AQS 的核心机制
- 功能对比
- 实战:线程安全的缓存
- 小结
为什么有两种锁
Java 提供了两种锁机制:synchronized 和 ReentrantLock。很多开发者写并发代码时,会习惯性地用 synchronized,因为语法简单,加个关键字就行。但翻看一些成熟的开源项目,会发现大量使用 ReentrantLock 的地方。
两者的核心区别在于抽象层级不同。synchronized 是 JVM 内置的,编译器自动在字节码中插入加锁和解锁指令,开发者不需要关心锁的获取和释放过程。ReentrantLock 是 JDK 在 API 层面提供的锁实现,需要手动调用 lock() 和 unlock(),但换来的是更丰富的功能:可中断、可超时、公平锁、多条件变量。
这篇文章从字节码和 AQS 的层面拆解两者的实现原理,搞清楚它们各自的设计思路,以及什么时候该用哪个。
synchronized 的本质
synchronized 看起来只是一个关键字,但在字节码层面,它会被编译成两条指令:monitorenter和monitorexit。
publicvoidsyncMethod(){synchronized(this){// 临界区代码}}编译后的字节码大致是这样:
monitorenter // 尝试获取 this 对象的 Monitor // 临界区字节码 monitorexit // 释放 this 对象的 Monitor monitorexit // 异常退出时也需要释放(编译器自动插入)注意有两个 monitorexit:第二个是编译器为了异常安全自动插入的,确保即使临界区抛出异常,锁也能被释放。这就是为什么 synchronized 不需要手动释放锁,JVM 会帮你兜底。
这里有个关键概念:synchronized 锁的是对象的 Monitor 结构。每个 Java 对象都可以作为 Monitor 的关联对象,HotSpot 会在对象头(Mark Word)中记录锁状态信息。当 synchronized 竞争某个对象时,JVM 会通过 Monitor 机制管理线程同步。Monitor 是一种逻辑结构,不一定在对象创建时就存在,而是在发生锁竞争时才被关联。
synchronized 有三种用法,锁的对象不同:
// 1. 实例方法 — 锁的是当前实例对象 thispublicsynchronizedvoidinstanceMethod(){}// 2. 静态方法 — 锁的是 Class 对象(类级别的锁)publicstaticsynchronizedvoidstaticMethod(){}// 3. 代码块 — 锁的是括号里指定的对象publicvoidblockMethod(){synchronized(lockObject){}}实例方法锁 this,静态方法锁 Class 对象,代码块锁你指定的对象。同一个对象的 Monitor 在同一时刻只能被一个线程持有,其他线程想获取就得排队。
JVM 锁优化
早期的 synchronized 直接就是重量级锁,依赖操作系统的 Mutex Lock,涉及用户态到内核态的切换,开销很大。JDK 6 之后,JVM 引入了锁升级机制,根据竞争程度动态调整锁的状态,把锁的开销降了下来。
在早期 HotSpot 中,锁升级分三个阶段:偏向锁 → 轻量级锁 → 重量级锁。但从 JDK 15 开始,偏向锁默认关闭,JDK 18 的 HotSpot 已经彻底移除了偏向锁。现在更准确的描述是轻量级锁和重量级锁之间的动态调整。
无锁状态 │ ▼ (线程进入 synchronized 块) 轻量级锁 │ ▼ (自旋失败,竞争激烈) 重量级锁轻量级锁:线程在用户态通过 CAS 自旋尝试获取锁,不涉及内核态切换。自旋几次之后通常就能拿到锁,因为大多数锁的竞争窗口很短。
重量级锁:如果自旋多次还是拿不到锁,轻量级锁会膨胀为重量级锁。此时线程会被阻塞,进入 Monitor 的 EntryList 等待队列,涉及用户态到内核态的切换,开销明显增大。
锁膨胀通常不是简单的状态切换,JVM 会根据竞争情况动态调整。过去版本中常描述为"锁升级不可逆",但现代 HotSpot 的实现更加复杂,不能简单理解为永远只能升级。
| 锁状态 | Mark Word 内容 | 适用场景 | 开销 |
|---|---|---|---|
| 轻量级锁 | 指向栈中锁记录的指针 | 低竞争、短临界区 | CAS 自旋 |
| 重量级锁 | 指向 ObjectMonitor 的指针 | 高竞争 | 线程阻塞、内核切换 |
ReentrantLock 的设计
ReentrantLock 是 JDK 在 API 层面提供的锁,位于 java.util.concurrent.locks 包下。它不依赖 synchronized 的 monitorenter/monitorexit 指令,而是基于 AQS,通过 CAS 和 LockSupport 实现线程竞争、阻塞和唤醒。
ReentrantLocklock=newReentrantLock();lock.lock();try{// 临界区代码}finally{lock.unlock();// 必须在 finally 中释放}和 synchronized 相比,最大的区别是unlock() 必须手动调用。忘记释放锁会导致死锁,标准写法是把 unlock() 放在 finally 块里。这是 ReentrantLock 的一个使用门槛,换来的是更灵活的控制能力。
ReentrantLock 提供了几个 synchronized 做不到的特性:
可中断等待:synchronized 的线程一旦进入阻塞状态,只能傻等。ReentrantLock 支持在等待锁的过程中响应中断。
ReentrantLocklock=newReentrantLock();Threadt=newThread(()->{try{lock.lockInterruptibly();// 可中断地获取锁// 正常业务逻辑}catch(InterruptedExceptione){System.out.println("等待锁的过程中被中断了");}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}});t.start();// 主线程在 1 秒后中断子线程Thread.sleep(1000);t.interrupt();超时获取:tryLock 支持设置等待时间,超时后自动放弃,不会无限等待。
if(lock.tryLock(5,TimeUnit.SECONDS)){try{// 拿到锁,执行业务}finally{lock.unlock();}}else{// 5 秒内没拿到锁,走降级逻辑System.out.println("获取锁超时,执行降级方案");}公平锁:synchronized 的锁竞争是非公平的,刚来的线程可能插队抢到锁。ReentrantLock 支持公平模式,严格按照等待顺序分配锁。
ReentrantLockfairLock=newReentrantLock(true);// 公平锁公平锁保证了先来后到的顺序性,但吞吐量会低于非公平锁,因为每次都要从队列头部取出等待线程,不能直接竞争。大多数场景下用默认的非公平锁就行。
AQS 的核心机制
ReentrantLock 的底层是 AQS(AbstractQueuedSynchronizer),Semaphore、CountDownLatch、ReadWriteLock 底层也是它。AQS 的核心结构就两样东西:一个 volatile int state + 一个 FIFO 队列。
// AQS 的核心字段(简化版)publicabstractclassAbstractQueuedSynchronizer{privatevolatileintstate;// 同步状态privatetransientNodehead;// 队列头节点privatetransientNodetail;// 队列尾节点}在 ReentrantLock 中,state 表示锁的重入次数。state = 0 是未锁定,state > 0 是锁定且值为重入次数。但 state 在不同同步器中含义不同——Semaphore 中它是剩余许可数量,CountDownLatch 中它是倒计数数量。AQS 只提供框架,具体语义由子类定义。
ReentrantLock 默认是非公平锁(NonfairSync),这也是大多数场景的首选。非公平锁和公平锁的 acquire 流程有一个关键区别:
非公平锁允许插队:刚到的线程不需要检查队列,直接 CAS 抢锁。如果恰好锁空闲,它就拿到了,省去了入队、出队的开销。这种"插队"看似不公平,但在高并发场景下吞吐量更高,因为减少了线程切换的次数。公平锁则严格按 FIFO 顺序,保证没有饥饿,但每次获取锁都要检查队列,性能会低一些。
来看非公平锁的完整获取流程:
release 流程:
release(): state-- if state == 0: // 可重入时减到 0 才真正释放 exclusiveOwnerThread = null LockSupport.unpark(队列中下一个等待线程)AQS 把锁的竞争和排队逻辑抽象成通用框架。ReentrantLock 只需定义 state 的语义和公平/非公平策略,队列管理、线程阻塞和唤醒这些通用逻辑全交给 AQS。这也是 JUC 包里那么多并发工具都能复用 AQS 的原因。
功能对比
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现层级 | JVM 内置,字节码指令 | JDK API,AQS + CAS |
| 阻塞机制 | ObjectMonitor | AQS + LockSupport |
| 锁获取/释放 | 自动(进入/退出同步块) | 手动 lock()/unlock() |
| 可重入 | 支持 | 支持 |
| 可中断 | 不支持 | lockInterruptibly() |
| 超时获取 | 不支持 | tryLock(timeout) |
| 公平锁 | 不支持 | new ReentrantLock(true) |
| 条件变量 | 只有 wait/notify | 多个 Condition |
| 锁状态查询 | 不支持 | isLocked(), getHoldCount() |
synchronized 的优势是简单。关键字级别的语法支持,不用关心锁的释放,JVM 自动保证异常安全。对于"一个方法加锁"这种简单场景,synchronized 写起来更简洁,也更难犯错。
ReentrantLock 的优势是灵活。可中断、可超时、公平锁、多条件变量,这些功能在复杂并发场景下非常有用。比如"尝试获取锁,拿不到就走降级逻辑"这种需求,synchronized 就做不了。
总结
现代 JVM 对 synchronized 的优化已经很深,轻量级锁、锁消除、锁粗化这些技术让两者的性能差异在多数业务场景下并不明显。选择标准是需不需要 ReentrantLock 的额外功能。
简单决策:需要超时获取、可中断等待、多条件变量这三个中的任何一个,用 ReentrantLock;其他场景使用 synchronized 就够了。