1. 现实问题:两个请求同时扣库存会凭空多卖
单线程时,读取库存、判断够不够、扣减是连续的;两个线程交错后,可能都读到 1,都判断成功,最后库存被扣两次。问题不是“线程太快”,而是读—判断—写这组操作需要一个不可被插入的临界区。
线程是执行路径,任务是要完成的工作,ExecutorService 是管理任务生命周期的组件。先用两个任务并发调用一个有锁的库存对象,观察锁保护的不是某一行语法,而是完整业务不变量。
2. 最小可运行示例:锁住读改写链
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class LockedStockDemo {
static final class Inventory {
private int stock;
Inventory(int stock) { this.stock = stock; }
synchronized boolean sellOne() {
if (stock <= 0) return false;
stock--;
return true;
}
synchronized int stock() { return stock; }
}
public static void main(String[] args) throws InterruptedException {
Inventory inventory = new Inventory(1);
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
for (int i = 0; i < 2; i++) pool.submit(() -> System.out.println(inventory.sellOne()));
pool.shutdown();
pool.awaitTermination(1, TimeUnit.SECONDS);
}
System.out.println("剩余=" + inventory.stock());
}
}
try 关闭 ExecutorService 是 JDK 21 中 AutoCloseable 执行器的便捷用法;重要的是仍然等待任务完成,否则主线程可能先打印结果。synchronized 锁住的是当前 Inventory 实例,同一实例上的两个 sellOne 不能同时进入临界区。
3. 调用链与对象变化
主线程创建 Inventory 对象和线程池,submit 把 lambda 包装成任务放入工作队列。两个工作线程竞争同一个对象监视器;一个线程获得锁后读取 stock=1,减为 0 并返回 true,退出方法时释放锁,另一个线程才读取到 0 并返回 false。
锁保护的是对象字段和相关不变量,不会自动保护所有别的字段。stock() 也加锁,保证读取和写入使用同一内存可见性规则。关闭线程池只是不再接收任务,awaitTermination 才提供等待终止的结果;线程生命周期和业务任务生命周期是两件事。
4. 为什么这样设计
临界区越大,安全性更容易证明但并发度更低;越小,性能可能更好但要保证整个不变量仍被覆盖。不要先把每个 getter 都加锁就宣布线程安全,必须画出共享状态和所有读写路径。对象锁适合保护一个对象内部状态,跨对象协调需要更谨慎的锁顺序。
volatile 能保证可见性但不能把 stock-- 变成原子操作;synchronized 同时提供互斥和进入/退出时的内存可见性。线程池把线程创建与任务提交分开,避免每个请求无限创建线程,但仍需设置队列、拒绝策略和关闭边界。
5. 项目落点:把并发控制放在资源拥有者
库存服务可以用数据库条件更新、锁或原子数据结构保证扣减;如果资源最终在数据库,Java 对象锁只保护当前进程,无法保护多个实例。先定位共享资源的真实拥有者,再选择同步层。异步发送邮件则把任务提交到有界执行器,记录订单 id 和任务结果,不让 HTTP 线程无限等待。
练习:去掉 synchronized,运行 1000 个任务重复卖 100 件,收集成功次数和最终库存;不要用 sleep 制造“看起来能复现”,使用 CountDownLatch 让任务同时开始。再把锁恢复,验证不变量和测试时间。
6. 易错排查
- 主线程提前结束:未 shutdown/await,测试结果没有等待所有任务。
- 锁加在不同对象:
synchronized (new Object())每次都是新锁,实际没有互斥。 - 锁住 this 并暴露给外部:外部也能获得同一锁,优先使用私有锁对象或封装状态。
- 死锁:多个锁以不同顺序获取;统一锁顺序,缩小持锁时间并保留线程 dump。
7. 一页复习
先列共享状态和不变量,再圈出读—判断—写临界区;线程池管理任务,锁保护共享对象,await 确认任务完成。volatile 不是万能原子性,单机锁也不是分布式锁。并发代码的第一证据是可重复测试和线程状态,而不是偶尔出现的一次错误。
把竞态写成一个具体交错:线程 A 读取 stock=1,线程 B 也读取 stock=1;A 判断通过但尚未写回,B 判断也通过;若两者都执行减一,最终结果可能是 0,却返回两个成功。加锁后,A 完成读—判断—写并释放监视器,B 才能读取 0。锁改变的是允许进入临界区的时序,不是让单个减法变得神奇。
线程池里的输入是两个 sellOne 任务,输出是两个 boolean 和一个最终 stock;任务提交完成不代表任务执行完成,shutdown 也不代表已经退出。awaitTermination 返回 false 时要记录超时并取消或中止策略,不能继续把未完成的 stock 当最终状态。对于 HTTP 请求,线程池还要和连接池、队列长度和超时一起设计。
项目中 Inventory 可以拥有私有锁和状态,SaleService 负责订单规则,ExecutorService 只位于异步边界。不要让 Controller 直接创建线程,也不要把线程池作为每次请求的局部变量。应用关闭时统一停止执行器,未完成任务要有状态和补偿,日志记录任务 id、开始、结束和异常。
排错时先用 CountDownLatch 同时释放任务,避免 sleep 只是在等待一个偶然时机;线程 dump 能看到谁持有锁、谁等待锁;库存仍越界可能是另一个方法绕过同一锁,或系统有多个实例。死锁通常来自锁顺序不同,线程泄漏通常来自没有关闭 ExecutorService。练习是分别运行无锁、有锁和数据库条件更新三个版本,比较成功数、耗时和证据。
线程调度实验要保存每次任务的输入、开始时间、获得锁的时间、输出和异常。无锁版本可能在一次运行中“看起来正确”,但把任务数提高并用 CountDownLatch 同时开始后才暴露竞态;有锁版本的成功数应等于库存上限,失败请求不能改变 stock。测试结果还要记录超时,因为死锁和线程泄漏不一定立刻抛异常。
锁的项目落点通常是资源拥有者,而不是 Controller。Inventory 保护 stock,SaleService 组合订单和库存,数据库层再用条件更新保护跨实例资源。若业务移到多实例,Java synchronized 只保护一个 JVM,必须把一致性边界下移到数据库或消息系统。ExecutorService 由应用生命周期管理,任务对象不应自己创建无限线程。
排错时线程 dump 看 BLOCKED、WAITING 和 RUNNABLE,日志比较任务 id 的交错;库存错误可能是某个读方法没有用同一锁,死锁可能是锁顺序不一致,线程池耗尽可能是任务阻塞在外部 IO。把超时时间调大只会延迟证据,应该先缩小任务和资源边界。练习是增加 cancel、超时和关闭钩子,观察未完成销售如何被标记。
并发练习的验收条件是成功次数、最终库存、任务完成数和线程池关闭结果都可断言。若只能靠延长等待时间得到正确结果,说明同步边界或测试起点还没有被证明。
验证清单:设置库存为 3,使用 CountDownLatch 让 10 个任务同时开始,断言成功数不超过 3、最终库存不为负且所有 Future 都被消费;再让一个任务抛异常,确认线程池仍能关闭且错误带任务 id。用线程 dump 检查锁等待和任务阻塞,不能用 Thread.sleep 代替同步信号。复盘时比较 synchronized、数据库条件更新和多实例部署三个边界:前者保护一个对象,第二个保护共享表行,第三个还需要跨实例的唯一约束或消息协调。
进阶附录:JDK 21 虚拟线程
JDK 21 的虚拟线程适合大量阻塞型、短生命周期任务,它降低了线程创建成本,但不会让数据库连接、CPU 或外部服务容量无限增加。不要在线程池大小、连接池大小和事务边界都未知时直接替换;先测吞吐、等待时间和资源上限。
本课按「Java 21 Thread、ExecutorService 与基本锁语义」的学习范围组织,正文与示例均为本站原创整理。