synchronized案例

wen java案例 2

深入剖析 synchronized 案例:从锁升级到死锁预防,一篇讲透并发安全

目录导读

  1. 引言:一个崩溃的订单系统引发的思考
  2. synchronized 核心机制回顾(附案例)
  3. 静态方法与实例方法的锁差异
  4. 锁升级与偏向锁撤销的实战观察
  5. 可重入性与死锁的经典双线程案例
  6. synchronized + wait/notify 生产者消费者
  7. 高频问答:面试官最爱问的 5 个问题
  8. 总结与避坑指南

一个崩溃的订单系统引发的思考

假设你负责一个电商系统的库存扣减模块,上线第一天就出现超卖:100 件商品卖出 120 单,排查后定位到核心代码:

synchronized案例

public void deductStock(int count) {
    int stock = getStock(); // 从数据库读取
    if (stock >= count) {
        stock = stock - count;
        updateStock(stock); // 写回数据库
    }
}

此时如果两个线程同时读到 stock=10,都判断 10>=5,然后都写回 5,库存就变成 5 而非 0——典型的竞态条件,而 Java 中最基础、最直接的解决方案就是 synchronized,本文通过 4 个完整案例,带你彻底掌握它的用法、原理与陷阱。


synchronized 核心机制回顾(附案例)

synchronized 是 Java 内置的互斥锁,保证同一时刻只有一个线程执行临界区代码,它锁定的是对象头(Object Header)中的 Mark Word,而非代码本身,基本语法有两种:

// 同步方法
public synchronized void methodA() { ... }
// 同步代码块
public void methodB() {
    synchronized(this) { ... }
}

关键点:锁的对象是谁,决定了线程阻塞的粒度,锁 this 只锁当前实例;锁 Class 对象则锁所有实例。


案例一:静态方法与实例方法的锁差异

场景

一个工具类有静态方法 syncStatic() 和实例方法 syncInstance(),两个线程分别调用它们。

public class LockDiff {
    public synchronized static void syncStatic() {
        System.out.println("静态方法开始");
        sleep(2000);
        System.out.println("静态方法结束");
    }
    public synchronized void syncInstance() {
        System.out.println("实例方法开始");
        sleep(2000);
        System.out.println("实例方法结束");
    }
}

测试结果

两个线程同时执行,互不阻塞,几乎同时完成。

  • 静态方法锁的是 LockDiff.class(类锁)
  • 实例方法锁的是 this(对象锁)
  • 两者是不同的锁,所以不互斥

踩坑提示

如果静态方法内要访问共享静态变量,建议统一使用类锁,否则可能产生不一致。


案例二:锁升级与偏向锁撤销的实战观察

场景

JDK 6 之后的 synchronized 是重量级锁的“升级版”,经历 无锁→偏向锁→轻量级锁→重量级锁 的过程,我们用一段代码观察锁对象头的变化(通过 JOL 工具):

public class LockUpgrade {
    public static void main(String[] args) throws InterruptedException {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        Thread.sleep(4000); // 等待偏向延迟
        synchronized (obj) {
            System.out.println("偏向锁阶段:");
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
    }
}

输出观察

  • 无锁状态:Mark Word 末尾两位为 01
  • 偏向锁:末尾 101,且记录了线程 ID
  • 当另一个线程竞争时,偏向锁撤销,升级为轻量级锁(00),再竞争激烈时升级为重量级锁(10)

实际意义

  • 偏向锁适合单线程访问的场景,撤销时需要 STW(安全点)
  • 重量级锁依赖 OS 互斥量,会挂起线程,性能最差
  • 所以不要过度使用 synchronized 包装无关紧要的代码,但如果锁竞争不激烈,性能是可接受的

案例三:可重入性与死锁的经典双线程案例

可重入性证明

同一个线程可以重复获得同一把锁:

public synchronized void outer() {
    System.out.println("外层");
    inner(); // 同一个线程,允许进入
}
public synchronized void inner() {
    System.out.println("内层");
}

死锁案例

线程 A 持有锁1 等待锁2;线程 B 持有锁2 等待锁1。

final Object lockA = new Object();
final Object lockB = new Object();
// 线程A
new Thread(() -> {
    synchronized (lockA) {
        sleep(100);
        synchronized (lockB) { ... }
    }
}).start();
// 线程B
new Thread(() -> {
    synchronized (lockB) {
        sleep(100);
        synchronized (lockA) { ... }
    }
}).start();

实际检测

jstack <pid> 可以看到“Found one Java-level deadlock”,并显示线程互相等待的锁。

预防策略

  1. 加锁顺序一致(都先锁 lockA 再锁 lockB)
  2. 使用 tryLock 超时返回(但 synchronized 不支持,需换 ReentrantLock)
  3. 减少锁的持有时间,避免嵌套锁

案例四:synchronized + wait/notify 生产者消费者

场景

一个容量为 1 的缓冲区,生产者放数据,消费者取数据。

class Buffer {
    private final Object lock = new Object();
    private int data;
    public void put(int value) throws InterruptedException {
        synchronized (lock) {
            while (data != 0) { // 用 while 而非 if,防止虚假唤醒
                lock.wait();
            }
            data = value;
            lock.notifyAll();
        }
    }
    public int get() throws InterruptedException {
        synchronized (lock) {
            while (data == 0) {
                lock.wait();
            }
            int v = data;
            data = 0;
            lock.notifyAll();
            return v;
        }
    }
}

要点

  • wait() 会释放锁并进入等待队列
  • notify() 随机唤醒一个,notifyAll() 唤醒所有,推荐后者
  • 必须在 synchronized 块内调用,否则抛 IllegalMonitorStateException

易错点

while 循环判断条件,而不是 if,因为线程被唤醒后可能竞争不到锁,或者条件又被改变,需重新检查。


高频问答:面试官最爱问的 5 个问题

Q1:synchronized 和 ReentrantLock 有什么区别? A:两者都是可重入,synchronized 自动释放锁,ReentrantLock 需手动 unlock(配合 try-finally),synchronized 支持锁升级,ReentrantLock 支持公平锁、中断、超时、多条件(Condition),性能上无大差异,现代 JVM 中 synchronized 已优化。

Q2:锁的是代码还是对象? A:锁的是对象,无论是同步方法还是同步块,最终都要指定一个锁对象,锁对象可以是实例、Class 对象,或者任意 Object。

Q3:为什么 wait/notify 必须放在 synchronized 里? A:因为要确保 wait 操作前已经拿到锁,否则无法正确管理等待队列,也避免“丢失唤醒”问题(Lost Wakeup),wait 释放锁必须基于锁对象,需要对象头中的 monitor 记录。

Q4:synchronized 能否保证可见性? A:可以,进入 synchronized 块(获取锁)会清空工作内存,同步块结束(释放锁)会把修改刷新到主内存,这是 Java 内存模型(JMM)中 Happens-Before 规则保证的。

Q5:实战中如何选择锁? A:如果资源竞争不激烈,优先 synchronized,简单可靠,如果涉及公平性、超时、可中断、多条件队列,改用 ReentrantLock,若追求极致性能,可以考虑无锁(CAS)、LongAdder、ReadWriteLock 等。


总结与避坑指南

场景 推荐方案 原因
单实例方法 synchronized 方法 简单直观
静态变量 synchronized(Class) 全局锁
多条件协作 wait/notify 内置支持
有超时需求 ReentrantLock 灵活可控

避坑清单

  1. 锁的对象不要用 String 常量或基本类型包装类,因为常量池会复用导致锁冲突。
  2. 不要在同步块内调用 Thread.sleep() 或网络 IO,会拖长时间。
  3. 不要将 synchronized 用在 Spring Bean 的代理方法上,注意 AOP 是否会改变锁对象。
  4. 多线程操作集合时,用 Collections.synchronizedListConcurrentHashMap,但慎用 synchronized(list) 遍历。

最后一句:synchronized 不是银弹,但它是并发编程的地基,通过上述案例,希望你能真正理解“锁的是对象,护的是数据,防的是竞态”,写代码时多想想锁的粒度、顺序和释放,就能避开大部分并发陷阱。

抱歉,评论功能暂时关闭!