前言
在Java开发中,synchronized、ReentrantLock是我们解决线程并发问题的常用工具,也就是大家口中的本地锁。很多新手会有一个误区:只要加了锁,就能保证并发安全。但实际项目上线集群部署后,库存超卖、重复下单、定时任务重复执行等问题频频出现。
究其根本:本地锁仅适用于单JVM进程内的线程互斥,一旦涉及多实例、多机器、多容器的分布式场景,本地锁会彻底失效。
本文从底层原理、适用场景、失效原因、实战案例、解决方案、优劣对比等维度,全面拆解本地锁与分布式锁的区别,同时讲解单机多实例、跨服务、集群定时任务等典型场景的落地选型,帮你彻底理清并发锁的使用边界。
一、基础概念:线程、JVM进程、应用实例
在讲解锁之前,先厘清三个核心概念,这是理解锁失效的前提,也是大部分人混淆的关键点。
1. 线程
JVM内部最小执行单元,一个Java应用中会包含Tomcat工作线程、业务线程池、定时任务线程等多个线程。线程共享同一个JVM的堆内存,这也是本地锁能生效的基础。
2. JVM进程
通过java -jar xxx.jar启动SpringBoot/Java项目,就会生成一个独立的JVM操作系统进程,拥有独立的堆内存、虚拟机栈等内存空间,进程之间内存完全隔离,无法直接通信。
3. 应用实例
日常开发中,一个应用实例等价于一个JVM进程。集群部署、单机启动多个端口的Jar包,本质就是启动了多个独立JVM进程。
简单总结:多线程 = 同一内存空间;多JVM进程 = 多块隔离内存。
二、Java本地锁:原理与适用场景
2.1 主流本地锁底层原理
Java中最常用的两类本地锁:synchronized(隐式锁)和ReentrantLock(显式锁),二者底层实现不同,但作用范围一致。
1. synchronized
基于JVM对象头的监视器锁(Monitor)实现,锁信息存储在当前JVM的对象头中,仅对当前进程内的线程可见。支持偏向锁、轻量级锁、重量级锁的锁升级,是JVM原生支持的锁机制。
2. ReentrantLock
基于AQS(队列同步器)实现,依靠JVM内存中的state状态变量、等待队列完成线程互斥,锁标记保存在进程内存中。具备可重入、可中断、公平锁/非公平锁等特性,灵活性更高。
2.2 本地锁的有效场景
本地锁的核心能力是单JVM进程内多线程互斥,在以下场景中可以放心使用:
- 单体架构应用,仅部署单台服务器、单个实例;
- 单机单Jar包运行,所有请求由同一个JVM处理;
- 同一应用内部多线程竞争共享资源(接口并发、方法并发等)。
在该场景下,多个请求对应的线程共享同一块JVM内存,锁状态全局可见,能够有效防止并发问题,避免库存超卖、重复提交、数据脏写等问题。
三、核心痛点:本地锁在哪些场景会彻底失效?
只要出现多个独立JVM进程,无论这些进程运行在同一台物理机还是多台服务器,本地锁都会失效,这也是分布式架构下的核心难题。
3.1 场景1:集群多机器部署(最常见)
互联网项目为了保证高可用、负载均衡,几乎都会做集群部署。例如:商品服务部署在机器A、机器B两台服务器上,各自运行一个JVM实例。
执行流程:
- 用户请求1转发至机器A,线程获取本地锁,查询库存并执行业务;
- 用户请求2转发至机器B,机器B的JVM完全感知不到机器A的锁标记(内存隔离),直接获取资源;
- 两个请求同时修改共享数据,最终引发库存超卖、重复下单、重复扣款等严重问题。
3.2 场景2:单机多实例部署(同一机器启动多个Jar)
很多测试、小型项目会在同一台服务器启动多个Jar包,不同端口对外提供服务,这依然是多个独立JVM进程。
示例命令:
java -jar goods.jar --server.port=8080
java -jar goods.jar --server.port=8081两个进程内存相互隔离,本地锁无法跨JVM生效,并发操作数据库依然会出现数据错乱,不要误以为同一台机器就可以用本地锁。
3.3 场景3:Docker/K8s多容器部署
当下容器化部署已成主流,每一个Docker容器都会运行独立的JVM,容器之间网络、内存隔离。哪怕宿主机是同一台机器,容器内的本地锁也无法互通,集群容器场景和多机器场景问题完全一致。
3.4 场景4:跨微服务调用
微服务架构下,订单、库存、支付、用户都是独立服务,部署在不同实例/机器。本地锁只能锁住当前服务内部的线程,无法约束其他微服务的并发操作,跨服务资源竞争时本地锁完全无力。
3.5 场景5:集群定时任务
若定时任务服务部署多个实例,所有实例会在同一时间触发任务执行。本地锁只能限制单个实例内的线程,无法阻止其他实例执行任务,最终导致数据重复统计、消息重复推送、业务重复同步。
3.6 场景6:单实例内创建操作系统子进程
Java通过ProcessBuilder、Runtime.exec调用Shell、Python等外部程序时,会生成独立的操作系统子进程。子进程拥有独立内存,JVM本地锁对子进程无效,父子进程竞争资源也会出现并发问题(该场景为小众场景,不属于分布式核心问题)。
失效本质总结
本地锁的锁状态存储在当前JVM的内存中,多JVM进程内存相互隔离、无数据互通,锁标记无法全局可见,自然无法实现跨进程的互斥控制。
四、实战案例:秒杀库存超卖(直观演示锁失效)
4.1 业务场景
商品服务部署两个实例,商品初始库存 = 1,模拟高并发秒杀请求。
错误流程(使用本地锁)
- 秒杀请求1进入实例1,线程获取
ReentrantLock本地锁,查询库存为1; - 秒杀请求2进入实例2,实例2无任何锁标识,直接查询库存为1;
- 两个线程均判断库存大于0,同时执行库存扣减逻辑
stock = stock - 1; - 最终数据库库存为0,但实际卖出了2件商品,典型超卖事故。
正确流程(使用分布式锁)
借助Redis/Zookeeper等第三方共享存储维护全局锁:
- 实例1向Redis申请分布式锁,加锁成功,执行业务逻辑;
- 实例2申请锁失败,进入自旋/阻塞等待状态;
- 实例1执行完毕,释放分布式锁;
- 实例2获取锁并执行逻辑,最终仅卖出1件,数据正常。
五、分布式场景下的主流解决方案
既然本地锁无法满足多JVM并发需求,我们就需要借助全局共享存储实现分布式锁。根据架构、并发量、运维成本,分为两大类方案。
5.1 方案一:基于数据库实现分布式锁(低并发兜底方案)
利用所有实例共用的MySQL数据库作为共享资源,实现全局互斥,无需额外引入中间件,适合小型项目、低并发场景。
1)数据库悲观锁 select ... for update
利用MySQL行级锁实现互斥,事务未提交时,其他事务会阻塞等待。
-- 锁定id=1的商品数据,全局互斥
select stock from goods where id = 1 for update;
-- 执行业务逻辑:库存判断、扣减
update goods set stock = stock - 1 where id = 1;优点:实现简单,无需中间件;
缺点:长事务会造成大量请求阻塞,并发吞吐量极低;易产生死锁;高并发下耗尽数据库连接;服务宕机会导致锁无法及时释放。
2)数据库乐观锁(Version版本号机制)
无阻塞锁,通过版本号校验判断数据是否被修改,冲突后业务重试,基于CAS思想实现。
-- 更新时校验版本号,版本不匹配则更新失败(代表已被其他线程修改)
update goods set stock=stock-1, version=version+1
where id=1 and version = #{oldVersion};优点:无阻塞,性能优于悲观锁;
缺点:高并发下大量请求重试,CPU开销大;秒杀等极限高并发场景体验差。
3)自定义锁表
单独创建一张分布式锁表,通过INSERT抢占锁、DELETE释放锁,适用于非库存类通用锁场景。
5.2 方案二:基于中间件实现分布式锁(生产主流方案)
借助Redis、Zookeeper等独立中间件作为全局锁存储,是互联网中高并发集群项目的首选方案。
1)Redis分布式锁
业界使用最广泛的方案,基于Redis的SET NX EX命令实现,配合过期时间、看门狗、防误删、Lua脚本保证原子性。
优点:性能极高,网络IO开销小;支持锁超时、续锁、可重入;灵活度高;
缺点:需要额外运维Redis集群;需处理主从同步、锁超时、集群脑裂等问题。
2)Zookeeper分布式锁
基于Zookeeper临时有序节点、Watcher监听机制实现,可靠性更强。
优点:锁释放可靠性高,宕机自动释放;天然支持集群一致性;
缺点:性能弱于Redis;运维复杂度更高;不适合超高并发场景。
5.3 方案选型建议
- 低并发、单体转集群、不想引入中间件:优先使用数据库乐观锁;
- 普通并发、传统业务系统:数据库悲观锁(谨慎使用长事务);
- 高并发、秒杀、电商、微服务集群:首选Redis分布式锁(行业标准);
- 对锁可靠性要求极高、并发量一般:选择Zookeeper分布式锁。
六、拓展误区:本地锁 + 数据库锁 可以替代分布式锁吗?
部分开发者会尝试“本地锁 + 数据库悲观锁”组合方案,认为可以提升性能,这里明确结论:仅能临时兜底,不推荐用于线上高并发场景。
原因如下:
- 数据库悲观锁本身存在性能瓶颈,高并发下大量请求阻塞,系统吞吐量急剧下降;
- 事务超时、数据库死锁、连接耗尽等问题概率大幅提升;
- 服务意外宕机时,依赖事务自动释放锁,无法自定义锁超时时间,灵活性极差;
- 多层锁叠加会增加代码复杂度,排错难度提升。
该组合仅适合临时修复问题、老旧项目过渡,正规分布式架构请直接使用中间件分布式锁。
七、本地锁 VS 分布式锁 全面对比
| 对比维度 | 本地锁(synchronized/ReentrantLock) | 分布式锁(Redis/Zookeeper) |
|---|---|---|
| 作用范围 | 单个JVM进程内存,仅管控进程内线程 | 全局跨机器/跨实例/跨容器,全集群互斥 |
| 并发适配 | 单实例单体应用 | 集群、微服务、容器化分布式架构 |
| 性能表现 | 纯内存操作,性能极致高 | 存在网络IO,性能略低于本地锁 |
| 锁释放 | JVM退出/线程执行完毕自动释放 | 需手动释放、配置过期时间、看门狗续锁 |
| 实现复杂度 | 代码简单,原生支持 | 需处理原子性、死锁、超时、集群一致性等问题 |
| 运维成本 | 无额外组件 | 需要运维Redis/ZK集群 |
八、总结与最佳实践
8.1 核心结论
- 本地锁(
synchronized/ReentrantLock)的作用域仅限单个JVM进程,依靠JVM内存实现线程互斥; - 只要存在多个独立JVM进程(多机器集群、单机多Jar、多容器),无论物理机是否相同,本地锁都会失效;
- 分布式架构下,必须依靠全局共享存储(数据库、Redis、Zookeeper)实现分布式锁,才能保证集群并发安全。
8.2 开发最佳实践
- 单体单实例:放心使用本地锁,追求极致性能;
- 集群/微服务/容器化:直接采用Redis分布式锁,作为标准解决方案;
- 老旧项目、低并发临时过渡:使用数据库乐观锁,避免悲观锁滥用;
- 定时任务集群:禁止依赖本地锁,必须搭配分布式锁防止重复执行;
- 跨微服务资源竞争:统一使用全局分布式锁,不要在单个服务内加本地锁。
理解锁的作用边界,是Java后端开发的必备基础。很多线上并发事故,根源都是混淆了本地锁与分布式锁的使用场景。