学习、探究Java设计模式——单例模式

#前言
单例模式是一个开发者耳熟能详的设计模式,在各种书籍或者文章都会见到这个名字,它的应用场景是:当一个对象的创建开销是十分昂贵的时候;当我们希望全局范围内只对已实例化的这个对象进行操作,而不希望重复实例化这一对象的时候,我们可以使用单例模式,以达到节省资源和协调系统运作的目的。

#定义

确保一个类只有一个实例,并在全局范围内只能通过单例类来获取这个实例。

#类图
根据定义,我们可以导出类图:
学习、探究Java设计模式——单例模式
#设计原则
1、单例具有全局唯一的特性,也就是说不能在别的地方实例化这个类,所以我们要让它的构造器私有化,这样就能防止在类以外的地方new这个对象了。我们只能在类的内部来实例化这个类。
2、单例类的内部用一个静态变量保存这个类的实例。即:

private static Singleton sInstance;

3、通过一个静态方法来返回上面的静态变量即单例对象。

public static Singleton getInstance() { }

至于为什么要使用静态变量以及静态方法,可以试想一下,如果使用的是普通的成员变量和方法,那么在外部就必须先获取到这个类的实例才能访问其成员方法,这就违反了原则1,因为我们不可能在类的外部实例化这个单例类。而使用我们可以直接通过静态方法来访问类的静态变量。

#实现方法
下面就来列举几个单例模式的实现方法。
1、饿汉单例模式

public class Singleton1 {

    private static Singleton1 sInstance = new Singleton1();

    private Singleton1(){
    }

    public static Singleton1 getInstance(){
        return sInstance;
    }

    public void doSomething(){
        System.out.println("Singleton do something.From:" + this.toString());
    }
}

所谓的饿汉单例模式,就是在声明sInstance的同时实例化对象给它,这样sInstance静态变量在类被加载的同时也会被初始化,确保了每次调用getInstance方法都会返回唯一的实例。

2、懒汉模式(延迟初始化模式)

public class Singleton2 {

    private static Singleton2 sInstance = null;

    private Singleton2(){

    }

    public synchronized static Singleton2 getInstance(){
        if (sInstance == null) {
            sInstance = new Singleton2();
        }
        return sInstance;
    }
    
    public void doSomething(){
        System.out.println("Singleton do something.From:" + toString());
    }
}

所谓懒汉模式,就是把该单例类的实例化过程推迟到需要用到的时候才进行初始化,也即用户第一次调用*getInstance()*的时候才会实例化单例。可以看出,当这个单例类并不一定会使用的时候,可以把它的实例化过程推迟到需要使用的时候,这样节约了系统资源。

使用懒汉模式的时候,我们使用了synchronized关键字来对getInstance方法进行加锁,这样的目的是当多个线程调用该方法的时候,确保只有一个线程可以进入临界区,从而确保Singleton只会被实例化一次。为了方便理解这一种情况,举个例子:如果没有加锁,那么当线程A、B同时调用了这个方法,并且都判断sInstance为空的,就会进行Singleton的实例化,这样就产生了两个Singleton的实例。

使用该方法能确保产生的实例是单实例,但同时也产生了一个新的问题:如果getInstance方法被频繁调用,那么就会频繁地加锁、释放锁,这样会产生较大的性能开销。产生这个问题的原因在于整个方法被加锁了,导致每次判空都需要上锁、解锁,这样其实也是一种多余的步骤。进一步地说,我们可以把synchronized的范围缩小,以减少不必要的同步。这样就引出了第三种方法如下。

3、双重检查模式(DCL:Double Check Lock)

public class Singleton3 {
    
    private static Singleton3 sInstance = null;
    
    private Singleton3(){
        
    }
    
    public static Singleton3 getInstance(){
        if (sInstance == null){
            synchronized (Singleton3.class){
                if (sInstance == null){
                    sInstance = new Singleton3();
                }
            }
        }
        return sInstance;
    }

    public void doSomething(){
        System.out.println("Singleton do something.From:" + this.toString());
    }
}

我们直接观察*getInstance()*方法,首先会先判断是否为空,如果是空的话,会先加锁,在同步代码块内部,再次判断是否为空,如果是空的话才会进行实例化。因为这里判断了两次空值,所以双重检查模式由此得名。

这个方法看上去已经很完善了,解决了多余的同步带来的性能损耗的问题,也解决了延迟初始化的问题。但实际上,采用了上述写法的DCL模式会存在一定的缺陷,这种缺陷是由Java内存模型和指令重排序所带来的。这里只是简单地提及一下原因,详细的知识点读者可以参考有关Java内存模型的相关文章。

上述的DCL代码的问题出现在第一层判空上,sInstance是一个共享变量,在没有加锁的条件下,当多个线程对它进行读取的时候,有可能会获得一个失效的值,从而导致对sInstance进行操作的时候会产生一些难以察觉的错误。简单地说,如果一个变量是为多线程所共享的,那么在不加锁的条件下读取这个变量是一种不可靠的行为。为了解决这个问题,JDK1.5之后,Java增强了关键字volatile的能力,我们在声明共享变量的同时为这个变量加上volatile关键字,就能使得DCL变得可靠。

3.1、加入volatile关键字的DCL模式
改进很简单,我们只需要在下面这一行代码做改动即可:

private volatile static Singleton3 sInstance = null;

简单来说,volatile关键字的作用就是使得每次都从主存中获取sInstance变量,避免了不同线程的工作内存的sInstance的值不一致的问题以及禁止了指令重排序。同时,使用该方法对性能的影响很小。

4、静态内部类单例模式(lazy initialization holder class)

public class Singleton4 {

    private Singleton4(){ }

    public static Singleton4 getInstance(){
        return SingletonHolder.sInstance;
    }
    
    public void doSomething(){
        System.out.println("Singleton do something.From:" + this.toString());
    }
    
    private static class SingletonHolder{
        private static final Singleton4 sInstance = new Singleton4();
    }
}

Singleton内部有一个静态内部类Holder,Holder持有一个静态变量sInstance,当外部第一次调用Singleton#getInstance()方法时,会导致SingletonHolder类被加载,从而初始化sInstance,并返回这个对象。使用这种方法,能确保线程安全以及实现了延迟实例化的功能,同时避免了DCL可能的缺陷。

5、枚举单例模式

public enum  Singleton5 {
    
    INSTANCE;

    public void doSomething(){
        System.out.println("Singleton do something.From:" + this.toString());
    }
    
}

枚举是JDK1.5发行版所添加的功能,枚举的创建过程是线程安全的;同时,枚举类不能提供一个Public的构造器,所以不能通过外部来实例化枚举类,所以枚举只能是单实例的;并且重要的一点是,枚举能够防止反序列化时产生新实例,因而,使用枚举能轻松为我们实现单例模式。(注意:前4种方法并没有处理反序列化生成新对象这一问题,所以它们的单例模式会在反序列化的情况下会失效。解决办法可以往下看。)

#各实现方法比较
上面列举了5种实现方法,各种方法都有各自的特点,比如饿汉模式把单例的初始化放在了类加载的时候,这样避免了第一次调用的时候需要消耗性能来加载实例。而懒汉模式、DCL、Holder模式等都是把初始化延迟到了需要使用的时候,可以避免不必要的性能浪费。在JDK1.5以后,volatile关键字的加强以及枚举的引入,使得单例模式在多线程环境下更加安全了,使用枚举形式的单例不但写法简单并且它由Java确保是单例的,开发者不必担心由于自己代码的问题而造成单例不唯一的现象。而Holder模式写法相对于DCL来说也是简洁的,它把多线程的问题交给了JVM来处理,即利用类加载机制来避免了多实例的问题。总的来说,笔者推荐使用Holder模式以及枚举模式。

#知识拓展
本部分主要对上面所述的内容进行知识点的补充,方便大家更深入地了解相关地知识点以及单例模式。

1、反序列化生成新对象而造成前4种方法失效的问题
为了方便说明这个问题,我们在Singleton4的代码做点改动,让它实现Serializable接口,即:

public class Singleton4 implements Serializable{
  //...
}

在Java中,Serializable接口表示该类可以被序列化和反序列化。接着,我们写一个测试类,把Singleton4序列化然后反序列化,观察生成的对象是否是同一个对象。

public class SingletonTest {

    public static void main(String args[]) throws IOException, ClassNotFoundException {
        Singleton4 s1 = Singleton4.getInstance();
        s1.doSomething();   //让Singleton做一些事情,里面打印了它的地址

        //序列化过程
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("test.txt"));
        objectOutputStream.writeObject(s1);
        objectOutputStream.close();

        //反序列化过程
        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("test.txt"));
        Singleton4 s2 = (Singleton4) objectInputStream.readObject();
        objectInputStream.close();

        s2.doSomething();
    }
}

运行main函数,观察控制台输出结果:
学习、探究Java设计模式——单例模式
可以看出,两个对象有着不同的内存地址,所以这两个对象不是同一个对象,因而单例模式在序列化的条件下失效了。那么,该如何解决这个问题呢?我们在实现了Serializable接口的类中,添加一个*readResolve()*方法,如下所示:

public class Singleton4 implements Serializable {

    //...

    private Object readResolve() throws ObjectStreamException {
        return SingletonHolder.sInstance;
    }
}

*readResolve()方法的作用就是反序列的时候,控制对象的生成,也就是说它和构造函数的作用类似,也能生成一个对象。而上面的改动,把SingletonHolder.sInstance返回,而不是去新生成一个对象。改动完后,可以再运行一次测试代码,会发现两个对象的地址是一样的,也即是同一个对象。
综上所述,如果单例类要实现序列化这一功能,就要实现
readResolve()*方法做出处理,否则单例模式会失效。而采用枚举形式的单例则没有这个问题。

2、Java内存模型(JMM:Java Memory Model)
这里只是简单介绍一下相关的知识点。JMM规定了所有变量都存储在主内存中,而每条线程都有自己的工作内存,工作内存保存的是主内存共享变量的一份拷贝。线程对变量的操作(读取/存储)都通过工作内存的变量来完成,而不能直接读写主内存的值。如果两条线程之间需要对某一共享变量的值进行数据交换,那么就要通过主内存。比如,线程1修改了自己工作内存的变量A,然后刷新到主内存的变量A,接着线程2从主内存中获取变量A,然后复制到线程2的工作内存中。用下图来表示工作内存和主内存的关系:
学习、探究Java设计模式——单例模式

由上面的内存模型,我们可以看出,如果线程1对某一共享变量进行了修改,而还没有及时刷新回主内存,此时线程2读取这个共享变量就是一个已经失效的值。也就是说线程2不能感知到线程1对变量所做的修改,即“不可见性”,我们不能确定被某一线程修改的值在什么时候会同步到主内存中。因此如果需要实现准确无误的线程间通信,就需要加锁,或者给共享变量加上volatile关键字来确保可见性。

3、指令重排序
指令重排序是引起DCL失效的根本原因。所谓指令重排序简单来说就是编译器和处理器为了提高程序的运行性能,对指令进行重新排序。只要在单线程环境下,指令A、指令B交换位置对运行结果没有影响,那么就能交换指令A、B的执行顺序以提高执行效率。考察下面的例子:

1.为Singleton的实例s1分配内存A
2.调用Singleton的构造函数,初始化s1
3.引用s1指向内存A

由于Java编译器允许CPU执行指令的时候对指令进行重排序,所以2和3的执行顺序是不确定的。假设执行顺序是1-3-2,如果线程A执行到3的时候,把s1指向了内存区A,然后该操作被同步到了主内存中;同时,线程B从主内存中读取了s1的值,那么它此时还没有初始化,直接拿去用肯定会出错的。这也就产生了DCL失效。
在jdk5之后,volatile关键字不但确保了可见性,同时也禁止了指令重排序,每次都从主存中获取最新的值,从而使得DCL变得可靠。

好了,本文到这里就结束啦,谢谢大家的阅读,欢迎留言沟通交流~