
做Java开发的这些年我面试过别人也被别人面试过几乎每次都会遇到同一个话题锁。你说它难吧synchronized 和 ReentrantLock 谁都能说两句你说它简单吧从偏向锁到分布式锁从 MySQL 行锁到 Redis 的 Lua 脚本每一层都有能让人栽跟头的细节。很多人在简历里写着“熟悉并发编程”可一到线上出现问题压测一开锁用得对不对就全露馅了。这篇文章我打算把 Java 里的锁从单机到分布式完整梳理一遍。不仅有 synchronized、Lock、CAS 这些基础知识点还会配套一个 Spring Boot 实战项目把锁真正落到业务代码里。无论你是准备面试还是排查线上问题这篇文章都值得收藏慢慢看。1. 悲观锁与乐观锁先建立宏观认知1.1 悲观锁默认冲突处处加锁悲观锁的核心理念就是我先假设这数据一定会被别人改所以在操作之前就先把资源锁住别人想碰就得等。在 Java 里synchronized 和 ReentrantLock 就是典型的悲观锁。在数据库里SELECT ... FOR UPDATE 也是悲观锁。这种方式的优点是实现简单、语义清晰只要锁住了数据就不会被并发修改完全不用考虑冲突检测的问题。但缺点也明显线程阻塞、上下文切换、死锁风险而且在高并发场景下锁的竞争会让吞吐量直线下降。我一直跟团队里的人说悲观锁适合那种“冲突确实很严重”的场景。比如库存扣减你抢购的时候本来就有大量线程同时操作同一行数据这种情况下乐观锁的重试成本反而更高直接加锁反而更干脆。1.2 乐观锁版本号与CAS乐观锁的核心理念正好相反我默认数据不会被别人改所以在操作时不上锁而是在提交修改时检查一下在这期间有没有人改过这条数据。最常见的实现方式就是版本号机制。给每条数据加一个 version 字段更新时带上上次读到的版本号UPDATE t_account SET balance balance - 100, version version 1 WHERE id 123 AND version 5;如果影响行数为 0说明版本已经变了这次更新失败需要重试。在 Java 并发包里乐观锁的典型代表是 CASCompare And Swap比如AtomicInteger.incrementAndGet()就是不停地尝试把旧值改成新值中间发现旧值不匹配就重来。CAS 是硬件层面的一条指令所以它的性能很好但它有一个著名的 ABA 问题一个线程把值从 A 改成 B 又改回 A另一个线程比较时发现还是 A就认为没人动过。解决方法是加版本号Java 里的AtomicStampedReference就是干这个的。1.3 实际项目中锁的选择逻辑我在实际项目里的选择逻辑可以总结成一句话冲突多、性能要求高、数据准确率要求极高用悲观锁冲突少、追求吞吐量、能容忍偶尔重试用乐观锁。但这不是绝对的。举个具体的例子一个点赞接口用户一天只能点赞一次这种场景冲突率极低如果用 synchronized 把整个方法锁住性能浪费很严重。这时候用乐观锁发现失败就提示“您已点赞”体验和性能都兼顾。而如果是转账接口金额必须分毫不差我宁可让线程排队等锁也不愿意让它更新失败后用户看到报错。2. synchronized 深度解析Java 内置的“万能锁”2.1 synchronized 的三种用法与锁对象很多人对 synchronized 的理解停留在“方法加个 synchronized 关键字就行”但这里的门道其实不少。synchronized 有三种用法每种用法锁的对象完全不同// 用法一实例方法锁的是当前实例对象 public synchronized void methodA() { } // 用法二静态方法锁的是当前类的 Class 对象 public static synchronized void methodB() { } // 用法三同步代码块锁的是括号里指定的对象 public void methodC() { synchronized (this) { } }如果没搞清楚锁的对象是谁就会踩一个非常经典的坑两个线程一个调实例方法一个调静态方法你以为它们互斥实际上它们锁的是不同的对象根本不会阻塞。这个在面试时我会优先考察因为能答明白这个的人说明对 synchronized 的底层语义是有概念的。2.2 锁升级过程偏向锁到重量级锁HotSpot 虚拟机对 synchronized 做了大量优化它的锁状态一共有四种无锁、偏向锁、轻量级锁、重量级锁。整体是一个升级的过程不能降级至少在大多数场景下不降级。偏向锁只有一个线程反复进入同步块时JVM 会在对象头里记录这个线程的 ID之后这个线程再进来就不需要做任何同步操作了直接执行。这相当于给“常客”开了个专用通道。轻量级锁当第二个线程开始竞争时偏向锁会撤销升级为轻量级锁。这个线程会通过 CAS 尝试在对象头中替换锁记录如果成功就拿到锁如果失败说明真的有人在竞争就会膨胀为重量级锁。重量级锁依赖操作系统层面的互斥量Mutex线程拿不到锁就会真正挂起涉及用户态和内核态的切换开销最大。很多人面试时背了这个过程但没想过为什么。其实核心就一句话锁的状态升级是为了适应不同竞争程度下的性能需求。无竞争或低竞争时用偏向锁和 CAS 这种用户态手段就足够了没必要把线程挂到内核去只有竞争激烈时才付出挂起和唤醒的高昂代价来换取强互斥。2.3 锁消除与锁粗化JVM 的隐藏操作除了锁升级JVM 里还有两个容易被忽略的优化锁消除和锁粗化。锁消除指的是 JIT 编译器通过逃逸分析发现某个对象不会逃逸出当前方法根本不会被他线程访问那这个对象上的锁就可以安全消除。比如public void concat(String s1, String s2) { String s new StringBuilder().append(s1).append(s2).toString(); }如果没有锁消除StringBuilder内部方法上的 synchronized古老的 StringBuffer 才有就是纯粹的性能浪费。锁粗化则是把连续加锁、解锁的小代码块合并成一个大的锁范围。比如循环里每次都用 synchronized 锁同一个对象JVM 可能直接把它拉到循环外面避免频繁的加锁解锁开销。这两个优化告诉我们一个道理现代 JVM 很聪明不要为了所谓的“性能优化”刻意写绕弯的代码清晰、正确地表达意图剩下的交给 JIT。3. JUC 锁体系从 synchronized 到 Lock3.1 Lock 接口与 ReentrantLocksynchronized 虽然好用但功能上有些局限不能响应中断、不能设置超时、不能尝试非阻塞获取锁、非公平的机制也没法调。Java 并发包里的 Lock 接口正好补上了这些能力。ReentrantLock 是 Lock 接口最核心的实现我从实际使用体验总结它有四个独门绝技第一是可响应中断。线程在等待锁时调用lockInterruptibly()如果被其他线程中断会直接抛出 InterruptedException不用傻傻地等下去。第二是超时获取。tryLock(3, TimeUnit.SECONDS)表示最多等 3 秒等不到就放弃。这个能力在生产环境太重要了可以避免无限等待造成的故障蔓延。第三是公平锁。构造时传入new ReentrantLock(true)锁会按线程等待的顺序分配避免线程饥饿。不过公平锁有额外的排序开销吞吐量不如非公平锁一般不建议默认开启。第四是 Condition它把锁和等待/通知机制绑定在一起可以实现精准唤醒。这个后面实战部分会用到。3.2 ReentrantLock 与 synchronized 怎么选这是面试高频题我一般会建议先讲结论再展开原因。结论是能上 synchronized 就优先 synchronized只有当你确实需要中断、超时、公平锁或 Condition 这些能力时才用 ReentrantLock。为什么优先 synchronized因为 synchronized 是 JVM 原生支持的锁升级过程是自动的JDK 后续版本还在持续优化它。而且 synchronized 的锁释放是自动的即使方法里抛异常也不会忘记解锁。ReentrantLock 则需要你在 finally 块里手动解锁一不留神就会造成锁泄漏这种 bug 线上排查起来相当酸爽。我见过有位同事把方法里所有 synchronized 都替换成 ReentrantLock理由是“ReentrantLock 更高级”。结果有一次忘记在异常路径上释放锁整个服务就慢慢卡死了。所以工具没有高下之分只有合不合适。3.3 读写锁与 StampedLock按场景细化粒度读写分离是加锁粒度优化的经典思路。ReentrantReadWriteLock维护了两把锁读锁是共享锁多个线程可以同时持有写锁是排他锁只有拿到写锁的线程能改数据。有个特别容易记混的点读锁和写锁之间是互斥的即使两个线程都只是读数据只要其中一个持有写锁另一个也不能拿读锁。所以读写锁适合“读多写少”的场景比如配置中心、缓存刷新读操作占绝大多数读锁之间不互斥能大幅提升吞吐量。StampedLock 是 JDK 8 引入的升级版支持三种模式写锁、读锁和乐观读。乐观读不真正加锁而是先读取一个 stamp类似版本号操作完再校验这个 stamp 是否有效无效就升级成读锁重试。它在读多写少且数据一致性要求不极端严格的场景下能比 ReadWriteLock 再快一截。但 StampedLock 不可重入而且不支持 Condition我用它的次数不多绝大多数场景 ReentrantReadWriteLock 已经够用了。4. 并发工具类锁的进阶玩法4.1 Semaphore 与 CountDownLatch限流和协作严格来说Semaphore 和 CountDownLatch 不是锁但它们都依赖 AQSAbstractQueuedSynchronizer这套并行框架在思想上跟锁一脉相承。Semaphore 我把它理解为“令牌管理”。它维护 N 个许可证线程执行前要先 acquire() 拿一个执行完 release() 还回去。超过 N 个线程同时访问时后面的线程就会排队。这个在接口限流、数据库连接池、线程池饱和策略里都能看到它的身影。CountDownLatch 则是“倒计时门闩”。new CountDownLatch(3)创建一个门闩三个线程分别执行完调用countDown()计数减到 0 时等待在await()上的主线程才会继续。它的典型场景是并行请求多个微服务最后等待全部返回再汇总处理。CyclicBarrier 和 CountDownLatch 容易搞混记住一句话CountDownLatch 是“等别人都干完我再继续”CyclicBarrier 是“一伙人互相等到齐了再一起出发”而且 CyclicBarrier 可以循环使用。4.2 ConcurrentHashMap 里的锁优化如果不是用 JDK 8 以上的 ConcurrentHashMap锁的认知就算不上完整。JDK 7 及以前ConcurrentHashMap 用分段锁把数据分成若干个 Segment每个 Segment 一把锁读写时只锁对应的段。JDK 8 放弃了分段锁改成了 CAS synchronized 锁定链表的头节点。我觉得 JDK 8 这个转变很有意思一面用 CAS 处理“插入前桶为空”的乐观场景一面用 synchronized 处理“桶里已经有元素链表”的悲观场景。加锁的粒度从段细化到了桶锁的争用范围大幅缩减。这也印证了前文说的悲观乐观不是对立而是融合结合场景混合使用才是正解。4.3 ThreadLocal不显眼的“锁”替代方案ThreadLocal 不是锁但它的作用跟锁的目的殊途同归解决数据竞争问题只是思路从“互斥”变成了“隔离”。每个线程都有自己独立的变量副本大家各写各的自然就没了竞争。这在处理 SimpleDateFormat 线程不安全、传递用户登录信息、保存数据库连接等场景非常实用。但 ThreadLocal 有个大坑线程池环境下核心线程会复用ThreadLocal 里的值如果不清理下一个任务会读到上一个任务留下的“脏数据”。我在这上面吃过亏。一个定时任务把用户标识写进 ThreadLocal下一个任务没写就直接读结果所有日志里的用户都是同一个人的排查了整整一天。所以在线程池任务结束时务必调用ThreadLocal.remove()。5. 分布式锁从单机走向集群5.1 为什么需要分布式锁单机环境下synchronized 和 ReentrantLock 锁的是 JVM 内存里的对象只对当前进程里的线程生效。可一旦服务部署了多台实例或者拆成微服务请求可能落到不同的进程上各自的锁互不相通数据竞争照旧。我最早接手一个库存服务时就是这么踩坑的。单机压测没问题一上多节点库存就超卖。原因很简单两台机器上的 synchronized 各自为政每个机器都认为自己拿到了锁同时扣减同一个 Redis 里的库存数据。这时候就需要一把所有节点都能访问的“公共锁”也就是分布式锁。一条实用的选型经验分布式锁的底层存储无外乎 Redis、Zookeeper、数据库。Redis 性能最好Zookeeper 的一致性最可靠数据库最简单直接。选型时要看你的业务对“锁的可靠性”要求有多高以及对性能的敏感度。5.2 Redis 实现分布式锁的正确姿势Redis 实现分布式锁最经典的命令是SET key value NX EX seconds。NX 表示只有当 key 不存在时才设置成功EX 表示过期时间。用一条原子命令同时搞定“加锁”和“过期”避免了分开两步带来的隐患。SET lock:order:123 uuid-xxx NX EX 30释放锁时不能简单地DEL key因为要防止“我的锁被别人释放”。正确的姿势是用 Lua 脚本保证“先校验再删除”的原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endvalue 为什么要用 UUID就是给锁一个“唯一标识”只有持有者才能释放自己的锁。这点太重要了否则会出这个问题线程 A 的锁 30 秒到期自动删了线程 B 加锁成功这时 A 处理完了去 DEL直接把 B 的锁删掉了B 的锁形同虚设超卖又来了。5.3 Redisson 与看门狗机制每次手动写 SET NX EX 和 Lua 脚本有点繁琐而且还要自己处理续期问题。生产环境我更推荐直接用 Redisson 封装好的分布式锁。Redisson 的RLock用法和 ReentrantLock 几乎一样RLock lock redissonClient.getLock(order:pay: orderId); boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { return Result.error(系统繁忙请稍后重试); } try { // 业务逻辑 } finally { lock.unlock(); }Redisson 最贴心的是“看门狗”机制默认情况下锁的过期时间是 30 秒只要拿到锁的线程还在执行Redisson 的后台线程就会每 10 秒自动把锁的过期时间续到 30 秒。这样即使业务执行超过 30 秒锁也不会自动失效避免了业务还没跑完锁就被人抢走的问题。但要注意看门狗只在不显式传入 leaseTime 时才生效。如果你在 tryLock 里手动指定了超时时间Redisson 就不会启动看门狗这时锁到点自动释放业务更长的话就要自己想办法了。5.4 Zookeeper 与数据库方案的对比Zookeeper 实现分布式锁用的是“临时顺序节点”。多个线程同时在一个锁路径下创建临时节点序号最小的那个获得锁其他线程监听前一个节点的删除事件。这种方式的好处是客户端与 ZK 之间是长连接客户端挂了临时节点自动消失锁随之释放不会出现 Redis 那种“锁过期但业务没跑完”的窗口。缺点是 ZK 的写入性能和可用性不如 Redis且引入额外组件会增加运维成本。数据库实现比如基于唯一索引的INSERT INTO lock_table最简单但性能最差且连接池耗尽风险高一般只在系统极小的场景才考虑。我个人比较习惯的业务权衡标准是如果是扣库存、支付这种高并发且可以接受极端情况下的“短暂不安全”用 Redis 足够如果是分布式任务调度、同一份数据必须严格串行处理用 ZK 更稳妥。6. Spring Boot 实战把锁落到代码里6.1 业务场景与表结构设计这次实战我们做一个账户余额扣款接口用户提交支付请求后从账户余额里扣钱。接口要求同一账户同一时刻只能有一个扣款请求在执行保证余额不为负数且扣款记录必须正确落库。我先设计一个简单的账户表CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_user_id (user_id) );再建一张流水表用于记录每次扣款CREATE TABLE t_account_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, amount DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );6.2 单机方案synchronized 与事务的经典坑在单机环境我用 synchronized 就能实现“同一账户串行扣款”。但这里有一个极其隐蔽的坑synchronized 锁的是 Java 代码块而事务是由 Spring 的 AOP 控制的事务提交发生在业务方法返回之后。换句话说如果这样写Override public synchronized void deduct(String userId, BigDecimal amount) { // 查询余额 // 扣减余额 // 插入流水 // 方法结束事务此时才提交 }线程 A 执行完扣减逻辑后释放锁但事务还没提交线程 B 立刻拿到锁去查询账户余额它读到的还是线程 A 扣减前的旧数据因为事务隔离级别默认的 RR 下A 的修改对 B 不可见还没提交。于是 B 也认为余额够扣也执行了一次扣减超扣就发生了。解决方案有几个。最稳妥的是用编程式事务把事务边界放到锁的内部public void deductWithLock(String userId, BigDecimal amount) { synchronized (userId.intern()) { transactionTemplate.execute(status - { // 查询余额、扣减、记录流水 }); } }或者用TransactionSynchronizationManager.registerSynchronization在事务提交后再释放锁。这个方案更优雅一些只要保证锁的持有时间包含“事务提交完成”。6.3 集群方案Redisson 分布式锁落地单机场景锁住一个userId.intern()就够了但我们的服务通常会多实例部署所以这里我直接用 Redisson 的分布式锁来实现同一账户全局互斥。核心代码是这样的Service public class AccountServiceImpl implements AccountService { Autowired private RedissonClient redissonClient; Autowired private AccountMapper accountMapper; Autowired private AccountLogMapper accountLogMapper; Autowired private TransactionTemplate transactionTemplate; Override public Result deduct(String userId, BigDecimal amount) { String lockKey account:deduct: userId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { return Result.error(系统繁忙请稍后重试); } boolean success transactionTemplate.execute(status - { Account account accountMapper.selectByUserIdForUpdate(userId); if (account null) { return false; } if (account.getBalance().compareTo(amount) 0) { // 余额不足标记业务失败 status.setRollbackOnly(); return false; } accountMapper.decreaseBalance(userId, amount); AccountLog log new AccountLog(); log.setUserId(userId); log.setAmount(amount); accountLogMapper.insert(log); return true; }); return success ? Result.success() : Result.error(余额不足); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.error(系统异常); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }注意两个细节查询余额时我用了selectByUserIdForUpdate这是一把数据库层面的悲观锁作用是在事务内把账户行锁住防止在“查询余额”到“扣减余额”这段空隙里被其他事务插进来修改。我更愿意把它看成分布式锁的“二次防线”分布式锁防住应用层并发数据库行锁防住底层并发两层都堵死高并发下才安心。释放锁之前检查isHeldByCurrentThread防止某些异常流程中锁已经过期当前线程再调用 unlock 把别人的锁释放掉。6.4 注解化封装一次性解决防腐与复用分布式锁代码如果在每个业务方法里都写一遍很容易出现遗漏。我一向的做法是把分布式锁封装成自定义注解用 AOP 统一处理加锁和解锁业务代码里只需要标一个RedisLock就行。先定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RedisLock { String key(); // 锁的 key支持 SpEL 表达式 long waitTime() default 3; // 获取锁等待时间 long leaseTime() default 30; // 持有锁时间 }再用 AOP 切面实现Aspect Component public class RedisLockAspect { Autowired private RedissonClient redissonClient; Around(annotation(redisLock)) public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable { String lockKey parseKey(redisLock.key(), joinPoint); RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(redisLock.waitTime(), redisLock.leaseTime(), TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后重试); } try { return joinPoint.proceed(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } private String parseKey(String key, ProceedingJoinPoint joinPoint) { // 解析 SpEL 表达式比如 #userId 就取参数名为 userId 的值 // 这里用 LocalVariableTableParameterNameDiscoverer 获取参数名再交给 SpEL 表达式解析 } }封装完成后业务方法只需要这样写RedisLock(key #userId, waitTime 3, leaseTime 30) public Result deduct(String userId, BigDecimal amount) { // 纯业务逻辑 }可读性和可维护性都提高了一大截。切面处理锁的获取和释放业务不感知锁的存在这是我在多个项目里沉淀下来最顺手的一种方式。7. 高频问题与排查经验7.1 死锁怎么发现怎么解决死锁的四个必要条件我在面试时经常考互斥、占有并等待、不可剥夺、循环等待。在 Java 里排查死锁推荐直接用jstack查看线程快照会直接打出Found one Java-level deadlock字样并标明哪些线程持有哪些锁、正在等待哪些锁。有一次线上告警我们通过 jstack 发现两个线程互相持有彼此的锁不放。原因是一个服务先锁 Redis 分布式锁再锁数据库行另一个先锁数据库行再锁分布式锁顺序反了。解决方法是统一加锁顺序所有线程都先拿分布式锁再操作数据库行循环等待自然消失。7.2 锁粒度到底怎么把握锁粒度越粗代码越简单但并发度越低粒度越细并发度高但复杂度上升还可能引发死锁。我之前参与过一个订单批量处理的任务最初直接锁了整个任务前缀结果几个批次任务互相拖慢。后来把锁 key 细化成batch:{批次号}互不关联的任务就能并行执行了。“在保证正确性的前提下能锁多细就锁多细”是我实践下来最有效的一句话。7.3 锁的过期时间不好定怎么办分布式锁的过期时间设太短业务没跑完锁就释放了设太长如果持有锁的节点宕机其他线程要等很久才能拿到锁。我一般分情况处理业务耗时比较均匀的接口直接预估一个偏保守的时间比如平时的 5 倍。业务耗时波动较大的接口使用 Redisson 的看门狗自动续期自己设置 leaseTime 反而容易出问题。还有一种思路是不依赖锁的过期时间而是用“锁续期 业务幂等”双保险就算锁被意外释放重复请求也能通过幂等规则挡住。7.4 用锁之后性能反而更差是怎么回事有些同学反馈加了分布式锁之后吞吐量没升反降。最常见的判断是你锁住了不必要的代码。锁的范围应该只保护“共享资源读改写”这一段而不是把整个方法体都包进去。比如在扣款前做了一个耗时 200ms 的第三方风控调用这段根本不需要锁结果因为锁的范围太粗所有线程都在排队性能自然崩了。把耗时的非共享操作放在锁外锁内只留真正的临界区代码性能问题一般能解决大半。我最后分享一个实践感触锁从来不是随便加的也不是加得越多越好。正确的套路是先把业务并发链路彻底搞清楚问自己三个问题——共享数据是什么并发冲突的概率多大如果锁失效会造成什么后果想清楚后再决定要不要加锁、加哪一级锁、用哪种锁。代码里的锁是你的最后防线但你首先得确保防线里真的躺着需要它保护的东西。