软件设计模式修炼 -- 单例模式

2年前 (2022) 程序员胖胖胖虎阿
245 0 0

通过单例模式可以保证系统中一个类只有一个实例而且该实例易于被外界访问,从而方便对实例个数的控制并节约系统资源。如果希望在系统中某个类的对象只能存在一个,单例模式是最好的解决办法。

模式动机与定义

对系统中某些类来说,只有一个实例很重要,例如,一个系统只能有一个窗口管理器或文件系统。因此确保系统中某个对象的唯一性即一个类只能有一个实例很重要。

单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问方法。单例模式有三个要点:一是某个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。

模式分析

单例模式的目的是保证类仅有一个实例,并提供一个访问它的全局访问点。单例类拥有一个私有构造函数,确保用户无法通过new关键字直接实例化。除此之外,该模式包含一个静态私有成员变量与静态公有的工厂方法,该工厂方法负责校验实例存在性并实例化自己,然后存储在静态成员变量中,以确保只有一个实例被创建。因此在单例模式的实现过程中要注意三点:

  1. 单例类的构造函数为私有
  2. 提供一个自身的静态私有成员变量
  3. 提供一个公有的静态工厂方法
public class Singleton {

    private static Singleton instance = null;

    //私有构造方法
    private Singleton() {}

    //静态公有工厂方法,返回唯一实例
    public static Singleton getInstance() {

        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}
public class Client {

    public static void main(String[] args) {

        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        System.out.println(s1 == s2);	//	true
    }
}

模式优缺点

单例模式优点如下:

  1. 提供对外唯一实例的受控访问
  2. 由于在系统内存中只存在一个对象,因此可以节约系统资源
  3. 允许可变数目的实例,基于单例模式我们可以进行扩展,使用与单例模式相似的方法来获得指定个数的对象实例

单例模式缺点如下:

  1. 由于单例模式中没有抽象层,因此单例类的扩展有很大困难
  2. 单例类的职责过重,一定程度上违背了单一职责原则
  3. 如果实例化的对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,将导致对象状态的丢失

模式适用环境

在以下情况可以适用单例模式:

  1. 系统只需要一个实例对象,或者需要考虑资源消耗太大而只允许创建一个对象
  2. 客户调用类的单个实例只允许一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例

使用单例模式有一个必要条件:在一个系统中要求一个类只有一个实例时才应当使用单例模式。反过来,如果一个类可以有几个实例共存,就需要对单例模式进行改进,使之成为多例模式。在使用过程中我们还需注意以下两个问题:

  1. 不要使用单例模式存取全局变量,因为这违背了单例模式的用意
  2. 不要将数据库连接做成单例,因为一个系统可能会与数据库有多个连接,否则可能造成资源无法及时释放

饿汉式单例类和懒汉式单例类

在定义静态变量的时候实例化单例类,在类加载的时候就已经创建了单例对象。在这个类被加载时,静态变量 instance 会被初始化,此时类的私有构造函数会被调用,单例类的唯一实例将被创建。

public class EagerSingleton {
	
    //可以由名知意,太饥饿了,一大早就准备好了实例等待使用,但很有可能一直没人使用
    private static final EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {}

    public static EagerSingleton getInstance() {
        return instance;
    }
}

懒汉式单例类不是在定义静态变量时实例化单例类,而是在调用静态工厂方法时实例化单例类,因此在类加载时并没有创建单例对象。

public class LazySingleton {
	
   	//等到人家使用才生产一个实例,所以叫懒汉模式
    private static LazySingleton instance = null;

    private LazySingleton() {}

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

从资源利用效率角度来说,懒汉式单例类比饿汉式单例类稍好。从速度和反应时间角度来说,饿汉式单例类比懒汉式稍好。然而,懒汉式单例类在实例化时,必须处理好多个线程同时首次引用此类时的访问限制问题,就上述的懒汉式单例类实例来说,是线程不安全的。为了实现线程安全,必须加锁 synchronized 实现单例,但加锁会影响效率。

public class LazySingleton {
	
    private static LazySingleton instance = null;

    private LazySingleton() {}

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

双重校验锁懒汉模式(DCL)

由于 synchronized 具有排他性,同一时刻只有一个线程进入到同步代码块或者同步方法之中,每次只能通过一个线程,效率自然很低。采用双锁机制,在保证安全的前提下还能保持高性能。

public class LazySingleton {
    
    private volatile static LazySingleton instance = null;
    
    private LazySingleton() {}
    
    public static LazySingleton getInstance() {
        //	由于单例模式只需创建一次实例即可
        //	所有当已经创建一个实例之后,再次调用就不需要进入同步代码块中了
        //	避免线程之间互相竞争锁,提高了性能
        if(instance == null) {
            synchronized(LazySingleton.class) {
                //	防止二次创建实例
                if(instance == null) {
                	instance = new LazySingleton();
                }
            }
        }
        return instance;
    }
}

其中变量 instance 还使用了 volatile 修饰,使用 volatile 的目的是防止指令重排序以及保证变量在多线程运行时的可见性。

  • 指令重排序

    指令是计算机执行某种操作的命令,一个线程内部的指令并不一定是按照顺序执行的。为了性能优化,在不改变程序执行结果的前提下,重新安排语句的执行顺序。这些重排序会导致线程问题,如 instance = new LazySingleton() 这句话可以分为三步:

    1. 为 instance 分配内存空间
    2. 初始化 instance
    3. 将 singleton 指向分配的内存空间

    由于执行重排序,执行顺序可能变成 1-3-2。指令重排在单线程下不会出现问题,但是在多线程下会导致一个线程获得一个未初始化的实例。例如:线程 T1 执行了 1 和 3,此时 T2 调用 getInstance() 后发现 instance 不为空,因此返回 instance , 但是此时的 singleton 还没有被初始化。

  • 保证变量在多线程运行时的可见性

    各个线程在工作时,会将共享变量从主内存中拷贝到本地的工作内存,然后基于工作内存中的数据进行操作处理,在修改完之后的某一个时刻(线程退出之前),自动把修改过后副本的值回写到主存。这就可能造成一个线程在主存中修改了变量的值,而另外一个线程还继续使用它在本地工作内存的变量值的拷贝,造成数据的不一致。

    使用 volatile 关键字,强制线程每次读取变量的值的时候都必须去主内存中取值,从而避免数据脏读的现象,保证每个线程都能获得该变量的最新值。

登记式/静态内部类模式

这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

public class Singleton {
    
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    private Singleton(){}
    
    private static final Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

内部类(不论是静态内部类还是非静态内部类)都是在第一次使用时才会被加载,而且静态内部类的加载不需要依附外部类,在使用时才加载,因此只有当调用 SingletonHolder.INSTANCE 这条语句时才会初始化 INSTANCE。

那么会不会出现多个线程同时加载 SingletonHolder 类的情况呢?答案是不会。JVM 规定,如多个线程用到同一个类,而这个类还未被加载,则只有一个线程去加载类,其他线程等待。因此静态内部类模式的单例可以保证线程安全。

枚举模式

枚举天然解决了多线程同步执行的问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。

public enum Singleton {
    INSTANCE;
    public void method() {}
}

版权声明:程序员胖胖胖虎阿 发表于 2022年10月7日 上午8:08。
转载请注明:软件设计模式修炼 -- 单例模式 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...