应用最广的模式——单例模式

2018-02-17  本文已影响0人  熊sir要早睡早起

单例模式的简单介绍:
单例模式是应用最广的模式之一,在应用这个模式时,单列对象的类必须保证只有一个实例的存在,许多时候整个系统只需要拥有一个全局对象,这有利于余我们协调系统的整体行为。在Android当中,对于Retrofit的使用者常常会写一个Retrofit的单例,来确保整个APP只有一个Retrofit的实例,再比如ImageLoader,可能含有线程池、缓存、网络请求等等,非常消耗系统资源,同样也没理由构建多个实例,还有我们经常使用到的Toast,重复点击易出现不友好的交互,这些情况都是单例模式的使用场景。

单例模式的定义:
确保一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

实现单例模式的几个关键点:
(1)构造函数一般私有化,不对外开放
(2)通过一个静态方法或者枚举返回单列类对象
(3)确保单例类对象有且只有一个,尤其是在多线程环境下
(4)确保单例类对象在反序列化时不会重新构建对象。

单例模式的简单示例:
单例模式在设计模式中比较简单,只有一个单例类,没有其他的层次结构与抽象。该模式需要确保该类只能生成一个对象,通常时该类需要消耗比较多的资源或者没有多个实例的情况。例如,一个学校只有一个校长,一个应用程序员只有一个Application对象等。以学校有一个校长为例:下面看代码体现。

首先创建一个Teacher类

/**
 * 学校有很多普通老师
 */
  public class Teacher {

    public void teach(){
    //教学
}
}

然后创建一个校长类,这里注意使用饿汉单例:

  /**
   * 校长,一个学校应该只有一个校长
 * 此处使用饿汉单例模式
 */
public class Principal extends Teacher{

private static final Principal pal = new Principal();
//构造方法私有化
private Principal(){};
//共有的静态函数,对外暴露获取单列对象的接口
public static Principal getPal(){
    return pal;
}
}

最后我们写一个测试类:

import java.util.ArrayList;
import java.util.List;

public class Test {

public static void main(String[] args) {

    List<Teacher> list = new ArrayList<>();

    //创建两个老师
    Teacher teacher1 = new Teacher();
    Teacher teacher2 = new Teacher();
    //通过暴露的getPal方法创建两个校长
    Principal principal1 =  Principal.getPal();
    Principal principal2 = Principal.getPal();

    list.add(teacher1);
    list.add(teacher2);
    list.add(principal1);
    list.add(principal2);
    //遍历集合,查看输出对象
    for (Teacher tech : list){
        System.out.println("Obj : "+tech.toString());
    }
}

}

运行程序查看控制台输出:


饿汉.png

使用了单例模式的校长,虽然调用两次getPal,但实际上只创建了一个校长。因为getPal方法返回的都是我们Principal类中已经创建好的校长啦,我们把构造方法私有化也是为了避免使用时误操作通过new直接创建了对象,这样就使得程序不仅仅有一个校长了,与我们单例的初衷所违背。正如其名,饿汉式在代码中也有体现,我们的校长是一个静态对象,在声明的时候已经初始化了,这样我们调用getPal时才保证了校长的唯一性。

单例模式的其它实现:
懒汉模式:懒汉模式是声明一个静态对象,并且在用户第一次调用getInstance时进行初始化,而上述的饿汉模式时在声明静态对象时就已经初始化。懒汉单例模式的实现如下。

/**
 * 懒汉单例模式
 */
public class SingletonPal {

private static SingletonPal instance;
//私有化构造犯法
private SingletonPal (){};

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

同样的,我们运行测试类观察结果:


懒汉.png

同样的实现了校长的唯一性。

那么两者的区别是什么呢:
细心的读者发现了getInstance()方法中添加了同步锁,也就是说这是一个同步方法,每次调用getInstance方法都会进行同步,这样会造成不必要的同步开销,这也是懒汉单例模式存在的最大问题。而它的优点是只有在使用时才会被实例化,在一定成都上节约了资源。此模式一般不建议使用

有没有其它方式实现单例呢?
DCL方式:Double Check Lock方式实现单例模式的优点是既能够在需要时才初始化单例,又能保证线程安全,且单例对象初始化后调用getInstance不进行同步锁。代码如下所示:

/**
 * DCL单例模式
 */
public class DCLPal {

private volatile static DCLPal instance = null;
//私有化构造方法
private DCLPal(){};
public static DCLPal getInstance(){
    if (instance==null){
        synchronized (DCLPal.class){
            if (instance==null){
                instance = new DCLPal();
            }
        }
    }
    return instance;
}
}

运行结果如下:


DCL.png

此模式的亮点也在getInstance方法上,可以看到getInstance方法中对instance进行了两次为空判断,第一次时为了避免不必要的同步,第二层是在为空的情况下创建实例。

其实此处也有一个bug,那就是instance = new DCLPal语句虽然是一句代码,但实际上这并不是一个原子操作(原子操作是指不会被线程调度打断的操作,这种操作一旦开始就会一直运行到结束。。原子操作(atomic operation)是不需要synchronized),这句代码最终会被编译成多条汇编指令,它大致做了三件事情:
(1)给DCLPal的实例分配内存
(2)调用DCLPal()构造函数,初始化成员字段
(3)将instance对象指向分配的内存空间(此时instance就不为null了)
But,由于Java编译器运行处理器乱序执行,以及JDK1.5之前的JMM(Java内存模型)中cache、寄存器到主内存回写顺序的规定,第二步和第三步的顺序无法保证,如果遇到执行了1-3之后,2还没有执行就被切换到另外一个线程当中,此时我们的instance已经是非null了,所以线程B直接取走了instance,使用时就会出现报错,这就是DCL失效。
在JDK1.5之后,SUN官方已经注意到了此问题,所以调整了JVM,具体化了volatile关键字,因此,如果是JDK1.5或之后的版本,只需要在private 后面加上volatile关键字来保证instance对象每次都是从主内存中读取,这样就可以使用DCL的方式来实现单例。此方式一般能够满足需求

静态内部类单例模式:
DCL虽然在一定程度上解决了咨询员消耗,多余的同步,线程安全等问题,但是在某些情况下仍然会出现失效的问题。这个问题被称为双重检查锁定失效,在《Java并发编程》中指出这种“优化”是丑陋的,不赞成使用,而是建议使用如下的代码代替。

/**
 * 匿名内部类实现单例
 */
public class InnerClassPal extends Teacher{
//私有化构造方法
private InnerClassPal(){};

public static InnerClassPal getInstance(){
    return PalHolder.instance;
}

private static class PalHolder{
    private static final InnerClassPal instance = new InnerClassPal();
}
}

运行结果如下:


匿名内部类.png

当第一次加载InnerClassPal类时并不会初始化instance,只有在第一次调用InnerClassPal的getInsatnce方法时才会导致instance被初始化,因此,第一次调用getInstance方法会导致虚拟机加载InnerClassPal类,因为在多线程环境下,jvm对一个类的初始化会做限制,同一时间只会允许一个线程去初始化一个类,这种方式不仅能保证线程安全,也能够保证单例对象的唯一性,同时也延迟了单例子的实例化,所以这是最推荐使用的单例模式的实现方法。

枚举单例
枚举单例是最简单的实现方式,因为枚举在Java中与普通的类是一样的,不仅能够又字段,还能够拥有直接的方法,最重要的是枚举实例的创建是线程安全的,在任何情况下都是一个单例。(以上几种情况在反序列化的情况下都会重新创建对象。。如果需要杜绝单例对象在被反序列化时重新生成对象,必须加入readResolve函数)

首先是枚举类型代码实现:

class Pal{

public void teach(){
    System.out.println("教书");
}

}

public enum  PalEnum {

INSTANCE;

private Pal instance;

PalEnum(){
    instance = new Pal();
}

public Pal getInstance() {
    return instance;
}
}

运行结果如下:


枚举.png

readResolve避免反序列化重新生成对象:

/**
 * 添加readResolve方法避免反序列化后重复创建对象
 */
public class Singleton implements Serializable{

private static final long serialVersionUID = 0L;
private static final Singleton INSTANCE = new Singleton();

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

public static Singleton getInstance(){
    return INSTANCE;
}

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

}

使用容器实现单例模式:
在学习了上述各类单例的实现之后,再来看看一种另类的实现,具体代码如下:

public class SingletonManager {

private static Map<String,Object> objMap = new HashMap<>();
//私有化构造方法
private SingletonManager(){};

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

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

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

总结:
不管以那种形式实现的单例模式,它们的核心原理都是将构造函数私有化,并且通过静态方法获取一个唯一的实例,在这个获取过程中必须保证线程安全,防止反序列化导致重新生成实例对象等问题。选择哪种实现方式取决于项目本身,如是否复杂的并发环境、JDK版本是否过低、单例对象的资源消耗等问题。

原文出自Android源码设计模式解析与实战一书。

上一篇下一篇

猜你喜欢

热点阅读