「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 已经拆过为什么必须给 instance 加 volatile(禁止 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 + Condition 用 notFull/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 到底是什么关系,把这几个天天挂在嘴边却不一定说得清楚的概念一次讲透。