项目中一次单例模式的优化
封装了一个 ConsumerClient, 项目中有多个任务需求需要使用到 Kafaka, 为了保证项目中只有一个 Kafka 连接实例, 提供一个全局访问点, 所以我使用单例模式来创建.
一、线程不安全的懒汉单例
直接使用懒汉单例, 这样如果系统中没有连接 Kafka 需求时就不需要 创建连接了.最简单直接的方法.
假设此时有 A、B 两个线程需要调用 getInstance()
, 并且 A 线程调用执行到代码 1 处的同时 B 线程执行到 代码 2 处,
线程 A 看见 consumerClient 没有被创建, 而线程 B 又正在创建实例, 由此引发线程被创建两次, 这是一个不安全的线程;
public class ProducerClient {
private static ProducerClient producerClient;
public static ConsumerClient getInstance() {
if (consumerClient == null) { // 1: A 线程 执行
consumerClient = new ConsumerClient(); // 2: B 线程 执行
}
return consumerClient;
}
}
二、线程安全的单例
所以接下来最简单的方法就是对 getInstance()
做同步处理来实现线程安全, 这样每次只有一个线程能够进入 getInstance()
, 其他线程就需要等待了;
但是如果getInstance()
被多个线程频繁调用, synchronized
也会随之带来性能开销 (其实这里已经足够满足当前项目中使用了, 另外JDK从1.6开始已经做了很大优化);
这里带来的问题时尽管 ProducerClient
实例已将被创建, 但是后面线程每次调用 getInstance()
都需要获取锁.
即如果 B 线程在 getInstance()
中, 则 A 需要在方法外等待;
public class ProducerClient {
private static ProducerClient producerClient;
public synchronized static ConsumerClient getInstance() { // 加锁处理
if (consumerClient == null) {
consumerClient = new ConsumerClient();
}
return consumerClient;
}
}
参考
三、使用双重校验锁优化
不过我在阅读 <<Java并发编程的艺术>>第3章 Java内存模型 中提到早期人们为了应对 synchronized
带来的性能瓶颈问题, 降低同步开销,
提出了一个"聪明"的技巧: 双重检查校验锁(Double-Checked Locking).
双重检查, 即两次检查实例是否创建.
- 加锁处理保证了只有一个线程能创建对象;
- 第一次检查的作用是为了实例如果被创建, 执行
getInstance()
就不需要获取锁了, 直接返回实例对象.
public class ProducerClient {
private static ProducerClient producerClient;
public static ConsumerClient getInstance() {
if (consumerClient == null) { // 1: 第一次检查对象是否创建
synchronized(ProducerClient.class) { // 2: 没有创建, 加锁处理
if (consumerClient == null) { // 3: 再一次检查对象是否创建
consumerClient = new ConsumerClient(); // 4: 创建对象
}
}
}
return consumerClient;
}
}
这里看着很完美啊, 我也没有看出有什么问题, 继续看书;
重排序, 是重排序问题, 代码 4 可以被一些 JIT 编译器重排序
如下, 2 和 3 之间没有数据依赖, 满足 as-if-serial 语义, 符合happens-before 规则, 可以被重排序.
单程序倒是没有问题, 这里重排序后就导致问题:
对象实例还没有完成初始化就返回, 导致别的线程获取到一个还没有完成初始化的对象.
解决方案有两个:
- 不允许 2 3 重排序;
- 允许2、3 重排, 当是不允许别的线程 "看到" 这个重排序.
// 拆解 consumerClient = new ConsumerClient(); // 4: 创建对象
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
// 重排序
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
ctorInstance(memory); // 2:初始化对象
参考
四、双重校验锁的优化
结局方案有两个, 一是基于 volatile
, 二是基于类初始化的解决方案.
基于 volatile
关键字
(JDK 5 之后的JSR-133 内存模型规范, 这个规范增强了 volatile 的语义)
申明之后, 在多线程环境中此重排序会被禁止;
public class ProducerClient {
private volatile static ProducerClient producerClient; // volatile 关键字
public static ConsumerClient getInstance() {
if (consumerClient == null) {
synchronized(ProducerClient.class) { // 2: 没有创建, 加锁处理
if (consumerClient == null) { // 3: 再一次检查对象是否创建
consumerClient = new ConsumerClient(); // 4: 创建对象
}
}
}
return consumerClient;
}
}
基于类初始化的方案 (InstanceHolder单例模式 )
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在
执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为
Initialization On Demand Holder idiom)。
这个方案解释起来比较复杂, 感兴趣可以去看看书中的分析, 这里不在赘述.
public class ProducerClient {
private static class InstanceHolder{
public static ProducerClient client = new ProducerClient();
}
public static ConsumerClient getInstance() {
return InstanceHolder.client; // 这里将导致 InstanceHolder 类被初始化
}
}
如何选择这两个方案
字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段
的开销。在大多数时候,正常的初始化要优于延迟初始化。
- 需要对 实例字段 使用线程安全的延迟初始化,基于volatile的方案;
- 需要对 静态字段 使用线程安全的延迟初始化,基于类初始化的方案.
我最终选用使用 volatile
优化, 而不选类初始化的方案, 原因是因为我在new ProducerClient()
中会去连接 Kafka, 避免造成每次启动项目都去连接, 有时候不需要连接 Kafka.