一、💡 一句话理解
核心结论
Java 锁不是把一段代码永久“锁死”,而是让多个线程访问同一份共享数据时,先竞争同一把锁:拿到锁的线程进入临界区,其他线程等待。Java 锁只在同一个 JVM 内有效,跨 Pod 或跨 JVM 的互斥需要使用 Redis 分布式锁 等分布式协调手段。
如果对线程和线程池还不熟悉,可以先阅读 线程。
二、🧭 理论:锁到底在解决什么问题
2.1 前提:多个线程共享同一份可变数据
下面三个条件同时出现时,才需要考虑线程安全:
- 有多个线程。
- 这些线程访问同一个对象或变量。
- 至少一个线程会修改它。
例如,两个线程同时操作同一个计数器:
count++;这行代码看起来只有一步,实际可以理解为:
读取 count
→ 加 1
→ 把新值写回 count假设 count 原来是 0,线程 A 和线程 B 都在对它加一:
线程 A 读取 0
线程 B 读取 0
线程 A 写入 1
线程 B 写入 1两个线程总共执行了两次加一,结果却是 1。线程 A 的结果被线程 B 覆盖了,这叫线程干扰(thread interference),也常被称为丢失更新。
2.2 临界区是什么
操作共享数据、不能被多个线程交错执行的代码区域,叫作临界区(critical section)。
// 这段“读取 → 修改 → 写回”应该作为一个整体执行。
count++;锁的任务,就是保护临界区:
线程 A 获得锁 → 执行临界区 → 释放锁
线程 B 等待 → 获得锁 → 执行临界区 → 释放锁这种“同一时间只允许一个线程进入”的性质叫作互斥(mutual exclusion)。
2.3 锁住的到底是什么
经常听到“锁住方法”“锁住代码”,但 Java 真正竞争的是一个锁对象。
private final Object lock = new Object();
public void increment() {
// 多个线程只有竞争同一个 lock 对象,才能形成互斥。
synchronized (lock) {
count++;
}
}synchronized 后面的对象可以理解为会议室钥匙。代码块是会议室,锁对象是钥匙。两个线程使用不同的钥匙,就算它们执行的是同一段代码,也不会互相等待。
锁必须一致
保护同一份共享数据的所有读写操作,都要遵守同一套加锁规则并竞争同一把锁。只给写方法加锁、读取时完全不加锁,仍可能读到不一致或不可见的数据。
2.4 锁同时解决互斥和可见性
Java 锁不只让线程排队,还建立内存可见性保证:一个线程释放某把锁之前的修改,对之后成功获得同一把锁的线程可见。
Java 内存模型把这种先后保证称为 happens-before。初学时可以先记住:
线程 A 修改数据并释放锁
happens-before
线程 B 随后获得同一把锁并读取数据如果没有这样的同步关系,不同线程可能对同一份数据看到不同的值,这叫内存一致性错误(memory consistency error)。
三、⚙️ 理论:Java 中常见的同步方式
3.1 synchronized:使用对象自带的内置锁
每个 Java 对象都关联着一个内置锁(intrinsic lock),也叫监视器锁(monitor lock)。synchronized 会自动获得并释放这把锁。
3.1.1 同步代码块
private int count;
private final Object lock = new Object();
public void increment() {
// 进入代码块前获得 lock 的内置锁。
synchronized (lock) {
count++;
}
// 正常结束或抛出异常离开代码块时,锁都会自动释放。
}同步代码块可以准确控制临界区大小。没有访问共享状态的耗时操作,通常不必放进锁里。
3.1.2 实例同步方法
public synchronized void increment() {
count++;
}实例同步方法竞争的是当前对象,也就是 this 的内置锁。下面两种写法的锁对象相同:
public synchronized void increment() {
count++;
}
public void incrementEquivalent() {
synchronized (this) {
count++;
}
}如果创建了两个不同实例,它们各自拥有自己的锁,彼此不会阻塞:
Counter first = new Counter();
Counter second = new Counter();
// first 和 second 不是同一个对象,因此不是同一把锁。3.1.3 静态同步方法
public static synchronized void incrementGlobalCount() {
globalCount++;
}静态同步方法竞争的是 当前类.class 对象的内置锁,不是某个业务实例的锁:
public static void incrementGlobalCountEquivalent() {
synchronized (Counter.class) {
globalCount++;
}
}因此,实例同步方法和静态同步方法通常不是同一把锁,不能互相保护同一份共享状态。
3.2 可重入是什么意思
synchronized 和 ReentrantLock 都是可重入锁(reentrant lock)。同一个线程已经持有某把锁时,可以再次获得它,而不会把自己阻塞住。
public synchronized void outer() {
// 当前线程已经持有 this 的锁。
inner();
}
public synchronized void inner() {
// 仍然是同一个线程、同一个 this 锁,因此可以再次进入。
}可重入解决的是“线程调用了另一个也需要同一把锁的方法”这种常见场景。它不代表别的线程可以一起进入。
3.3 ReentrantLock:需要更多控制时使用
ReentrantLock 与 synchronized 一样可以提供互斥和内存可见性,但它还支持:
tryLock():拿不到锁就立即返回,不一直等待。- 带超时的
tryLock(...):只等待指定时间。 lockInterruptibly():等待锁时可以响应线程中断。- 可选公平策略:等待较久的线程更优先,但通常会降低吞吐量。
Condition:实现更灵活的条件等待。
基本写法:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockCounter {
private int count;
// final 防止程序运行中把 lock 换成另一个对象。
private final Lock lock = new ReentrantLock();
public void increment() {
// lock:获得锁;如果其他线程持有锁,当前线程在这里等待。
lock.lock();
try {
count++;
} finally {
// unlock:即使业务代码抛出异常,也必须释放锁。
lock.unlock();
}
}
}必须在
finally中解锁
ReentrantLock不会像synchronized那样自动释放。如果获得锁后发生异常而没有执行unlock(),其他线程可能一直等下去。
3.4 volatile、原子类和锁怎么选
它们解决的问题有重叠,但不是一回事:
| 工具 | 适合解决的问题 | 不适合的情况 |
|---|---|---|
volatile | 一个线程修改简单状态,其他线程需要及时看见,例如停止标记 | count++、先判断再修改等复合操作 |
AtomicInteger | 对单个数值做原子加减、比较并更新 | 同时维护多个变量之间的业务约束 |
synchronized | 保护一段临界区,语法简单且自动释放锁 | 需要超时获取、可中断等待等精细控制 |
ReentrantLock | 保护一段临界区,并需要 tryLock、超时或条件队列 | 简单场景中可能增加忘记解锁的风险 |
| 并发容器 | 多线程共享集合,例如 ConcurrentHashMap | 一个容器操作线程安全,不代表多步业务流程整体原子 |
volatile int count 仍不能让 count++ 变成原子操作,因为它只能保证读写的可见性,不能把“读取、加一、写回”合并成不可分割的一步。
3.5 常见错误:锁对象不同
下面的代码看起来加了锁,实际没有互斥:
public void increment() {
// 每次调用都创建一把新锁,线程之间不会竞争同一个对象。
Object lock = new Object();
synchronized (lock) {
count++;
}
}锁对象通常应是稳定的成员变量:
private final Object lock = new Object();也不要使用可能在业务代码中被替换或被外部代码持有的对象作为锁。专用的 private final 锁对象更容易控制。
3.6 常见风险:死锁
两个线程分别拿着对方需要的锁,并且都不释放,就形成死锁(deadlock):
线程 A:持有锁 1,等待锁 2
线程 B:持有锁 2,等待锁 1最基础的规避方式是:程序中需要同时获得多把锁时,始终使用统一的加锁顺序。
统一规定:先获得锁 1,再获得锁 2此外,应尽量缩小临界区,避免持锁期间执行无法控制耗时的网络调用。
3.7 Java 锁为什么不能代替分布式锁
Java 锁对象只存在于当前 JVM 的内存中:
Pod A / JVM A:lock 对象 A
Pod B / JVM B:lock 对象 B即使两边的变量名和代码完全相同,它们也不是同一个对象。Pod A 获得 Java 锁,不会阻止 Pod B 执行业务。
因此要先判断并发发生在哪里:
| 并发范围 | 常见手段 |
|---|---|
| 同一个 JVM 中的线程 | synchronized、ReentrantLock、原子类、并发容器 |
| 多个 JVM 或多个 Pod | Redis、ZooKeeper 等分布式协调机制 |
| 数据库记录并发修改 | 事务、行锁、乐观锁、唯一约束、条件更新 |
不要看到多 Pod 就立即加分布式锁
如果数据库事务、唯一约束、乐观锁或幂等设计已经能保证正确性,就不需要额外增加 Redis 锁。锁的选择取决于共享资源在哪里,而不是服务有多少个 Pod。
3.8 乐观锁与悲观锁
乐观锁和悲观锁首先是两种并发控制思想,不是专指某两个 Java 类。
悲观锁:先锁住,再操作。
乐观锁:先操作,提交修改时再检查有没有冲突。3.8.1 悲观锁:先认为冲突会发生
悲观锁认为其他线程或事务很可能同时修改数据,因此先获得锁,成功后才继续操作。
synchronized、ReentrantLock 和 Redis 分布式锁都体现了这种思路:
获得锁
→ 读取或修改共享资源
→ 释放锁在数据库中,常见例子是在事务内使用 SELECT ... FOR UPDATE:
BEGIN;
-- 查询账号并尝试获得相应的数据库锁。
SELECT id, nickname
FROM account
WHERE id = 1001
FOR UPDATE;
-- 当前事务持有锁期间再修改账号。
UPDATE account
SET nickname = '小明'
WHERE id = 1001;
COMMIT;事务 A 获得锁后,其他事务如果也要修改受保护的数据,通常需要等待事务 A 提交或回滚。具体锁定范围和等待行为由数据库、索引、隔离级别与执行计划共同决定,不能只凭 SQL 外观断定。
悲观锁适合并发冲突较多、修改过程较短,而且不能接受频繁失败重试的场景。代价是等待和阻塞;同时持有多把锁时,还要注意死锁。
3.8.2 乐观锁:更新时检查数据有没有变化
乐观锁认为并发冲突通常不多,因此读取时不长期占住数据库锁,而是在更新时检查数据是否仍是自己读到的版本。
最常见的实现是增加 version 字段。假设账号当前版本是 5:
-- 只有数据库中的版本仍然是 5,才能更新成功。
UPDATE account
SET nickname = '小明',
version = version + 1
WHERE id = 1001
AND version = 5;执行结果有两种:
- 影响行数为
1:版本没有变化,本次更新成功,版本变为6。 - 影响行数为
0:读取后已有其他事务修改过数据,本次更新发生冲突。
发生冲突后,程序需要根据业务选择重新读取、有限次数重试,或者提示用户“数据已经被修改”。不能忽略影响行数,否则乐观锁就没有真正生效。
在 Jakarta Persistence 中,可以使用 @Version 让持久化框架维护版本并检测冲突:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
@Entity
public class Account {
@Id
private Long id;
private String nickname;
// @Version:持久化框架更新实体时检查并递增版本。
// 如果数据库版本已经变化,会报告乐观锁冲突。
@Version
private Long version;
}具体抛出异常的时机可能在刷新或提交事务时。检测到冲突后,Jakarta Persistence 会抛出 OptimisticLockException,相应事务需要回滚。
3.8.3 两种策略怎么选
| 对比项 | 悲观锁 | 乐观锁 |
|---|---|---|
| 基本思路 | 先占住资源,再执行操作 | 不提前长期占锁,更新时检查版本 |
| 冲突结果 | 其他线程或事务通常等待 | 当前更新失败,由程序处理冲突 |
| 常见实现 | synchronized、ReentrantLock、数据库行锁、Redis 锁 | version 字段、条件更新、CAS |
| 适合场景 | 冲突频繁,操作较短 | 读多写少,冲突较少 |
| 主要风险 | 阻塞、吞吐量下降、死锁 | 冲突后需要重试或提示失败 |
AtomicInteger 常用的 CAS(比较并交换)也体现了乐观思想:先比较当前值是不是预期旧值,相同才更新;不相同说明其他线程已经修改过。
选择关键
不要只比较哪个锁“性能更好”。冲突很少时,乐观锁可以避免长期阻塞;冲突非常频繁时,大量乐观锁失败和重试也可能更昂贵。
四、🚀 实践:从错误计数到正确加锁
4.1 前置准备
本例不需要 Maven、Spring 或第三方依赖,只需要 JDK 17 或更高版本。
检查环境:
java -version
javac -version下面的代码可以保存为任意临时目录中的 JavaLockDemo.java。它会让多个线程分别测试:
- 不加锁的计数器。
- 使用
synchronized的计数器。 - 使用
ReentrantLock的计数器。
4.2 可以拿来干什么
4.2.1 保护同一个 JVM 内的共享对象
例如,一个 Spring 单例 Bean 中保存了会变化的内存状态。多个请求线程可能同时调用它,就需要使用线程安全的设计。
public class Inventory {
private int stock = 10;
public synchronized boolean deduct() {
// 判断和扣减必须作为一个整体执行。
if (stock <= 0) {
return false;
}
stock--;
return true;
}
}输入是多个线程对同一个 Inventory 实例的调用;处理过程是在同一把 this 锁内完成“判断库存和扣减”;输出是最多只有 10 次调用返回 true。
不过,实际库存通常保存在数据库中,而且服务可能有多个 Pod。这时不能只依赖 Java 锁,应优先使用数据库条件更新、乐观锁或其他跨进程方案。
4.2.2 获取不到锁时快速放弃
当业务不希望线程无限等待时,可以使用 tryLock():
import java.util.concurrent.locks.ReentrantLock;
public class ReportGenerator {
private final ReentrantLock lock = new ReentrantLock();
public boolean generate() {
// tryLock:只尝试一次;锁正被占用时立即返回 false。
if (!lock.tryLock()) {
return false;
}
try {
// 当前线程已获得锁,在这里生成报告。
return true;
} finally {
// 只有成功获得锁后,才执行 unlock。
lock.unlock();
}
}
}输入是多个线程同时调用同一个 ReportGenerator;第一个线程获得锁并执行,其他线程立即得到 false,适合“任务已在执行,请稍后再试”这类单 JVM 场景。
4.3 完整实践:代码、运行和验证
新建 JavaLockDemo.java,写入以下完整代码:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class JavaLockDemo {
// 每一种计数器都提供相同的方法,便于使用同一套并发测试代码。
interface Counter {
void increment();
int get();
}
static class UnsafeCounter implements Counter {
private int count;
@Override
public void increment() {
// count++ 包含“读取、加一、写回”,不是原子操作。
count++;
}
@Override
public int get() {
return count;
}
}
static class SynchronizedCounter implements Counter {
private int count;
@Override
public synchronized void increment() {
// 实例同步方法获得当前 SynchronizedCounter 对象的内置锁。
count++;
}
@Override
public synchronized int get() {
// 读取和写入都使用同一把 this 锁。
return count;
}
}
static class ReentrantLockCounter implements Counter {
private int count;
private final Lock lock = new ReentrantLock();
@Override
public void increment() {
// lock:获得显式锁;锁被占用时,当前线程等待。
lock.lock();
try {
count++;
} finally {
// unlock:放在 finally 中,防止异常导致锁无法释放。
lock.unlock();
}
}
@Override
public int get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
runTest("不加锁", new UnsafeCounter());
runTest("synchronized", new SynchronizedCounter());
runTest("ReentrantLock", new ReentrantLockCounter());
}
private static void runTest(String name, Counter counter)
throws InterruptedException {
int taskCount = 100;
int incrementsPerTask = 10_000;
int expected = taskCount * incrementsPerTask;
// 创建固定大小的线程池,让多个工作线程并发处理任务。
ExecutorService pool = Executors.newFixedThreadPool(8);
// CountDownLatch:初始值等于任务数;每完成一个任务就减一。
CountDownLatch done = new CountDownLatch(taskCount);
for (int task = 0; task < taskCount; task++) {
// submit:把任务交给线程池,任务可能由不同线程同时执行。
pool.submit(() -> {
try {
for (int i = 0; i < incrementsPerTask; i++) {
counter.increment();
}
} finally {
// countDown:无论任务正常结束还是抛出异常,都标记一次完成。
done.countDown();
}
});
}
// await:主线程等待所有计数任务结束,再读取最终结果。
done.await();
// shutdown:不再接收新任务,已提交的任务仍会执行完。
pool.shutdown();
// awaitTermination:等待线程池真正结束,避免残留工作线程。
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
System.out.printf(
"%-15s 预期值:%d,实际值:%d%n",
name,
expected,
counter.get()
);
}
}在文件所在目录编译并运行:
javac JavaLockDemo.java
java JavaLockDemo一次可能的结果:
不加锁 预期值:1000000,实际值:721364
synchronized 预期值:1000000,实际值:1000000
ReentrantLock 预期值:1000000,实际值:1000000结果解释:
- 不加锁时,多个线程的“读取、加一、写回”相互覆盖,实际值通常小于预期值。
synchronized让同一时间只有一个线程执行count++。ReentrantLock实现了相同的互斥效果,但锁的获得和释放由代码显式控制。- 不加锁的结果具有不确定性,偶尔也可能刚好等于预期值;这不能证明代码线程安全。
4.4 关键报错与排查
| 现象 | 常见原因 | 排查方式 |
|---|---|---|
| 加锁后结果仍不正确 | 不同方法使用了不同锁对象 | 检查所有共享数据访问是否竞争同一个稳定的锁对象 |
| 程序一直不结束 | ReentrantLock 获得后没有释放,或发生死锁 | 检查 unlock() 是否在 finally 中,以及多把锁的获取顺序 |
| 实例方法之间没有互斥 | 实际操作的是不同对象实例 | 打印或检查对象来源,确认线程共享同一个实例 |
| 单机正常,多 Pod 仍重复执行 | Java 锁只能作用于当前 JVM | 改用数据库约束、幂等设计或合适的分布式协调方案 |
使用 volatile 后计数仍丢失 | volatile 不保证复合操作原子性 | 改用 AtomicInteger 或用锁保护完整操作 |
五、🧾 如何选择
遇到并发问题时,可以按下面的顺序判断:
是否真的有共享的可变数据?
├─ 没有 → 通常不需要锁
└─ 有
↓
共享范围是否只在一个 JVM?
├─ 是 → 简单临界区优先 synchronized
│ 需要超时、可中断等控制时使用 ReentrantLock
│ 单变量简单更新可考虑原子类
└─ 否
↓
共享资源是否是数据库记录?
├─ 是 → 冲突少时考虑 version 乐观锁或条件更新
│ 冲突多且必须等待时考虑事务与悲观锁
└─ 否 → 检查幂等设计或合适的分布式锁不要先问“应该使用哪一种锁”,而要先问:
- 哪些线程会访问哪一份共享数据?
- 哪几步操作必须作为一个整体?
- 所有访问是否真的竞争同一把锁?
- 这些线程是否处于同一个 JVM?
- 能否使用数据库约束或无共享设计直接消除问题?
六、📌 总结
- 锁保护的是对共享状态的一段完整操作,也就是临界区。
- 多个线程必须竞争同一个锁对象,互斥才会生效。
synchronized使用对象的内置锁,离开代码块时自动释放。ReentrantLock支持更多控制,但必须在finally中调用unlock()。volatile主要解决可见性,不能让count++变成原子操作。- Java 锁只在同一个 JVM 内有效,不能直接协调多个 Pod。
- 悲观锁是“先锁再操作”,乐观锁是“更新时检查版本”。
- 乐观锁冲突后必须检查结果,并选择重试、重新读取或提示失败。
记忆句:先找共享数据,再圈出临界区;悲观锁先占座,乐观锁结账时对版本。
七、📚 官方资料
版本说明
Oracle 的并发教程以 JDK 8 编写,本文用它确定“线程干扰 → 内存一致性 → synchronized → 内置锁”的学习顺序;API 行为同时参考 Java SE 25 文档。本文使用的基础概念和示例适用于现代 JDK。
- Oracle Java Tutorial:Thread Interference
- Oracle Java Tutorial:Memory Consistency Errors
- Oracle Java Tutorial:Synchronized Methods
- Oracle Java Tutorial:Intrinsic Locks and Synchronization
- Java SE 25 API:Lock
- Java SE 25 API:ReentrantLock
- Java SE 25 API:java.util.concurrent.atomic
- Jakarta EE Tutorial:Controlling Concurrent Access to Entity Data with Locking
- Jakarta Persistence API:Version