单例模式(特点,实现方式)

本贴最后更新于 1543 天前,其中的信息可能已经天翻地覆

优点

  • 提供了对唯一实例的受控访问。
  • 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象单例模式无疑可以提高系统的性能。
  • 允许可变数目的实例。

缺点

  • 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
  • 单例类的职责过重,在一定程度上违背了“单一职责原则”。
  • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。

特点

  • 构造方法私有。
  • 内部对象私有。
  • 提供返回对象的函数公有。

Java 中单例模式的实现方式

利用私有的内部工厂类(线程安全,内部类也可以换成内部接口,不过工厂类变量的作用于要改为 public)

public class Singleton {
  
    private Singleton(){
        System.out.println("Singleton: " + System.nanoTime());
    }
  
    public static Singleton getInstance(){
        return SingletonFactory.singletonInstance;
    }
  
    private static class SingletonFactory{
        private static Singleton singletonInstance = new Singleton();
    }
}

为什么使用静态内部类实现单例模式,可以保证线程安全?

  • 加载一个类时,其内部类不会同时被加载。一个类被加载,当且仅当其某个静态成员(静态域、构造器、静态方法等)被调用时发生。
  • 类的加载的过程是单线程执行的。它的并发安全是由 JVM 保证的。所以,这样写的好处是在 instance 初始化的过程中,由 JVM 的类加载机制保证了线程安全,而在初始化完成以后,不管后面多少次调用 getInstance 方法都不会再遇到锁的问题了。

饿汉式和懒汉式

饿汉式和懒汉式的区别?

在程序启动或单件模式类被加载的时候,单件模式实例就已经被创建。

  • 饿汉式:在程序启动或单件模式类被加载的时候,单件模式实例就已经被创建。
  • 懒汉式:当程序第一次访问单件模式实例时才进行创建。

饿汉式(线程安全)

在程序启动或单件模式类被加载的时候,单件模式实例就已经被创建。

  1. 不让外界调用构造方法创建对象,构造方法使私有化,使用 private 修饰。
  2. 怎么让外部获取本类的实例对象?通过本类提供一个方法,供外部调用获取实例。由于没有对象调用,所以此方法为类方法,用 static 修饰。
  3. 通过方法返回实例对象,由于类方法(静态方法)只能调用静态方法,所以存放该实例的变量改为类变量,用 static 修饰。
  4. 类变量,类方法是在类加载时初始化的,只加载一次。由于外部不能创建对象,并且实例只在类加载时创建一次,饿汉式单例模式完成。
public class Single2 {

    private static Single2 instance = new Single2();
  
    private Single2(){
        System.out.println("Single2: " + System.nanoTime());
    }
  
    public static Single2 getInstance(){
        return instance;
    }
}

懒汉式(如果方法没有 synchronized,则线程不安全)

public class Single3 {

    private static Single3 instance = null;
  
    private Single3(){
        System.out.println("Single3: " + System.nanoTime());
    }
  
    public static synchronized Single3 getInstance(){
        if(instance == null){
            instance = new Single3();
        }
        return instance;
    }
}

懒汉模式改良版(线程安全,使用了 double-check,即 check-加锁-check,目的是为了减少同步的开销)

public class Single4 {
	// volatile关键字必须加,保证可见性
    private volatile static Single4 instance = null;
  
    private Single4(){
        System.out.println("Single4: " + System.nanoTime());
    }
  
    public static Single4 getInstance(){
        if(instance == null){
            synchronized (Single4.class) {
                if(instance == null){
                    instance = new Single4();
                }
            }
        }
        return instance;
    }
}
指令重排序是怎么回事?

在给 instance 对象初始化的过程中,jvm 做了下面 3 件事:

  1. 给 instance 对象分配内存
  2. 调用构造函数
  3. 将 instance 对象指向分配的内存空间

由于 jvm 的"优化",指令 2 和指令 3 的执行顺序是不一定的,当执行完指定 3 后,此时的 instance 对象就已经不在是 null 的了,但此时指令 2 不一定已经被执行。

假设线程 1 和线程 2 同时调用 getInstance()方法,此时线程 1 执行完指令 1 和指令 3,线程 2 抢到了执行权,此时 instance 对象是非空的。

所以线程 2 拿到了一个尚未初始化的 instance 对象,此时线程 2 调用这个 instance 就会抛出异常。

为什么 volatile 关键字可以保证双检锁不会出现指令重排序的问题?
  • volatile 关键字可以保证 jvm 执行的一定的“有序性”,在指令 1 和指令 2 执行完之前,指定 3 一定不会被执行。为什么说是一定的"有序性"呢,因为对于非易失的读写,jvm 仍然允许对 volatile 变量进行乱序读写
  • 保证了 volatile 变量被修改后立刻刷新到 CPU 的缓存中。

枚举类型实现单例模式

在 Java 引入了 enum 关键字以后,可以使用枚举来实现单例类:

public class Single5 {

    private Single5(){

    }

    /**
     * 枚举类型是线程安全的,并且只会装载一次
     */
    private enum Singleton{
        INSTANCE;

        private final Single5 instance;

        Singleton(){
            instance = new Single5();
        }

        private Single5 getInstance(){
            return instance;
        }
    }

    public static Single5 getInstance(){

        return Singleton.INSTANCE.getInstance();
    }
}

枚举类实现单例模式是 effective java 作者极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一次,设计者充分的利用了枚举的这个特性来实现单例模式,枚举的写法非常简单,而且枚举类型是所用单例实现中唯一一种不会被破坏的单例实现模式。

反射如何破坏单例模式

演示

一个单例类:

public class Singleton {
    private static Singleton instance = new Singleton();  
 
    private Singleton() {}
 
    public static Singleton getInstance() {
        return instance;
    }
}

通过反射破坏单例模式:

public class Test {
    public static void main(String[] args) throws Exception{
        Singleton s1 = Singleton.getInstance();
 
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton s2 = constructor.newInstance();
 
        System.out.println(s1.hashCode());
        System.out.println(s2.hashCode());
    }
}

输出结果:

671631440
935563443

结果表明 s1 和 s2 是两个不同的实例了。

分析

通过反射获得单例类的构造函数,由于该构造函数是 private 的,通过 setAccessible(true)指示反射的对象在使用时应该取消 Java 语言访问检查,使得私有的构造函数能够被访问,这样使得单例模式失效。

注释

publicConstructor<T> getDeclaredConstructor(Class<?>... parameterTypes)

获取单个构造方法(能获取私有的,但要用 Constructor 类的 setAccessible(true) 方法设置访问权限),参数表示的是:你要获取的构造方法的构造参数个数及数据类型的 class 字节码文件对象。

破坏单例模式的方法及解决办法

除枚举方式外, 其他方法都会通过反射的方式破坏单例,反射是通过调用构造方法生成新的对象,所以如果我们想要阻止单例破坏。

  • 可以在构造方法中进行判断,若已有实例, 则阻止生成新的实例:

    private SingletonObject1(){
        if (instance !=null){
            throw new RuntimeException("实例已经存在,请通过 getInstance()方法获取");
        }
    }
    
  • 如果单例类实现了序列化接口 Serializable, 就可以通过反序列化破坏单例,所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法 readResolve(), 反序列化时直接返回相关单例对象:

      public Object readResolve() throws ObjectStreamException {
          return instance;
      }
    
  • 防止构造函数被成功调用两次,在构造函数中对实例化次数进行统计,大于一次就抛出异常。

    public class Singleton {
        private static int count = 0;
    
        private static Singleton instance = null;
    
        private Singleton(){
            synchronized (Singleton.class) {
                if(count > 0){
                    throw new RuntimeException("创建了两个实例");
                }
                count++;
            }
    
        }
    
        public static Singleton getInstance() {
            if(instance == null) {
                instance = new Singleton();
            }
            return instance;
        }
    
        public static void main(String[] args) throws Exception {
    
            Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
            constructor.setAccessible(true);
            Singleton s1 = constructor.newInstance();
            Singleton s2 = constructor.newInstance();
        }
    
    }
    

    执行结果

    Exception in thread "main" java.lang.reflect.InvocationTargetException
        at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
        at sun.reflect.NativeConstructorAccessorImpl.newInstance(Unknown Source)
        at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(Unknown Source)
        at java.lang.reflect.Constructor.newInstance(Unknown Source)
        at com.yzz.reflect.Singleton.main(Singleton.java:33)
    Caused by: java.lang.RuntimeException: 创建了两个实例
        at com.yzz.reflect.Singleton.<init>(Singleton.java:14)
        ... 5 more
    

    分析

    在通过反射创建第二个实例时抛出异常,防止实例化多个对象。构造函数中的 synchronized 是为了防止多线程情况下实例化多个对象。

引用/参考

设计模式:懒汉式和饿汉式 - 北京小辉 - CSDN

"泡泡 201908061058789"的回答 - 牛客

内部类加载顺序及静态内部类单例模式 - CSDN

java 中双检锁为什么要加上 volatile 关键字 - CSDN

反射如何破坏单例模式 - Everglow 的博客

  • 设计模式

    设计模式(Design pattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。

    200 引用 • 120 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...