synchronized与volatile

ℹ️本篇定位

并发安全的两大关键字:synchronized 解决"互斥"(同一时刻只有一个线程进临界区),volatile 解决"可见性与有序性"(但不保证原子性)。本篇的地基是 JMM 与内存可见性——先理解"工作内存/主内存"模型再回来,所有现象都解释得通。

先看问题:不加锁会怎样

public class UnsafeCounter {
    static int count = 0;
    public static void main(String[] args) throws Exception {
        Runnable r = () -> {
            for (int i = 0; i < 10000; i++) count++;   // 非原子:读→加→写
        };
        Thread t1 = new Thread(r), t2 = new Thread(r);
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println(count);   // 常见输出 12000~18000,几乎不会是 20000
    }
}

count++ 是三条字节码指令(读字段、加 1、写字段),两个线程交错执行就会互相覆盖——这就是竞态条件(Race Condition)

synchronized:三种用法,锁的是同一个东西吗

public class SyncDemo {
    // 1. 修饰实例方法:锁的是当前实例 this
    public synchronized void instanceMethod() { }

    // 2. 修饰静态方法:锁的是 Class 对象(全局唯一)
    public static synchronized void staticMethod() { }

    // 3. 修饰代码块:锁的是括号里的对象
    private final Object lock = new Object();
    public void blockMethod() {
        synchronized (lock) {
            // 临界区
        }
    }
}

核心认知:synchronized 是可重入的互斥锁,任何对象都可以当锁(对象头 Mark Word 记录锁状态)。注意——两个线程用不同的锁对象就互不排斥,这既是灵活性也是 bug 来源。

字节码层:同步代码块靠 monitorenter/monitorexit 指令,同步方法靠 ACC_SYNCHRONIZED 标志位;每个对象关联一个 monitor(管程),线程抢的其实就是 monitor 的持有权。

锁升级(JDK 6 优化,面试必考)

对象锁状态随竞争激烈程度单向升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。

级别 适用场景 做法
偏向锁 始终只有一个线程访问 在对象头记下线程 ID,之后该线程进入无需任何同步操作
轻量级锁 多线程交替执行、无实质竞争 栈上锁记录 + CAS 把对象头指向栈帧;失败则自旋几次
重量级锁 竞争激烈 依赖操作系统 mutex,涉及用户态/内核态切换,线程阻塞挂起

注意:JDK 15 起偏向锁默认禁用并逐步移除(维护成本高于收益),面试提一句"新版本已弃用偏向锁"是加分项。

死锁:锁的极端后果(面试必考)

ℹ️本节定位

讲完"锁怎么用",必须讲"锁用错了会怎样"。死锁是 synchronized 互斥性的直接代价,四条件+避免手段+现场排查是面试三板斧。

一、两分钟复现一个死锁

public class DeadLockDemo {
    static final Object A = new Object();
    static final Object B = new Object();
    public static void main(String[] args) throws Exception {
        new Thread(() -> {
            synchronized (A) {
                Thread.sleep(100);          // 给对方抢到 B 的时间窗
                synchronized (B) { System.out.println("t1 拿到 A+B"); }
            }
        }, "t1").start();
        new Thread(() -> {
            synchronized (B) {
                Thread.sleep(100);
                synchronized (A) { System.out.println("t2 拿到 B+A"); }
            }
        }, "t2").start();
    }
}

t1 持有 A 等 B,t2 持有 B 等 A——两边永远等下去。加锁顺序不一致就是最典型的死锁成因。

二、产生的四个必要条件(Coffman 条件,缺一不可)

条件 含义 对应的破坏手段
互斥 资源同一时刻只能被一个线程持有 不可破坏(锁的本义)
持有并等待 拿着一个锁的同时去等另一个 一次性申请所有资源(把多把锁的获取包进一个大 synchronized 或统一资源管理器)
不可剥夺 锁只能由持有者主动释放 synchronized 做不到;ReentrantLock.tryLock(timeout) 拿不到就放弃已持有的锁(见 04-Lock与AQS
循环等待 t1 等 t2 的、t2 等 t1 的形成环 全局统一加锁顺序(所有线程都先拿 A 再拿 B,环就成不了)

四个条件同时成立才死锁——所以工程上只需破坏任意一条。"固定加锁顺序"是最常用、成本最低的一条

三、定位死锁:现场排查三板斧

  1. jstack(最常用):jstack <pid> 输出末尾直接给出结论 Found one Java-level deadlock,并打印"Found 1 deadlock"的线程互相等待链;
  2. jconsole / jvisualvm:Threads 页签点"检测死锁"按钮,图形化看到两个线程的 Monitor 状态;
  3. 代码级预警:ReentrantLock 的 tryLock + 超时重试,或 Lock.newCondition() 带超时的 await——把"永久等待"变成"限时等待"。

顺带认识两个亲戚:活锁(线程没阻塞,但互相谦让导致谁也走不下去,如重试时都用相同退避节奏)和饥饿(低优先级线程永远抢不到锁,非公平锁的副作用)——三者统称"活跃性问题"。

四、勾连

  • JUC 工具类如何从设计上绕开死锁:BlockingQueue 把"锁+等待"封装进队列,生产者消费者两边永远只碰队列不互相持锁,见 05-并发容器与工具类
  • 排查线上问题(含死锁)的系统动作见 02-内存溢出 的线上排查清单。

volatile:可见性 + 有序性,不保证原子性

// volatile 的典型正确用法:一写多读的状态标志
public class TaskRunner {
    private static volatile boolean running = true;
    public static void main(String[] args) throws Exception {
        new Thread(() -> {
            while (running) { /* 干活 */ }
            System.out.println("看到 running=false,退出");
        }).start();
        Thread.sleep(100);
        running = false;   // 没有 volatile,子线程可能永远看不到这个修改
    }
}

volatile 三件事:

  1. 可见性:写 volatile 变量立即刷回主内存,读时强制从主内存加载(MESI 缓存一致性协议 + 内存屏障);
  2. 禁止指令重排序:编译器和 CPU 不能把 volatile 读写与前后操作随意换序;
  3. 不保证原子性volatile int i; i++ 依然会丢更新——它不是锁。

happens-before 一句话:volatile 写 happens-before 后续的 volatile 读——这就是"写线程的修改对读线程可见"的规范表述(JMM 细节见 JMM 篇)。

双重检查锁单例(DCL)——volatile 最经典的面试题

public class Singleton {
    private static volatile Singleton instance;   // volatile 必须加

    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查:避免每次加锁
            synchronized (Singleton.class) {
                if (instance == null) {            // 第二次检查:防止重复创建
                    instance = new Singleton();    // 三步:分配内存→初始化→赋值引用
                }
            }
        }
        return instance;
    }
}

不加 volatile 的 bug:instance = new Singleton() 可能被重排为"分配→赋值引用→初始化",另一线程在第一次检查时拿到未初始化完成的实例。

synchronized vs volatile 一句话总结

维度 synchronized volatile
原子性 ✅ 保证 ❌ 不保证
可见性 ✅ 保证 ✅ 保证
有序性 ✅ 保证 ✅ 保证
阻塞 可能阻塞 不阻塞
适用 写多读少的临界区 状态标志、一写多读

⬅️ 01-线程基础与生命周期 🏠 00-Java ➡️ 03-线程池