这是「Java 从基础到实战」系列的第 8 篇。本文手写一个车站卖票 demo,先复现多线程下的「超卖」问题,再分别用 synchronized 和 ReentrantLock 修复,最后给出两者的选型结论。所有代码在 JDK 17 下可直接运行。
前言
线程安全问题的可怕之处在于:单元测试跑一百遍都正常,上了生产偶发出错还无法复现。本文先让 Bug 稳定复现,你才能真正理解锁在保护什么。
环境准备
| 软件 | 版本 |
|---|---|
| JDK | 17(Eclipse Temurin) |
| IntelliJ IDEA | Community 社区版 |
在 java-basics 项目中新建包 lock。
步骤 1:复现问题 —— 100 张票被卖出 100 多次
新建 UnsafeTicket.java:
package lock; |
多运行几次,典型输出:
剩余库存: -3 |
100 张票卖出了 103 张,库存变成负数——这就是超卖。
原因:if (stock > 0) 和 stock-- 之间存在时间窗,两个线程可能同时通过检查,再先后扣减。stock-- 本身也不是原子的(读→减→写三步)。
步骤 2:方案一 —— synchronized 修复
把 sell 方法改为:
public synchronized void sell() { // 锁的是当前对象 this |
或者用同步块缩小锁范围(推荐,锁粒度更小):
private final Object lockObj = new Object(); // 专用锁对象 |
再运行,输出稳定为:
剩余库存: 0 |
synchronized 的三个要点:
- 同一把锁才互斥:8 个线程必须竞争同一个对象的锁;
- 修饰实例方法锁
this,修饰静态方法锁类.class; - 可重入:同一线程可重复获取自己持有的锁,不会自己死锁自己。
步骤 3:方案二 —— ReentrantLock 修复
新建 LockTicket.java:
package lock; |
效果与 synchronized 相同,但它是一个「功能更多的手动挡」:
// 1. 尝试加锁:拿不到就干别的,不傻等(synchronized 做不到) |
步骤 4:选型结论
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 加解锁 | 自动(JVM 管理) | 手动 lock/unlock |
| 尝试加锁 / 超时 | ❌ | ✅ tryLock |
| 可中断等待 | ❌ | ✅ lockInterruptibly |
| 公平锁 | ❌ | ✅ 可选 |
| 多条件队列 | 单一 wait/notify | ✅ 多个 Condition |
| 性能(JDK 6+) | 基本持平 | 基本持平 |
选型原则:默认用 synchronized(简单、不会忘记解锁),只有需要 tryLock、超时、公平锁、多条件这些高级能力时才用 ReentrantLock。
💡 补充:如果共享变量只是简单的计数/标志位,优先考虑无锁方案
AtomicInteger(CAS 实现)或volatile(只保证可见性),比上锁更轻量。上面 demo 中的sold计数就是这么做的。
常见坑
坑 1:锁错了对象——每个线程一把锁,等于没锁
错误示范 ❌:
public void sell() { |
正确写法:锁必须是所有线程共享的同一个对象,声明为 private final 字段 ✅。同理,用 String、Integer 等会被缓存/复用的对象做锁也是雷区。
坑 2:unlock 没放 finally,异常后锁永不释放
错误示范 ❌:
lock.lock(); |
正确写法:lock() 之后紧跟 try,unlock() 永远放 finally ✅(见步骤 3)。
坑 3:死锁——两个线程互相等对方的锁
错误示范 ❌:
// 线程 A:先锁 lock1 再锁 lock2;线程 B:先锁 lock2 再锁 lock1 → 互相等待 |
正确写法:所有线程按相同的顺序获取多把锁(如统一先 lock1 后 lock2)✅。排查手段:IDEA 运行窗口点相机图标抓线程 dump,或命令行 jstack <pid>,会直接标出 Found one Java-level deadlock。
小结
- ✅ 线程安全问题根源:检查与修改之间的时间窗 + 非原子操作
- ✅ synchronized:自动挡,默认首选;锁对象必须共享且唯一
- ✅ ReentrantLock:手动挡,tryLock/超时/公平锁/多 Condition 时才上场,unlock 必须在 finally
- ✅ 简单计数用 AtomicInteger,比锁更轻
下一篇继续并发:《JUC 并发工具箱:CountDownLatch、Semaphore、CompletableFuture 实战》,用「并行调接口聚合结果」的真实场景串讲,敬请期待。
上一篇:《Java 多线程入门:Thread、Runnable、线程池一次讲清》
本系列完整目录见博客「技术博文」分类。
💬 评论