设计模式:如何优雅地手写单例模式

单例模式是一种常用的设计模式,该模式提供了一种创建对象的方法,确保在程序中一个类最多只有一个实例。

单例有什么用处?

有一些对象其实我们只需要一个,比如线程池、缓存、对话框、处理偏好设置和注册表的对象、日志对象,充当打印机、显示等设备的驱动程序对象。其实,这类对象只能有一个实例,如果制造出来多个实例,就会导致许多问题,如:程序的行为异常、资源使用过量,或者是不一致的结果。

Singleton通常用来代表那些本质上唯一的系统组件,比如窗口管理器或者文件系统。

在Java中实现单例模式,需要一个静态变量、一个静态方法和私有的构造器。

经典的单例模式实现

对于一个简单的单例模式,可以这样实现:

  1. 定义一个私有的静态变量uniqueInstance;

  2. 定义私有的构造方法。这样别处的代码无法通过调用该类的构造函数来实例化该类的对象,只能通过该类提供的静态方法来得到该类的唯一实例;

  3. 提供一个getInstance()方法,该方法中判断是否已经存在该类的实例,如果存在直接返回,不存在则新建一个再返回。代码如下:

public class Singleton{
    private static Singleton uniqueInstance;//私有静态变量
    
    //私有的构造器。这样别处的代码无法通过调用该类的构造函数来实例化该类的对象,只能通过该类提供的静态方法来得到该类的唯一实例。
    private Singleton(){}
    
    //静态方法
    public static Singleton getInstance(){
        //如果不存在,利用私有构造器产生一个Singleton实例并赋值到uniqueInstance静态变量中。
        //如果我们不需要这个实例,他就永远不会产生。这叫做“延迟实例化(懒加载)“
        if(uniqueInstance == null){
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }
}

这段代码使用了延迟实例化,在单线程中没有任何问题。但是在多线程环境下,当有多个线程并行调用 getInstance(),都认为uniqueInstance为null的时候,就会调用uniqueInstance = new Singleton();,这样就会创建多个Singleton实例,无法保证单例。

解决多线程环境下的线程安全问题,主要有以下几种写法:

同步getInstance()方法

关键字synchronized可以保证在他同一时刻,只有一个线程可以执行某一个方法,或者某一个代码块。

同步getInstance()方法是处理多线程最直接的做法。只要把getInstance()变成同步(synchronized)方法,就可以解决并发问题了。

public class Singleton{
    private static Singleton uniqueInstance;//私有静态变量

    //私有构造器
    private Singleton() {}
    
    //synchronized同步方法
    public static synchronized Singleton getInstance(){
        if(uniqueInstance == null){
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }
}

但是,同步的效率低,会降低性能。只有第一次执行此方法的时候,才真正需要同步。也就是说,一旦设置好uniqueInstance变量,就不再需要同步这个方法了。之后每次调用这个方法,同步都是一种累赘。同步getInstance()方法既简单又有效。如果说对性能要求不高,这样就可以满足要求。

“急切”实例化

之前的实现采用的是懒加载方式,也就是说,当真正用到的时候才会创建;如果没被使用到,就一直不会创建。

懒加载方式在第一次使用的时候, 需要进行初始化操作,可能会比较耗时。

如果确定一个对象一定会使用的话,可以采用“急切”地实例化,事先准备好这个对象,需要的时候直接使用就行了。这种方式也叫做饿汉模式。具体代码:

public class Singleton{
    //在静态初始化器中创建单例,保证了线程安全性
    private static Singleton uniqueInstance = new Singleton();
    
    private Singleton() {}
    
    public static Singleton getInstance(){
        return uniqueInstance;
    }
}

饿汉模式是如何保证线程安全的?

饿汉模式中的静态变量是随着类加载时被初始化的。static关键字保证了该变量是类级别的,也就是说这个类被加载的时候被初始化一次。注意与对象级别和方法级别进行区分。

因为类的初始化是由类加载器完成的,这其实是利用了类加载器的线程安全机制。类加载器的loadClass方法在加载类的时候使用了synchronized关键字。也正是因为这样, 除非被重写,这个方法默认在整个装载过程中都是同步的(线程安全的)。

双重检查加锁

杀鸡用牛刀。实现单例模式可以利用双重检查加锁(double-checked locking),首先检查是否实例已经创建了,如果尚未创建,“才”进行同步。这样,只有第一次会同步。

public class Singleton{
    //使用volatile关键字,确保当uniqueInstance变量被初始化成为Singleton实例时,多线程可以正确地处理uniqueInstance变量。
    private volatile static Singleton uniqueInstance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if(uniqueInstance == null){//第一次检查
            synchronized(Singleton.class){
                if(uniqueInstance == null){//第二次检查
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
    
}

如果性能是关注的重点,双重检查加锁可以大幅减少getInstance()的时间消耗成本。

在Java 1.5发行版本之前,双重检查模式的功能很不稳定,因为volatile修饰符的语义不够强,难以支持它。Java 1.5发行版本中引入的内存模式解决了这个问题,如今,双重检查模式是延迟初始化的一个实例域的方法。

为什么要进行双重检查?只检查一次不行吗?

解答:只检查一次不行。只检查一次的代码如下:

     if(uniqueInstance == null){//第一次检查
            synchronized(Singleton.class){
                    uniqueInstance = new Singleton();
            }
        }

当两个线程同时判断uniqueInstance == null的时候,都会去获得Singleton.class的锁对象,由于两个线程拥有的锁对象是同一个Singleton.class,两个线程先后执行,也就是两个线程都会进入同步代码块创建一个新的对象,造成返回的uniqueInstance 并不是唯一的,这样也就不符合单例模式了。

最佳方法

从Java 1.5发行版本起,实现Singleton只需要编写一个包含单个元素的枚举类型:

public enum Singleton {  
    INSTANCE;  
}  

使用枚举实现单例的方法虽然还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。注意:如果Singleton必须拓展一个超类,而不是扩展Enum的时候,则不宜使用这个方法。

参考

  1. Eric Freeman;ElElisabeth Freeman.HeadFirst设计模式[M]. 北京:中国电力出版社, 2007.
  2. Joshua Bloch.Effective Java中文版(原书第3版)[M]. 北京:机械工业出版社, 2018.
  3. 漫话:如何给女朋友解释什么是单例模式?
posted @ 2019-04-22 20:00  James_Shangguan  阅读(950)  评论(0编辑  收藏