ReentrantLock:AQS家的"锁二代",但比 synchronized 更会"来事儿"🔓
一个曾在
synchronized和ReentrantLock之间反复横跳,最终"全都要"的Java端水大师。🍵
朋友们,上回书说到,AQS是那位深藏功与名的"包租公",管着所有锁的排队和调度。那么今天这位主角------ReentrantLock ------就是包租公最出息的"儿子",江湖人称 "锁二代" 。
但你可别小看这"二代",他虽然姓"Reentrant"(可重入),但本事比他那老古董叔叔 **synchronized** 要大得多,也灵活得多。如果说synchronized是Java语言内置的"自动挡老头乐",那ReentrantLock就是可以手动切换赛道、带涡轮增压的"性能小钢炮"。🏎️
一、ReentrantLock 是啥?一个"手工高端锁"!
简单说,ReentrantLock是基于AQS实现的一个可重入的互斥锁 。它完全用Java写成,提供了与synchronized相同的基本行为和内存语义,但多了些"高级功能"。
"可重入"是啥?
就是说,同一个线程可以多次进入同一把锁 。不会把自己锁死在外面。比如你在一个synchronized方法里调用另一个synchronized方法,如果是同一把锁,线程可以进入。ReentrantLock也一样,它内部有个计数器,记录锁被同一线程获取了多少次,必须释放同样次数,锁才真正释放。
csharp
ReentrantLock lock = new ReentrantLock();
void outer() {
lock.lock();
try {
inner(); // 同一个线程可以再次获取锁!
} finally {
lock.unlock();
}
}
void inner() {
lock.lock(); // 这里不会死锁!
try {
// do something
} finally {
lock.unlock();
}
}
二、为啥不用 synchronized ?因为它"不够秀"!
synchronized是JVM原生支持的,简单粗暴,用起来很香。但它在有些场合显得"力不从心":
- 它不能"中途放弃" :线程在等
synchronized锁时,会一直傻等,不能被中断。ReentrantLock提供了lockInterruptibly(),等锁时可以被别的线程中断,优雅退出。 - 它没有"超时机制" :等锁等多久?等到地老天荒。
ReentrantLock有tryLock(long time, TimeUnit unit),等一段时间还拿不到,老子不玩了!去做点别的。 - 它只能是"非公平"的 :
synchronized的锁策略是非公平的,可能造成线程"饿死"。ReentrantLock可以选择公平模式 (new ReentrantLock(true)),让等待时间最长的线程优先获取锁,讲究一个先来后到。 - 它不能"条件等待" :一个
synchronized锁只有一个等待队列(wait/notify)。ReentrantLock可以关联多个Condition对象 ,实现更精细的线程等待/唤醒。比如经典的"生产者-消费者"模型,可以用两个Condition,一个给队列满时等,一个给队列空时等,精准唤醒,不"惊群"。
三、解决了什么?把"锁"的控制权交给你!
ReentrantLock的核心思想是:将锁的获取和释放操作,从JVM的隐式管理,变成程序员显式控制。
synchronized:锁的获取和释放由JVM在代码块进入和退出时自动管理。你只管用,丢了不负责。ReentrantLock:锁的获取(lock())和释放(unlock())必须手动配对写在代码里 。通常unlock()要放在finally块里,确保无论如何都会释放锁。
这带来了无与伦比的灵活性,但也带来了责任。 就像给你一辆手动挡跑车,你可以玩漂移,但也可能熄火。
四、工作中的注意事项:别开翻车了!🚨
-
"忘写 unlock() 是大忌!"
这是
ReentrantLock最经典的坑。一旦忘记unlock(),锁就永远不释放,其他线程全卡住,系统"静默式死亡"。务必、务必、务必将unlock()放在finally块中!csharpReentrantLock lock = new ReentrantLock(); lock.lock(); // 危险!如果这里抛异常,锁永远不释放! try { // 临界区代码 } finally { lock.unlock(); // 必须放这里! } -
"tryLock 不是 lock!"
tryLock()是"尝试获取锁",它不会阻塞! 获取成功返回true,失败返回false。千万别把它当成lock()的替代品。它通常用于避免死锁,或者尝试获取锁失败时执行备用逻辑。csharpif (lock.tryLock()) { // 尝试一下,拿不到就算了 try { // 拿到锁了,干活 } finally { lock.unlock(); } } else { // 没拿到锁,我去做点别的不行吗? doSomethingElse(); } -
"公平锁性能有代价!"
公平锁(
new ReentrantLock(true))保证了绝对的先来后到,但引入了额外的线程切换和调度开销,吞吐量通常低于非公平锁 。除非你的业务对公平性有严格要求(比如防止线程饿死导致严重不公),否则默认用非公平锁就好。 -
"Condition 用对了是神器,用错了是迷宫"
Condition的await()和signal()必须放在对应的lock()和unlock()之间。并且,signal()一次只唤醒一个等待在该Condition上的线程,比Object.notifyAll()的"全叫醒"高效得多。但要小心,如果用多个Condition,唤醒错了队列,程序就可能永远等下去。
五、怎么合适恰当地用?选"老头乐"还是"小钢炮"?
口诀:默认用 synchronized,不够用了再上 ReentrantLock。
用 synchronized当你的需求是:
- 锁的获取和释放非常规律(基于代码块)。
- 不需要中断、超时、公平锁、多个等待条件这些高级功能。
- 代码简洁性至上,不想处理
try...finally。
用 ReentrantLock当你的需求是:
- 需要可中断的锁获取:不想让线程无限期等待。
- 需要尝试性获取锁 (
tryLock):拿不到锁时,有备用方案。 - 需要公平锁:业务上要求严格的先来后到。
- 需要多个等待条件 (
Condition):实现复杂的线程协作模型(如生产-消费者、连接池)。 - 需要进行锁的细粒度调试 :
ReentrantLock提供了一些监控方法,如getQueuedThreads(),方便排查问题。
一个经典场景:高竞争下的"锁升级"策略
csharp
ReentrantLock lock = new ReentrantLock();
public void accessResource() {
if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) { // 先快速尝试
// 快速路径失败,可能锁竞争激烈,走慢速路径
lock.lock(); // 阻塞等待
}
try {
// 访问共享资源
} finally {
lock.unlock();
}
}
结语
synchronized和 ReentrantLock不是"谁取代谁"的关系,而是"互补"的武器。JVM一直在优化synchronized,在大多数无竞争或低竞争场景下,它的性能已经不输甚至优于ReentrantLock,而且写法简单安全。
把 ReentrantLock看作你并发工具箱里的一把"瑞士军刀" ------平时用自带的小刀(synchronized)就够了,但当你需要开瓶器、剪刀、镊子(可中断、超时、公平、多条件)时,它会是你最得力的助手。
记住,能力越大,责任越大 。享受ReentrantLock带来的灵活性时,也请务必担起手动管理锁生命周期的责任。不然,你得到的将不是高性能,而是一串死锁的"惊喜"。😉
(现在,你可以自信地根据场景,在synchronized的"简单"和ReentrantLock的"灵活"之间做出选择了。)