项目中一次单例模式的优化

封装了一个 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 规则, 可以被重排序.

单程序倒是没有问题, 这里重排序后就导致问题:

对象实例还没有完成初始化就返回, 导致别的线程获取到一个还没有完成初始化的对象.

解决方案有两个:

  1. 不允许 2 3 重排序;
  2. 允许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.

posted @ 2021-04-11 11:10  小鸣Cycling  阅读(109)  评论(0编辑  收藏  举报