news 2026/10/8 10:17:56

Java锁机制全解析:从synchronized到分布式锁实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java锁机制全解析:从synchronized到分布式锁实战

提到Java里的锁,不少人的第一反应是synchronized关键字,再深一点能说出ReentrantLock、ReadWriteLock。但真正到了生产环境,你很快会发现锁的问题远不止几个API那么简单——单机下锁得住,一上多实例就穿帮;数据库明明有锁,业务上还是出了超卖;Redis分布式锁加了,又碰上主从切换锁丢失。这篇文章我就把Java里所有涉及"锁"的知识点系统过一遍,从JVM内置锁的底层机制到JUC显式锁的适用边界,再到分布式环境下Redis与ZooKeeper两种锁方案的取舍,最后用一个Spring Boot库存扣减场景把单机锁、乐观锁、分布式锁完整串起来。不管你是准备Java面试,还是正在排查线上并发问题,这篇都值得收藏慢慢看。

1. 先说清楚一个根本问题:锁究竟在锁什么

我见过不少初级开发把锁理解成"挡住别的线程执行",这个说法对了一半。锁的本质不是挡人,而是保证临界区内共享变量的操作具有原子性、可见性和有序性。这三件事不解决,并发程序就跟没系安全带的乘客一样——平时没事,一刹车就飞出去。

1.1 并发场景的三个底层风险

先看一个最经典的例子:两个线程同时对同一个int count做count++。count++在字节码层面是三步:读取count、加1、写回count。两个线程交错执行时,最后写回的值可能不是2而是1,这就是原子性被破坏。

再来看可见性。Java内存模型(JMM)规定,每个线程有自己的工作内存,共享变量从主内存拷贝到工作内存后再操作。如果没有同步机制,线程A改了变量,线程B的工作内存里还是旧值。这也是为什么单核CPU下并发问题不明显,多核环境下才频繁暴露——问题不在CPU计算速度,而在缓存一致性。锁的核心作用之一,就是在线程进入临界区时刷新工作内存,退出时把修改同步回主内存,这样后来的线程读到的就是最新值。

还有重排序问题。Java编译器和CPU为了性能会对指令做重排,单线程内重排不影响结果,多线程共享变量时就可能出诡异Bug。锁通过内存屏障禁止了临界区内外指令的重排序。

1.2 锁不是万能的,粒度才是灵魂

很多性能事故不是"没用锁",而是锁加得太粗。比如把整个方法都加上synchronized,方法里只有一行代码需要保护,其他全是无关计算和IO,那么所有线程都堵在门口排队,吞吐量直接崩掉。

我做过一个批处理优化:原始代码在方法级别加锁,处理1万条数据耗时40秒;我把锁的范围缩小到"写入共享Map的那一行",耗时降到6秒。加锁粒度每缩小一档,并发能力往往提升数倍。但粒度也不能太小,否则会出现检查与执行之间被其他线程插入的"TOCTOU"(Check-Then-Act)问题,这个后面讲Spring Boot实战时会具体踩到。

2. synchronized在JDK各版本里的升级路径与真实用法

synchronized是Java最早的内置锁,也是JVM层面实现的锁,不需要手动释放。这玩意儿用起来简单,但面试最爱考的是它背后的锁升级机制。

2.1 对象头与Mark Word:锁信息藏在哪

Java对象在堆内存里的布局分三块:对象头、实例数据、对齐填充。对象头里的Mark Word存放对象的哈希码、GC分代年龄和锁标志位。64位JVM下Mark Word是8字节,里面腾出两位来标记锁状态:无锁、偏向锁、轻量级锁、重量级锁,对应00、01、10、11。

每次加锁不是直接向操作系统申请互斥量,而是先通过CAS(Compare And Swap)在用户态操作Mark Word。只有在竞争激烈时才会升级为重量级锁,这时才涉及操作系统内核态的系统调用。这就是synchronized在JDK 1.6之后性能大幅提升的原因——大多数场景下锁竞争并不激烈,用户态CAS搞定就够了,根本不需要进内核。

2.2 无锁→偏向锁→轻量级锁→重量级锁

来梳理一遍完整的升级链路,面试时按这个顺序讲,面试官基本不会再追问:

  • 无锁状态:Mark Word低位为01,对象未被任何线程锁定时。
  • 偏向锁(JDK 1.6引入,JDK 15后默认关闭,JDK 18废弃):第一个获取锁的线程把线程ID写入Mark Word,后续它再进入同步块直接判断线程ID是否一致,一致就用不着CAS了。偏向锁的语义是"这个锁大概率被同一线程反复获取",相当于给锁打了"专属标签"。
  • 轻量级锁:一旦第二个线程来竞争,偏向锁撤销,进入轻量级锁,即通过CAS在栈帧里记录Lock Record并尝试替换Mark Word。这个阶段不阻塞线程,失败则自旋(Spin)重试若干次。
  • 重量级锁:自旋超过阈值(默认10次,或者自适应自旋次数)仍然失败,锁膨胀为重量级锁,Mark Word指向Monitor对象,未抢到锁的线程进入阻塞队列,由操作系统调度唤醒。

这里有个常见的面试追问:为什么JDK 15要把偏向锁默认关掉?我的理解是偏向锁的撤销需要STW(Stop The World)停顿,在JDK 14之前偏向锁的收益在高并发场景下已经不如撤销成本了,而且JVM团队做了大量压测发现现代应用很少有长时间单线程重复获取同一锁的模式,干脆默认关闭。

2.3 synchronized在实际项目里的正确姿势

实际开发中,synchronized最常用来做三类操作:静态方法锁类对象、实例方法锁当前对象、同步代码块锁指定对象。第三个最灵活,你完全可以锁一个private final Object lock = new Object(),把锁对象和业务对象解耦。

用synchronized有四个实操要点:

  1. 锁对象必须是引用不变的。用new String("xxx")当锁对象,每个线程都new一个,锁等于没加。
  2. 字符串常量当锁对象有坑。JVM的字符串常量池可能让两个不相关的类共享同一把锁,容易造成无意义的阻塞。
  3. 锁内不要做耗时操作。同步块里只保留必须的共享变量读写,把耗时的网络IO和数据库查询移到锁外。
  4. 注意可重入行为。synchronized是可重入的,同一个线程可以多次进入同一把锁保护的临界区,不用担心自己锁死自己。

2.4 补充:synchronized锁的是什么

按锁的对象类型分,可以分成对象锁和类锁(Class对象锁)。类锁相当于是全局锁,实例锁只锁当前对象。一个常见的面试题是"两个不同实例加同一把静态方法的锁是否会互斥",答案是会——因为静态方法的锁对象是Class,只有一把。这既是优势也是隐患,全局锁容易把无关实例之间的并发也串行化。

3. JUC显式锁的控制力:ReentrantLock与读写锁的使用边界

synchronized用起来省心,但控制力太弱。JDK 5引入的java.util.concurrent.locks包补上了短板,你现在需要处理高并发问题时,显式锁的控制力更精准。

3.1 ReentrantLock相比synchronized多了哪些能力

ReentrantLock核心比synchronized多了四项能力:

  • 公平锁:new ReentrantLock(true)可指定公平策略,按请求顺序分配锁,避免线程饥饿。但公平锁的吞吐量低于非公平锁,除非业务确实要求严格顺序,否则别开。
  • 可中断:lock.lockInterruptibly()允许线程在等待锁的过程中响应中断,适合处理死锁预防。
  • 尝试获取:tryLock(timeout, TimeUnit)在指定时间内尝试获取锁,拿不到就返回失败,避免无限期阻塞。
  • 多个条件队列:newCondition()可以创建多个等待队列,实现生产者-消费者里"满则等、空则等"的精细调度。

3.2 ReentrantLock使用中必须注意的细节

ReentrantLock最核心的规则:锁必须手动释放。我在代码Review里反复强调的一句话是"lock之后立即try-finally unlock",顺序反了或者漏了导致线程占着锁不放,整个系统卡死。另外尽量别在持有锁的代码块里调用外部接口,一旦接口响应慢,锁被长时间占用,下游全部阻塞。

实际项目里用ReentrantLock的另一个问题是锁的申请顺序,比如多个线程同时申请多把锁时,顺序不一致就会死锁。一台线上服务曾经出现过两个线程互相持锁等待的死锁问题,后来通过强制所有线程按全局顺序申请锁解决。

3.3 ReadWriteLock与StampedLock:读多写少的优化武器

读线程之间本来就不需要互斥,读写锁把"读与读"放开,写与读、写与写互斥。适合缓存、配置加载这类读量大、写量小的场景。ReentrantReadWriteLock配合ConcurrentHashMap做本地缓存,性能提升很明显。

不过读写锁有一个经典坑:锁降级。你持有写锁时,可以再获取读锁,再释放写锁,这叫锁降级。反过来不行——持有读锁再去获取写锁会死锁。原因是读锁是共享锁,多个线程持有读锁时,任何一个都不能升级为写锁,否则互相等待。

JDK 8新增的StampedLock则在读写锁基础上增加了乐观读(Optimistic Read):读操作不加锁,先获取一个stamp版本号,执行完业务代码后再校验stamp是否变化。如果变化说明期间有写线程介入,再升级为悲观读或重新读。这种模式适合读多但偶尔写的场景,比如配置中心的热更新。

3.4 LockSupport与AQS的关系

顺带把AQS(AbstractQueuedSynchronizer)说透,这是理解整个JUC的基础。AQS内部维护一个volatile的state变量、一个CLH变体的等待队列、以及通过CAS修改state的逻辑。ReentrantLock的"加锁"本质就是CAS把state从0改成1,成功则持有锁;失败则进入队列并调用LockSupport.park()阻塞自己。相比synchronized,AQS的等待队列支持中断响应和带超时的获取,所以控制力更强。

提示:面试时可以说"AQS是JUC锁的骨架,ReentrantLock、Semaphore、CountDownLatch都是基于它实现的",这句话能让面试官知道你对并发框架有结构性理解。

4. 分布式环境的锁困局:Redis与ZooKeeper两种方案的博弈

单机锁讲完了,接下来是大部分线上系统真正头疼的部分。synchronized和ReentrantLock只能锁住当前JVM进程,多实例部署后,每个实例都有各自的锁监测器,完全不互相感知。这时候需要分布式锁:让多个进程之间对同一资源互斥访问。

4.1 Redis分布式锁的基础实现

最经典的Redis分布式锁通过SET key value NX PX 30000落地:只有key不存在时才能设置成功,相当于抢到了锁;PX指定过期时间,防止持有者崩溃导致死锁。释放锁要使用Lua脚本先判断value是否匹配再删除,否则可能误删别人的锁(因为锁过期了,其他线程重新获取到同一把锁)。

单纯这样写还有一个隐患:如果执行业务的时间超过了锁的过期时间,A线程的锁自动过期了,B线程获得锁开始执行,A执行完再释放锁——这把锁已经不属于A了,业务数据就乱了。所以比较成熟的做法是使用Redisson的RLock,它默认30秒有效期,并启动一个看门狗(Watchdog)线程在锁有效期内每10秒续期一次,直到业务执行完主动释放。这样既不会死锁,也不会提前过期。

4.2 Redis锁的致命短板:主从切换可能导致锁丢失

即使加了Redisson,依然存在一个理论缺陷:Redis主节点刚写入锁数据,还没同步到从节点就宕机了,从节点晋升为主节点后,这个锁并不存在,其他线程可以同时获取到锁。Redis官方为此提出过RedLock算法——向多个独立的Redis节点依次尝试加锁,超过半数节点加锁成功才算最终获得锁。但这个方案因为本身过重和时钟跳跃等依赖问题,业内争议很大,很多团队并不实际采用。

从我实践的角度看,如果你的业务场景允许少量锁失效(比如幂等性有兜底),单节点的Redis分布式锁加Redisson完全够用。如果对锁一致性要求极高(比如资金类操作),建议直接上ZooKeeper方案。

4.3 ZooKeeper分布式锁:强一致性的另一条路

ZooKeeper实现分布式锁的核心机制是临时顺序节点:多个进程在同一个锁路径下创建临时有序节点,每个进程判断自己创建的节点是不是序号最小的,是就获取锁;不是就监听前一个节点的删除事件。持有锁的进程如果崩溃,临时节点会自动消失,下一个进程收到通知获得锁,不会遇到Redis那种过期时间不好把握的问题。

ZooKeeper方案放弃了Redis方案的高并发吞吐,换来了高可靠性。因为ZooKeeper的所有写操作都由Leader节点处理,并且通过ZAB协议保证大多数节点数据的同步一致,几乎不存在"锁写入后丢失"的场景。缺点是要多维护一套ZooKeeper集群,且加锁的网络交互次数比Redis多,在极高并发下吞吐量受限。

4.4 分布式锁的经典使用场景

什么时候必须上分布式锁?我排查过这些常见需求:

  • 防重复提交:多实例下用户快速点击"提交订单",两个请求落到不同实例,单机锁根本拦不住,分布式锁可以保证同一用户同一时间只有一个请求在创建订单。
  • 定时任务抢占:多实例的定时任务都用Quartz去跑,为了避免所有实例同时执行任务,用一个分布式锁让每分钟只有一个实例真正执行任务。
  • 库存扣减:热点商品的库存需要在多个实例间保持一致,经典的超卖问题就要靠分布式锁(或数据库乐观锁)解决。
  • 幂等消费:MQ消息消费端重复投递时,分布式锁保证同一消息只被处理一次。

5. 数据库层面的锁:MySQL行锁、间隙锁与MVCC的协作

聊Java锁绕不开数据库锁。业务系统里数据最终落到数据库,应用层的锁只是管控进程间的竞争,数据库层的锁才真正约束多台机器上的多个连接对同一行数据的操作。

5.1 表锁、行锁、间隙锁怎么选

MySQL InnoDB引擎支持行锁、表锁和间隙锁。行锁通过索引项加锁,如果SQL没走索引,就会退化成表锁,这是性能杀手。实践中排查慢SQL时,检查执行计划里possible_keys和key,如果key为NULL而表又很大,基本就是锁全表了。

间隙锁(Gap Lock)是InnoDB在事务隔离级别为REPEATABLE READ时为了解决幻读引入的:在索引记录之间加锁,阻止其他事务往这个区间插入新记录。典型案例:SELECT * FROM order WHERE amount BETWEEN 100 AND 200 FOR UPDATE不仅锁住已有的行,还在100到200这个区间加了间隙锁,另一个事务向里面插一条amount=150的记录会被阻塞。

5.2 悲观锁与乐观锁的取舍

数据库层面的锁有两种应用方式:

悲观锁:SELECT ... FOR UPDATE直接锁行,事务提交或回滚才释放。适合并发冲突概率高的场景,比如订单主状态变更。但要注意FOR UPDATE必须在事务内执行,否则锁在autocommit模式下瞬间就释放了,等于白锁。

乐观锁:不加数据库锁,通过UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?实现。更新时检查版本号,影响行数为0说明并发修改了,需要重试或报错。适合冲突概率低的场景,比如用户资料更新。我做过压测对比,同样1000并发扣库存,悲观锁TPS大约380,乐观锁可以跑到900多,但乐观锁在高冲突下失败重试多了反而会放大数据库压力。

5.3 死锁的根因与检测手段

死锁的本质是多个事务以不同顺序持有和申请资源。MySQL的死锁检测默认是开启的,发生死锁时会回滚掉代价较小的事务并抛异常。我们在代码里收到Deadlock found when trying to get lock就是这种情况。

实际运维中,死锁的规避手段优先级从高到低是:

  1. 统一加锁顺序,例如多个表操作时都按相同order排序。
  2. 缩小事务范围,减少持有锁的时间。
  3. 采用乐观锁替代悲观锁,从根上不申请行锁。
  4. 合理设计索引,尽量让行锁命中索引而不是锁区间。

6. Spring Boot实战:库存扣减场景的锁方案演进全过程

理论知识再多,落地才是考验。这部分我用一个非常常见的"商品库存扣减"业务,逐步升级锁方案,展示每一步的代码形态和踩坑点。

6.1 场景定义与初始代码

假设库存表结构为product_stock(product_id, stock, version),下单接口需要原子地扣减库存,不能超卖。很多同学第一版会这样写:

@Transactional public boolean deductStock(Long productId, Integer quantity) { ProductStock stock = stockMapper.selectById(productId); if (stock.getStock() < quantity) { return false; } stock.setStock(stock.getStock() - quantity); stockMapper.updateById(stock); return true; }

这段代码看起来逻辑完整,但高并发下必然超卖。两个事务同时读到stock=100,都校验通过,都扣成99,数据库里库存最终是99,实际却卖出去2件。单实例部署时加个synchronized能解决吗?能,但只在单实例下有效,而且大事务持有锁的时间很长,性能也堪忧。

6.2 第二阶段:数据库乐观锁改造

先把超卖问题用乐观锁解决掉,代码调整为:

@Transactional public boolean deductStock(Long productId, Integer quantity) { int updated = stockMapper.deductWithVersion(productId, quantity); return updated == 1; }

对应的SQL是:

UPDATE product_stock SET stock = stock - #{quantity}, version = version + 1 WHERE product_id = #{productId} AND stock >= #{quantity} AND version = #{version}

这里一步完成"校验库存充足+扣减+版本递增",stock >= quantity条件在数据库层面保证了不会扣成负数。影响行数为0时,说明库存不够或并发导致版本不一致,业务层可以重试或快速失败。这个方案的缺陷在于并发高时大量请求失败重试,用户体验不好,而且多个商品一起扣减(比如组合下单)时事务较长,容易死锁。

6.3 第三阶段:Redisson分布式锁介入

多实例部署后,乐观锁依然能阻止超卖,但高冲突场景下大量重试,数据库压力大。更好的方案是先让分布式锁挡住并发,只有获得锁的线程去读库存、扣库存:

@Autowired private RedissonClient redissonClient; public boolean deductStockWithLock(Long productId, Integer quantity) { RLock lock = redissonClient.getLock("stock-lock:" + productId); try { if (!lock.tryLock(3, 30, TimeUnit.SECONDS)) { return false; } return doDeductStock(productId, quantity); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }

这里把锁的粒度缩小到单个商品维度,不同商品不同锁Key,互不影响。tryLock(3, 30, TimeUnit.SECONDS)表示最多等待3秒拿锁,拿到后锁有效期30秒。注意finally里释放锁前要判断当前线程是否还持有锁,避免锁过期后误释放别人的锁。

6.4 Watchdog续期与锁内禁IO的实测体验

Redisson的Watchdog会让锁在业务执行超过30秒时自动续期到下一个30秒,直到unlock()被调用。这个机制缓解了"锁提前过期"问题,但引入了一个新的纪律要求:锁内绝对不要做跨网络的长耗时操作。我曾经在锁内调用第三方物流接口查询,接口超时35秒,Redisson不断续期,大量线程在锁外排队,最终导致订单服务雪崩。后来把第三方调用挪到锁外,锁内只做库存的读写校验,问题立刻消失。

在压测环境里,1000并发同时抢100件库存,纯乐观锁方案有大约420个请求返回"库存不足"或"重试失败",而Redisson分布式锁方案把这个数字压到85左右。锁确实把绝大部分冲突在应用层拦下来了,数据库压力小很多。

6.5 Redis不可用时的降级方案

分布式锁也不是100%高可用。Redis实例故障期间应该怎么办?我在架构评审时给出的方案是锁降级+限流兜底:

  • 正常情况下用Redisson分布式锁。
  • Redisson连接异常时,降级为数据库乐观锁(依然保证最终一致)。
  • 两种情况都失败,则快速拒绝请求,不把压力继续往下打。

这样即使Redis集群暂时抖动,业务也不会出现超卖,只是并发能力短暂下降。取舍原则很清晰:任何时候都不能为了可用性牺牲数据正确性。

6.6 事务边界与锁的时序陷阱

最后提醒一个极容易出错的细节:锁释放的时机必须先于事务提交,或者至少保证事务内执行的是纯数据库操作。

我在Spring Boot里见过这种写法:@Transactional标注的方法里,先调用Redisson加锁,方法结束时自动释放锁,但此时事务还没提交(事务提交发生在方法返回后的AOP切面里)。这个时间窗内,下单线程已经释放了锁,库存的数据库事务尚未提交,另一个线程拿到锁后读取到的库存还是旧值,超卖问题依旧。解决方案是:让锁的unlock操作在事务提交之后执行,比如把加锁逻辑抽到Service的外层方法,或者直接在锁内调用TransactionTemplate手动控制事务提交,确认提交后再释放锁。

实战后的总结建议

从我这些年排查并发问题的经验看,锁不是越高级越好,而是越匹配场景越好。单实例下synchronized足够简单可靠;单实例读多写少用ReadWriteLock或StampedLock;多实例对一致性要求没那么苛刻时,Redisson分布式锁是性价比最高的选择;资金类强一致场景,ZooKeeper虽然重一点,但省心。数据库乐观锁永远是一个重要的兜底方案,它不依赖任何中间件,即使分布式锁全部失效,也能守住数据一致性的底线。

面试聊锁的时候,能把synchronized锁升级、AQS原理、分布式锁的失效边界这三层都讲出"为什么",基本就没有什么能难住你的了。我踩过的最大的一个坑,就是在锁内做了一次网络请求,导致线上事故,所以现在写任何加锁代码的第一原则都是:锁里的代码越短越好,越纯粹越好。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 10:17:26

从零解析JSP/Servlet传统架构:医院预约挂号系统的设计与落地

简介&#xff1a;基于JAVA WEB的医院预约挂号系统压缩包&#xff0c;包含完整源码与数据库文件&#xff0c;面向Java Web初学者、毕业设计学生及医疗信息化开发者&#xff0c;解决在线预约、门诊排班、挂号信息管理等场景需求。压缩包共577个文件&#xff0c;约14.75MB&#xf…

作者头像 李华
网站建设 2026/10/8 10:16:27

本地部署AI编程助手:Docker容器化与GPU推理实战指南

1. 为什么要在本地折腾一个 AI 编程助手 把 AI 编程助手跑在自己机器上&#xff0c;这件事在两年前还属于“实验室玩具”的范畴&#xff0c;现在已经变成不少开发者日常写代码的标配。原因很直接&#xff1a;云端服务虽然开箱即用&#xff0c;但代码片段一旦离开本机&#xff0…

作者头像 李华
网站建设 2026/10/8 10:16:09

本地部署AI编程助手:Docker与Ollama实战指南

1. 为什么要在本地跑一个 AI 编程助手 把 AI 编程助手放到自己机器上跑&#xff0c;这件事在两年前还属于"折腾党专属"&#xff0c;现在已经变成很多团队的标准动作。原因很直接&#xff1a;代码是敏感资产&#xff0c;把整段业务逻辑贴到外部服务里&#xff0c;心里…

作者头像 李华
网站建设 2026/10/8 10:15:41

深度体验pi:本地部署的AI编程智能体从安装到实战

最近群里好几个朋友都在问同一个问题&#xff1a;pi 到底是什么&#xff1f;有人以为是树莓派&#xff0c;有人以为是圆周率&#xff0c;还有人发来一张控制器截图&#xff0c;问 PI 参数怎么调。这些理解都没错&#xff0c;但最近一段时间&#xff0c;开发者圈子里频繁出现的 …

作者头像 李华
网站建设 2026/10/8 10:15:28

从PMBOK第六版到第八版:项目经理角色与团队文化的价值转型

如果你对项目管理的印象还停留在 PMBOK 第六版——也就是把项目当成一条流水线&#xff0c;按启动、规划、执行、监控、收尾五个过程组&#xff0c;把十大知识领域里的动作一项项做完——那看到第八版的新框架时&#xff0c;第一反应很可能是&#xff1a;这怎么像一本讲领导力和…

作者头像 李华