在 Java 开发中,“多线程” 是提升程序效率的利器 —— 比如秒杀系统用多线程处理并发下单、后台任务用多线程批量处理数据。但很多开发者一写多线程就出问题:库存明明够却显示 “已抢完”、数据计算结果时而正确时而错乱。其实这些 bug 大多是 “线程不安全” 导致的,而解决问题的关键,就是用好 Java 的 4 种多线程同步机制。今天用大白话讲清它们的区别,再结合秒杀案例教你怎么用,以后写多线程再也不慌!
一、先搞懂:为啥需要 “同步机制”?
简单说,多线程就像 “多个工人同时抢一个工具”—— 如果不加以控制,就会出现 “工具被抢乱、活儿干错” 的情况。
举个通俗例子:假设秒杀系统有 100 件商品库存,用 1000 个线程模拟用户抢单。如果不做同步,多个线程会同时读取 “库存 = 100”,然后都执行 “库存 - 1”,最后可能出现 “100 个线程抢单,库存却只减了 10” 的 bug(这就是 “线程安全问题”)。
而 “同步机制” 就像 “工具管理员”,控制工人按顺序使用工具,保证每个操作都完整执行 —— 让 100 个抢单线程依次减库存,最终库存准确变为 0。
二、4 种同步机制:一张表分清,用法全掌握
很多人记不住同步机制的区别,其实用一张表对比核心特点和适用场景,立马就能分清:
同步机制
核心作用
优势
适用场景
synchronized 关键字
保证同一时刻只有一个线程执行代码
用法简单(不用手动释放锁)
简单同步场景(如单线程操作共享变量)
Lock 接口
手动控制锁的获取和释放
灵活(可尝试获取锁、超时释放)
复杂同步场景(如秒杀、分布式锁)
volatile 关键字
保证变量 “可见性”(线程读最新值)
轻量级(不阻塞线程)
变量只被单个线程写、多线程读的场景
wait/notify 机制
让线程 “等待” 或 “唤醒”,实现协作
控制线程执行顺序
线程间需通信的场景(如生产者 - 消费者)
简单解释关键概念:
可见性:一个线程修改了变量值,其他线程能立刻读到最新值(没有 volatile 的话,线程可能读缓存里的旧值);
原子性:操作要么全执行,要么全不执行(比如 “库存 - 1” 是原子操作,不会出现 “减到一半被其他线程打断” 的情况)。
三、实战案例:秒杀系统用同步机制防超卖(附完整代码)
光说理论没用,我们用 “秒杀库存扣减” 这个高频场景做案例,看看如何用Lock接口(最适合秒杀的同步机制)解决超卖问题,再对比 “无同步” 的 bug 效果。
需求场景:
模拟 1000 个用户同时抢购 100 件商品,要求:
防止超卖(库存不能减到负数);
记录每个用户的抢购结果(成功 / 失败);
统计最终成功抢购的人数(必须等于 100)。
代码实现思路:
用ReentrantLock(Lock 接口的实现类)控制库存扣减的同步,保证每个线程依次执行;
用CountDownLatch让 1000 个线程同时开始抢单(模拟真实并发场景)。
完整代码(带详细注释):
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// 秒杀服务类
class SeckillService {
private int stock = 100; // 商品库存:100件
private int successCount = 0; // 成功抢购人数
// Lock接口实现类:创建可重入锁(同一线程可多次获取锁)
private Lock lock = new ReentrantLock();
// 抢购方法:返回true表示抢购成功,false表示失败
public boolean seckill(String userId) {
// 1. 手动获取锁(加锁):保证只有一个线程进入后续逻辑
lock.lock();
try {
// 2. 判断库存是否充足(必须在锁内判断,否则多线程会同时读旧库存)
if (stock > 0) {
// 3. 库存充足:扣减库存,增加成功人数
stock--;
successCount++;
System.out.println("用户" + userId + "抢购成功!剩余库存:" + stock);
return true;
} else {
// 4. 库存不足:抢购失败
System.out.println("用户" + userId + "抢购失败!商品已抢完");
return false;
}
} finally {
// 5. 手动释放锁(必须在finally里,防止代码抛异常导致锁不释放)
lock.unlock();
}
}
// 获取最终统计结果
public void getResult() {
System.out.println("-------------------");
System.out.println("秒杀结束!");
System.out.println("成功抢购人数:" + successCount);
System.out.println("剩余库存:" + stock);
}
}
// 测试类:模拟1000个并发用户
public class SeckillDemo {
public static void main(String[] args) throws InterruptedException {
SeckillService seckillService = new SeckillService();
int userCount = 1000; // 模拟1000个用户
// CountDownLatch:让1000个线程同时开始执行(倒计时器,减到0才继续)
CountDownLatch countDownLatch = new CountDownLatch(userCount);
// 循环创建1000个线程(每个线程代表一个用户)
for (int i = 1; i <= userCount; i++) {
String userId = "U" + i; // 生成用户ID(U1、U2...U1000)
new Thread(() -> {
try {
// 等待所有线程创建完成(倒计时器减1)
countDownLatch.countDown();
countDownLatch.await(); // 阻塞,直到所有线程都调用countDown()(倒计时器为0)
// 执行抢购
seckillService.seckill(userId);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
// 等待所有抢购线程执行完成
Thread.sleep(3000);
// 打印最终结果
seckillService.getResult();
}
}
代码运行结果(关键部分):
用户U56抢购成功!剩余库存:1
用户U78抢购成功!剩余库存:0
用户U99抢购失败!商品已抢完
...
-------------------
秒杀结束!
成功抢购人数:100
剩余库存:0
对比 “无同步” 的 bug 效果:
如果删掉lock.lock()和lock.unlock(),运行后会出现:
成功抢购人数超过 100(比如 105);
剩余库存变成负数(比如 - 5);
这就是 “超卖 bug”,而Lock接口通过同步控制,完美解决了这个问题。
其他机制的用法补充:
如果用synchronized替代Lock,只需把seckill方法改成synchronized修饰:
public synchronized boolean seckill(String userId) {
// 逻辑和之前一样,不用手动加锁解锁
}
如果变量stock只需 “单线程写、多线程读”,用volatile修饰即可(但秒杀场景需要原子性,volatile 不够,必须用 Lock 或 synchronized):
private volatile int stock = 100;
四、新手必记:3 个 “避坑” 小技巧
synchronized 和 Lock 别混用:比如用 synchronized 加锁,却用 Lock 解锁,会导致锁错乱;
Lock 必须手动释放:一定要在finally里写unlock(),否则代码抛异常后锁不释放,其他线程会一直阻塞;
volatile 不保证原子性:别以为用了 volatile 就万事大吉 —— 比如 “stock++” 是 “读 - 改 - 写” 三步操作,volatile 管不了,必须用锁保证原子性。
互动话题
你之前写多线程时,遇到过最头疼的 bug 是什么?是超卖、数据错乱,还是线程死锁?最后用哪种同步机制解决的?评论区说说你的经历,也聊聊你觉得哪种同步机制最难用~