🚀 Java 巩固进阶 · 第17天
主题:线程安全 & synchronized 同步锁 ------ 并发编程的第一道防线
📅 进度概览 :今天攻克 多线程最核心难题:线程安全。这是面试必考、生产环境必用的知识点,直接决定你的代码能否扛住高并发。
💡 核心价值:
- 数据安全:防止超卖、重复扣款、库存负数等资损事故,守护业务底线。
- 面试通关 :
synchronized原理、JMM 三大特性、锁升级,是初级→高级开发的分水岭。- 框架基石 :理解 SpringBoot
@Transactional、Redis 分布式锁、数据库行锁的底层思想。- 思维升级:从"能跑就行"到"并发正确",建立线程安全的编码意识。
一、线程安全本质:为什么 i++ 会出错?🔍
1. 什么是线程安全?
┌─────────────────────────────────────┐
│ ✅ 线程安全 │
│ 多线程操作共享数据时,无论系统如何 │
│ 调度,结果都与预期一致 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ ❌ 线程不安全 │
│ 多线程操作共享数据时,结果不可预测 │
│ 可能:数据错误、丢失更新、脏读 │
└─────────────────────────────────────┘
2. 经典陷阱:i++ 为什么不是原子操作?
java
// ❌ 看似简单,实则危险!
private int count = 0;
public void increment() {
count++; // 线程不安全!
}
底层拆解 (count++ 实际是 3 步操作):
线程A执行 count++:
1️⃣ 读:从内存读取 count 值(假设=5)→ 寄存器
2️⃣ 改:寄存器中 +1 → 6
3️⃣ 写:将 6 写回内存
⚠️ 问题:线程切换可能发生在任意一步!
时间线演示:
T0: 线程A 读 count=5
T1: 线程B 读 count=5 (A还没写完!)
T2: 线程A 写 count=6
T3: 线程B 写 count=6 (B也基于5计算,覆盖了A的结果!)
✅ 预期:执行2次++,count=7
❌ 实际:count=6,丢失1次更新!
3. 真实业务场景(资损高发区!)
| 场景 | 不安全代码 | 可能后果 |
|---|---|---|
| 库存扣减 | stock-- |
超卖:库存-1,订单+2 |
| 余额扣款 | balance -= amount |
透支:余额负数,资损 |
| 订单号生成 | orderId++ |
重复:两个订单同号,数据冲突 |
| 计数器统计 | pv++ |
少计:UV/PV 数据不准,影响决策 |
💡 记忆口诀 :
"共享变量 + 多线程 + 至少一个写 = 线程不安全"只要满足这三个条件,就必须考虑同步!
二、解决方案:synchronized 同步锁 🔐
1. 核心原理:Monitor(监视器锁)
┌─────────────────────────────────────┐
│ 🔄 synchronized 底层机制 │
│ 每个 Java 对象都有一个 Monitor │
│ 线程执行 synchronized 代码前: │
│ 1. 尝试获取对象的 Monitor 锁 │
│ 2. 成功 → 进入临界区执行 │
│ 3. 失败 → 阻塞等待,直到锁释放 │
│ 4. 执行完毕/异常 → 自动释放锁 │
└─────────────────────────────────────┘
2. 三种用法 & 锁对象对比(⭐ 必背)
java
public class SyncDemo {
// 🎯 场景1:同步代码块(最灵活,推荐⭐)
private final Object lock = new Object(); // ✅ 专用锁对象(避免外部干扰)
public void method1() {
// 只锁关键代码,粒度最小,性能最优
synchronized (lock) {
// 临界区:操作共享数据
sharedData++;
}
// 非临界区:可并发执行,提升吞吐
doOtherWork();
}
// 🎯 场景2:同步实例方法(锁 this)
public synchronized void method2() {
// 等价于:synchronized(this) { ... }
// ⚠️ 注意:锁的是当前实例,外部可通过 this 获取锁
sharedData++;
}
// 🎯 场景3:同步静态方法(锁 Class 对象)
public static synchronized void method3() {
// 等价于:synchronized(SyncDemo.class) { ... }
// ⚠️ 注意:锁的是类对象,所有实例共享同一把锁
staticCounter++;
}
}
🔍 锁对象选择指南
| 锁对象 | 作用范围 | 适用场景 | 风险提示 |
|---|---|---|---|
this |
当前实例 | 单实例内的共享数据 | 外部代码可能也用 this 加锁,导致意外阻塞 |
Class |
整个类 | 静态变量/单例模式 | 锁粒度大,并发度低 |
专用对象 private final Object lock |
自定义范围 | 生产环境首选 | ✅ 避免外部干扰,锁粒度可控 |
字符串常量 "lock" |
❌ 禁止使用 | - | 字符串常量池可能被其他类复用,导致死锁! |
⚠️ 致命陷阱:锁对象不一致
java// ❌ 错误:两个线程用不同锁对象,无法互斥! synchronized ("lock1") { ... } // 线程A synchronized ("lock2") { ... } // 线程B → 同时执行,线程不安全! // ✅ 正确:必须用同一把锁 private static final Object LOCK = new Object(); synchronized (LOCK) { ... } // 所有线程都用 LOCK
三、实战案例:卖票系统(从不安全到安全)
❌ 版本1:线程不安全(演示问题)
java
class UnsafeTicket implements Runnable {
private int tickets = 10; // 共享库存
@Override
public void run() {
while (true) {
if (tickets <= 0) break;
// ⚠️ 临界区:读-判断-写,非原子操作
System.out.println(Thread.currentThread().getName() +
" 卖票:" + tickets);
tickets--; // ❌ 多线程下可能超卖!
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
}
}
// 测试:3 个窗口卖 10 张票
UnsafeTicket task = new UnsafeTicket();
new Thread(task, "窗口-1").start();
new Thread(task, "窗口-2").start();
new Thread(task, "窗口-3").start();
// 🐛 可能输出:
// 窗口-1 卖票:3
// 窗口-2 卖票:3 ← 重复卖!
// 窗口-3 卖票:2
// ... 最终票数 < 0(超卖)
✅ 版本2:synchronized 修复(标准写法)
java
class SafeTicket implements Runnable {
private int tickets = 10;
private final Object lock = new Object(); // ✅ 专用锁
@Override
public void run() {
while (true) {
// 🔐 加锁:同一时间只有一个线程能进入临界区
synchronized (lock) {
if (tickets <= 0) break; // 二次检查(防唤醒后超卖)
System.out.println(Thread.currentThread().getName() +
" 卖票:" + tickets);
tickets--; // ✅ 原子执行,不会超卖
} // 🔓 自动释放锁,其他线程可竞争
// 非临界区:休眠不放锁,提升并发
try { Thread.sleep(10); } catch (InterruptedException e) {
Thread.currentThread().interrupt(); // ✅ 恢复中断
break;
}
}
}
}
🔍 关键细节解析
-
为什么锁内要二次检查
if (tickets <= 0)?线程A: 获得锁,检查 tickets=1,准备卖 线程B: 阻塞等待 线程A: 卖完 tickets=0,释放锁 线程B: 获得锁,如果不二次检查,会卖 tickets=0(超卖!) ✅ 二次检查:确保获得锁后数据仍有效 -
为什么
sleep()放在锁外?锁内 sleep:持有锁休眠 → 其他线程全部阻塞 → 并发度=1 ❌ 锁外 sleep:释放锁后休眠 → 其他线程可竞争 → 并发度>1 ✅ 💡 原则:锁粒度越小,并发性能越高
四、锁的三大黄金法则(生产环境守则)⚖️
法则1:必须是同一把锁才能互斥
java
// ❌ 错误:锁对象不同,形同虚设
public void wrong() {
synchronized (new Object()) { // 每次 new 新对象
// 线程A 和 线程B 的锁不同,可同时进入!
sharedData++;
}
}
// ✅ 正确:锁对象单例,全局唯一
private static final Object LOCK = new Object();
public void right() {
synchronized (LOCK) { // 所有线程竞争同一把锁
sharedData++;
}
}
法则2:锁粒度越小,并发性能越高
java
// ❌ 粗粒度:锁住整个方法,非关键代码也串行
public synchronized void processOrder(Order order) {
validate(order); // 纯计算,无需同步
saveToDB(order); // ✅ 关键:写数据库,需同步
sendEmail(order); // 网络调用,无需同步(且应异步)
}
// ✅ 细粒度:只锁共享资源,其他代码并发执行
public void processOrder(Order order) {
validate(order); // 并发执行
synchronized (dbLock) { // 🔐 只锁数据库操作
saveToDB(order);
}
sendEmail(order); // 并发执行,甚至可异步
}
法则3:避免死锁(进阶预警)
java
// ⚠️ 死锁示例:线程循环等待对方锁
// 线程A: 持有 lock1,等待 lock2
// 线程B: 持有 lock2,等待 lock1
// 结果:互相等待,永久阻塞 ❌
// ✅ 预防:统一锁获取顺序
// 所有线程都先获取 lock1,再获取 lock2
synchronized (lock1) {
synchronized (lock2) {
// 业务逻辑
}
}
💡 死锁排查命令(线上应急):
bashjps -l # 找到 Java 进程 ID jstack <pid> | grep "deadlock" -A 20 # 打印死锁线程栈
五、深层原理:JMM 三大特性 & synchronized 如何保证 🧠
1. 线程不安全根源:JMM 内存模型
┌─────────────────────────────────────┐
│ 🧵 每个线程有自己的工作内存 (Working Memory)│
│ 🗄️ 所有线程共享主内存 (Main Memory) │
│ │
│ 线程操作变量流程: │
│ 1. 从主内存复制变量到工作内存 │
│ 2. 在工作内存中计算 │
│ 3. 将结果刷新回主内存 │
│ │
│ ⚠️ 问题:线程间工作内存不可见! │
│ 线程A 修改了变量,线程B 可能看不到 │
└─────────────────────────────────────┘
2. 三大特性详解
| 特性 | 含义 | 不安全示例 | synchronized 如何保证 |
|---|---|---|---|
| 原子性 | 操作不可分割,要么全做要么不做 | i++(读-改-写三步) |
✅ 锁确保临界区代码互斥执行,整体原子 |
| 可见性 | 一个线程修改,其他线程立即可见 | 线程A 改 flag=true,线程B 仍看到 false |
✅ 解锁前强制刷新工作内存→主内存;加锁前强制从主内存重新加载 |
| 有序性 | 程序执行顺序与代码顺序一致 | 指令重排序:a=1; b=2; → b=2; a=1; |
✅ 禁止锁内代码与锁外代码重排序(happens-before 原则) |
🔍 happens-before 原则(简化版)
1️⃣ 程序顺序规则:单线程内,代码顺序即执行顺序
2️⃣ 锁规则:unlock 操作 happens-before 后续对同一锁的 lock 操作
→ 确保:线程A 解锁前的写,对线程B 加锁后的读可见
3️⃣ volatile 规则:写 volatile 变量 happens-before 后续读该变量
4️⃣ 传递性:A happens-before B, B happens-before C → A happens-before C
💡 一句话理解 :
synchronized通过"加锁时刷新内存 + 临界区互斥执行 + 解锁时写回内存",一举解决原子性、可见性、有序性三大问题!
六、🎯 今日实战任务:银行账户系统
任务1:复现"余额扣款"线程不安全
java
/**
* 要求:
* 1. 创建 BankAccount 类,余额 1000 元
* 2. 实现 withdraw(amount) 方法:余额充足则扣款,返回成功/失败
* 3. 3 个线程同时尝试取款 300 元(理论应 2 成功 1 失败)
* 4. 不加锁运行 10 次,观察是否出现"余额负数"或"重复扣款"
*
* 💡 提示:
* - 在 withdraw 中加入 Thread.sleep(10) 模拟网络延迟,放大竞争问题
* - 打印每次操作的线程名、操作前余额、操作后余额
*/
任务2:用 synchronized 修复账户安全
java
/**
* 要求:
* 1. 为 withdraw 方法添加 synchronized 同步
* 2. 对比修复前后的执行结果
* 3. 思考:锁粒度是否合理?能否优化?
*
* 💡 挑战:
* - 如果增加"转账"功能(A→B),如何设计锁避免死锁?
* - 提示:按账户 ID 排序后加锁,统一获取顺序
*/
任务3:SpringBoot 服务层同步实践
java
@Service
public class OrderService {
@Autowired
private InventoryService inventoryService;
/**
* 创建订单:扣库存 + 生成订单(需保证原子性)
*
* 要求:
* 1. 用 synchronized 保证"查库存-扣库存-创建订单"的原子性
* 2. 锁对象选择:this / 专用锁 / 商品 ID 锁?分析利弊
* 3. 思考:高并发下,单锁会成为瓶颈吗?如何优化?(明天学 ReentrantLock)
*/
public synchronized OrderResult createOrder(CreateOrderRequest req) {
// TODO: 实现业务逻辑
// 1. 校验库存
// 2. 扣减库存
// 3. 生成订单
// 4. 返回结果
}
}
任务4:性能对比实验(理解锁的代价)
java
/**
* 对比:无锁 / synchronized / 细粒度锁 的吞吐量
*
* 要求:
* 1. 创建 Counter 类,实现三种版本的 increment()
* 2. 用 10 线程并发执行 100 万次累加
* 3. 统计每种方案的耗时 + 最终结果正确性
*
* 💡 预期结论:
* - 无锁:最快,但结果错误 ❌
* - 粗粒度 synchronized:安全,但较慢 ⚠️
* - 细粒度锁(如 LongAdder 思想):安全 + 较快 ✅
*/
📝 第17天 · 核心总结(极简背诵版)
-
线程安全判定:
共享变量 + 多线程 + 至少一个写操作 = 🔴 线程不安全 解决方案:同步机制(synchronized / Lock / volatile) -
synchronized 三种用法:
java// ✅ 推荐:同步代码块 + 专用锁对象 private final Object lock = new Object(); synchronized (lock) { /* 临界区 */ } // ⚠️ 慎用:同步实例方法(锁 this,易被外部干扰) public synchronized void method() { ... } // ⚠️ 慎用:同步静态方法(锁 Class,粒度大) public static synchronized void staticMethod() { ... } -
锁的三大黄金法则:
- 🔑 同一把锁:所有竞争线程必须用同一个锁对象
- 🔬 最小粒度:只锁共享数据操作,非关键代码放锁外
- 🔄 避免死锁:多锁时统一获取顺序,设置超时(进阶)
-
JMM 三大特性 & synchronized 保障:
特性 问题 synchronized 解决方案 原子性 i++非原子临界区互斥执行,整体原子 可见性 工作内存不可见 解锁前刷主内存,加锁前重载 有序性 指令重排序 禁止锁内外代码重排序 -
生产环境守则:
- ✅ 锁对象用
private final Object,避免外部干扰 - ✅ 临界区代码越少越好,休眠/网络调用放锁外
- ✅ 日志记录加锁/解锁时间,便于性能分析
- ❌ 禁止在锁内调用外部未知方法(可能死锁)
- ✅ 锁对象用