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

「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 到底是什么关系,把这几个天天挂在嘴边却不一定说得清楚的概念一次讲透。

相关推荐
猫猫不是喵喵.1 分钟前
Java开发高频面试题汇总(通俗易懂版)
java·开发语言·java高频面试
newerp6 分钟前
Golang 切片扩容策略
后端·程序员·go
k4m7v2pz8 分钟前
用 rust-verb-shell 重写进程管理:从 game.sh 到 .rvs 的迁移实录
开发语言·后端·rust
我的div丢了肿么办11 分钟前
go中make声明切片, 修改切片,append给切片扩容,合并切片,复制切片
后端·go
我命由我1234511 分钟前
Android 设备的日志缓冲区被写满,logcat: Unexpected EOF!
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
xcl092517 分钟前
分销返利商城系统开发实战:返利算法与架构设计指南
java·spring boot
171320330计算机毕设编程21 分钟前
2027计算机毕设五大方向对比&选题推荐
java·ide·python·算法·django·php·推荐算法
171320330计算机毕设编程22 分钟前
基于SpringBoot的在线拍卖系统
android·java·spring boot·小程序·课程设计
Zane199423 分钟前
去重用 set 到底能快多少?实测差距接近三百倍
后端·python
未秃头的程序猿27 分钟前
一次秒杀把服务打挂了,我用Sentinel规则配置化救了回来
java·后端·spring cloud