锁相关专题面试要点:从JDK锁、Redis锁到数据库锁的系统梳理
前言
锁相关问题是后端面试里的高频专题,不只包括 JDK 自带的 synchronized、ReentrantLock、AQS,也包括 Redis 分布式锁、数据库锁、ZooKeeper 锁、消息队列串行化和业务幂等设计。
常见问题包括:synchronized 锁的是什么,锁升级怎么理解,synchronized 和 ReentrantLock 有什么区别,AQS 是什么,公平锁和非公平锁怎么选,读写锁适合什么场景,CAS 有什么问题,volatile 能不能替代锁,死锁怎么排查,Redis 分布式锁为什么要设置过期时间,数据库乐观锁和悲观锁怎么选,ZooKeeper 锁和 Redis 锁有什么区别。
如果只背“锁保证线程安全”,回答很容易停留在概念层。更好的方式是把锁理解成一条完整主线:
1 | 共享资源竞争 -> 可见性/原子性/有序性问题 -> 单机锁或分布式协调 -> 阻塞/唤醒/队列管理 -> 性能和正确性权衡 -> 线上排查 |
本文按面试中最常见的模块梳理锁相关要点,适合作为 Java 并发和后端一致性专题的复习清单。
为什么需要锁
多线程访问共享资源时,常见问题有三类:
- 原子性:多个操作不能被打断。
- 可见性:一个线程修改的数据,其他线程能及时看到。
- 有序性:避免指令重排序破坏并发语义。
例如:
1 | count++; |
这行代码看起来是一条语句,实际可能包含读取、加一、写回多个步骤。多个线程同时执行时可能丢失更新。
锁的核心作用是保护临界区:
1 | 进入临界区前加锁 -> 操作共享资源 -> 退出临界区后释放锁 |
flowchart TD
A[多个线程访问共享变量] --> B{是否有同步控制}
B -->|没有| C[竞态条件]
B -->|有| D[进入锁保护的临界区]
D --> E[串行修改共享状态]
E --> F[保证结果正确]
锁的分类与选型地图
面试中不要只把锁理解成 Java 语法。更完整的锁体系可以按保护范围划分:
| 类型 | 代表方案 | 保护范围 | 典型场景 |
|---|---|---|---|
| JVM 内置锁 | synchronized |
单个 JVM 进程内 | 普通对象状态保护 |
| JUC 显式锁 | ReentrantLock、ReadWriteLock、StampedLock |
单个 JVM 进程内 | 超时、中断、公平锁、读写分离 |
| 无锁原子操作 | CAS、Atomic、LongAdder | 单个 JVM 进程内 | 计数、状态流转、轻量并发更新 |
| 数据库锁 | 行锁、间隙锁、乐观锁、唯一约束 | 数据库事务范围内 | 库存、账户、订单状态 |
| Redis 分布式锁 | SET NX PX、Redisson |
多实例服务之间 | 防重复执行、定时任务抢占、短临界区 |
| ZooKeeper/etcd 锁 | 临时节点、租约、顺序节点 | 多实例服务之间 | 强一致协调、主从选举、配置变更 |
| 消息队列串行化 | Kafka 分区、MQ 顺序消息 | 同一业务 key 的消费链路 | 同订单串行处理、削峰、最终一致 |
| 业务幂等 | 唯一流水号、状态机、去重表 | 业务语义层 | 防重复提交、支付回调、重试补偿 |
flowchart TD
A[需要保护共享资源] --> B{资源是否只在单 JVM 内}
B -->|是| C{是否需要高级控制}
C -->|不需要| D[synchronized]
C -->|需要超时/中断/多条件| E[ReentrantLock 或 JUC 工具]
B -->|否| F{是否能用数据库约束表达}
F -->|可以| G[唯一约束/乐观锁/事务行锁]
F -->|不适合| H{是否需要强一致协调}
H -->|需要| I[ZooKeeper/etcd]
H -->|一般短任务互斥| J[Redis 分布式锁]
H -->|可异步串行| K[消息队列按 key 串行]
回答选型题时可以先讲一句总原则:锁只是实现互斥的一种手段,能用唯一约束、状态机、幂等、队列串行化解决的问题,不一定要上分布式锁。
synchronized
synchronized 是 Java 内置锁,可以修饰实例方法、静态方法和代码块。
1 | public synchronized void update() { |
等价于锁当前实例对象。
常见用法:
1 | synchronized (lock) { |
锁对象不同,保护范围不同:
- 实例方法:锁当前对象
this。 - 静态方法:锁当前类的 Class 对象。
- 代码块:锁括号里指定的对象。
面试中要强调:synchronized 锁的是对象,不是代码。多个线程只有竞争同一个锁对象时才会互斥。
synchronized 底层
synchronized 的底层和对象头、Monitor 有关。
Java 对象头中包含 Mark Word,里面会记录锁状态、hash、GC 分代年龄、线程 ID 等信息。重量级锁场景下,对象会关联到 Monitor。
Monitor 可以理解为一个管程结构,里面有进入队列、等待队列和持有锁的线程。
flowchart TD
A[Thread A] --> B{尝试进入 synchronized}
B -->|获取成功| C[Owner 持有 Monitor]
B -->|获取失败| D[EntryList 阻塞等待]
C --> E[执行临界区]
E --> F[释放锁]
F --> G[唤醒等待线程竞争]
H[wait 线程] --> I[WaitSet]
I -->|notify/notifyAll| D
常见追问:
wait会释放锁。sleep不会释放锁。notify只唤醒等待线程,但不立即释放锁。- 被唤醒线程需要重新竞争锁。
锁升级
早期 HotSpot 中 synchronized 有偏向锁、轻量级锁、重量级锁等优化思路,用于减少无竞争或低竞争场景的成本。
简化理解:
1 | 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 |
锁升级是为了在不同竞争程度下选择不同成本的实现:
- 无竞争时尽量少做同步操作。
- 低竞争时通过 CAS 尝试获取锁。
- 高竞争时膨胀为重量级锁,线程阻塞等待。
注意:不同 JDK 版本对偏向锁支持状态不同。面试中更重要的是理解 JVM 会对内置锁做优化,而不是机械背某个版本的细节。
Lock 和 ReentrantLock
Lock 是 JUC 提供的显式锁接口,ReentrantLock 是最常用实现。
1 | Lock lock = new ReentrantLock(); |
和 synchronized 相比,ReentrantLock 提供更多能力:
- 可中断获取锁。
- 可设置超时获取锁。
- 可选择公平锁或非公平锁。
- 支持多个 Condition 条件队列。
- 需要手动释放锁。
常见区别:
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 类型 | JVM 内置关键字 | JUC 类库 |
| 释放方式 | 自动释放 | 手动 unlock |
| 可中断 | 不支持普通阻塞中断 | 支持 lockInterruptibly |
| 超时获取 | 不支持 | 支持 tryLock |
| 公平锁 | 不支持配置 | 支持公平/非公平 |
| 条件队列 | 一个 wait set | 多个 Condition |
如果只是普通同步,synchronized 更简单;如果需要可中断、超时、公平或多个条件队列,使用 ReentrantLock 更灵活。
AQS
AQS 是 AbstractQueuedSynchronizer,是很多 JUC 同步器的基础。
核心思想:
- 用一个 volatile int state 表示同步状态。
- 用 CAS 修改 state。
- 获取失败的线程进入 CLH 队列。
- 释放锁时唤醒后继节点。
flowchart TD
A[线程尝试获取锁] --> B{CAS 修改 state 成功}
B -->|成功| C[获得锁]
B -->|失败| D[封装为 Node]
D --> E[进入 AQS 队列]
E --> F[阻塞等待]
G[持锁线程释放锁] --> H[修改 state]
H --> I[唤醒后继节点]
I --> A
基于 AQS 的常见组件:
- ReentrantLock。
- ReentrantReadWriteLock。
- Semaphore。
- CountDownLatch。
- CyclicBarrier 部分依赖锁和条件队列。
面试中可以这样回答:
AQS 帮同步器解决排队、阻塞、唤醒和状态管理问题。具体同步语义由子类通过 tryAcquire、tryRelease 等方法定义。
公平锁和非公平锁
公平锁按照等待顺序获取锁,非公平锁允许新来的线程插队竞争。
ReentrantLock 默认是非公平锁:
1 | new ReentrantLock(); // 非公平 |
公平锁优点:
- 等待时间更均衡。
- 避免线程长期饥饿。
公平锁缺点:
- 吞吐通常更低。
- 线程切换更多。
非公平锁优点:
- 吞吐通常更高。
- 减少唤醒后还没运行时锁又空闲的浪费。
生产中多数场景使用非公平锁。只有对等待顺序有明确要求时才考虑公平锁。
可重入锁
可重入表示同一个线程已经持有锁时,可以再次获取同一把锁。
1 | public synchronized void a() { |
可重入的意义是避免同一线程递归调用或内部调用时被自己阻塞。
synchronized 和 ReentrantLock 都是可重入锁。
ReadWriteLock
读写锁适合读多写少场景。
规则:
- 多个读锁可以同时持有。
- 写锁和读锁互斥。
- 写锁和写锁互斥。
1 | ReadWriteLock rwLock = new ReentrantReadWriteLock(); |
读写锁能提升读多写少场景吞吐,但不适合写很多的场景。写多时读写锁会频繁互斥,复杂度增加但收益有限。
StampedLock
StampedLock 是 JDK 8 引入的锁,支持:
- 写锁。
- 悲观读锁。
- 乐观读。
乐观读不阻塞写,但读取后需要校验 stamp 是否有效。
1 | long stamp = lock.tryOptimisticRead(); |
StampedLock 适合读多写少且允许乐观读校验的场景。它不是可重入锁,使用复杂度比 ReentrantReadWriteLock 更高。
volatile
volatile 不是锁,但常被和锁一起问。
volatile 能保证:
- 可见性。
- 禁止指令重排序。
volatile 不能保证:
- 复合操作原子性。
例如:
1 | volatile int count = 0; |
count++ 仍然不是线程安全的,因为它包含读、加、写多个步骤。
volatile 适合状态标志:
1 | private volatile boolean running = true; |
也常用于双重检查锁定:
1 | private static volatile Singleton instance; |
CAS
CAS 是 Compare And Swap,比较并交换,是无锁并发的基础。
逻辑是:
1 | 如果当前值 == 预期值,则更新为新值;否则失败重试 |
Java 中 AtomicInteger 等原子类大量使用 CAS。
1 | AtomicInteger count = new AtomicInteger(); |
CAS 优点:
- 避免阻塞。
- 在低竞争场景性能好。
CAS 问题:
- ABA 问题。
- 自旋过多消耗 CPU。
- 只能保证单个变量原子更新,复杂一致性仍需要锁或更高层设计。
ABA 可以用版本号思路解决,例如 AtomicStampedReference。
ThreadLocal
ThreadLocal 不是锁,它通过线程隔离避免共享变量竞争。
常见用途:
- 用户上下文。
- traceId。
- 数据库连接或事务上下文。
- 日期格式化对象。
风险:
- 线程池复用线程时,ThreadLocal 不清理会造成上下文污染。
- ThreadLocalMap 的 key 是弱引用,但 value 可能残留。
- 大对象放入 ThreadLocal 容易造成内存泄漏。
最佳实践:
1 | try { |
死锁
死锁是多个线程互相等待对方持有的资源,导致都无法继续执行。
死锁四个必要条件:
- 互斥。
- 请求并保持。
- 不可剥夺。
- 循环等待。
典型代码:
1 | synchronized (lockA) { |
另一个线程反过来先拿 lockB 再拿 lockA,就可能死锁。
flowchart LR
A[Thread A 持有 lockA] --> B[等待 lockB]
C[Thread B 持有 lockB] --> D[等待 lockA]
B --> C
D --> A
解决思路:
- 固定加锁顺序。
- 减少锁嵌套。
- 使用 tryLock 超时退出。
- 缩小锁粒度。
- 避免在持锁期间调用外部接口。
锁优化
常见锁优化方向:
- 缩小锁范围。
- 降低锁粒度。
- 减少锁持有时间。
- 避免锁内远程调用和 IO。
- 读多写少使用读写锁。
- 低冲突计数使用原子类或 LongAdder。
- 使用并发容器替代手写加锁。
- 分段锁或按 key 加锁。
示例:按用户维度加锁,而不是全局加锁。
1 | 全局锁:所有用户请求串行 |
但按 key 加锁要注意锁对象生命周期,避免 Map 中锁对象无限增长。
synchronized 和 Lock 怎么选
普通场景优先考虑 synchronized:
- 语义简单。
- 自动释放锁。
- JVM 优化成熟。
- 代码不容易漏 unlock。
需要高级能力时考虑 ReentrantLock:
- 需要 tryLock。
- 需要可中断。
- 需要公平锁。
- 需要多个 Condition。
- 需要更灵活的锁获取和释放控制。
并发工具优先级可以这样理解:
1 | 能无共享就无共享 -> 能用并发容器就用并发容器 -> 简单互斥用 synchronized -> 复杂控制用 JUC Lock -> 跨进程再考虑分布式锁 |
分布式锁
单 JVM 内的锁只能保护当前进程内的共享资源。多个服务实例同时操作同一资源时,需要分布式锁或其他一致性设计。
常见实现:
- Redis 分布式锁。
- ZooKeeper 临时顺序节点。
- etcd 租约和事务。
- 数据库唯一约束、乐观锁或悲观锁。
- 消息队列按业务 key 串行消费。
- 业务幂等和状态机。
Redis 分布式锁
Redis 分布式锁适合短时间、低成本的互斥控制,例如防止多个服务实例同时执行同一个定时任务、同一用户请求重复提交、同一资源短时间重复处理。
最基本的加锁方式:
1 | SET lock_key request_id NX PX 30000 |
关键要求:
- 加锁要原子。
- 必须设置过期时间。
- 解锁要校验 owner,不能删别人的锁。
- 解锁脚本要原子执行。
- 业务执行时间要小于锁过期时间,或有续期机制。
- 加锁失败时要有明确策略:立即失败、重试、排队还是降级。
sequenceDiagram
participant Client
participant Redis
participant Resource
Client->>Redis: SET key requestId NX PX ttl
Redis-->>Client: OK
Client->>Resource: 执行业务
Client->>Redis: Lua 校验 requestId 后删除 key
Redis-->>Client: 删除结果
常见追问:
- 为什么 value 要放唯一 requestId:为了释放锁时确认自己是 owner,避免误删别人后来加上的锁。
- 为什么释放锁要用 Lua:校验 owner 和删除 key 必须是一个原子操作。
- 锁过期了业务还没执行完怎么办:要么合理评估 TTL,要么使用看门狗续期,要么让业务天然幂等。
- Redis 主从切换会不会有问题:主从异步复制可能导致锁丢失,强一致要求高时要谨慎评估。
- Redisson 做了什么:封装加锁、解锁、续期、可重入、等待重试等能力,但业务幂等仍然要自己保证。
数据库锁
数据库本身也是很重要的锁实现位置,尤其是订单、库存、账户余额这类最终落库的数据。
常见方式:
- 悲观锁:
select ... for update,依赖事务行锁。 - 乐观锁:
version字段或状态条件更新。 - 唯一约束:利用数据库唯一索引防重复插入。
- 状态机:只允许合法状态迁移,例如
待支付 -> 已支付。
库存扣减常见写法:
1 | update sku |
带版本号的乐观锁:
1 | update sku |
数据库锁的优势是和数据修改在同一个事务边界内,正确性更容易解释。缺点是容易增加数据库压力,事务过长还可能导致锁等待、死锁和吞吐下降。
ZooKeeper 和 etcd 锁
ZooKeeper 常见做法是使用临时顺序节点:
- 客户端在锁目录下创建临时顺序节点。
- 序号最小的节点获得锁。
- 其他客户端监听自己前一个节点。
- 持锁客户端释放锁或会话断开,临时节点删除。
- 后继节点被唤醒并竞争锁。
sequenceDiagram
participant C1 as Client A
participant C2 as Client B
participant ZK as ZooKeeper
C1->>ZK: 创建临时顺序节点 lock-0001
C2->>ZK: 创建临时顺序节点 lock-0002
C2->>ZK: 监听 lock-0001
C1->>ZK: 删除 lock-0001 或会话断开
ZK-->>C2: 通知前驱节点删除
C2->>ZK: 判断自己序号最小,获得锁
它的优势是强一致协调能力更好,适合主从选举、配置变更、分布式调度等场景。缺点是系统复杂度和性能成本高于 Redis,不适合每个高频业务请求都去抢锁。
etcd 常用租约、事务和 revision 实现分布式锁,思路和 ZooKeeper 类似:依赖一致性存储、租约过期释放、按创建顺序竞争。
消息队列串行化
有些问题不一定要加锁,可以把同一业务 key 的消息发到同一个分区或同一个顺序队列,让消费天然串行。
例如:
- 同一个订单号发送到 Kafka 同一分区。
- 同一个用户账户变更发送到同一顺序队列。
- 同一商品库存变更按商品 ID 分片处理。
这种方式适合可以异步化、能接受最终一致的场景。它避免了大量分布式锁竞争,但要处理消息重复、消费失败、重试和积压问题。
分布式锁选型对比
| 方案 | 优点 | 风险 | 适合场景 |
|---|---|---|---|
| Redis 锁 | 性能高、实现简单、生态成熟 | TTL、续期、主从切换、误删锁 | 短临界区、定时任务抢占、防重复处理 |
| 数据库锁 | 和数据事务一致,语义直观 | 锁等待、死锁、数据库压力 | 库存、账户、订单状态 |
| 唯一约束 | 简单可靠,天然防重复 | 只能表达唯一性,冲突靠异常处理 | 防重复创建、幂等流水 |
| ZooKeeper/etcd | 强一致协调能力好 | 成本高、吞吐不适合高频业务锁 | 选主、调度、配置变更 |
| MQ 串行化 | 削峰、顺序处理、避免抢锁 | 延迟、积压、重复消费 | 订单流转、账户流水、最终一致 |
面试中要注意:分布式锁不是银弹。如果能用数据库唯一约束、状态机、幂等、队列串行化或乐观锁解决,通常比引入分布式锁更简单可靠。
乐观锁和悲观锁
悲观锁认为冲突经常发生,所以先加锁再操作。
例如数据库:
1 | select * from account where id = 1 for update; |
乐观锁认为冲突不常发生,先不加锁,提交时校验版本。
1 | update product |
选择原则:
- 冲突少、读多写少:乐观锁。
- 冲突多、强一致修改:悲观锁。
- 高并发扣库存:还要结合限流、队列、分段库存、缓存和数据库能力综合设计。
常见排查思路
如果面试官问“Java 服务线程卡住怎么排查”,可以按以下路径回答:
- 看接口 RT、线程池活跃数、队列和拒绝数。
- 用
jstack查看线程状态。 - 关注
BLOCKED、WAITING、TIMED_WAITING线程。 - 查找是否有 deadlock 信息。
- 定位竞争的锁对象和持锁线程。
- 看持锁线程是否在执行慢 SQL、远程调用、IO 或死循环。
- 结合日志 traceId 和业务代码确认临界区。
如果 CPU 高:
- 找高 CPU 进程。
- 找高 CPU 线程。
- 转十六进制线程 ID。
- 对照 jstack 线程栈。
- 判断是自旋、死循环、频繁 CAS、GC 还是计算热点。
如果是分布式锁异常:
- 看是否加锁成功率下降。
- 看锁过期时间是否过短。
- 看业务执行是否超过 TTL。
- 看是否误删其他线程或实例的锁。
- 看 Redis 延迟、主从切换和网络抖动。
- 看 ZooKeeper/etcd 是否有会话抖动、租约过期或节点堆积。
- 看数据库是否有锁等待、死锁、慢事务或索引缺失。
- 看是否缺少幂等和补偿。
高频面试题
synchronized 锁的是什么?
锁的是对象。实例同步方法锁当前实例,静态同步方法锁 Class 对象,同步代码块锁指定对象。只有多个线程竞争同一个锁对象时才会互斥。
synchronized 和 ReentrantLock 有什么区别?
synchronized 是 JVM 内置锁,自动释放,简单可靠;ReentrantLock 是 JUC 显式锁,需要手动 unlock,但支持可中断、超时、公平锁和多个 Condition。
AQS 是什么?
AQS 是 JUC 同步器基础框架,用 state 表示同步状态,用 CAS 修改状态,用队列管理获取失败的线程。ReentrantLock、Semaphore、CountDownLatch 等都基于 AQS。
volatile 能保证线程安全吗?
只能保证可见性和禁止重排序,不能保证复合操作原子性。状态标志适合用 volatile,计数累加这类操作需要锁或原子类。
CAS 有什么问题?
常见问题是 ABA、自旋消耗 CPU、只能保证单变量原子更新。ABA 可以通过版本号或 AtomicStampedReference 解决。
读写锁适合什么场景?
适合读多写少场景。多个读可以并发,写和读、写和写互斥。如果写很多,读写锁收益可能不明显。
死锁怎么避免?
固定加锁顺序、减少锁嵌套、缩小锁范围、使用 tryLock 超时退出、避免持锁期间调用外部接口或执行慢操作。
Redis 分布式锁要注意什么?
加锁必须原子,必须设置过期时间,解锁必须校验 owner,解锁用 Lua 保证原子性,业务耗时要小于锁 TTL 或有续期机制,并且业务侧仍要做幂等和补偿。
Redis 锁和 ZooKeeper 锁怎么选?
Redis 锁性能更高、实现更轻,适合短临界区和一般业务互斥。ZooKeeper 锁一致性协调能力更强,适合主从选举、分布式调度、配置变更等强协调场景,但成本和复杂度更高。
数据库乐观锁和 Redis 分布式锁有什么区别?
数据库乐观锁通常保护最终数据更新,通过 version 或条件更新判断是否冲突;Redis 分布式锁是在执行业务前先做跨实例互斥。能直接用数据库条件更新保证正确性的场景,通常优先数据库乐观锁。
唯一约束算不算锁?
严格说它不是显式加锁,但它能实现并发下的互斥语义,比如同一个业务流水只能插入一次。面试中可以把它归为“用数据库约束替代分布式锁”的方案。
MQ 顺序消费能替代锁吗?
部分场景可以。把同一业务 key 的消息路由到同一分区或顺序队列,可以让处理天然串行。但它适合异步和最终一致场景,不适合必须同步返回强一致结果的接口。
总结
锁相关面试的主线可以围绕四个问题展开:
- 单机锁怎么保证线程安全:synchronized、Monitor、Lock、AQS。
- 不同锁怎么选型:公平锁、非公平锁、可重入锁、读写锁、StampedLock。
- 无锁和隔离怎么理解:volatile、CAS、原子类、ThreadLocal。
- 跨进程锁怎么设计:Redis、数据库、ZooKeeper/etcd、MQ 串行化。
- 线上问题怎么治理:死锁、锁竞争、线程阻塞、锁等待、幂等和补偿。
把这条链路讲清楚,再结合 synchronized、AQS、ReentrantLock、volatile、CAS、Redis 锁、数据库锁和 ZooKeeper 锁的取舍,锁相关问题就能从“会背概念”升级成“能解释并发正确性、系统边界和性能权衡”。


