单例线程安全、生产者消费者、死锁:并发面试三连问串讲

「Java 进阶之路」系列 Day14

写在前面

模块一并发编程系列写到这篇算收官,最后一篇不再引入新知识点,而是把前面十三篇里最容易被单独拎出来问的三类经典问题串一遍:单例模式怎么写才是线程安全的、生产者消费者模型有哪几种实现、死锁是怎么发生又怎么排查的。


一、单例模式:5 种写法的线程安全全景

java 复制代码
// 1. 饿汉式:类加载时就创建,天然线程安全,代价是不管用不用都占着这份内存
public class Singleton1 {
    private static final Singleton1 INSTANCE = new Singleton1();
    private Singleton1() {}
    public static Singleton1 getInstance() { return INSTANCE; }
}

// 2. 懒汉式:用到才创建,但线程不安全------两个线程可能同时通过if判断,各自new出一个实例
public class Singleton2 {
    private static Singleton2 instance;
    private Singleton2() {}
    public static Singleton2 getInstance() {
        if (instance == null) {
            instance = new Singleton2();   // 并发场景下这里可能被创建两次
        }
        return instance;
    }
}

// 3. 同步方法懒汉式:加synchronized解决了线程安全,但每次调用都要抢锁,性能差
public class Singleton3 {
    private static Singleton3 instance;
    private Singleton3() {}
    public static synchronized Singleton3 getInstance() {
        if (instance == null) {
            instance = new Singleton3();
        }
        return instance;
    }
}

// 4. 静态内部类:借助类加载机制实现懒加载+线程安全,且没有锁开销
public class Singleton4 {
    private Singleton4() {}
    private static class Holder {
        private static final Singleton4 INSTANCE = new Singleton4();
    }
    public static Singleton4 getInstance() { return Holder.INSTANCE; }
}

// 5. 枚举单例:写法最简单,JVM保证枚举实例全局唯一
public enum Singleton5 {
    INSTANCE;
    public void doSomething() { /* ... */ }
}

DCL(双重检查锁)版本 Day03 已经拆过为什么必须给 instancevolatile(禁止 new 对象的指令重排,不是为了可见性),这里不重复展开,只补一句:DCL 的价值是"懒加载 + 线程安全 + 只在第一次创建时加锁"三者兼得,但代码最复杂、最容易漏写 volatile

静态内部类能做到和 DCL 一样的效果、代码却简单得多,原理是 JVM 类加载机制本身保证了 <clinit> 类初始化过程的线程安全(多个线程同时触发某个类的初始化,只有一个线程会真正执行,其他线程阻塞等待),而且 Holder 类只有在 getInstance() 第一次被调用时才会加载,天然懒加载。

枚举单例容易被低估,但它是唯一天生能防住"反射攻击"和"反序列化攻击"的写法------反射调用私有构造器可以绕过前面 4 种实现的 private 限制强行 new 出第二个实例,反序列化也可能生成新对象;而枚举的反序列化是 JVM 按名字查找已有实例、反射也无法调用枚举的构造器(会直接抛异常),这是语言层面的保证,不是靠代码技巧。

懒加载 线程安全 防反射/反序列化 推荐度
饿汉式 简单场景够用
懒汉式(无锁) 不能用
synchronized 懒汉 性能差,不推荐
DCL 是(需 volatile) 能用,但容易写错
静态内部类 推荐
枚举 最推荐

二、生产者消费者模型:三种实现方式怎么选

这个模型前面已经用两种方式实现过,这里补上最原始的 wait/notify 版本,三者放在一起才看得出演进关系:

java 复制代码
class Buffer {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int capacity;
    public Buffer(int capacity) { this.capacity = capacity; }

    public synchronized void put(int value) throws InterruptedException {
        while (queue.size() == capacity) {
            wait();               // 满了,生产者等待,且只能被notifyAll唤醒后重新抢锁竞争
        }
        queue.offer(value);
        notifyAll();              // 唤醒所有等待线程,消费者/生产者混在一起被吵醒
    }

    public synchronized int take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }
        int value = queue.poll();
        notifyAll();
        return value;
    }
}

三者的关系是一条清晰的演进链:wait/notify 是 JDK 最早的方案,缺点是一个对象只有一个等待队列,notifyAll() 会把生产者、消费者一起吵醒,醒了发现条件不满足的线程只能重新睡回去,存在无效唤醒;Day06 讲的 Lock + ConditionnotFull/notEmpty 两个独立等待队列解决了这个问题,实现了精准唤醒;Day09 讲的 BlockingQueue 则是 JDK 把这套逻辑封装成现成的容器,业务代码里已经不需要自己写等待/唤醒逻辑了。

实现方式 底层机制 等待队列数量 实际项目里怎么选
wait/notify synchronized 内置锁 1 个,共用 只用来理解原理,不建议在业务代码里手写
Lock + Condition AQS 可以拆多个,精准唤醒 需要自定义复杂等待条件时用
BlockingQueue 内部用 Lock+Condition 或 CAS 实现 已封装好 优先选它,没有特殊需求不用自己造轮子

三、死锁:怎么必然复现,怎么排查

java 复制代码
Object lockA = new Object();
Object lockB = new Object();

new Thread(() -> {
    synchronized (lockA) {
        sleep(100);
        synchronized (lockB) { /* ... */ }   // 拿到A后想要B
    }
}).start();

new Thread(() -> {
    synchronized (lockB) {
        sleep(100);
        synchronized (lockA) { /* ... */ }   // 拿到B后想要A,和上面互相等待
    }
}).start();

死锁的发生必须同时满足四个条件:互斥 (锁本身独占,同一时刻只能有一个线程持有)、请求与保持 (线程已经拿到一把锁,还在申请另一把)、不可剥夺 (锁只能自己主动释放,不能被外部强行夺走)、循环等待(多个线程形成环形的等待链)。这四个条件里,前三个是锁机制本身的天然属性、很难破坏,实际排查和预防基本都是在打破第四个------比如上面的例子只要让两个线程都按固定顺序(先 A 后 B)申请锁,环形等待就不成立,死锁自然消失。

排查手段是执行 jstack <pid>,输出里如果存在死锁,会直接给出一段 Found one Java-level deadlock 的报告,列出每个线程持有哪把锁、又在等哪把锁,比自己肉眼分析日志高效得多。


四、面试追问

Q1:为什么推荐用静态内部类实现单例,而不是 DCL?

两者都能做到懒加载+线程安全,但静态内部类利用 JVM 类加载机制本身的线程安全保证(多线程同时触发同一个类初始化,只有一个线程真正执行),代码简单、没有锁开销,也不需要记得加 volatile;DCL 需要手写双重判断加锁,还必须给实例变量加 volatile 防止指令重排,容易漏写出错,实践中更容易踩坑。

Q2:枚举单例为什么能防止反射和反序列化破坏单例?

反射可以调用其他写法里私有构造器强行创建新实例,但枚举类型的构造器反射调用会被 JVM 直接拒绝并抛异常;反序列化正常会通过反射创建新对象,但枚举的反序列化是 JDK 按枚举名字在已有实例里查找,不会新建对象。这两条都是 JVM 对枚举类型的语言级保证,不是靠代码技巧实现的。

Q3:生产者消费者模型,实际业务代码里应该优先选哪种实现?

优先选 BlockingQueue,它已经把等待、唤醒、线程安全这些细节封装好了,业务代码只需要调用 put/take。只有当默认的等待条件满足不了需求(比如需要多种不同的等待条件分别精准唤醒)时,才考虑自己用 Lock+Condition 实现;wait/notify 现在基本只用来面试或者理解原理,不建议在新代码里手写。

Q4:死锁的四个必要条件里,实际预防时一般破坏哪一个?

互斥、请求与保持、不可剥夺这三个通常是锁机制本身需要保留的特性,很难去破坏。实际最常用的手段是破坏循环等待条件------约定所有线程都按照固定的全局顺序去申请多把锁,这样就不可能出现"A等B持有的锁、B又等A持有的锁"这种环形等待。

Q5:怎么在线上定位一个死锁问题?

jstack <pid> 导出线程栈,如果确实存在死锁,输出里会有一段明确的 Found one Java-level deadlock 报告,列出每个相关线程当前持有哪把锁、正在等待哪把锁,直接就能看出锁的环形等待链条,不需要靠猜测或者逐行看业务日志去排查。


下一篇预告

模块一并发编程系列到这篇正式收官。Day15 开始进入模块二 Java 基础与 OOP 查漏补缺,第一篇聊聊 JDK / JRE / JVM 到底是什么关系,把这几个天天挂在嘴边却不一定说得清楚的概念一次讲透。

相关推荐
用户69371750013841 小时前
#DeepSeek+Pi‑Agent 王炸组合跑赢 Claude‑Code!
前端·人工智能·后端
Zane19941 小时前
类变量与实例变量:一个共享列表引发的线上事故
后端·python
Scene2161 小时前
AgentScope 2.0:2. 快速上手 从零构建生产级智能体
后端
前端一课1 小时前
用 TRAE Work 把项目踩坑经验沉淀成「团队可复用工程规范」,新人再也不重复掉坑
前端·后端
神奇小汤圆2 小时前
一文吃透 Spring 框架:原理、实践与面试全解析
后端
站大爷IP2 小时前
Python 的切片把我坑惨了,原来 `[:]` 是浅拷贝,而 `copy.deepcopy` 才是我的救命稻草
后端
神奇小汤圆2 小时前
Java 万字长文:从零基础到高级应用的完整教程——把面向对象讲透
后端
云烟成雨TD2 小时前
Micrometer 系列【42】链路追踪:Span 体系 | 核心 API
java·链路追踪·micrometer
孓最求完美2 小时前
实战经验:JT808/JT809 车联网高并发服务端性能优化指南
后端