跳至主要內容

【设计模式】创建型-③单例模式

holic-x...大约 19 分钟设计模式设计模式

【设计模式】创建型-③单例模式

学习核心

  • 单例模式核心
    • 单例模式的基本概念、应用场景
    • 单例模式的实现(懒汉式、饿汉式、双检锁、枚举,版本演进)
    • 单例模式邪恶论
    • 基于单例模式案例分析
  • 业务场景案例
    • 【Spring框架】:Spring框架创建单例对象(面向无状态服务)
    • 【业务开发相关】:一些工具类(xxxUtils)、全局属性(constant)、枚举(enums)相关定义

单例模式构建图示

​ 从懒加载、线程安全等方面结合场景应用进行分析(单例模式的实现演进),选择较为合适的方式构建单例

image-20220201231734900

基本概念

​ 单例模式又叫做单态模式或者单件模式。在 GOF 书中给出的定义为:保证一个类仅有一个实例,并提供一个访问它的全局访问点。单例模式中的“单例”通常用来代表那些本质上具有唯一性的系统组件(或者叫做资源)。比如文件系统、资源管理器等等。

​ 单例模式的目的就是要控制特定的类只产生一个对象,当然也允许在一定情况下灵活的改变对象的个数

单例模式类图参考如下:

image-20220202105921046

​ 单例模式可分为有状态的和无状态的。有状态的单例对象一般也是可变的单例对象,多个单态对象在一起就可以作为一个状态仓库一样向外提供服务。没有状态的单例对象也就是不变单例对象,仅用做提供工具函数。

场景案例

1>数据库的连接池不会反复创建

2>spring中⼀个单例模式bean的⽣成和使⽤

3>日常的代码中需要设置全局的的⼀些属性保存

单例模式的实现:

​ 一个类的对象的产生是由类构造函数来完成的,如果想限制对象的产生,一个办法就是将构造函数变为私有的(至少是受保护的),使 得外面的类不能通过引用来产生对象;同时为了保证类的可用性,就必须提供一个自己的对象以及访问这个对象的静态方法。

1>定义全局的静态私有变量
2>构造函数私有化
3>提供公共方法供外部访问类属性

模式应用

​ 单例模式的实现⽅式较多,主要在实现上是否⽀持懒汉模式、是否线程安全中运⽤各项技巧。也有⼀些场景不需要考虑懒加载也就是懒汉模式的情况,会直接使⽤ static 静态类或属性和⽅法的⽅式进⾏处理,供外部调⽤

00.静态类使用

/**
 * 00.静态类使用
 * 第一次运行的时候直接初始化Map
 */
public class Singleton_00 {

    public static Map<String,String> cache = new ConcurrentHashMap<String, String>();

}

01.懒汉模式(线程安全、线程不安全)

​ **懒汉模式(线程不安全)**的实现满足了懒加载,但如果同时有多个访问者去获取对象实例,则有可能造成多个同样的实例并存,从而没有达到单例的要求

并发场景分析:并发场景下,如果多个线程同时进入到instance==null的分支,那么就会执行new Singleton()创建新实例,那么就会导致多个实例并存,打破单例限制

/**
 * 懒汉模式(线程不安全)
 */
public class Singleton_01 {

    // 构建一个静态的全局的变量供内部调用
    private static Singleton_01 instance;

    // 构造函数私有化
    private Singleton_01() {
    }

    // 对外提供公共接口
    public static Singleton_01 getInstance(){
        // 懒汉模式:在调用的时候进行判断,如果实例为null则构建
        if (null != instance) {
            return instance;
        }
        return new Singleton_01();
    }

}

​ **懒汉模式(线程安全)**的实现满足线程安全,但由于将锁加到方法上,所有的访问都因需要锁占用从而导致资源的浪费

​ 基于这种修改方案:同步锁会增加锁竞争,带来系统性能开销,从而导致系统性能下降,因此这种方式也会降低单例模式的性能

​ 从代码层面分析,如果将synchronized加在getInstance方法上,除了第一次请求instance会为null(第一次请求判断为null,然后会创建实例),其他的每一次请求都是不为null。但是由于锁加在方法上,就会导致每次请求这个方法是都要加锁,基于这种无脑加锁的方式,就会变相导致程序运行的开销变大(因为加锁可能涉及到用户态->内核态 之间的转换,这样的转换成本很高)

/**
 * 懒汉模式(线程安全)
 */
public class Singleton_02 {

    // 构建一个静态的全局的变量供内部调用
    private static Singleton_02 instance;

    // 构造函数私有化
    private Singleton_02() {
    }

    // 对外提供公共接口:将锁加到方法上,满足线程安全,所有访问需要锁占用
    public static synchronized Singleton_02 getInstance(){
        // 懒汉模式:在调用的时候进行判断,如果实例为null则构建
        if (null != instance) {
            return instance;
        }
        return new Singleton_02();
    }

}

02.饿汉模式(线程安全)

​ 程序启动的时候直接运行加载(初始化实例),提供方法供外部调用。非懒加载概念

​ 可能导致的问题:例如下载游戏软件,游戏地图还没有打开,程序自动将地图全部实例化,最明显的体验就是还没开始玩游戏就内存炸裂

/**
 * 饿汉模式(线程安全)
 */
public class Singleton_03 {

    // 构建一个静态的全局变量,并进行初始化
    private static Singleton_03 instance = new Singleton_03();

    // 构造函数私有化
    private Singleton_03() {
    }

    // 对外提供接口访问实例
    public static Singleton_03 getInstance() {
        return instance;
    }

}

03.使用类的内部类(线程安全)-推荐

​ 使⽤类的静态内部类实现的单例模式,既保证了线程安全有保证了懒加载,同时不会因为加锁的⽅式耗费性能

​ JVM虚拟机可以保证多线程并发访问的正确性,即⼀个类的构造⽅法在多线程环境下可以被正确的加载

/**
 * 使⽤类的内部类(线程安全)
 */
public class Singleton_04 {

    // 构建静态内部类实现单例模式
    private static class SingletonHolder {
        private static Singleton_04 instance = new Singleton_04();
    }

    // 构造函数私有化
    private Singleton_04() {
    }

    // 对外提供接口访问实例
    public static Singleton_04 getInstance() {
        return SingletonHolder.instance;
    }

}

04.双重锁校验(线程安全)

方案演进思考

① 条件锁

​ 基于【懒汉模式-线程安全】的版本优化,考虑是否可以将方法级的锁加在条件上,通过缩小锁的范围以优化性能

public static LazySingleton getInstance() {
  synchronized (LazySingleton.class) {
    if (instance == null) {
      instance = new LazySingleton();
    }
  }
  return instance;
}

② 双检锁

​ 基于这种修改方案:每次访问还是会进行加锁(会带来额外的性能开销),因此需要在外层加上一个null判断,如果是第一次访问则会进入到方法判断是否创建实例,如果实例创建完成后其他访问都不会再去进入被锁的代码块,而是直接引用对象。这种模式一般被成为Double-Check模式(双检模式),它可以大大提高支持多线程的懒汉模式的运行性能

public static LazySingleton getInstance() {
  // 懒汉式是只有在用到实例的时候进行判断,如果实例为null则常见
  if(instance == null) { // 第一次判断如果为null则进入代码块
    synchronized (LazySingleton.class) { // 加锁
      if (instance == null) { // 第二次判断如果实例为null则创建
        instance = new LazySingleton();
      }
    }
  }
  // 一旦实例被创建,后续所有的访问都会直接返回引用,而不会重复判断
  return instance;
}

③ 指令重排序问题

​ 但是进一步思考,基于现有版本是否还存在其他问题?此处需要引入Happens-Before 规则和重排序概念

​ 编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值,比如以下代码,如果没有进行重排序优化,正常的执行顺序是步骤 1/2/3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤 1/3/2,这样就能减少一次寄存器的存取次数。

int a = 1;//步骤1:加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
int b = 2;//步骤2 加载b变量的内存地址到寄存器中,加载2到寄存器中,CPU通过mov指令把2写入到寄存器指定的内存中
a = a + 1;//步骤3 重新加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中

​ 在 JMM 中,重排序是十分重要的一环,特别是在并发编程中。如果 JVM 可以对它们进行任意排序以提高程序性能,也可能会给并发编程带来一系列的问题。以Double-Check 的单例问题为例,如果类中还有有其它的属性也需要实例化,则除了要实例化单例类本身,还需要对其它属性也进行实例化,则类的实例化过程分析如下:instance = new LazySingleton();

  • 步骤1:给 LazySingleton 分配内存
  • 步骤2:调用 LazySingleton 的构造函数来初始化成员变量
  • 步骤3:将 LazySingleton 对象指向分配的内存空间(执行完这步 instance 就为非 null )

​ 如果虚拟机发生了重排序优化,这个时候 3 可能发生在步骤 2 之前。此时设想一种情况,线程A完成步骤3但是步骤2还没有进行的时候(也就是说这个时候成员变量还没有被初始化),此时另一个线程B进行了第一次判断(因为线程A已经完成步骤3,此时对象判断为非null)的结果是非null(直接返回对象使用),但这个时候属性的构造并没有完成,如果线程B又进一步使用了某个属性就会导致异常。此处Synchronized 只能保证可见性、原子性,无法保证执行的顺序。

​ 基于这种情况,就体现出 Happens-Before 规则的重要性了。通过字面意思,可能会误以为是前一个操作发生在后一个操作之前,但其真正的含义是:前一个操作的结果可以被后续的操作获取。Happens-Before 规则规范了编译器对程序的重排序优化

​ volatile 关键字可以保证线程间变量的可见性,简单地说就是当线程 A 对变量 X 进行修改后,在线程 A 后面执行的其它线程就能看到变量 X 的变动。除此之外,volatile 在 JDK1.5 之后还有一个作用就是阻止局部重排序的发生,也就是说,volatile 变量的操作指令都不会被重排序。

​ 即在原有的版本基础上,将instance用volatile关键字修饰,确保其在JVM加载的时候其操作指令不会被重排序,进而规避了前面“JVM重排序导致的异常问题”

补充说明:只有在很低版本的Java才会出现这个重排序问题。在Java的高版本中,已经不需要增加volatile来禁止类重排序。因为高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。

简单总结分析

​ 由于JVM重排序优化的存在,类对象指向分配的内存空间可能会在构造函数初始化之前执行完成(也就是对象不为null但成员属性可能还没初始化完成),这个时候可能会导致多线程场景下一些线程引用到还没有被初始化的成员变量而触发异常。

​ 为了解决上述这个问题,引入Happens-Before 规则规范编译器对程序的重排序优化。而通过引入volatile关键字对对象(instance)进行修饰,可以确保volatile 变量的操作指令都不会被重排序。

修改方案3:引入volatile关键字对对象(instance)进行修饰

private volatile static LazySingleton instance = null;

最终版本演进

​ 双重锁检验的方式,在满足懒加载的同时采用⽅法级锁的优化,减少了部分获取实例的耗时

  • 第1层:instance==null
    • 在实例还没有被创建的情况下,多个线程(假设线程a、b、c)进入到这一层,随后会进入下一层判断(创建实例)
    • 如果实例已经被创建则直接返回
  • 第2层:instance==null ,此处通过条件锁进行控制,如果实例此时还没有被创建,那么线程a、b、c就会抢占锁资源,等待进入同步方法创建实例(一旦某个线程抢到锁,那么此时其他线程就会被阻塞),假设线程b抢到锁资源,那么线程a、c只能等待,当线程b创建实例成功之后,此时a、c线程再去抢锁资源,就会发现实例已经创建好了,则会直接返回
/**
 * 双重锁校验(线程安全)
 */
public class Singleton_05 {

    private static volatile Singleton_05 instance; // volatile 处理指令重排序问题

    private Singleton_05() {
    }

    public static Singleton_05 getInstance(){
        // 懒汉式是只有在用到实例的时候进行判断,如果实例为null则常见
        if(instance == null) { // 第一次判断如果为null则进入代码块
            synchronized (LazySingleton.class) { // 加锁
                if (instance == null) { // 第二次判断如果实例为null则创建
                    instance = new LazySingleton();
                }
            }
        }
        // 一旦实例被创建,后续所有的访问都会直接返回引用,而不会重复判断
        return instance;
    }

}

05.CAS「AtomicReference」(线程安全)

​ java并发库提供了很多原⼦类来⽀持并发访问的数据安全性: AtomicIntegerAtomicBooleanAtomicLongAtomicReferenceAtomicReference可以封装引⽤⼀个V实例,下述⽀持并发访问的单例模式就是使⽤了这样的⼀个特点

​ 使⽤CAS的好处就是不需要使⽤传统的加锁⽅式保证线程安全,⽽是依赖于CAS的忙等算法,依赖于底层硬件的实现,来保证线程安全。相对于其他锁的实现没有线程的切换和阻塞也就没有了额外的开销,并且可以⽀持较⼤的并发性。CAS也有⼀个缺点就是忙等,如果⼀直没有获取到将会处于死循环中

/**
 * CAS「AtomicReference」(线程安全)
 */
public class Singleton_06 {

    private static final AtomicReference<Singleton_06> INSTANCE = new AtomicReference<Singleton_06>();

    private static Singleton_06 instance;

    private Singleton_06() {
    }

    public static final Singleton_06 getInstance() {
        for (; ; ) {
            Singleton_06 instance = INSTANCE.get();
            if (null != instance) return instance;
            INSTANCE.compareAndSet(null, new Singleton_06());
            return INSTANCE.get();
        }
    }

    public static void main(String[] args) {
        // org.itstack.demo.design.Singleton_06@2b193f2d
        System.out.println(Singleton_06.getInstance()); 
        // org.itstack.demo.design.Singleton_06@2b193f2d
        System.out.println(Singleton_06.getInstance()); 
    }

}

06.枚举单例-推荐(Effective Java)

​ Effective Java作者推荐使⽤枚举的⽅式解决单例模式,这种方式解决了“线程安全、自由串行化、单一实例”等问题

public enum Singleton_07 {
    INSTANCE;
    public void test(){
        System.out.println("hi~");
    }

}

单例模式线程安全问题

​ 以【懒汉式-非线程安全】版本为例,模拟多线程获取实例,查看结果

/**
 * 单例模式:懒汉式(只有在需要的时候才进行构建)
 * 线程不安全 ver
 */
public class Singleton02 {
    // ① 构建一个私有的、静态的、属于自身的对象
    private static Singleton02 instance = null; // 初始化为null

    // ② 构造函数私有化
    private Singleton02() {
        System.out.println("构造函数被调用....");
    }

    // ③ 对外提供一个静态的获取实例的方法
    public static Singleton02 getInstance() {
        // 判断instance是否为null(为null则进行创建)
        if (instance == null) {
            instance = new Singleton02();
        }
        return instance;
    }

    public static void main(String[] args) {
        // 多线程获取实例
        // 验证多线程场景下懒汉式的不安全性
        Runnable task = ()->{
            String threadName = Thread.currentThread().getName();
            Singleton02 instance = Singleton02.getInstance();
            System.out.println("线程 " + threadName + "\t => " + instance.hashCode());
        };
        // 模拟多线程环境下使用 Singleton 类获得对象
        for(int i=0;i<100;i++){
            new Thread(task,"" + i).start();
        }
    }
}

​ 其他多线程场景测试单例模式版本参考

构建多个线程

System.out.println("多线程获取实例");
for (int i = 1; i <= 20; i++) {
    Thread t = new Thread(new Runnable() {
        @Override
        public void run() {
            Singleton02 s = new Singleton02();
            System.out.println(Thread.currentThread().getName() + "获取的实例:" + s.getInstance().hashCode());
        }
    }, "线程" + i);
    t.start();
}

借助线程池模拟开启多个任务

ExecutorService executor = Executors.newFixedThreadPool(100);
// 创建10个任务
for (int i = 1; i <= 100; i++) {
    executor.submit(new Runnable() {
        @Override
        public void run() {
            Singleton02 s = new Singleton02();
            System.out.println(Thread.currentThread().getName() + "获取实例:" + s.getInstance().hashCode());
        }
    });
}
/**
 * 单例模式多线程测试
 */
public class SingletonMultiThreadTest01 {
    public static void main(String[] args) throws InterruptedException {
        final int threadCount = 100; // 线程数量
        final Set<Singleton02> instances = new HashSet<>(); // 用于存储单例实例
        final CountDownLatch latch = new CountDownLatch(threadCount); // 用于同步线程

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                Singleton02 instance = Singleton02.getInstance();
                synchronized (instances) {
                    instances.add(instance); // 将实例添加到集合中
                }
                latch.countDown(); // 线程完成任务
            }).start();
        }

        latch.await(); // 等待所有线程完成
        System.out.println("创建的实例数量: " + instances.size());
    }
}

借助线程池,并控制线程任务同时开始

/**
 * 单例模式多线程测试
 */
public class SingletonMultiThreadTest02 {

    @SneakyThrows
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        final int threadCount = 100; // 线程数量
        final Set<Singleton02> instances = Collections.synchronizedSet(new HashSet<>()); // 用于存储单例实例
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount); // 创建线程池

        // 使用 CountDownLatch 确保所有线程同时开始
        CountDownLatch startLatch = new CountDownLatch(1);
        // 使用 CountDownLatch 确保所有线程完成任务
        CountDownLatch endLatch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    startLatch.await(); // 等待所有线程准备就绪
                    Singleton02 instance = Singleton02.getInstance();
                    instances.add(instance); // 将实例添加到集合中
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    endLatch.countDown(); // 线程完成任务
                }
            });
        }

        startLatch.countDown(); // 所有线程同时开始
        endLatch.await(); // 等待所有线程完成
        executorService.shutdown(); // 关闭线程池

        System.out.println("创建的实例数量: " + instances.size());
    }

}

扩展概念

单例模式邪恶论

​ 单例模式在 java 中的使用存在很多陷阱和假象,如果没有意识到单例模式使用局限性,往往会在在系统中布下隐患

​ 多个虚拟机 :当系统中的单例类被拷贝运行在多个虚拟机下的时候,在每一个虚拟机下都可以创建一个实例对象。在使用了 EJB、JINI、RMI 技术的分布式系统中,由于中间件屏蔽掉了分布式 系统在物理上的差异,所以想知道具体哪个虚拟机下运行着哪个单例对象是很困难的。 因此,在使用以上分布技术的系统中,应该避免使用存在状态的单例模式,因为一个有状态的单例类,在不同虚拟机上,各个单例对象保存的状态很可能是不一样的,问题也就随之产生。而且在 EJB 中不要使用单例模式来控制访问资源,因为这是由 EJB 容器来负责的。 在其它的分布式系统中,当每一个虚拟机中的资源是不同的时候,可以考虑使用单例模式来 进行管理。

​ 多个类加载器:当存在多个类加载器加载类的时候,即使它们加载的是相同包名,相同类名甚至每个字节都完全相同的类,也会被区别对待的。因为不同的类加载器会使用不同的命名空间 (namespace)来区分同一个类。因此,单例类在多加载器的环境下会产生多个单例对象。也许你认为出现多个类加载器的情况并不是很多。其实多个类加载器存在的情况并不少见。在很多 J2EE 服务器上允许存在多个 servlet 引擎,而每个引擎是采用不同的类加载器的; 浏览器中 applet 小程序通过网络加载类的时候,由于安全因素,采用的是特殊的类加载器, 等等。 这种情况下,由状态的单例模式也会给系统带来隐患。因此除非系统由协调机制,在一 般情况下不要使用存在状态的单例模式。

​ 错误的同步处理:在使用上面介绍的懒汉式单例模式时,同步处理的恰当与否也是至关重要的。不然可能 会达不到得到单个对象的效果,还可能引发死锁等错误。因此在使用懒汉式单例模式时一定 要对同步有所了解。不过使用饿汉式单例模式就可以避免这个问题。

​ 子类破坏了对象控制:如果类构造函数变得不再私有,就有可能失去对对象的控制。这种情况只能通过良好的文档来规范。

​ 串行化(可序列化):为了使一个单例类变成可串行化的,仅仅在声明中添加“implements Serializable”是不够的。因为一个串行化的对象在每次返串行化的时候,都会创建一个新的对象,而不仅仅是一个对原有对象的引用。为了防止这种情况,可以在单例类中加入 readResolve 方法。关于这个方法的具体情况请参考《Effective Java》一书第 57 条建议。 其实对象的串行化并不仅局限于上述方式,还存在基于 XML 格式的对象串行化方式。 这种方式也存在上述的问题,所以在使用的时候要格外小心。

​ 上面罗列了一些使用单例模式时可能会遇到的问题。而且这些问题都和 java 中的类、 线程、虚拟机等基础而又复杂的概念交织在一起,在使用的时候要结合应用场景考虑周全。

​ 抛开单例模式,使用下面一种简单的方式也能得到单例,而且如果你确信此类永远是单例的,使用下面这种方式也许更好一些。

public static final Singleton INSTANCE = new Singleton(); 

​ 而使用单例模式提供的方式,这可以在不改变 API 的情况下,改变我们对单例类的具体要求。

场景案例

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3