5、单例模式(Singleton Pattern)

1. 简介

  单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问的方法。

  单例模式的要点有三个:一是某个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。单例模式是一种对象创建型模式。单例模式又名单件模式或单态模式。

在单例模式的实现过程中,需要注意如下三点:

  • 单例类的构造函数为私有(即无法创建对象);
  • 提供一个自身的静态私有成员变量;
  • 提供一个公有的静态工厂方法。

优点:

  • 提供了对唯一实例的受控访问。因为单例类封装了它的唯一实例,所以它可以严格控制客户怎样以及何时访问它,并为设计及开发团队提供了共享的概念。
  • 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统的性能。
  • 允许可变数目的实例。我们可以基于单例模式进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例。

缺点:

  • 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
  • 单例类的职责过重,在一定程度上违背了“单一职责原则”。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。
  • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;现在很多面向对象语言(如Java、C#)的运行环境都提供了自动垃圾回收的技术,因此,如果实例化的对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致对象状态的丢失。

2. 单例实现

  常见的单例实现方式有五种:饿汉式、懒汉式、双重检测锁式、静态内部类式和枚举单例,根据不同的业务场景选用不同的单例实现方式。

2.1 饿汉式

  线程安全,调用效率高,但是不能延迟加载。

public class Singleton1 {
    // 私有构造,不可用于对象创建
    private Singleton1() {}

    // 静态对象即类对象,全局唯一,类加载时即进行初始化,保证单一
    private static Singleton1 single = new Singleton1();

    // 静态工厂方法
    public static Singleton1 getInstance() {
        return single;
    }
}

  饿汉式单例在类加载初始化时就创建好一个静态的对象供外部使用,除非系统重启,这个对象不会改变,所以本身就是线程安全的。

  Singleton通过将构造方法限定为private避免了类在外部被实例化,在同一个虚拟机范围内,Singleton的唯一实例只能通过getInstance()方法访问(事实上通过Java反射机制是能够实例化构造方法为private的类的,那基本上会使所有的Java单例实现失效)。

2.2 懒汉式

  非线程安全,调用效率高,可延迟加载。

public class Singleton2 {
    // 私有构造
    private Singleton2() {}
    private static Singleton2 single = null;
    public static Singleton2 getInstance() {
        // 多线程并发破坏单例的设计原则,不安全
        if(single == null){
            single = new Singleton2();
        }
        return single;
    }
}

  该示例在多线程环境下会产生多个single对象,在下面双重校验模式予以改进。

2.3 双重检测锁式(double check)

  双重校验锁DCL(double checked locking)

public class Singleton3 {
    // 私有构造
    private Singleton3() {}

    private volatile static Singleton3 single = null;

    public static Singleton3 getInstance() {
        
        // 等同于 synchronized public static Singleton3 getInstance()
        synchronized(Singleton3.class){
          // 注意:里面的判断是一定要加的,否则出现线程安全问题
            if(single == null){
                single = new Singleton3();
            }
        }
        return single;
    }
}

  在方法上加synchronized同步锁或是用同步代码块对类加同步锁,此种方式虽然解决了多个实例对象问题,但是该方式运行效率却很低下。

  双重校验锁式是线程安全的。然而,在jdk1.5之前的版本,许多JVM对于volatile关键字的实现会导致dcl(double check locking)失效。所以,在JDK1.5以前的DCL是不稳定的,有时也可能创建多个实例,在1.5以后开始提供volatile关键字修饰变量来达到稳定效果。

  volatile对变量single的修饰必不可少,因为volatile保证了原子性和有序性。如果没有volatile,则single = new Singleton3();的执行很可能会被重排序。

禁止指令重排序(有序性)实例化一个对象其实可以分为三个步骤:

  • (1)分配内存空间。
  • (2)初始化对象。
  • (3)将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  • (1)分配内存空间。
  • (2)将内存空间的地址赋值给对应的引用。
  • (3)初始化对象。

  如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果(如题目的描述,这里就是因为 instance = new Singleton3(); 不是原子操作,编译器存在指令重排,从而存在线程1 创建实例后(初始化未完成),线程2 判断对象不为空(因为已经有了地址,所以判定为非空)后对其操作,但实际对象仍为空(没有进行初始化),造成错误)。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量,volatile的禁止重排序保证了操作的有序性。

  Singleton对象的内存可见性 这里由于synchronized锁的是Singleton.class对象,而不是Singleton对象,所以synchronized只能保证Singleton.class对象的内存可见性,但并不能保证Singleton对象的内存可见性;这里用volatile声明Singleton,可以保证Singleton对象的内存可见性。这一点作用也是非常重要的(如题目的描述,避免因为线程1 创建实例后还只存在自己线程的工作内存,未更新到主存。线程 2 判断对象为空,创建实例,从而存在多实例错误)。

双重检查锁定与延迟初始化

2.4 静态内部类

  线程安全,调用效率高,可以延迟加载。

public class Singleton5 {
    // 私有构造
    private Singleton5() {}

    // 静态内部类
    private static class InnerObject{
        private static Singleton5 single = new Singleton6();
    }
    
    public static Singleton5 getInstance() {
        return InnerObject.single;
    }
}

  和饿汉式一样采用的是classLoader机制,保证了线程安全问题,但不同的是,静态内部类同样满足懒加载(当调用getInsstance()方法时,实例才会被创建),静态内部类即使Singleton类被加载也不会创建单例对象,除非调用里面的getInstance()方法。因为当Singleton类被加载时其静态内部类SingletonHolder没有被主动使用。只有当调用getInstance方法时,才会装载SingletonHolder类,从而实例化单例对象。

2.5 枚举单例

  线程安全,调用效率高,不能延迟加载,可以天然的防止反射和反序列化调用。单例的枚举实现在《Effective Java》中有提到,因为其功能完整、使用简洁、无偿地提供了序列化机制、在面对复杂的序列化或者反射攻击时仍然可以绝对防止多次实例化等优点,单元素的枚举类型被作者认为是实现Singleton的最佳方法。

  不可能出现序列化、反射产生对象的漏洞,但是不能做到延迟加载,默认的枚举实例的创建是线程安全的。

枚举单例模式,有三个好处:

  • 1.实例的创建线程安全,确保单例
  • 2.防止被反射创建多个实例
  • 3.没有序列化的问题

枚举类:

public enum DataSourceEnum {
    DATASOURCE;
    private DBConnection connection = null;
    private DataSourceEnum() {
        connection = new DBConnection();
    }
    public DBConnection getConnection() {
        return connection;
    }
}  

客户端调用示例:

 public class Main {
    public static void main(String[] args) {
        DBConnection con1 = DataSourceEnum.DATASOURCE.getConnection();
        DBConnection con2 = DataSourceEnum.DATASOURCE.getConnection();
        System.out.println(con1 == con2);
    }
}

返回true

把上面枚举编译后的字节码反编译,得到的代码如下:

public final class DataSourceEnum extends Enum {
      public static final DataSourceEnum DATASOURCE;
      public static DataSourceEnum[] values();
      public static DataSourceEnum valueOf(String s);
      static {};
}

线程安全问题:

  • DATASOURCE 被声明为 static 的,由类加载过程,可以知道虚拟机会保证一个类的() 方法在多线程环境中被正确的加锁、同步。所以,枚举实现是在实例化时是线程安全。

序列化问题:

  • Java规范中规定,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,因此在枚举类型的序列化和反序列化上,Java做了特殊的规定。
  • 在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过 java.lang.Enum 的 valueOf() 方法来根据名字查找枚举对象。
  • 也就是说,以下面枚举为例,序列化的时候只将 DATASOURCE 这个名称输出,反序列化的时候再通过这个名称,查找对于的枚举类型,因此反序列化后的实例也会和之前被序列化的对象实例相同。

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

1、除枚举方式外,其他方法都会通过反射的方式破坏单例,反射是通过调用构造方法生成新的对象,所以如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例,则阻止生成新的实例,解决办法如下

    private SingletonObject1() {
        if (instance != null) {
            throw new RuntimeException("\"实例已经存在,请通过 getInstance()方法获取\"");
        }
    }

2、如果单例类实现了序列化接口Serializable,就可以通过反序列化破坏单例,所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(),反序列化时直接返回相关单例对象。

    public Object readResolve() throws ObjectStreamException {
        return instance;
    }

参考:

  • 枚举实现单例原理
  • 【白话设计模式四】单例模式(Singleton)
  • 深度分析 Java 的枚举类型:枚举的线程安全性及序列化问题
  • 饿汉式与类加载
  • 为什么用枚举类来实现单例模式越来越流行

你可能感兴趣的:(5、单例模式(Singleton Pattern))