JAVA重点基础、进阶知识及易错点总结(17)线程安全 & synchronized 同步锁

🚀 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;
            }
        }
    }
}

🔍 关键细节解析

  1. 为什么锁内要二次检查 if (tickets <= 0)

    复制代码
    线程A: 获得锁,检查 tickets=1,准备卖
    线程B: 阻塞等待
    线程A: 卖完 tickets=0,释放锁
    线程B: 获得锁,如果不二次检查,会卖 tickets=0(超卖!)
    ✅ 二次检查:确保获得锁后数据仍有效
  2. 为什么 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) {
        // 业务逻辑
    }
}

💡 死锁排查命令(线上应急):

bash 复制代码
jps -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天 · 核心总结(极简背诵版)

  1. 线程安全判定

    复制代码
    共享变量 + 多线程 + 至少一个写操作 = 🔴 线程不安全
    解决方案:同步机制(synchronized / Lock / volatile)
  2. synchronized 三种用法

    java 复制代码
    // ✅ 推荐:同步代码块 + 专用锁对象
    private final Object lock = new Object();
    synchronized (lock) { /* 临界区 */ }
    
    // ⚠️ 慎用:同步实例方法(锁 this,易被外部干扰)
    public synchronized void method() { ... }
    
    // ⚠️ 慎用:同步静态方法(锁 Class,粒度大)
    public static synchronized void staticMethod() { ... }
  3. 锁的三大黄金法则

    • 🔑 同一把锁:所有竞争线程必须用同一个锁对象
    • 🔬 最小粒度:只锁共享数据操作,非关键代码放锁外
    • 🔄 避免死锁:多锁时统一获取顺序,设置超时(进阶)
  4. JMM 三大特性 & synchronized 保障

    特性 问题 synchronized 解决方案
    原子性 i++ 非原子 临界区互斥执行,整体原子
    可见性 工作内存不可见 解锁前刷主内存,加锁前重载
    有序性 指令重排序 禁止锁内外代码重排序
  5. 生产环境守则

    • ✅ 锁对象用 private final Object,避免外部干扰
    • ✅ 临界区代码越少越好,休眠/网络调用放锁外
    • ✅ 日志记录加锁/解锁时间,便于性能分析
    • ❌ 禁止在锁内调用外部未知方法(可能死锁)

相关推荐
_MyFavorite_2 小时前
JAVA重点基础、进阶知识及易错点总结(13)File 类 + 路径操作
java·开发语言
Lyyaoo.2 小时前
Spring Boot自动配置
java·spring boot·后端
不会写DN2 小时前
如何使用PHP创建图像验证码
android·开发语言·php
禾小西2 小时前
深入理解 Java String:从底层原理到高性能优化实战
java·开发语言·性能优化
渔民小镇2 小时前
不用前端也能测试 —— 模拟客户端请求模块详解
java·服务器·前端·分布式·游戏
Huangjin007_2 小时前
【C++类和对象(四)】手撕 Date 类:赋值运算符重载 + 日期计算
开发语言·c++
飞Link2 小时前
深入挖掘 LangChain Community 核心组件,从数据接入到企业级 RAG 实战
开发语言·python·langchain
@atweiwei2 小时前
基于Go语言构建轻量级微服务框架的设计与实现
开发语言·微服务·golang