
先聊个真实感受。我最早接触高并发的时候以为锁就是个“给代码加个修饰符”的事直到在某电商Demo的秒杀模块里亲眼看到库存被扣成负数才意识到锁这个东西远没有表面那么简单。Synchronized、ReentrantLock、CAS、分布式锁每一个都对应一套完整的设计逻辑和适用边界。这篇文章从JVM内置锁的底层原理开始一路写到Redis、ZooKeeper方案下的分布式锁实现全程带代码、带参数、带踩坑记录适合刚入门并发编程的读者也适合已经在项目里使用锁、但没系统梳理过原理的同学。搞懂锁机制才算真正迈过高并发的第一道坎。1. 先搞清楚锁到底在防什么1.1 一次并发事故的现场还原先看一段再常见不过的代码private int count 0; public void increment() { count; }这段代码放在单线程环境下没有任何问题但如果10个线程同时调用increment()方法每个线程执行1000次最终count值往往不是10000而是8000多、9000多每次跑结果还不一样。很多人第一反应是“count不是一行代码吗怎么会错”。问题就出在这里——count翻译成CPU指令其实是三步读取count当前值、把值加1、把新值写回内存。两个线程同时读到count5都计算成6又一个写回覆盖另一个结果就从2变成了1。这是典型的竞态条件。锁要防的正是这种事同一时刻只允许一个线程进入临界区读改写这个完整流程不会被其他线程插入。所以锁的本质不是“给代码加个标记”而是保证一组操作在并发环境下的原子性、可见性和有序性。提到可见性再说深一层。JMM内存模型规定线程操作变量时先拷贝到自己的工作内存操作完再刷回主内存。如果一个线程修改了count但没刷到主内存另一个线程读到的还是旧值这就看到了“脏数据”。锁不仅限制进入还负责建立happens-before关系保证解锁前对共享变量的修改对后续获取同一把锁的线程完全可见。1.2 锁的代价用性能换正确性锁不是免费的午餐。线程A持有锁线程B进不来B要么自旋重试要么被挂起。线程挂起涉及用户态到内核态的切换这个开销不小。我实测过一个场景临界区只做一次HashMap读取耗时不到1微秒但加锁后吞吐量直接掉了一个量级罪魁祸首就是频繁的线程阻塞和唤醒。还有个容易忽略的问题缓存竞争。多核CPU下每个核心有自己的L1/L2缓存X86架构为了保证锁的互斥加锁时要把缓存行写回主内存这会导致其他核心持有同一缓存行的数据全部失效。所以锁竞争激烈时CPU缓存命中率会明显下降即使临界区里没做什么复杂操作性能依然上不去。这也是为什么有些系统看似锁用的不多PostgreSQL的MVCC实现了多版本并发控制让读写不互相阻塞MySQL的InnoDB则用行级锁减少锁冲突面。Java世界里同步容器直接串行化所有操作ConcurrentHashMap却用分段锁思想把竞争分散到篮子本质都是在降低锁的颗粒度换取更高的并行度。2. JVM内置锁从偏向锁到重量级锁的升级之路2.1 synchronized的锁升级流程synchronized常被叫“内置锁”因为它不需要手动锁和解锁由JVM自动管理。它的锁状态是动态变化的这个过程叫“锁升级”。JDK早期synchronized性能很差就是因为一上来就申请操作系统级别的重量级锁后来引入偏向锁和轻量级锁才把性能拉了上来。锁状态一路这么升级无锁对象头里的Mark Word存普通数据哈希码、分代年龄。偏向锁一个线程来访问Mark Word记录线程ID后续这个线程再次进入不需要CAS操作直接通过。线程交替执行导致竞争时偏向锁会被撤销。轻量级锁撤销偏向锁后进入轻量级锁线程尝试用CAS把对象头Mark Word替换为自己栈帧中的Lock Record。如果多个线程同时CAS争抢膨胀为重量级锁。重量级锁基于操作系统monitor实现未抢到锁的线程进入阻塞队列等待操作系统唤醒。这里有个后来才注意到的点JDK 15起默认禁用了偏向锁JDK 18直接将其废弃。原因是现代应用里线程交替执行场景少偏向锁的撤销逻辑在高竞争场景下反而成了性能负担。如果业务线程模型偏向短任务、多线程竞争宁可让它直接走轻量级锁路径。写锁相关的性能测试时我会在测试JVM参数里加-XX:UseBiasedLocking或者手动关闭对比两种情形的吞吐差异这样对“锁升级到底优化了什么”会有更直观的感受。2.2 ReentrantLock与AQS的公平与非公平ReentrantLock是JDK提供的显式锁它的底层是AQS。AQS核心是个状态标识statestate0代表锁空闲state0代表已被持有state的值同时记录重入次数。线程获取锁本质上是用CAS操作把state从0改成1谁改成功谁拿锁。ReentrantLock有两个构造函数传true表示公平锁false表示非公平锁ReentrantLock lock new ReentrantLock(true); // 公平锁 ReentrantLock lock2 new ReentrantLock(); // 非公平锁默认公平锁严格按请求顺序排队先到先得非公平锁允许新来的线程直接参与竞争如果刚好抢到了就直接插队成功。很多人觉得公平锁听起来更合理但实际项目中非公平锁更多。因为线程唤醒有开销新线程插队成功可以减少一次唤醒整体吞吐量反而更高。公平锁适合对等待时间敏感的场景比如任务调度平台避免慢任务被不断插队饿死。AQS还支持中断响应和超时获取。ReentrantLock的lockInterruptibly()可以在等待锁的过程中响应中断信号tryLock(timeout, unit)可以控制最多等多久超时直接返回失败。这在业务上非常实用比如接口不想因为锁等待拖垮整个调用链路就可以用tryLock(1, TimeUnit.SECONDS)设定等待上限。2.3 读写锁、乐观锁什么时候不用悲观锁synchronized和ReentrantLock都是悲观锁默认认为一定会冲突所以先锁住再操作。但有些场景下大部分都是读操作读读之间本来就可以并发用互斥锁反而白白牺牲了并行度。ReadWriteLock就是为此设计的读锁共享写锁互斥读写之间互斥。实际使用中还要注意锁升级问题。假设线程先拿到读锁然后在读锁没有释放的情况下尝试获取写锁如果此时有其他线程正在持读锁尝试写锁的线程会死等导致死锁。所以不要在同一线程内做读锁到写锁的升级操作。Java 8之后引入的StampedLock则更进一步它提供乐观读模式读操作不加锁先试读写完再校验版本号如果期间被写操作打断再升级为悲观读或重试。读多写少的场景下StampedLock可能比ReadWriteLock吞吐量更高。乐观锁的另一种典型形态是CAS。AtomicInteger、LongAdder都基于CAS实现无锁并发不阻塞线程自旋重试直到成功。LongAdder在竞争激烈时通过分段计数避免所有线程打同一个热点我在一个接口埋点统计场景里用LongAdder替换AtomicInteger并发压测下QPS提升了近30%。不过CAS有个著名的ABA问题变量被改回原值后其他线程误以为没被修改过。解决办法是使用带版本号的AtomicStampedReference。同时CAS自旋在竞争特别激烈的时候CPU占用会特别高所以自旋次数和退避策略要配好我一般会限制自旋次数超过阈值直接阻塞挂起。3. 锁粒度与死锁性能与稳定性要一起抓3.1 缩小锁范围从方法级到代码块级给整个方法加synchronized最省事却是性能大忌。方法里可能只操作一个变量结果连文件读取、RPC调用的时间都被锁占住了。锁范围越大其他线程等待时间越长系统吞吐掉得越凶。正确做法是把锁放在真正需要互斥的那一小段代码上public void processOrder(Order order) { // 不需要同步的预处理 order.validate(); synchronized (this) { orderService.deductStock(order.getProductId(), order.getQuantity()); } // 不需要同步的后续流程 orderService.sendNotify(order); }扣库存这种读改写操作必须放进临界区校验参数和发通知这类只读、幂等的操作就留在外面。锁范围缩小后临界区执行时间从几十毫秒降到几百微秒并发能力完全不是一个级别。还有一种思路是分段加锁。ConcurrentHashMap早期版本用Segment数组把哈希桶分成16段每段一把锁写操作只锁当前段不同段的写入互不阻塞。这是个很经典的思路后来JDK 8换了CAS synchronized锁桶本质都是分散竞争热点。如果业务上有类似的离散数据结构比如大量用户ID维度的计数也可以用“对userId哈希取模分桶”的方式做局部锁而不是一个全局锁扛下所有流量。3.2 锁消除与锁粗化的编译器优化JVM的JIT编译器会在运行时做一些锁优化这部分经常被忽略。锁消除就是编译器发现某把锁在逃逸分析中不会被其他线程访问到直接把这个加锁动作去掉。举个例子方法内部新建一个StringBuffer而StringBuffer的append方法自带synchronized如果这个对象没有逃逸出方法JIT就可能消除所有加锁开销。锁粗化则相反当JVM检测到连续几个操作频繁加锁、解锁同一把锁会把这几把锁合并成一次加锁减少锁切换开销。比如在循环里反复调用加锁方法JIT可能直接把锁提到循环外面。这跟我们手动缩小锁范围的思路看似矛盾实际不冲突——编译器的优化是基于运行期检测的它认为“反反复复进出临界区”比“一次锁住更久”更耗性能。了解这两个优化主要是避免对性能测试结果过度解读。我遇到过一段代码测试时表现很好结果上线后性能大跌就是本地测试时JIT做了锁消除线上实际竞争路径不同优化没生效。最好在压测时对JIT参数多做几组对比。3.3 用tryLock全局排序培养防死锁习惯死锁的四个必要条件是互斥、持有并等待、不可剥夺、循环等待。四个条件同时满足才会死锁业务里最常见的就是循环等待——线程A持有锁1等待锁2线程B持有锁2等待锁1谁也等不到谁。避免死锁最实操的办法有两个。第一所有需要多把锁的地方都按固定顺序加锁。比如转账接口同时操作A账户和B账户就按账户ID排序先锁小ID再锁大ID这样不会出现一个线程先锁A再锁B而另一个线程先锁B再锁A的循环。第二用tryLock并设置超时。如果拿不到锁就直接放弃而不是无限期等待。接口层我通常会在加锁代码块里包一层超时校验超过设定时间就返回系统繁忙避免请求堆积。if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 临界区 } finally { lock.unlock(); } } else { throw new BizException(系统繁忙请稍后再试); }这个习惯一定要养成锁释放放在finally里保证任何异常路径都不会留下未释放的锁。用synchronized时JVM自动处理但ReentrantLock如果遗漏unlock锁永远不会释放轻则线程卡死重则整个服务不可用。4. 分布式锁为什么不能用synchronized代替4.1 多实例部署后的锁失效现场单机时代synchronized很好用JVM保证进程内互斥。但到了微服务架构接口层做负载均衡同一个商品、同一个订单的请求会被分发到不同的服务实例。两个实例同时收到请求各自执行自己的synchronized代码块它们面对的锁根本不是同一把锁——synchronized管不了别的JVM。我做过一个多实例部署的扣库存演示两个服务节点共用一个数据库库存表每个节点内部都有synchronized扣库存方法压测时库存还是被扣成了负数。原因就一句话每个JVM里的锁只对它自己进程内的线程有效。JVM锁的保护边界是进程而分布式场景需要保护的是跨进程的共享资源。4.2 分布式锁的三要素分布式锁是在多进程环境中实现互斥访问的机制。不管用什么方案实现下面三个条件缺一不可互斥性任何时刻只能有一个客户端持有锁这是底线。不会死锁持有锁的客户端崩溃后锁必须能自动释放否则所有客户端都卡死。实现上靠“锁设置过期时间”或者“临时节点会话失效”。可重入性同一客户端再次获取同一把锁时要能成功否则容易出现业务代码自己把自己锁住的情况。日常使用中还有两个容易被忽略的隐含需求锁要防止被误释放也就是只有持有锁的人才能释放否则可能把别人刚获取的锁给删了。另外锁的获取本身要尽量快不能因为频繁的加锁操作拖垮正常请求链路。4.3 主流方案横向对比Redis主流的实现方案有三种我列个表对比一下对比维度Redis SETNXZooKeeper临时顺序节点数据库唯一索引/行锁性能单次毫秒级最常用毫秒级但ZNode创建销毁略重依赖数据库连接较差可靠性依赖Redis主从主从切换会丢锁ZK集群一致性较强依赖数据库事务较弱自动释放设置过期时间实现客户端崩溃后会话过期自动删除事务回滚释放可重入需要自己实现需要自己实现需要自己实现实现复杂度简单一个SET命令中等需要维护临时节点简单但容易产生行锁等待典型场景分布式缓存、幂等控制分布式协调、配置中心数据库分片场景的低频互斥Redis的方案成本最低、性能最好也是互联网项目中使用最广的。ZooKeeper胜在模型天然适合分布式协调客户端创建临时顺序节点监听前一个节点的删除事件拿到锁的节点持有最小的序号后面节点排队等待。数据库方案我不太推荐做分布式锁因为锁依赖数据库连接和事务并发稍高就会出现大量行锁等待和超时。5. 手写Redis分布式锁从能用走向好用5.1 第一版一条SET命令搞定互斥Redis分布式锁最本质的操作是一条命令SET key value NX EX。NX代表“如果key不存在才设置成功”EX代表设置过期时间秒。借助Redis单线程执行命令的特性这个操作天然是原子的多个客户端同时执行SETNX只有一个能成功。public boolean lock(String key, String requestId, long expireSeconds) { String result jedis.set(key, requestId, NX, EX, expireSeconds); return OK.equals(result); }requestId是客户端唯一标识这个值在后面防误删环节非常关键。第一版实现里很多人图省事直接存1后面释放锁时直接del key就会出现一个问题线程A的锁还没到期另一个线程B把key删掉并重新加锁成功然后A在finally里又执行del把B刚加的锁给误删了。这个版本能跑通但问题还是很明显的没有防误删、没有可重入、没有续期自定义样式看起来很像“能用但不敢上线”的状态。别急我们来一步步补全。5.2 防误删请求标识加Lua脚本原子释放正确释放锁的逻辑是先比较value是不是自己的requestId是才删除不是就跳过。这个比较和删除必须是原子的否则两个操作之间可能被穿插执行。public boolean unlock(String key, String requestId) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; Object result jedis.eval(script, Collections.singletonList(key), Collections.singletonList(requestId)); return Long.valueOf(1L).equals(result); }Lua脚本在Redis中执行时会被当作一个整体不会被其他命令插入这就是原子性保证。requestId我这里用UUID加线程ID组合确保同一客户端内不同线程拿到的值也不相同。每次加锁和释放锁时传同一个requestId就能做到“只释放自己的锁”。5.3 可重入与续期的实现思路可重入意味着同一线程多次加同一把锁要能成功。实现思路是用ThreadLocal保存锁持有信息和重入次数private final ThreadLocalMapString, LockHolder lockHolder new ThreadLocal(); public boolean lock(String key, String requestId, long expireSeconds) { MapString, LockHolder holderMap lockHolder.get(); if (holderMap ! null holderMap.containsKey(key)) { LockHolder holder holderMap.get(key); holder.retryCount; return true; } String result jedis.set(key, requestId, NX, EX, expireSeconds); if (OK.equals(result)) { MapString, LockHolder map lockHolder.get(); if (map null) { map new HashMap(); lockHolder.set(map); } map.put(key, new LockHolder(requestId, 1)); return true; } return false; }续期是另一个关键问题。如果业务方法执行时间超过了锁的过期时间锁已经自动过期了别的线程就能重新加锁原线程还在临界区里跑着两个线程一起执行分布式锁形同虚设。解决思路是“看门狗机制”后台起一个守护线程定期检查锁剩余时间如果锁快要过期且业务还没执行完就延长过期时间。Redisson的看门狗默认每10秒检查一次只要锁还存在就自动续期到30秒。自己实现的话逻辑基本一致一个调度任务每隔锁过期时间的1/3检查锁如果还在且当前线程还在持有就用Lua脚本重新设置过期时间。5.4 生产实践直接用Redisson自己实现分布式锁可以作为学习练习生产环境我还是建议直接用Redisson。它把上面的问题都处理好了RLock接口提供lock、tryLock、unlock支持可重入自带看门狗续期释放锁也是Lua脚本原子操作。RLock lock redissonClient.getLock(product: productId); try { if (lock.tryLock(100, 30, TimeUnit.SECONDS)) { try { // 扣库存等临界区操作 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } else { throw new BizException(系统繁忙); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(锁等待被中断); }这里的100是等待获取锁的时间30是锁自动过期时间两个参数值不能随便填。等待时间过长会拖住调用方线程过期时间过短业务没跑完锁就没了过长又会在服务宕机时长时间占锁。我的经验是等待时间按接口容忍的延迟来设一般50到200毫秒过期时间按业务方法P99耗时的3倍左右来设如果业务执行时间本身不稳定就配上更保守的看门狗续期。6. 分布式锁的经典坑与排查思路6.1 主从切换导致的锁丢失Redis主从架构下如果主节点写入锁后还没同步给从节点主节点突然宕机从节点被提升为主节点此时新主节点里根本没有这把锁的数据其他客户端就能轻松加锁成功。经典的锁丢失问题用再多的看门狗也救不了因为锁数据整体没了。针对这个问题业界有红锁方案向多个独立的Redis实例同时加锁超过半数实例加锁成功才算持有锁。但红锁争议也不少因为它在极端情况下仍有理论漏洞而且实现复杂。实际业务中我更推荐的做法是评估一下锁丢失的后果。如果是秒杀扣库存、支付防重复提交这类高价值场景建议使用ZooKeeper或etcd这类强一致组件实现而不是在Redis主从上硬扛。如果只是防缓存击穿、幂等控制这类可以容忍小概率冲突的场景Redis版分布式锁已经够用不必上重武器。6.2 业务GC停顿与锁自动续期的配合另一个隐蔽的问题是Full GC。假设线程A获取锁后进入临界区然后JVM发生了一次较长的Full GC停顿几秒甚至几十秒此时看门狗线程可能一起被暂停锁没有成功续期锁超时被Redis自动删除。线程A恢复后线程B成功加锁开始执行两边同时修改共享资源又变成了并发冲突。这个问题没有完全免疫的方案。能做的从两个角度入手一是尽量控制GC停顿对大内存实例用G1或者ZGC降低STW时间二是锁的value校验要严谨A恢复后如果发现自己持有的锁已经没了应该做一次业务上的校验或者干脆抛出异常让上层重试而不是稀里糊涂继续跑。我一般会在锁操作后加一个“持有校验”的环节检查操作结果是否符合预期发现异常立即终止。6.3 超时时间设置不能拍脑袋锁超时时间两个极端都有人踩过。设太短业务正常没执行完锁就没了并发问题复发设太长服务宕机后锁长时间被占所有请求一直拿不到锁服务雪崩。我自己的经验公式是这样先统计临界区代码的耗时分布拿到P99值。超时时间设置为P99耗时的3倍至少要大于一次完整业务执行时间。任何依赖固定超时的方案都有风险因为业务耗时是波动的所以续期机制比超时时间本身更重要。如果你用了续期超时时间可以设短一些相当于“兜底”真正让锁长期持有的是看门狗逻辑。另外要注意超时时间要用相对时间而非服务器本地时间。业务服务器如果做了时间同步比如NTP校准突然的时间跳跃可能导致锁过期时间失效。Redis的TTL机制用的是相对时间天然规避了这个问题自己实现时不要自己算绝对过期时间再比较直接信任TTL。6.4 代码Review时可以查的几个点最后分享一份我自己做Code Review时的检查清单覆盖面比较全加锁操作是否处理了获取失败的情况还是直接忽略了返回值忽略返回值等于锁形同虚设。锁释放是否在finally里try块里有没有可能抛异常导致释放不了释放锁时是否比较了value还是直接del直接del等于埋雷。锁的key设计是否包含业务维度比如“productId”粒度够不够细粒度太粗会让不相关的业务互相阻塞。是否考虑过重入同一个线程内部多次调加锁逻辑会不会把自己锁死锁的过期时间是否有配置依据还是随手写了个300秒临界区里是否做了耗时的网络IO或RPC锁内做网络调用是大忌一旦下游响应慢锁长时间不释放整个系统的线程池都会被耗尽。排查分布式锁问题的时候优先看Redis慢查询日志、客户端连接数、锁key的TTL变化。突发性的锁失效往往不是锁逻辑本身的问题而是Redis服务端抖动或网络分区导致的这属于运维层面的排查不展开讲了。回到开头那个扣库存的场景。我最终的做法是单机应用内用ReentrantLock加本地锁保证线程安全多实例部署后用Redisson分布式锁防止跨节点冲突锁的key精确到商品维度超时时间设置成P99耗时的3倍开启看门狗续期。这套组合拳打下来库存扣减的并发问题才算彻底解决。锁不是万能的但用对位置、选对方案它就是高并发场景下最稳的那道保险。