尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

锁的可重入问题一次讲透:从synchronized到分布式锁

锁的可重入问题一次讲透:从synchronized到分布式锁 锁的可重入问题一次讲透做后端开发的几乎没人能绕开锁。不管是处理并发请求、扣减库存还是分布式环境下的资源争抢锁都是最基础也最容易踩坑的手段之一。而可重入这个概念听起来高大上其实就是一个很朴素的问题同一个线程已经持有一把锁了它能不能再次获取同一把锁这个问题看似简单实际工作中却常常引发线上事故。我见过不少同事在项目里用自旋锁、自己封装的锁工具类结果在嵌套调用、递归调用时直接死锁服务卡死日志刷屏最后只能重启。也有人在面试时被问到synchronized 是可重入的吗ReentrantLock 的可重入是怎么实现的Redis 分布式锁可重入吗直接卡壳。这篇文章不玩虚的直接把我自己的实操经验、踩坑记录和一些底层原理讲清楚。不管你是刚入行的新手还是有几年经验的后端开发看完应该都能对锁的可重入建立起一个完整的认知框架并且知道在真实项目里该怎么设计、怎么避免踩坑。1. 可重入到底是什么意思先把这个概念掰开揉碎。锁的可重入性学术点说叫 reentrant lock指的是同一个线程在持有锁的情况下可以再次获取同一把锁而不会发生死锁。举个最直白的例子。假设有一把门锁你拿着钥匙进门了屋里还有一道暗门也用了同一把锁。如果这把锁是不可重入的你进了第一道门之后想开第二道门就必须先把第一道门锁上、交出钥匙这显然很荒谬。可重入锁就相当于你拿着钥匙进门后钥匙仍然是你的你可以继续开里面的暗门不需要先出去一趟。换成代码场景就更好理解了。一个方法加了锁方法内部调用了另一个也加了同一把锁的方法这就是典型的锁嵌套public synchronized void outer() { // 业务逻辑 inner(); } public synchronized void inner() { // 业务逻辑 }如果 synchronized 不可重入那么当线程执行 outer() 获取到锁之后进入 inner() 再次尝试获取同一把锁就会发现自己被自己挡住了直接死锁。但事实上 synchronized 是可重入的所以这段代码能正常执行。再比如递归场景public synchronized void recursive(int n) { if (n 0) { return; } // 业务逻辑 recursive(n - 1); }递归方法每次调用自己都会重新走一遍加锁逻辑。如果锁不可重入第一次递归就会卡死。所以可重入性在面向对象编程里几乎是刚需。这里需要强调一个关键点可重入是同一个线程的维度而不是同一个对象或多个线程。可重入保证的是自己不死锁自己而不是多个线程可以同时进入。多个线程之间的互斥性和可重入性完全是两码事。2. 为什么非要有可重入有的人可能会问我写代码的时候注意一点不搞嵌套调用、不用递归不就不需要可重入了吗这个想法太天真了。真实项目里锁嵌套往往是隐性的你自己都未必察觉得到。最常见的一种隐性场景就是一个加锁的方法调用了另一个加锁的方法而这两个方法可能属于不同的类、不同的模块。比如你的 Service 层方法加了锁内部调用了另一个 Service 的方法那个方法也加了同一把锁。这种代码在重构之前可能完全没问题某天你给内层方法加了个锁外层方法还蒙在鼓里线上就直接死锁了。还有一种非常隐蔽的场景就是通过 this 调用或者通过代理对象调用。Spring 的 Transactional 注解就经常引发类似的困惑其实锁也一样。如果你的方法通过 this 直接调用内部方法锁的传播是直接的如果通过代理对象调用锁的获取路径会绕一圈但最终还是可能落到同一把锁上。从设计模式的角度看模板方法模式、观察者模式这类需要回调的业务场景也极其容易产生锁嵌套。你在模板方法里加了锁回调方法里又加了一次不可重入就炸了。所以可重入不只是一个锦上添花的特性它更像是一种兜底保障。它允许你在写业务代码的时候不需要时刻警惕我当前是否已经持有了锁降低了思维负担。一旦锁不可重入你写每一行加锁代码之前都得先想想调用链上有没有重复加锁这几乎是不可能完成的任务。3. 各种锁的可重入性盘点3.1 synchronized天生可重入synchronized 是 Java 内置的锁机制它的可重入性由 JVM 底层保证。编译器在实现 monitorenter 和 monitorexit 指令时会记录锁的持有线程和计数器。同一个线程再次进入时计数器加一退出时计数器减一。只有当计数器归零时锁才真正被释放。这个计数器的机制很关键。synchronized 不是简单地记录有没有被持有而是记录被同一个线程持有了几次。所以嵌套多层 synchronized 都没问题只要每次进入都对应一次退出即可。实际开发中synchronized 的可重入性帮我们避免了很多坑。比如一个类里有多个 synchronized 方法它们之间互相调用完全不需要担心死锁问题。这也是 synchronized 在面试中经常被问到的点。3.2 ReentrantLock显式可重入ReentrantLock 是 java.util.concurrent 包下的显式锁从名字就能看出它就是为可重入而生的。它的内部维护了一个 state 变量加锁时 state 加一解锁时 state 减一。同时记录当前持有锁的线程通过 compare and set 来判断当前线程是否就是持有线程。ReentrantLock 的可重入还有一个好处就是它支持公平锁和非公平锁两种模式。公平锁按照线程请求锁的顺序分配非公平锁允许插队。这个特性在业务上有时候很关键比如某些场景要求先到先得避免线程饥饿。用 ReentrantLock 的时候必须手动加锁和解锁并且解锁要放在 finally 块里否则一旦业务代码抛出异常锁就永远释放不了了。这个坑我见得太多了下面专门讲。3.3 自旋锁默认不可重入自旋锁是很多新手容易迷糊的点。自旋锁的核心理念是线程在获取锁失败时不进入阻塞状态而是循环忙等直到获取到锁。这种方式避免了线程上下文切换的开销但如果锁持有时间过长CPU 就被白白浪费了。JDK 1.6 之后JVM 对 synchronized 做了大量优化引入了自适应自旋、锁消除、锁粗化等技术。所以JDK8 之后默认自旋锁么这个问题准确的答案是synchronized 内部已经包含了自旋优化但它本身不是纯粹的自旋锁。而你自己实现的自旋锁或者使用一些并发工具类默认是不可重入的。举个例子用 AtomicBoolean 或 CAS 实现一个简单自旋锁public class SpinLock { private AtomicBoolean locked new AtomicBoolean(false); public void lock() { while (!locked.compareAndSet(false, true)) { // 自旋等待 } } public void unlock() { locked.set(false); } }这个锁就是典型的不可重入。如果同一个线程连续调用两次 lock()第二次 CAS 必然失败因为 locked 已经是 true 了。线程会在 while 循环里死等自己释放锁直接死锁。这就是网上那些自旋锁不可重入说法的来源。要实现可重入的自旋锁需要额外记录持有线程和重入次数。这里给出一个简单的可重入版本public class ReentrantSpinLock { private AtomicReferenceThread owner new AtomicReference(); private int count 0; public void lock() { Thread current Thread.currentThread(); if (current owner.get()) { count; return; } while (!owner.compareAndSet(null, current)) { // 自旋等待 } count 1; } public void unlock() { Thread current Thread.currentThread(); if (current owner.get()) { if (count 1) { count--; } else { owner.set(null); count 0; } } } }这个版本通过 owner 引用判断当前线程是否已经持有锁用 count 记录重入次数。原理和 ReentrantLock 类似只是等待策略从阻塞变成了自旋。3.4 Redis 分布式锁默认不可重入分布式环境下Redis 分布式锁是使用最广泛的方案之一。经典的实现方式是 SETNX 加 Lua 脚本通过 Redis 的单线程特性保证原子性。但这个方案默认是不可重入的。原因很简单分布式锁的维度是跨进程的Redis 只认 key 是否存在不关心你是谁。即使你在同一个线程里连续两次获取同一把分布式锁第二次请求 Redis 时key 已经存在了获取失败。此时如果你在嵌套调用里第二次尝试获取锁就会出现死锁——你等着自己释放锁但在分布式环境下根本等不到。解决分布式锁可重入问题常见方案有几种第一种是 Redis 哈希结构记录重入计数。key 用锁的名称hash 的 field 用线程唯一标识value 记录重入次数。获取锁时如果 field 存在且属于当前线程则 value 加一否则尝试创建。释放锁时value 减一归零后删除 key。这种方式需要 Lua 脚本保证原子性。第二种是 ThreadLocal 记录重入状态。在进程内用 ThreadLocal 判断当前线程是否已经获取过锁如果已经获取过直接放行不再访问 Redis。释放时同样通过 ThreadLocal 判断是否真正需要删除 Redis key。这种方式实现简单但只解决单个进程内的重入跨进程场景不适用。第三种是使用 Redisson 这类现成框架。Redisson 的 RLock 实现了可重入分布式锁底层用的是 Redis 的 Hash 结构field 就是线程唯一标识。用现成框架的好处是经过大量项目验证坑相对少但需要引入额外依赖。说到 Redisson我建议团队在做分布式锁的时候尽量避免自己造轮子因为分布式锁的坑远不止可重入一个问题还有锁续期、误删、主从切换等一堆问题。自己实现一个看似简单实则风险极高。这块我后面会单独讲。3.5 MySQL 悲观锁和乐观锁MySQL 的悲观锁和乐观锁可重入性情况完全不同。悲观锁一般指的是 SELECT ... FOR UPDATE。在同一事务内同一个连接多次执行 FOR UPDATE 是可以重入的因为锁是在事务级别持有的。但如果是不同连接就不存在可重入的概念了。实际开发中同一个事务内嵌套查询同一行数据FOR UPDATE 是可以重复执行的这个特性有时候会被用来做事务内的一致性校验。乐观锁一般通过版本号或时间戳实现。更新时校验版本号如果版本号变化则更新失败。乐观锁天然不存在可重入问题因为它根本不持有锁只是在更新时做一次校验。多个并发操作同时读取同一版本号的数据只有一个人能更新成功其他人重试即可。从实现角度看乐观锁的冲突检测粒度可以控制得很细。比如更新前 SELECT 版本号更新时 SET version new_version WHERE version old_version通过数据库受影响行数判断是否更新成功。这种方式没有锁的持有过程自然也就不存在重入的概念。4. 实战中遇到的可重入问题排查4.1 一个真实的嵌套死锁案例以前在项目里遇到过一个问题某个订单接口偶发性卡死线程池被打满最终服务不可用。排查后发现是锁嵌套导致的死锁。当时业务逻辑大概是这样的订单服务有个入口方法 orderHandler()里面加了 synchronized 锁然后调用了库存服务的一个方法 stockService.deduct()这个方法内部也加了 synchronized 锁。理论上这两把锁是不同的对象锁不会冲突。但问题是库存服务内部又调回了订单服务的某个方法而那个方法也加了 synchronized 锁。于是形成了循环等待订单线程持有订单锁等待库存锁库存线程持有库存锁等待订单锁。两边互不相让全部卡死。这个案例给我的教训是锁的可重入性只能保证同一个线程嵌套获取同一把锁不死锁但多个线程之间不同锁的循环等待是可重入性无法解决的。排查这种问题最有效的手段就是生成线程转储看每个线程持有什么锁、在等待什么锁。4.2 自旋锁不可重入导致的线上事故还有一次一个同事在项目里自己实现了一个简单的自旋锁用于保护共享缓存。当时缓存的 key 比较多他用了一个 ConcurrentHashMap 数组每个桶一把自旋锁。结果某个方法里先获取了桶 A 的锁处理过程中又需要获取桶 A 的锁——因为逻辑上数据可能分布在同一个桶里。由于他的自旋锁不可重入线程直接卡死在第二次获取锁的地方。这个事故排查起来也费了不少劲。CPU 占用率飙升但线程状态看起来是 RUNNABLE因为自旋锁不阻塞、一直在循环。后来抓了线程堆栈才发现是自旋等待。这次之后我们对自定义锁的使用定了一条规矩要么用现成的 ReentrantLock要么实现可重入版本不允许裸写不可重入的自旋锁。4.3 分布式锁重入踩坑记录分布式锁的可重入问题是我在项目中遇到的最隐蔽的坑之一。当时我们自己封装了一个基于 Redis SETNX 的分布式锁工具工具本身很简单项目也运行了挺久。后来有一个需求需要在获取分布式锁之后内部再调用一个同样需要获取分布式锁的方法。结果每次调用都失败因为第二次获取锁时发现 key 已存在直接返回获取失败。当时的第一反应是业务上可能不需要嵌套加锁所以想通过调整代码结构来规避。后来发现嵌套调用无法避免因为内层方法是一个公共方法其他地方也在用不可能为了这个需求单独改逻辑。最终我们只能改造锁工具支持可重入。改造时我们用了 ThreadLocal 方案因为我们的场景是单进程部署进程内可重入就够了。核心逻辑是获取锁之前先检查 ThreadLocal 中是否有当前线程的锁记录如果有则重入次数加一直接返回成功如果没有则走 Redis 获取逻辑获取成功后把重入次数写入 ThreadLocal。释放锁时先检查 ThreadLocal统计重入次数减到零才真正删除 Redis key。实测下来这个方案能解决大部分单机部署的分布式锁重入问题但对多实例部署不支持。后来容器化部署实例数多了之后我们又切换到了 Redisson彻底解决了这个隐患。5. 如何在项目里优雅地处理可重入5.1 优先使用成熟的可重入锁我的经验是Java 项目里尽量使用成熟的可重入锁不要自己去实现。单机环境下首选 synchronized 和 ReentrantLock。synchronized 的好处是语法简洁、代码可读性高而且 JVM 底层的偏向锁、轻量级锁优化已经很成熟。ReentrantLock 的好处是功能丰富支持超时、可中断、公平性选择、条件变量等。具体用哪个主要看业务需求需要超时控制用 ReentrantLock追求简单直接用 synchronized。分布式环境下如果团队没有很强的中间件开发能力直接引入 Redisson 是比较稳妥的选择。Redisson 不仅解决了可重入问题还内置了看门狗续期机制避免锁过期导致业务未完成就释放锁的问题。自己实现分布式锁还要考虑锁误删、主从切换、时钟漂移等一系列问题这些坑我都踩过不推荐新手尝试。5.2 获取锁务必使用 try/finally并用时间参数防止死等很多人写锁代码只注意加锁和业务逻辑却忽略了解锁。一旦中间抛异常锁永远不会被释放其他线程只能无限等待。这是我自己项目中踩过的最多的坑。正确写法是用 try/finally 确保解锁被执行如果用 ReentrantLock建议使用带超时参数的 tryLock 方法这样即使逻辑出错也能自动退出锁等待避免线程被卡死导致线程池耗尽。5.3 消除不必要的嵌套加锁虽说可重入锁能处理嵌套但从设计角度仍然应该尽量减少嵌套。两个优化方向一是把加锁的操作尽量收敛在方法边界不要让锁跨越多个方法层二是把锁粒度缩小用不同的锁保护不相关的资源而不是全局一把锁。举个例子如果两个方法分别操作不同的数据但用了同一把锁那么它们之间的互相调用就会产生嵌套。改成两把独立的锁嵌套自然消失。5.4 监控锁等待和死锁线上环境一定要有锁相关的监控。Java 里可以通过 ThreadMXBean 定期检测死锁也可以配置 JVM 参数在死锁时输出线程转储。如果用了 Redisson要监控锁获取耗时和等待队列长度这些指标能早期暴露问题。推荐一个简单有效的做法写锁的地方统一打印加锁耗时超过阈值就告警。这样锁竞争激烈的时间点就能被观测到而不是等到事故发生了才排查。6. 常见问题速查6.1 synchronized 是可重入的吗为什么是。JVM 通过 monitor 计数器实现同一个线程重复进入时计数器递增退出时递减计数器归零才真正释放锁。这是 JVM 内置行为开发者不需要手动处理。6.2 ReentrantLock 的可重入是怎么实现的内部维护了一个 volatile int state 字段。获取锁时如果 state 为 0则通过 CAS 尝试获取如果 state 不为 0 但当前线程就是持有线程state 直接加一不需要 CAS。释放锁时state 减一只有 state 变为 0 才真正释放锁。6.3 自旋锁可重入吗你自己实现的自旋锁默认不可重入需要额外记录持有线程和重入次数。JDK 自带的一些并发工具中有类似实现。判断方法很简单看锁的持有信息里是否记录了线程标识和计数器。6.4 Redis 分布式锁可重入吗默认不可重入。可以通过 Redis Hash 结构或 Redisson 实现可重入。但对于多实例部署进程内 ThreadLocal 方案会失效必须用基于 Redis 的全局计数方案。6.5 可重入和互斥是什么关系互斥是指多个线程不能同时进入临界区可重入是指同一个线程可以重复进入临界区。二者是锁的两个不同维度互斥是默认行为可重入是高级特性。一把可重入锁必然同时具有互斥性反之则不一定。了解线程安全的同学还可以通过 ReentrantLock 的源码学习更多同步器的设计思路。虽然这类底层源码看起来比较复杂其实核心就是 CAS 加状态机。根据我的经验关于锁的问题最重要的不是背概念而是要在真实场景中不断思考和验证。一个小建议当你不确定你的锁是否可重入时先去查文档或者写测试用例验证千万不要拍脑袋上线。锁这个东西出问题的时候往往就是大问题。另外考虑到现在的开发环境越来越复杂单机锁和分布式锁混用的情况非常普遍。在引入任何第三方锁组件之前务必先明确它的可重入语义是否满足你的业务场景。一个小技巧是启动阶段写一个简单的嵌套拿锁的自测用例作为冒烟测试的一部分避免上线之后才发现问题。我这边自测用例已经跑了很久效果不错这里一并分享给有需要的同行。
返回列表