【笔记】设计模式 -- 单例设计模式

单例设计模式

参考:
[1] 史上最全设计模式导学目录(完整版)
[2] 《Android源码设计模式解析与实战》

文章目录

  • 单例设计模式
    • 1. 定义
    • 2. 使用场景
    • 3. 实现单例模式的关键点
    • 4. 经典单例模式:饿汉式与懒汉式
      • 4.1 饿汉式
      • 4.2 懒汉式
    • 4. 一种更好的单例实现方式:静态内部类单例模式
    • 5. 枚举单例
    • 6. 使用容器实现单例模式
    • 7. 单例模式的优缺点

单例设计模式是应用最广的模式之一。单例模式作为一种目标明确、结构简单、理解容易的设计模式,在软件开发中使用频率相当高,在很多应用软件和框架中都得以广泛应用。

1. 定义

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

2. 使用场景

确保某个类有且只有一个对象的场景,避免产生多个对象消耗过多资源,或者某种类型的对象只应该有且只有一个。例如,创建一个对象需要消耗的资源过多,如需要访问IO或数据库等资源。

3. 实现单例模式的关键点

(1)构造方法不对外开放(私有 private);

(2)通过一个静态方法或者枚举返回单例类对象;

(3)确保单例类对象有且只有一个,尤其在多线程环境下;

(4)确保单例类对象在反序列化时不会重新构建对象。

通过单例类的构造函数私有化,使得客户端无法通过new关键字创建单例类对象。单例类暴露一个公共静态方法返回单例类对象,客户端只需调用这个方法就可以获得单例类的唯一对象,获取这个单例类对象时需要保证线程安全,即多线程环境下单例类对象唯一。

4. 经典单例模式:饿汉式与懒汉式

4.1 饿汉式

饿汉式单例类是实现起来最简单的单例类。


public class EagerSingleton {

    private static final EagerSingleton mInstance = new EagerSingleton();

    private EagerSingleton() {}
    
    public static final EagerSingleton getInstance() {
        return mInstance;
    }
}

当类被加载时,静态变量mInstance会被初始化,此时私有构造方法会被调用,单例类的唯一实例将被创建。

4.2 懒汉式

懒汉式是声明一个静态对象,并且在第一次调用getInstance()方法时进行实例化,而上面的饿汉式在声明静态对象时初始化。在类加载时不自行实例化,称为懒加载(Lazy Load),即需要的时候再加载实例。为了适应多线程环境,可以使用关键字synchronized,代码如下

public class LazySingleton {

    private static LazySingleton mInstance = null;

    private LazySingleton(){}

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

上面代码中getInstance方法添加了synchronized关键字,作了线程同步,可以保证在多线程情况下对象的唯一性。虽然解决了线程安全问题,但每次调用getInstance方法都需要线程锁判断,在多线程高并发访问环境中,将会导致系统性能大大降低。那么,如何既解决线程安全问题又不影响系统性能呢?我们可以对懒汉式进行改进。

public class LazySingleton {

    private static LazySingleton mInstance = null;

    private LazySingleton(){}

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

上面代码不再对整个方法进行同步,只对代码mInstance = new LazySingleton();
进行同步,性能问题貌似得以解决,事实并非如此。用上面的方法实现单例,还是会存在单例对象不唯一的问题。原因是:

假如在某一瞬间线程A和线程B都在调用getInstance()方法,此时mInstance对象为null值,均能通过instance ==null的判断。由于实现了synchronized同步机制,线程A进入synchronized锁定的代码中执行实例创建代码,线程B处于排队等待状态,必须等待线程A执行完毕后才可以进入synchronized锁定代码。但当A执行完毕时,线程B并不知道实例已经创建,将继续创建新的实例,导致产生多个单例对象,违背单例模式的设计思想,因此需要进行进一步改进,在synchronized中再进行一次(instance==null)判断,这种方式称为双重检查锁定(Double-Check Locking,DCL)。使用双重检查锁定实现的懒汉式单例类完整代码如下所示:

public class LazySingleton {

    private volatile static LazySingleton mInstance = null;

    private LazySingleton(){}

    public static LazySingleton getInstance() {
        //第一重判断
        if (mInstance == null) {
            //同步(锁定)代码块
            synchronized (LazySingleton.class) {
                //第二重判断
                if (mInstance == null) {
                    mInstance = new LazySingleton();//创建实例
                }
            }
        }
        return mInstance;
    }
}

上面代码的亮点在于对mInstance进行了两次判空:第一重判空主要为了避免不必要的同步;第二重判空则是为了在null的情况下创建实例。

使用DCL实现懒汉式单例,似乎解决了上面的问题,性能也有一定提升,很大程度上保证了单实例。但是,还是没有完全保证单实例,原因看下面的情况。

假设A线程执行到了mInstance = new LazySingleton()语句,这里看起来是一句代码,但它实际上并不是一个原子操作,这句代码最终会被编译成多条汇编指令,它大致做了3件事:
(1)给LazySingleton的实例分配内存;
(2)调用LazySingleton的构造方法,初始化成员字段;
(3)将mInstance指向分配的内存空间(此时mInstance就不再为null了)。

Java编译器允许处理器乱序执行,以及JDK 1.5 之前 JMM(Java Memory Model,Java内存模型)中Cache、寄存器到主内存回写顺序的规定,上面的(2)和(3)的顺序是无法保证的。即在Java指令中创建对象和赋值操作是分开进行的。

也就是说,执行顺序可能是1-2-3,也可能是1-3-2。如果是后者,并且在3执行完毕,2未执行之前,被切换到了B线程,这时候mInstance已经在线程A内执行过了第3步,mInstance已经是非空了,所以B直接取走了mInstance,再使用时就会出错(只是分配配了内存,mInstance指向了内存,但没有完成实例化),这就是DCL失效问题,而且这种难以跟踪难以重现的错误可能会隐藏很久。

在JDK 1.5之后,SUN官方已经注意到了这个问题,调整了JVM,具体化了volatile关键字,因此在JDK 1.5 之后只需要将mInstance定义成private volatile static LazySingleton mInstance = null;就可以保证mIntance每次都是从主内存中读取,就可以使用DCL的写法来完成单例模式,但volatile关键字会屏蔽Java虚拟机所做的一些代码优化,或多或少的会影响到性能和效率,但考虑到程序的正确性,这点牺牲还是值得的。

DCL的优点:资源利用率高,第一次执行getInstance方法时才会实例化,效率高。缺点:第一次加载时反应稍慢,也由于Java内存模型的原因偶尔会失败,在高并发环境下也有一定的缺陷,虽然发生概率很小。DCL是使用最多的单例实现方式,它能够在需要时才实例化单例对象,并且能够在绝大多数场景下保证单例对象的唯一性,除非你的代码在并发场景比较复杂或者低于JDK 6 版本以下使用,否则这种方式一般能够满足需求。

饿汉式与懒汉式比较

饿汉式单例类在类被加载时就将自己实例化,它的优点在于无须考虑多线程访问问题,可以确保实例的唯一性;从调用速度和反应时间角度来讲,由于单例对象一开始就得以创建,因此要优于懒汉式单例。但是无论系统在运行时是否需要使用该单例对象,由于在类加载时该对象就需要创建,因此从资源利用效率角度来讲,饿汉式单例不及懒汉式单例,而且在系统加载时由于需要创建饿汉式单例对象,加载时间可能会比较长。

懒汉式单例类在第一次使用时创建,无须一直占用系统资源,实现了延迟加载,但是必须处理好多个线程同时访问的问题,特别是当单例类作为资源控制器,在实例化时必然涉及资源初始化,而资源初始化很有可能耗费大量时间,这意味着出现多线程同时首次引用此类的机率变得较大,需要通过双重检查锁定等机制进行控制,这将导致系统性能受到一定影响。

4. 一种更好的单例实现方式:静态内部类单例模式

DCL虽然在一定程度上解决了资源消耗、多余同步、线程安全等问题,但它还是在某些情况下出现失效的问题。这个问题被称为双重检查锁定(DCL)失效。建议使用如下方式实现单例:在单例类中增加一个静态内部类,在该类内部创建单例对象,再将该单例对象通过getInstance方法发回给外部使用,实现代码如下所示:

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

由于静态单例对象并没有作为Singleton的成员变量直接实例化,因此类加载时并不会实例化Singleton,第一次调用Singleton的getInstance方法时将加载Holder内部类,该类中定义了static类型的属性instance,此时会首先初始化这个成员变量,由Java虚拟机来保证其线程安全,确保成员变量只初始化一次。由于getInstance方法没有线程锁定,因此其性能不会造成任何影响。所以:

通过这种方式既可以实现延迟加载,又可以保证线程安全,不影响系统性能,还可以保证单例对象的唯一性,不失为一种最好的Java语言单例模式实现方式。

5. 枚举单例

使用枚举实现单例是更简单的一种单例实现方式。

public enum EnumSingleton {
    INSTANCE;
    public void doSomething(){
        System.out.println("do sth.");
    }
}

写法简单是枚举单例的最大优点。

枚举类在Java中与普通类是一样的,枚举的实例默认是线程安全的,并且在任何情况下它都是一个单例。

为什么这么说?在上述几种单例模式实现中,在一个情况下它们会出现重新创建对象的情况,即反序列化

通过序列化可以将一个单例的实例对象写到磁盘,然后再读回来,从而获得一个有效的实例。即使构造函数是私有的,反序列化时依然可以通过特殊的途径去创建一个新的实例,相当于调用该类的构造方法。反序列化操作提供了一个特别的钩子函数,类中具有一个私有的、被实例化的方法readResolve(),这个方法可以让开发人员控制对象的反序列化。例如,上述几个实例中如果要杜绝单例对象在被反序列化时重新生成对象,那么必须加入如下方法:

private Object readResolve() throws ObjectStreamException {
    return mInstance;
}

也就是在readResolve方法中将mInstance对象返回,而不是默认的重新生成一个新的对象。而对于枚举,并不存在这个问题,以为及时反序列化它也不会重新生成新的实例。

6. 使用容器实现单例模式

在程序初期,将多种单例类型注入到一个统一的管理类中,在使用时根据key获取对应类型的对象。这种方式使得我们可以管理多种类型的单例,并且在使用时可以通过统一的接口进行获取操作,降低了用户的使用成本,也对用户隐藏了具体实现,降低了耦合度。代码如下:

public class SingletonManager {

    private static final Map<String, Object> mInstanceMap = new HashMap<>();
    
    private SingletonManager() {}

    public static void registerService(String key, Object instance) {
        if (!mInstanceMap.containsKey(key)) {
            mInstanceMap.put(key, instance);
        }
    }

    public static Object getService(String key) {
        return mInstanceMap.get(key);
    }
}

7. 单例模式的优缺点

1.主要优点

单例模式的主要优点如下:

(1) 单例模式提供了对唯一实例的受控访问。因为单例类封装了它的唯一实例,所以它可以严格控制客户怎样以及何时访问它。

(2) 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象单例模式无疑可以提高系统的性能。

(3) 允许可变数目的实例。基于单例模式我们可以进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例,既节省系统资源,又解决了单例对象共享过多有损性能的问题

2.主要缺点

单例模式的主要缺点如下:

(1) 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。

(2) 单例类的职责过重,在一定程度上违背了“单一职责原则”。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。

(3) 现在很多面向对象语言(如Java、C#)的运行环境都提供了自动垃圾回收的技术,因此,如果实例化的共享对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致共享的单例对象状态的丢失。

你可能感兴趣的:(Java,设计模式,设计模式,单例)