ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java锁体系全解析:从synchronized到Redis分布式锁实战指南

Java锁体系全解析:从synchronized到Redis分布式锁实战指南 做了这么多年Java开发“锁”始终是我面试时最愿意聊的话题也是实际项目里最容易出事故的环节。很多人一说锁就是synchronized加一个关键字可等到生产环境出现超卖、死锁、锁失效的时候才发现自己根本没有建立起完整的锁知识体系。这篇博文我想一次性把Java里涉及的全部“锁”知识点整理清楚从JVM内置锁到JUC显式锁从MySQL数据库锁到Redis分布式锁最后用一个Spring Boot扣库存的实战案例串起来顺便把面试常问的点和容易踩的坑也一并交代清楚。内容偏后端实战适合1到5年经验的Java工程师复盘也适合准备Java面试时查漏补缺。1. 三个层面看懂Java锁体系JVM、数据库、分布式1.1 先给锁做一张全景图锁这个东西光在Java里就能牵扯出三套完全不同的体系。我见过不少同学把synchronized和Redis分布式锁混在一起讲最后自己也绕晕了。实际上我们平时讨论的“锁”至少要拆成三个层面来看。第一个层面是JVM层面。synchronized、ReentrantLock、ReadWriteLock、StampedLock都属于这类它们解决的是同一个JVM进程内多个线程之间的竞争问题。注意关键词是“同一个进程”跨进程的场景它们就管不到了。第二个层面是数据库层面。MySQL InnoDB引擎提供了行锁、表锁、间隙锁配合事务的隔离级别来控制并发读写。这一层锁解决的是多个数据库连接、多个事务之间的数据一致性问题和Java线程完全是两个维度。第三个层面是分布式锁。当相同的服务部署了多个实例多个JVM进程同时操作同一份资源时就需要Redis、ZooKeeper这类外部组件提供跨进程的互斥能力。这三个层面并不重叠但实际项目中往往会叠加使用。最典型的是秒杀扣库存本地用synchronized防住单机多线程数据库用行锁兜底库存不超卖Redis分布式锁保证多实例之间只放行一个请求。把这张全景图装进脑子里以后再听到“锁”就知道别人在说哪一层的锁不会鸡同鸭讲。1.2 五组高频概念一次说透除了按层面分锁还按特性分成很多维度这些概念在Java、数据库、分布式中都有对应先把它们理清。悲观锁和乐观锁。悲观锁认为并发冲突大概率发生所以先加锁再操作乐观锁认为冲突是少数所以不上锁在更新的时候通过版本号或CAS检查数据是否被改过。synchronized和ReentrantLock都是悲观锁的典型Java的AtomicInteger、数据库的version字段更新就是乐观锁。公平锁和非公平锁。公平锁按照线程请求锁的顺序分配先来先得非公平锁允许后来的线程插队。ReentrantLock默认是非公平的吞吐量更高但可能造成线程饥饿。数据库里的“先到先得”也是类似思路。可重入锁和不可重入锁。可重入锁表示同一个线程可以多次获取同一把锁不会死锁。synchronized和ReentrantLock都是可重入的。不可重入锁如果同一个线程重复加锁会直接卡死自己。共享锁和独占锁。独占锁同一时间只允许一个线程持有写锁、写操作都是这种共享锁允许多个线程同时持有读锁就是典型。数据库里的读锁和Java里的ReadWriteLock是同一个思路。自旋锁和阻塞锁。自旋锁通过CPU循环等待锁释放适合临界区很短的情况阻塞锁让线程进入WAITING状态等待被唤醒适合临界区较长的场景。这五组概念并不互斥一把锁可以同时是悲观锁、公平锁、可重入锁、独占锁、阻塞锁比如ReentrantLock就是。理解了这些维度后面讨论具体实现时会顺畅很多。2. JVM内置锁synchronized的完整升级链路2.1 synchronized的三种加锁方式synchronized是JVM内置锁用法上有三种形态锁的对象各不相同这直接决定了锁的粒度。修饰实例方法锁的是当前实例this调用同一个对象上的多个同步方法会互相竞争。修饰静态方法锁的是当前类的Class对象所有通过这个类创建的实例共享同一把锁。修饰代码块锁的是括号里指定的对象可以精确控制锁的粒度。实际开发里最常见的是synchronized代码块因为粒度可控。比如下面的代码锁的是this但只锁住库存扣减这一段逻辑public void deductStock(String skuId, int num) { // 校验、日志等耗时操作不需要加锁 synchronized (this) { Stock stock stockMapper.selectBySkuId(skuId); if (stock.getCount() num) { throw new RuntimeException(库存不足); } stock.setCount(stock.getCount() - num); stockMapper.updateById(stock); } }另外有一个很容易翻车的细节synchronized锁的是对象引用如果用new String(lock)这种方式每次创建出来的都是新对象锁就会失效。正确做法是用private final Object lock new Object()或静态常量对象作为锁。2.2 偏向锁、轻量级锁、重量级锁怎么升级JDK 1.6之后synchronized做了大量优化不再是一上锁就陷入内核态。一个对象的锁状态会经历“无锁 - 偏向锁 - 轻量级锁 - 重量级锁”的升级过程并且一般不会降级。偏向锁。当只有一个线程反复进入同步块时JVM会在对象头的Mark Word里记录这个线程的ID后续这个线程进入直接比对ID比对成功就无需任何CAS操作。可以把它理解成给单线程开的“绿色通道”省掉了加锁开销。如果来了另一个线程竞争偏向锁会撤销。轻量级锁。一旦出现竞争偏向锁撤销升级为轻量级锁。新来的线程会在自己的栈帧中创建锁记录用CAS把对象头的Mark Word替换成指向锁记录的指针。如果替换成功说明抢到了锁如果失败说明别人还在持锁线程开始自旋等待。重量级锁。当自旋超过一定次数或者同时在等待的线程数过多JVM就会把锁膨胀为重量级锁。重量级锁依赖操作系统的Monitor机制线程没抢到锁会进入阻塞状态涉及用户态和内核态的切换开销最大。我见过一个线上案例某个系统大量使用synchronized保护一段很短的代码本来应该停在轻量级锁甚至偏向锁的状态结果因为锁内做了一次远程调用持锁时间过长所有线程都在自旋等待CPU被打满最后直接膨胀成重量级锁。所以说锁内不要做IO操作这句话不是随便说说的。2.3 锁消除与锁粗化这两个是JIT编译期的优化不需要程序员手动干预但理解它们能解释一些“看起来应该加锁但没加锁也没出问题”的现象。锁消除是指JVM通过逃逸分析发现某个锁对象只可能被当前线程访问根本没有其他线程会竞争于是直接把这个加锁操作消除掉。典型的例子是StringBuffer的append方法它内部用了synchronized但如果是局部变量JIT编译后往往就等于没有加锁。锁粗化则是把相邻的多个加锁、解锁操作合并成一个更大的锁范围。比如在循环里每次都加锁解锁一段代码JVM会直接把锁扩展包裹整个循环减少频繁加锁解锁的开销。这两个优化告诉我们JVM层面的锁很多时候比我们想象中聪明不要为了微优化牺牲代码可读性。3. JUC显式锁从ReentrantLock到AQS3.1 ReentrantLock到底比synchronized强在哪synchronized用起来简单但有几个硬伤不可中断、不支持超时、不可实现公平锁。ReentrantLock是JUC提供的显式锁正好补齐了这些能力。ReentrantLock的核心用法是lock()加锁、unlock()解锁必须保证解锁所以规范写法是配合try-finallyLock lock new ReentrantLock(); lock.lock(); try { // 临界区代码 } finally { lock.unlock(); }它和synchronized的差异主要体现在三处。tryLock(long timeout, TimeUnit unit)可以超时获取锁拿不到就返回false避免无限等待导致线程堆积。lockInterruptibly()支持中断响应锁等待期间其他线程可以interrupt中断它。构造器传true可以创建公平锁让线程按等待时间排队获取锁。另外ReentrantLock支持多个Condition条件队列能做到精确唤醒。比如生产者消费者场景里生产线程和消费线程分别使用不同的Condition比synchronized加wait/notifyAll更高效notifyAll会把所有线程都唤醒白白增加无意义的竞争。3.2 读写锁与StampedLock的适用场景ReentrantLock是独占锁读多写少场景下全量串行效率很低。ReadWriteLock提供了读读共享、读写互斥、写写互斥的语义ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); Lock rLock rwLock.readLock(); Lock wLock rwLock.writeLock();读锁和写锁都支持但有几个细节要注意。写锁可以降级为读锁也就是持写锁的线程可以再获取读锁然后释放写锁保证数据可见性反过来读锁不能升级为写锁否则会死锁因为多个读线程可能都在等写锁。JDK 8还提供了StampedLock它的核心优化是乐观读读操作不加锁而是先读取一个版本号stamp读取完数据再验证版本号是否变化如果变了才升级为真正的读锁重读。在读多写少、数据一致性要求可以做短暂妥协的场景下性能会好很多。不过StampedLock不可重入也支持中断和条件队列使用门槛比ReadWriteLock高普通业务场景不建议硬上。3.3 AQS的设计精髓ReentrantLock、ReadWriteLock、Semaphore、CountDownLatch这些JUC组件底层全部依赖AQS也就是AbstractQueuedSynchronizer。理解AQSJUC锁就通了一半。AQS的核心是三个东西一个volatile修饰的state状态位一个等待队列CLH的变体以及一套模板方法。state在不同的组件里有不同含义在ReentrantLock里代表“持锁线程的可重入次数”在Semaphore里代表“剩余许可证数量”在CountDownLatch里代表“剩余计数”。线程抢不到锁就封装成Node节点进入等待队列前驱节点释放锁时唤醒后继节点。模板方法的设计很有意思实现一个自定义同步器只需要重写tryAcquire和tryRelease几个方法比如tryAcquire独占方式获取资源tryRelease独占方式释放资源tryAcquireShared共享方式获取资源tryTryReleaseShared共享方式释放资源AQS默认使用非阻塞CAS修改state失败则按照模板方法进入队列挂起。这套机制能把复杂的并发控制抽象成统一框架所以JUC里几乎所有同步工具都能构建在AQS之上。面试时如果只背ReentrantLock的API而不提AQS往往会被认为是背题而不是理解。4. 数据库里的锁行锁、表锁、间隙锁与死锁4.1 悲观锁与乐观锁的取舍到了数据库层面锁的选择逻辑和Java里很类似悲观锁用select for update乐观锁用版本号。悲观锁的写法是开启事务后先执行select ... for update把目标行锁住其他事务对同一行的更新会阻塞START TRANSACTION; SELECT stock_count FROM stock WHERE sku_id 1001 FOR UPDATE; -- 业务计算 UPDATE stock SET stock_count stock_count - 1 WHERE sku_id 1001; COMMIT;这种方案依赖数据库的行锁安全性高但持锁期间其他事务全部等待并发能力弱而且锁需要在事务提交后才释放事务里耗时操作越多锁持有时间越长。乐观锁则是不加锁通过版本号控制并发写。每次更新时检查版本号是否还是当初查到的那个值UPDATE stock SET stock_count stock_count - 1, version version 1 WHERE sku_id 1001 AND version #{oldVersion};如果影响行数为0说明版本号已经被别人改过需要重新查询再试。乐观锁适合写冲突少的场景实现简单也不需要长事务。但要注意如果更新频繁、冲突率高乐观锁会反复重试性能反而比悲观锁差。4.2 MySQL InnoDB的行锁家族InnoDB的行锁并不是一个简单的概念细分为记录锁、间隙锁和临键锁这三兄弟在不同隔离级别下配合工作。记录锁只锁索引记录本身。注意InnoDB的行锁是加在索引项上的如果查询没有走索引行锁会退化成表锁这是特别容易忽略的大坑。间隙锁锁住一个区间但不锁记录本身目的是防止并发事务在这段区间插入记录。间隙锁默认只在可重复读RR隔离级别下生效这也是MySQL默认隔离级别导致很多“奇怪死锁”的根源。临键锁记录锁和间隙锁的组合锁住左开右闭的索引区间既锁记录也锁区间。举个例子如果一张表的id主键上存在1、3、5三条记录一个事务执行select * from t where id between 1 and 5 for updateInnoDB不仅锁住id为1、3、5的记录还可能在RR隔离级别下锁住相关的整个区间阻止其他事务在间隙里插入id为2或4的记录。这个机制保证了当前读的一致性但也显著增加了并发冲突的概率。4.3 死锁排查与规避死锁是数据库锁应用中最常见的生产事故。我印象很深的一次是运营系统批量更新库存和订单时事务A先更新sku_1再更新sku_2事务B反着来两边各自持有一把行锁等对方释放数据库检测到死锁后就随机牺牲了一个事务抛出Deadlock found when trying to get lock的异常。排查死锁最直接的手段是开启InnoDB状态输出SHOW ENGINE INNODB STATUS;这份输出里能找到最近一次死锁涉及的SQL、持锁事务和等待关系一般能看到两条update语句和具体的行锁信息。还可以从performance_schema.data_locks和information_schema.innodb_trx里查当前锁等待情况。规避死锁有几个成熟经验。多个事务需要更新多行数据时统一按照固定顺序更新比如按主键从小到大排序后逐个操作。尽量缩短事务把耗时操作移出事务减少锁持有时间。避免无索引字段作为更新条件防止行锁退化成表锁导致范围扩大。合理设置innodb_lock_wait_timeout不要让锁等待无限拖下去。5. 分布式锁跨进程的并发屏障5.1 为什么说JVM锁解决不了分布式问题应用部署多副本以后一个典型问题出现了两个Tomcat实例同时接收到扣库存请求各自的synchronized锁只能挡住自己进程内的线程挡不住对方实例。库存总数在数据库里是同一行数据两个实例同时执行update后提交的会把先提交的覆盖掉超卖就是这么来的。分布式锁本质上是一种跨进程的互斥协议多个进程去同一的外部组件那里竞争一个“占位标记”谁抢到谁就有权利执行临界区操作。Redis、ZooKeeper、etcd都能实现只是实现机制和可靠性不同。5.2 手写Redis分布式锁的注意事项用Redis实现分布式锁最基本的思路是利用SET命令的NX参数只有key不存在时才能设置成功String requestId UUID.randomUUID().toString(); Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(lock:stock:1001, requestId, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(success)) { try { // 临界区代码 } finally { // 释放锁 } }看着简单但这里面坑非常多。第一加锁必须用原子命令。早期有人用setnx加锁、expire单独设置过期时间两个命令中间进程宕掉就会导致锁永远不释放必须用SET key value NX EX seconds这种原子操作。第二value必须携带唯一标识。因为释放锁需要先检查是不是自己加的锁只有匹配才能删。我用Redis分布式锁时习惯用UUID作为value释放时先比对再删除。这里有个致命细节比对和删除不能是两步独立操作否则可能出现“自己的锁已过期、删掉的是别人刚抢到的锁”必须用Lua脚本保证原子性if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end第三过期时间要留足余量。锁的过期时间必须大于临界区的最差执行时间否则业务没执行完锁就自动释放其他实例就能再抢到锁造成并发冲突。但过期时间也不能过长万一持有者崩溃锁会长时间占住。折中方案是使用Redisson的看门狗机制自动续期。5.3 Redisson把复杂留给成熟框架手写Redis分布式锁踩过几轮坑之后我开始在项目里使用Redisson。Redisson提供了RLock接口用法和ReentrantLock非常像RLock lock redissonClient.getLock(lock:stock:1001); boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { try { // 临界区代码 } finally { lock.unlock(); } }Redisson的优点是默认实现了锁自动续期。它启动一个定时任务默认每隔10秒检查一次锁是否还在持有如果还在就自动把锁的过期时间续期到30秒业务没跑完锁就不会过期。这个机制现在看非常实用因为它把“锁过期时间设置多少才合适”这个难题解决了。Redisson还支持可重入、公平锁、读写锁等高级特性底层都用Lua脚本保证多个操作的原子性。不过Redisson不是银弹它依赖Redis主节点分配锁如果Redis主节点挂了锁信息还没同步到从节点从节点被提升为主节点后可能丢失锁。严格意义上的高可靠分布式锁应该使用RedLock算法或者直接用ZooKeeper的临时顺序节点方案生产环境要根据可靠性要求去取舍。6. Spring Boot实战扣库存场景的三层锁方案6.1 场景设定与方案选型来一个真实项目里的场景多实例部署的Spring Boot电商服务Redis里缓存了一个SKU的库存数量用户下单时先扣Redis缓存再异步回调数据库同时还会写一个扣减流水。表面看这是两个独立操作但并发高起来之后所有实例都在扣同一个Redis key必须用分布式锁把“扣减Redis缓存”这个操作串行化。方案演进一般走三条路。第一步是只在本机加synchronized测出来大量重复扣减因为实例之间有间隔第二步是改成MySQL悲观锁把扣减逻辑包在事务里加行锁并发量直接被压到几千不到第三步是在Redis层实现分布式锁加锁成功后直接扣缓存计数写流水交给MQ异步处理整体吞吐上去了只把真正需要强一致的部分留在数据库。设计这个锁方案时要明确锁的粒度和范围。锁的key我习惯用业务前缀加业务主键比如lock:stock:1001这样每个SKU的锁互不干扰避免一把大锁把所有商品的扣减请求都串行化锁粒度过大会让并发退化成完全排队。6.2 从synchronized到Redis锁的代码演进先看本地版本也就是单机部署时的最简写法Service public class StockService { public Result deduct(Long skuId, int num) { synchronized (getLockObject(skuId)) { Integer stock stockRedisTemplate.get(stock:cache: skuId); if (stock ! null stock num) { stockRedisTemplate.set(stock:cache: skuId, stock - num); return Result.ok(); } } return Result.error(库存不足); } }这里的细节是getLockObject返回一个固定的锁对象一般用ConcurrentHashMap把skuId映射到同一个锁对象保证同一个sku的本地线程落到同一把锁上。但部署两实例就不行了。换成Redisson分布式锁版本Service public class StockService { private final RedissonClient redissonClient; public Result deduct(Long skuId, int num) { String lockKey lock:stock: skuId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(5, -1, TimeUnit.SECONDS); if (!locked) { return Result.error(系统繁忙); } Integer stock stockRedisTemplate.get(stock:cache: skuId); if (stock null) { // 缓存重建逻辑 stock stockMapper.selectBySkuId(skuId); stockRedisTemplate.set(stock:cache: skuId, stock); } if (stock num) { return Result.error(库存不足); } stockRedisTemplate.set(stock:cache: skuId, stock - num); stockCodeTemplate.convertAndSend(stock.exchange, new StockDeductMessage(skuId, num)); } finally { if (locked) { lock.unlock(); } } return Result.ok(); } }tryLock的第一个参数是等待锁时间第二个参数传-1时不启用指定过期时间启用看门狗默认续期。这里还有一个我没有写进正文的细节加锁成功后如果服务在临界区抛出未捕获异常finally里的unlock可能因为锁已经过期而失败Redisson的unlock底层会先判断是否持有锁所以整体上是安全的但消费侧仍然要保证幂等。数据库层是兜底事务方法里先查库存再更新用乐观锁的版本号防并发写Transactional(rollbackFor Exception.class) public void updateStockWithVersion(Long skuId, int num, Integer version) { Stock stock stockMapper.selectBySkuId(skuId); if (stock.getCount() num) { throw new RuntimeException(库存不足); } int rows stockMapper.deductWithVersion(skuId, num, version); if (rows 0) { throw new RuntimeException(库存已被修改请重试); } }这里的三层方案有明确的职责划分分布式锁负责缓存层的串行扣减MQ异步更新数据库负责最终一致性数据库乐观锁只在极端情况下起作用防止缓存和数据库之间出现数据滞后引发的超卖。三层各司其职不是简单的叠加。6.3 压测结果与复盘建议我在这套方案上做过一轮压测20个线程并发抢购同一个SKU库存200。纯synchronized方案最终Redis缓存出现负数超卖接近三成加上Redis分布式锁后扣减全部串行化缓存无负数数据库流水和缓存一致数据库悲观锁方案能保证一致但TPS只有分布式锁方案的十分之一左右。复盘时有几个结论想分享。分布式锁关注的是并发控制不是数据强一致数据最终一致性还要靠事务和MQ消峰。锁的value必须唯一释放锁必须校验持有者否则会出现解掉了别人的锁这种事故。锁的粒度越细越好能用业务ID做锁key就不要用全局锁能用缓存层锁就不要把整个数据库事务串行化。Redisson默认基于Redis主节点极端情况下会有锁丢失风险但对绝大多数电商非核心链路来说足够如果对一致性要求极高再考虑RedLock或ZooKeeper。7. 面试高频题与实战避坑指南7.1 常被追问的10道锁相关面试题整理一下面试里围绕锁出现频率最高的问题以及我建议的回答思路。synchronized的锁升级过程是怎样的回答时按“无锁 - 偏向锁 - 轻量级锁 - 重量级锁”讲一遍提到CAS、自旋、Mark Word并且点出过期Monitor的原因。ReentrantLock和synchronized有什么区别从自动释放、可中断、超时、公平性、Condition几个角度展开最好提到底层AQS。什么是公平锁和非公平锁说明ReentrantLock默认非公平以及非公平提升吞吐量、但可能导致饥饿的取舍。什么是可重入锁synchronized和ReentrantLock如何实现重入要点是JVM的Monitor记录持有线程ReentrantLock用state计数。MySQL行锁在什么情况下退化为表锁核心是条件没有索引或者索引失效导致全表扫描锁全部记录。间隙锁的作用是什么在RR隔离级别下防止幻读锁住索引区间防止其他事务插入。乐观锁和悲观锁各自适用什么场景冲突少用乐观锁冲突多用悲观锁配合失败重试机制。分布式锁有哪几种实现方式Redis、ZooKeeper、etcd各自特点Redis吞吐高有主节点丢失风险ZK强一致但性能一般。Redis分布式锁的key和value如何设计key用业务标识value用UUID过期时间大于业务执行时间释放锁用Lua脚本。Redisson的看门狗机制大概是怎么回事默认过期30秒每隔10秒续期业务结束释放锁。7.2 几个容易翻车的细节这些翻车点都是我实际踩过的写出来给你排雷。synchronized锁字符串字面量。如果用lock这种直接量的字符串作为锁对象所有用同一个字符串的代码段会被同一把锁控制包括那些本来无关的流程。更严重的是如果用了new String(lock)两个地方根本不是同一个对象锁直接失效。锁内调用远程接口或执行IO。锁的持有时间变长会导致等待线程堆积轻则性能下降重则触发锁升级或死锁超时。锁内尽量只做内存操作或本地事务远程调用放在锁外。数据库更新条件无索引。where条件字段没索引时InnoDB会对全表加锁你的行锁就变成了表锁并发能力直线下降排查时先看执行计划里有没有Using where Using filesort如果有大概率锁范围扩大。Redis释放锁时先get再del。get出来是自己的value就del这两个操作之间锁可能已经过期被别人抢走del就会误删别人的锁。必须用Lua脚本原子执行。tryLock之后忘记finally释放。Redis分布式锁释放锁的代码必须放在finally中否则抢到锁后抛出异常锁可能直到自动过期才释放期间整个业务链路阻塞。关于锁这个话题每写一段代码前我都会问自己三个问题锁的粒度够不够细锁的持有时间够不够短锁的范围是不是正好覆盖了需要保护的数据这三个问题想清楚了Java里这些锁的知识点其实就成了一个可以灵活调用的工具箱。我真正体会到锁的价值不是面试时背出AQS源码而是上线前用压测工具把并发拉高、亲眼看到锁真正挡住了超卖的那一刻。希望这篇整理能帮你省掉一点我当年踩坑的时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进