5

深入理解单例设计模式

 3 years ago
source link: https://segmentfault.com/a/1190000040146574
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

单例模式是面试中经常会被问到的一个问题,网上有大量的文章介绍单例模式的实现,本文也是参考那些优秀的文章来做一个总结,通过自己在学习过程中的理解进行记录,并补充完善一些内容,一方面巩固自己所学的内容,另一方面希望能对其他同学提供一些帮助。

本文主要从以下几个方面介绍单例模式:

  1. 单例模式是什么
  2. 单例模式的使用场景
  3. 单例模式的优缺点
  4. 单例模式的实现(重点)
  5. 总结

二、单例模式是什么

23 种设计模式可以分为三大类:创建型模式、行为型模式、结构型模式。单例模式属于创建型模式的一种,单例模式是最简单的设计模式之一:单例模式只涉及一个类,确保在系统中一个类只有一个实例,并提供一个全局访问入口。许多时候整个系统只需要拥有一个全局对象,这样有利于我们协调系统整体的行为。

三、单例模式的使用场景

1、 日志类

日志类通常作为单例实现,并在所有应用程序组件中提供全局日志访问点,而无需在每次执行日志操作时创建对象。

2、 配置类

将配置类设计为单例实现,比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。

3、工厂类

假设我们设计了一个带有工厂的应用程序,以在多线程环境中生成带有 ID 的新对象(Acount、Customer、Site、Address 对象)。如果工厂在 2 个不同的线程中被实例化两次,那么 2 个不同的对象可能有 2 个重叠的 id。如果我们将工厂实现为单例,我们就可以避免这个问题,结合抽象工厂或工厂方法和单例设计模式是一种常见的做法。

4、以共享模式访问资源的类

比如网站的计数器,一般也是采用单例模式实现,如果你存在多个计数器,每一个用户的访问都刷新计数器的值,这样的话你的实计数的值是难以同步的。但是如果采用单例模式实现就不会存在这样的问题,而且还可以避免线程安全问题。

5、在Spring中创建的Bean实例默认都是单例模式存在的。

适用场景:

  • 需要生成唯一序列的环境
  • 需要频繁实例化然后销毁的对象。
  • 创建对象时耗时过多或者耗资源过多,但又经常用到的对象。
  • 方便资源相互通信的环境

四、单例模式的优缺点

优点:

  • 在内存中只有一个对象,节省内存空间;
  • 避免频繁的创建销毁对象,减轻 GC 工作,同时可以提高性能;
  • 避免对共享资源的多重占用,简化访问;
  • 为整个系统提供一个全局访问点。

缺点:

  • 不适用于变化频繁的对象;
  • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;
  • 如果实例化的对象长时间不被利用,系统会认为该对象是垃圾而被回收,这可能会导致对象状态的丢失;

五、单例模式的实现(重点)

实现单例模式的步骤如下:

  1. 私有化构造方法,避免外部类通过 new 创建对象
  2. 定义一个私有的静态变量持有自己的类型
  3. 对外提供一个静态的公共方法来获取实例
  4. 如果实现了序列化接口需要保证反序列化不会重新创建对象

1、饿汉式,线程安全

饿汉式单例模式,顾名思义,类一加载就创建对象,这种方式比较常用,但容易产生垃圾对象,浪费内存空间。

优点:线程安全,没有加锁,执行效率较高
缺点:不是懒加载,类加载时就初始化,浪费内存空间

懒加载 (lazy loading):使用的时候再创建对象

饿汉式单例是如何保证线程安全的呢?它是基于类加载机制避免了多线程的同步问题,但是如果类被不同的类加载器加载就会创建不同的实例。

代码实现,以及使用反射破坏单例:

/**
 * 饿汉式单例测试
 *
 * @className: Singleton
 * @date: 2021/6/7 14:32
 */
public class Singleton  {
    // 1、私有化构造方法
    private Singleton(){}
    // 2、定义一个静态变量指向自己类型
    private final static Singleton instance = new Singleton();
    // 3、对外提供一个公共的方法获取实例
    public static Singleton getInstance() {
        return instance;
    }

}

使用反射破坏单例,代码如下:


public class Test {

    public static void main(String[] args) throws Exception{
        // 使用反射破坏单例
        // 获取空参构造方法
        Constructor<Singleton> declaredConstructor = Singleton.class.getDeclaredConstructor(null);
        // 设置强制访问
        declaredConstructor.setAccessible(true);
        // 创建实例
        Singleton singleton = declaredConstructor.newInstance();
        System.out.println("反射创建的实例" + singleton);
        System.out.println("正常创建的实例" + Singleton.getInstance());
        System.out.println("正常创建的实例" + Singleton.getInstance());
    }
}

输出结果如下:

反射创建的实例com.example.spring.demo.single.Singleton@6267c3bb
正常创建的实例com.example.spring.demo.single.Singleton@533ddba
正常创建的实例com.example.spring.demo.single.Singleton@533ddba

2、懒汉式,线程不安全

这种方式在单线程下使用没有问题,对于多线程是无法保证单例的,这里列出来是为了和后面使用锁保证线程安全的单例做对比。

优点:懒加载

缺点:线程不安全

代码实现如下:


/**
 * 懒汉式单例,线程不安全
 *
 * @className: Singleton
 * @date: 2021/6/7 14:32
 */
public class Singleton  {
    // 1、私有化构造方法
    private Singleton(){ }
    // 2、定义一个静态变量指向自己类型
    private static Singleton instance;
    // 3、对外提供一个公共的方法获取实例
    public static Singleton getInstance() {
        // 判断为 null 的时候再创建对象
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

使用多线程破坏单例,测试代码如下:

public class Test {

    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                System.out.println("多线程创建的单例:" + Singleton.getInstance());
            }).start();
        }
    }
}

输出结果如下:

多线程创建的单例:com.example.spring.demo.single.Singleton@18396bd5
多线程创建的单例:com.example.spring.demo.single.Singleton@7f23db98
多线程创建的单例:com.example.spring.demo.single.Singleton@5000d44

3、懒汉式,线程安全

懒汉式单例如何保证线程安全呢?通过 synchronized 关键字加锁保证线程安全,synchronized 可以添加在方法上面,也可以添加在代码块上面,这里演示添加在方法上面,存在的问题是每一次调用 getInstance 获取实例时都需要加锁和释放锁,这样是非常影响性能的。

优点:懒加载,线程安全

缺点:效率较低

代码实现如下:

/**
 * 懒汉式单例,方法上面添加 synchronized 保证线程安全
 *
 * @className: Singleton
 * @date: 2021/6/7 14:32
 */
public class Singleton  {
    // 1、私有化构造方法
    private Singleton(){ }
    // 2、定义一个静态变量指向自己类型
    private static Singleton instance;
    // 3、对外提供一个公共的方法获取实例
    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

4、双重检查锁(DCL, 即 double-checked locking)

实现代码如下:


/**
 * 双重检查锁(DCL, 即 double-checked locking)
 *
 * @className: Singleton
 * @date: 2021/6/7 14:32
 */
public class Singleton {
    // 1、私有化构造方法
    private Singleton() {
    }

    // 2、定义一个静态变量指向自己类型
    private volatile static Singleton instance;

    // 3、对外提供一个公共的方法获取实例
    public synchronized static Singleton getInstance() {
        // 第一重检查是否为 null
        if (instance == null) {
            // 使用 synchronized 加锁
            synchronized (Singleton.class) {
                // 第二重检查是否为 null
                if (instance == null) {
                    // new 关键字创建对象不是原子操作
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

优点:懒加载,线程安全,效率较高

缺点:实现较复杂

这里的双重检查是指两次非空判断,锁指的是 synchronized 加锁,为什么要进行双重判断,其实很简单,第一重判断,如果实例已经存在,那么就不再需要进行同步操作,而是直接返回这个实例,如果没有创建,才会进入同步块,同步块的目的与之前相同,目的是为了防止有多个线程同时调用时,导致生成多个实例,有了同步块,每次只能有一个线程调用访问同步块内容,当第一个抢到锁的调用获取了实例之后,这个实例就会被创建,之后的所有调用都不会进入同步块,直接在第一重判断就返回了单例。

关于内部的第二重空判断的作用,当多个线程一起到达锁位置时,进行锁竞争,其中一个线程获取锁,如果是第一次进入则为 null,会进行单例对象的创建,完成后释放锁,其他线程获取锁后就会被空判断拦截,直接返回已创建的单例对象。

其中最关键的一个点就是 volatile 关键字的使用,关于 volatile 的详细介绍可以直接搜索 volatile 关键字即可,有很多写的非常好的文章,这里不做详细介绍,简单说明一下,双重检查锁中使用 volatile 的两个重要特性:可见性、禁止指令重排序

这里为什么要使用 volatile

这是因为 new 关键字创建对象不是原子操作,创建一个对象会经历下面的步骤:

  1. 在堆内存开辟内存空间
  2. 调用构造方法,初始化对象
  3. 引用变量指向堆内存空间

对应字节码指令如下:

image.png

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序,从源码到最终执行指令会经历如下流程:

编译器优化重排序
指令级并行重排序
内存系统重排序
最终执行指令序列

所以经过指令重排序之后,创建对象的执行顺序可能为 1 2 3 或者 1 3 2 ,因此当某个线程在乱序运行 1 3 2 指令的时候,引用变量指向堆内存空间,这个对象不为 null,但是没有初始化,其他线程有可能这个时候进入了 getInstance 的第一个 if(instance == null) 判断不为 nulll ,导致错误使用了没有初始化的非 null 实例,这样的话就会出现异常,这个就是著名的 DCL 失效问题。

当我们在引用变量上面添加 volatile 关键字以后,会通过在创建对象指令的前后添加内存屏障来禁止指令重排序,就可以避免这个问题,而且对 volatile 修饰的变量的修改对其他任何线程都是可见的。

5、静态内部类

代码实现如下:


/**
 * 静态内部类实现单例
 *
 * @className: Singleton
 * @date: 2021/6/7 14:32
 */
public class Singleton {
    // 1、私有化构造方法
    private Singleton() {
    }
    
    // 2、对外提供获取实例的公共方法
    public static Singleton getInstance() {
        return InnerClass.INSTANCE;
    }

    // 定义静态内部类
    private static class InnerClass{
        private final static Singleton INSTANCE = new Singleton();
    }

}

优点:懒加载,线程安全,效率较高,实现简单

静态内部类单例是如何实现懒加载的呢?首先,我们先了解下类的加载时机。

虚拟机规范要求有且只有5种情况必须立即对类进行初始化(加载、验证、准备需要在此之前开始):

  1. 遇到 newgetstaticputstaticinvokestatic 这4条字节码指令时。生成这4条指令最常见的 Java 代码场景是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(final修饰除外,被final修饰的静态字段是常量,已在编译期把结果放入常量池)的时候,以及调用一个类的静态方法的时候。
  2. 使用 java.lang.reflect 包方法对类进行反射调用的时候。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 1.7的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果是REF_getStaticREF_putStaticREF_invokeStatic 的方法句柄,则需要先触发这个方法句柄所对应的类的初始化。

这5种情况被称为是类的主动引用,注意,这里《虚拟机规范》中使用的限定词是 "有且仅有",那么,除此之外的所有引用类都不会对类进行初始化,称为被动引用。静态内部类就属于被动引用的情况。

当getInstance()方法被调用时,InnerClass 才在 Singleton 的运行时常量池里,把符号引用替换为直接引用,这时静态对象 INSTANCE 也真正被创建,然后再被 getInstance()方法返回出去,这点同饿汉模式。

那么 INSTANCE 在创建过程中又是如何保证线程安全的呢?在《深入理解JAVA虚拟机》中,有这么一句话:

 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时很长的操作,就可能造成多个进程阻塞(需要注意的是,其他线程虽然会被阻塞,但如果执行<clinit>()方法后,其他线程唤醒之后不会再次进入<clinit>()方法。同一个加载器下,一个类型只会初始化一次。),在实际应用中,这种阻塞往往是很隐蔽的。

从上面的分析可以看出INSTANCE在创建过程中是线程安全的,所以说静态内部类形式的单例可保证线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

6、枚举单例

代码实现如下:

/**
 * 枚举实现单例
 *
 * @className: Singleton
 * @date: 2021/6/7 14:32
 */
public enum Singleton {
    INSTANCE;
    public void doSomething(String str) {
        System.out.println(str);
    }
}

优点:简单,高效,线程安全,可以避免通过反射破坏枚举单例

枚举在java中与普通类一样,都能拥有字段与方法,而且枚举实例创建是线程安全的,在任何情况下,它都是一个单例,可以直接通过如下方式调用获取实例:

Singleton singleton = Singleton.INSTANCE;

使用下面的命令反编译枚举类

javap Singleton.class

得到如下内容

Compiled from "Singleton.java"
public final class com.spring.demo.singleton.Singleton extends java.lang.Enum<com.spring.demo.singleton.Singleton> {
  public static final com.spring.demo.singleton.Singleton INSTANCE;
  public static com.spring.demo.singleton.Singleton[] values();
  public static com.spring.demo.singleton.Singleton valueOf(java.lang.String);
  public void doSomething(java.lang.String);
  static {};
}

从枚举的反编译结果可以看到,INSTANCE 被 static final 修饰,所以可以通过类名直接调用,并且创建对象的实例是在静态代码块中创建的,因为 static 类型的属性会在类被加载之后被初始化,当一个Java类第一次被真正使用到的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的,所以创建一个enum类型是线程安全的。

通过反射破坏枚举,实现代码如下:

public class Test {
    public static void main(String[] args) throws Exception {
        Singleton singleton = Singleton.INSTANCE;
        singleton.doSomething("hello enum");

        // 尝试使用反射破坏单例
        // 枚举类没有空参构造方法,反编译后可以看到枚举有一个两个参数的构造方法
        Constructor<Singleton> declaredConstructor = Singleton.class.getDeclaredConstructor(String.class, int.class);
        // 设置强制访问
        declaredConstructor.setAccessible(true);
        // 创建实例,这里会报错,因为无法通过反射创建枚举的实例
        Singleton enumSingleton = declaredConstructor.newInstance();
        System.out.println(enumSingleton);
    }
}

运行结果报如下错误:

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
    at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:492)
    at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
    at com.spring.demo.singleton.Test.main(Test.java:24)

查看反射创建实例的 newInstance() 方法,有如下判断:

image.png

所以无法通过反射创建枚举的实例。

在java中,如果一个Singleton类实现了java.io.Serializable接口,当这个singleton被多次序列化然后反序列化时,就会创建多个Singleton类的实例。为了避免这种情况,应该实现 readResolve 方法。请参阅 javadocs 中的 Serializable () 和 readResolve Method () 。


public class Singleton implements Serializable {
    // 1、私有化构造方法
    private Singleton() {
    }

    // 2、对外提供获取实例的公共方法
    public static Singleton getInstance() {
        return InnerClass.instance;
    }

    // 定义静态内部类
    private static class InnerClass{
        private final static Singleton instance = new Singleton();
    }


    // 对象被反序列化之后,这个方法立即被调用,我们重写这个方法返回单例对象.
    protected Object readResolve() {
            return getInstance();
    }
}

使用单例设计模式需要注意的点:

  • 多线程- 在多线程应用程序中必须使用单例时,应特别小心。
  • 序列化- 当单例实现 Serializable 接口时,他们必须实现 readResolve 方法以避免有 2 个不同的对象
  • 类加载器- 如果 Singleton 类由 2 个不同的类加载器加载,我们将有 2 个不同的类,每个类加载一个。
  • 由类名表示的全局访问点- 使用类名获取单例实例。这是一种访问它的简单方法,但它不是很灵活。如果我们需要替换Sigleton类,代码中的所有引用都应该相应地改变。

本文简单介绍了单例设计模式的几种实现方式,除了枚举单例,其他的所有实现都可以通过反射破坏单例模式,在《effective java》中推荐枚举实现单例模式,在实际场景中使用哪一种单例实现,需要根据自己的情况选择,适合当前场景的才是比较好的方式。

https://blog.csdn.net/mnb6548...

https://www.oodesign.com/sing...


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK