[设计模式] 单例模式的各种写法

(参考《在java中写出完美的单例模式》 、《Effective Java》(第3版)第3条:用私有构造器或者枚举类型强化Singleton属性 P24/306、《为什么最好的单例模式是枚举单例》《Java并发笔记——单例与双重检测》

1.1 懒汉式(延迟加载)

简单版本
public class Singleton{
    private static Singleton instance;
    private Singleton(){}
    public static Singleton getInstance(){
        if(instance==null){
            instance = new Singleton();
        }
        return instance;
    }
}

问题在于,当多线程工作的时候,如果有多个线程同时运行到if (instance == null),都判断为null,那么两个线程就各自会创建一个实例——这样一来,就不是单例了。

synchronized版本
public class Singleton{
    private static Singleton instance;
    private Singleton(){}
    public static synchronized Singleton getInstance(){
        if(instance == null){
            instance = new Singleton();
        }
        return instance;
    }
}

这种写法也有一个问题:给getInstance方法加锁,虽然会避免了可能会出现的多个实例问题,但是会强制除进入临界区的线程之外的所有线程等待,实际上会对程序的执行效率造成负面影响。

双重检查版本
public class Singleton {
    private static Singleton instance;
    private Singleton() {}
    public static Single3 getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

但是这种双重检测机制在JDK1.5之前是有问题的。要弄清楚为什么这个版本可能出现的问题,首先,我们需要弄清楚几个概念:原子操作、指令重排。

简单来说,原子操作(atomic)就是不可分割的操作,在计算机中,就是指不会因为线程调度被打断的操作。

指令重排简单来说,就是计算机为了提高执行效率,会做的一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。

问题主要在于singleton = new Singleton()这句,这并非是一个原子操作:

  1. 给 singleton 分配内存

  2. 调用 Singleton 的构造函数来初始化成员变量,形成实例

  3. 将singleton对象指向分配的内存空间(执行完这步 singleton才是非 null 了)

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

这里的关键在于——线程T1对instance的写操作没有完成,线程T2就执行了读操作。

终极版本
public class Singleton {
    private static volatile Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

JDK1.5之后,可以使用volatile关键字修饰变量来解决无序写入产生的问题,因为volatile关键字的一个重要作用是禁止指令重排序,即保证不会出现内存分配、返回对象引用、初始化这样的顺序,从而使得双重检测真正发挥作用。把instance声明为volatile之后,对它的写操作就会有一个内存屏障(什么是内存屏障?),这样,在它的赋值完成之前,就不用会调用读操作。

注意:volatile阻止的不singleton = new Singleton()这句话内部[1-2-3]的指令重排,而是保证了在一个写操作([1-2-3])完成之前,不会调用读操作(if (instance == null))。

不过,非要挑点刺的话还是能挑出来的,就是这个写法有些复杂了,不够优雅、简洁。

1.2 饿汉式(非延迟加载)

如上所说,饿汉式单例是指:指全局的单例实例在类装载时构建的实现方式。

由于类装载的过程是由类加载器(ClassLoader)来执行的,这个过程也是由JVM来保证同步的,所以这种方式先天就有一个优势——能够免疫许多由多线程引起的问题。

公有域方法
public class Singleton{
    public static final Singleton INSTANCE = new Singleton();
    private Singleton(){}
}

要提醒一点:享有特权的客户端可以借助AccessibleObject.setAccessable方法,通过反射机制调用私有构造器。如果要抵御这种攻击,可以修改构造器,让它在被要求创建第二个实例的时候抛出异常。

公有域方法的主要优势在于,API很清楚地表明了这个类是一个Singleton:公有的静态域是final的,所以该域总是包含相同的对象引用。第二个优势在于它很简单。

静态工厂方法
public class Singleton{
    private static final Singleton INSTANCE = new Singleton();
    private Singleton(){}
    public static Singleton getInstance(){
        return INSTANCE;
    }
}

静态工厂方法的优势之一在于,它提供了灵活性:在不改变其API的前提下,我们可以改变这个类是否应该为Singleton的想法。工厂方法返回该类的唯一实例,但是,它很容易被修改,比如改成为每个调用该方法的线程返回一个唯一的实例。第二个优势是,如果应用程序需要,可以编写一个泛型Singleton工厂。使用静态工厂的最后一个优势是,可以通过方法引用作为提供者,比如Singleton::instance就是一个Supplier<Singleton>。除非满足以上任意一种优势,否则还是优先考虑公有域的方法。

它们的缺点也就只是饿汉式单例本身的缺点所在了——由于INSTANCE的初始化是在类加载时进行的,而类的加载是由ClassLoader来做的,所以开发者本来对于它初始化的时机就很难去准确把握:

  1. 可能由于初始化的太早,造成资源的浪费
  2. 如果初始化本身依赖于一些其他数据,那么也就很难保证其他数据会在它初始化之前准备好。

当然,如果所需的单例占用的资源很少,并且也不依赖于其他数据,那么这种实现方式也是很好的。

1.3 Effective Java的写法

静态内部类

《Effective Java》一书的第一版中推荐了一个写法:

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

这种写法非常巧妙:

  • 对于内部类SingletonHolder,它是一个饿汉式的单例实现,在SingletonHolder初始化的时候会由ClassLoader来保证同步,使INSTANCE是一个真·单例。
  • 同时,由于SingletonHolder是一个内部类,只在外部类的Singleton的getInstance()中被使用,所以它被加载的时机也就是在getInstance()方法第一次被调用的时候。

——它利用了ClassLoader来保证了同步,同时又能让开发者控制类加载的时机。从内部看是一个饿汉式的单例,但是从外部看来,又的确是懒汉式的实现。

简直是神乎其技。

枚举

实现Singleton还可以声明一个包含单个元素的枚举类型:

// Effective Java 第二版推荐写法
public enum SingleInstance {
    INSTANCE;
    public void fun1() { 
        // do something
    }
}
// 使用
SingleInstance.INSTANCE.fun1();

由于创建枚举实例的过程是线程安全的,所以这种写法也没有同步的问题。

枚举类还能能防止利用反射方式获取枚举对象,调用反射newInstance方法时会检查是否为枚举类,如果是将报错,错误如下:Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects

枚举类能防止使用序列化与反序列化获取新的枚举对象。在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。

Effective Java(第3版)中的评价:这种方法在功能上与公有域方法相似,但更加简洁,无偿提供了序列化机制,绝对防止多次实例化,即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型经常称为实现Singleton的最佳方法。注意,如果Singleton必须扩展一个超类,而不是扩展Enum的时候,则不宜使用这个方法(虽然可以声明枚举去实现接口)。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值