设计模式——单例模式

246 阅读8分钟

单例模式

单例模式用来保证一个对象在运行期间只会被创建一次,通常,单例类会提供一个类静态方法 getInstance 提供该类的唯一实例。

单例模式只需要保证类实例只会被创建一次,所以单例模式有多种实现。

饿汉模式

@ThreadSafe
public class Singleton {

    // 提前初始化,未被使用则造成资源浪费
    private static Singleton instance = new Singleton();

    // 使其它类无法创建新对象
    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }

}

饿汉模式在类初始化时就创建了对象,在调用 getInstance 的方法时直接返回该对象即可,不需要再去创建该对象的实例。但是如果该类没有被使用到,则实例占用的空间就浪费了,这种方式也算是用空间换时间。

懒汉模式

非线程安全的懒汉模式

@NotThreadSafe
public class Singleton {

    private static Singleton instance = new Lazy();

    // 使其它类无法创建新对象
    private Singleton() {
    }

    // 非线程安全
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

这种方式将实例的创建延迟到第一次调用 getInstance 方法时,如果该类没有被使用到,不会造成资源浪费。

但是在并发访问时,可能会出现多个线程同时进入 if 判断,又同时创建了对象的情况发生,所以非线程安全,不可取。

普通懒汉模式

@ThreadSafe
public class Singleton {

    private static Singleton instance = null;

    // 使其它类无法创建新对象
    private Singleton() {
    }

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

这里在对象创建前加锁,避免出现多线程并发访问时创建多个对象的意外。这种写法相当于在方法上加锁,这种写法主要是方便与双重检测锁写法对比。

这种实现的主要问题在于,getInstance 方法每次被调用时,都需要获取锁,当有大量线程访问时,会出现很多线程被阻塞的情况,影响性能。而实际上,实例被初始化之后,直接返回被初始化的对象即可,不会有线程安全问题。

双重检测锁

@ThreadSafe
public class DoubleCheckLock {
	// volatile 禁止重排序,保证对 instance 操作可见性
    private volatile static DoubleCheckLock instance = null;

    // 使其它类无法创建新对象
    private DoubleCheckLock() {
    }

    public static DoubleCheckLock getInstance() {
        if (instance == null) {//①
            synchronized (DoubleCheckLock.class) {//②
                if (instance == null) {//③
                    instance = new DoubleCheckLock();//④
                }
            }
        }
        return instance;
    }
}

第①步的判断,是对于上述普通懒汉模式的优化。在实例已经被初始化之后,调用 getInstance 方法,不再需要获取同步状态。

第②步,是在实例的创建入口加锁保证线程安全。

第③步,是保证阻塞在第②步的线程获取锁之后,重新检测一下实例是否已经被初始化,避免二次创建。

第④步,是创建实例的过程。

与普通懒汉模式相比,双重检测锁实现不仅是多了第①步,类静态属性 instance 也多了一个修饰符 volatile

volatile 的作用是防止第④步创建实例时的重排序,以及保证 instance 可见性。

重新分析第④步 instance = new DoubleCheckLock(),这并不是原子操作,它可以被拆分成:

  1. 堆内存开辟空间准备初始化对象
  2. 初始化对象
  3. 栈中引用 instance 指向堆内存空间地址

三步操作中,2 与 3 都依赖 1, 2 和 3 无依赖关系,因此 2 和 3 是可以被优化重排序的。

假设当线程 A 创建实例时,出现了重排序的情况,1 之后执行了 3,而暂未执行 2 时,会出现 instance != null 的情况,而此时实例未被初始化。此时若有线程 B 调用了 getInstance 方法,会直接返回 instance,当线程 B 该实例操作时,会抛出 NullPointerException,之后线程 A 才执行 2。

此处加上 volatile 修饰 instance ,可以保证第④步不会被重排序,同时 instance 的指向更新后能立马被其它线程看到,避免了该问题的发生。

在单线程中,即使 2 和 3 被重排序了,也一定是 2 执行后才会执行后续操作,所以不会出现该问题。

静态内部类

@ThreadSafe
public class Singleton {

    private Singleton() {
    }
    
    private static class SingletonHolder {
        private static Singleton instance = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.instance;
    }
}

由于 JVM 只在特定的 5 种场景(类的主动引用)下才会对类初始化,而静态内部类不在其中,称为被动引用。只有当他被其它操作访问时,才会被初始化。所以静态内部类是延迟加载的。

而静态内部类的 instance 实例初始化过程是由 JVM 来保证线程安全的。《深入理解 Java 虚拟机》中描述如下:

虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行

< clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时很长的操作,就可能造成多个进程阻塞 (需要注意的是,其他线程虽然会被阻塞,但如果执行 < clinit>() 方法后,其他线程唤醒之后不会再次进入 < clinit>() 方法。同一个加载器下,一个类型只会初始化一次。),在实际应用中,这种阻塞往往是很隐蔽的。

因此静态内部类保证了单例的唯一性,同时也延迟了单例的实例化。

静态内部类的唯一缺陷在于静态内部类创建对象时,无法接收到 getInstance 方法传递的参数。当需要根据参数创建对象时,可以选择使用 DCL

反射问题

上述单例模式的实现,都可以用反射来破坏单例特性。

public class ReflectionCase {

    public static void main(String[] args) throws Exception {
        // 静态内部类实现单例
        Singleton instance = Singleton.getInstance();
        Singleton newInstance;
        Class<Singleton> singletonClass = Singleton.class;
        Constructor constructor = singletonClass.getDeclaredConstructor();
        constructor.setAccessible(true);
        newInstance = (Singleton) constructor.newInstance();
        System.out.println(instance == newInstance);
    }
    
}

该测试运行后打印的结果为 false,说明已经产生了两个对象,单例被破坏。

序列化问题

使静态内部类实现中的 Singleton 类实现序列化接口。

public class SerializationCase {

    public static void main(String[] args) throws Exception {
        // 静态内部类实现单例
        Singleton instance = Singleton.getInstance();
        Singleton newInstance;
        // 序列化
        ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("singleton.ser"));
        outputStream.writeObject(instance);
        outputStream.close();

        // 反序列化
        ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("singleton.ser"));
        newInstance = (Singleton) inputStream.readObject();
        inputStream.close();

        System.out.println(instance == newInstance);
    }
}

运行该程序,打印结果为 false,同样说明已经产生了两个对象,单例被破坏。

克隆问题

@NotThreadSafe
public class Singleton implements Cloneable{
   // 静态内部类实现单例
    ...
        
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}


public class CloneCase {
    public static void main(String[] args) throws CloneNotSupportedException {
        // 静态内部类实现单例
        Singleton instance = Singleton.getInstance();
        Singleton newInstance = (Singleton) instance.clone();
        System.out.println(instance == newInstance);
    }
}

在这种情况下,打印的结果为 false

此时枚举类实现的单例都能被破坏。因为 clone 方法在 Object 类中的实现是会将内存中的对象拷贝一份生成新对象,所以必然会产生新对象,即使是枚举单例也会被破坏。因此,需要保证单例的类不会实现 Cloneable 接口。只要满足该条件,用枚举实现的单例就是绝对安全的。

枚举类

@ThreadSafe
public enum EnumSingleton {
    INSTANCE;
}
  1. 该类在编译之后类的声明会变为 public final class EnumSingleton extends Enum,枚举值INSTANCE 也会被声明为 public static final EnumSingleton INSTANCE。因此枚举类实现的单例是线程安全的。

  2. 枚举类的构造由 JVM 控制,无法被我们直接构造。在用反射破坏静态内部类实现的单例模式时, 调用的 java.lang.reflect.ConstructornewInstance 方法中,对 ENUM 有特别的标注:

    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    		  throw new IllegalArgumentException("Cannot reflectively create enum objects");
    

    所以枚举类单例无法被序列化破坏。

  3. 枚举类序列化后的内容只包括枚举类的 name;反序列化的时候则是通过 java.lang.EnumvalueOf 方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的修改。在序列化与反序列化过程中,枚举类中定义的 writeObjectreadObjectreadObjectNoDatawriteReplacereadResolve 等方法都会被忽略。所以枚举类实现的单例也不会被序列化破坏。

《Effective Java》 中也提到:

使用枚举实现单例的方法虽然还没有广泛采用,但是单元素的枚举类型已经成为实现 Singleton 的最佳方法。

枚举单例的缺点在于所有的属性都必须在创建时指定, 也就意味着不能延迟加载; 并且使用枚举时占用的内存比静态变量的 2 倍还多, 这在性能要求严苛的应用中是不可忽视的.

总结

在实际开发中,使用双重检测锁 、静态内部类以及枚举类实现单例都是可以的。