单例模式

概述

《设计模式》一书中对单例模式的 “动机” 描述如下:

保证一个额类仅有一个实例,并提供一个访问它的全局访问点

一般情况下,为了避免资源的浪费,可以考虑将一些不可变类或者无状态类设计成单例

具体实例

在当下环境中,对于单例模式的实现方式主要有两种方式:饿汉式和懒汉式。一般来将,如果创建对象实例的过程不是特别耗费资源的情况,推荐使用 “饿汉式” 的方式实现

饿汉式单例

公有域的实现方式如下:

public class Singleton {
    public final static Singleton INSTANCE = new Singleton();
    
    /*
    	私有构造器防止被客户端重复构造实例
    */
    private Singleton() {
        /*
        	防止客户端通过反射的方式重复访问此构造器
        */
        if (INSTANCE != null) {
            throw new RuntimeException("重复的单例实例对象构造");
        }
    }
}

使用工厂方法的实现代码如下:

public class Singleton {
    private final static Singleton INSTANCE = new Singleton();
    
    // 省略构造器相关的代码
    
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

一般来讲,对于 staticfinal 同时修饰的属性,在 “准备” 阶段就会为其赋值,但事实上,对于对象属性的处理有些许不同。对于上面的单例实现,编译器会对其做相关的处理,等价于下面的方式:

public class Singleton {
    private final static Singleton INSTANCE;

    static {
        INSTANCE = new Singleton();
    }
    // 省略部分代码
}

相当于只有在触发类的初始化的时候才会进行实际的对象实例化

对于公有域和工厂方法的实现来讲,一般推荐优先选择公有域的实现方式,但如果希望加强 API 的灵活性,那么推荐使用工厂方法的实现方式

然而,对于需要实现 Serializable 的类来讲,单纯地防止构造函数重复初始化是不够的。Java 在反序列化的过程中,不会通过构造器来创建实例对象。为了避免这个问题,需要重写反序列化的方法,使其返回预定义的实例:

import java.io.ObjectStreamException;
import java.io.Serializable;

public class Singleton implements Serializable {

    private final static long serialVersionUID = 1L;

    private final static Singleton INSTANCE = new Singleton();
    
    // 对于非静态属性,即状态值,需要将其使用 transient 修饰,以防止序列化
    private transient Object field = new Object();
    
    // 替换从输入流中反序列化得到的对象实例
    protected Object readResolve()
            throws ObjectStreamException {
        return INSTANCE;
    }
    
    // 省略部分代码
}

似乎上面的方式或多或少有些冗余,在 《Effective Java》(第三版)第三条中建议使用枚举的方式实现单例,这是因为枚举的实现不仅在功能上提供了类似公有域的方式,同时还确保不会因为序列化的原因而导致对象的重复构造。具体代码如下所示:

public enum Singleton {
    INSTANCE
}

实际上最终编译后的代码等价于如下的形式:

public final class Singleton extends java.lang.Enum<Singleton> {
    public static final Singleton INSTANCE;
    
    static {
        INSTANCE = new Singleton();
    }
}

因为枚举无法被继承,同时它也没有实现 Serializable 接口,因此它能够确保实际的单例属性。不过这种实现方式的缺点在于对应类型无法继承相关的父类,这是因为它已经继承了 Enum,无法再继承其它父类

懒汉式单例

如果实例化一个对象十分耗费资源,那么可以考虑延迟初始化类的形式来进行单例的实现,主要存在以下两种实现方式:延迟初始化类和双重检查锁

延迟初始化类的实现代码如下:

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

这种方式的优势在于 JVM 实现了同步操作,在上述代码中,通过访问 Holder.INSTANCE 触发了 Holder 类的初始化,而类的初始化由 JVM 进行同步,无需再手动同步

如果由于某些原因,不得不访问实例域的属性来实现单例,那么在这种情况下就需要使用 “双重检查锁” 的方式,具体代码如下:

public class Singleton {
    /*
    	volatile 防止对象实例化的代码被重排序
    */
    private volatile Singleton instance;
    
    public Singleton getInstance() {
        /*
        	确保 instance 被访问一次,可以是当提高性能
        */
        Singleton result = instance;
        if (result == null) { // 第一次检查,过滤需要进入同步队列的线程
            synchronized(this) {
                if (result == null) { // 第二次检查防止进入同步队列的线程重复初始化
                    instance = result = new Singleton();
                }
            }
        }
        return result;
    }
}

这种实现方式看起来貌似有些复杂,首先对于 instance 域需要通过 volatile 进行修饰,这样的目的是为了防止对象实例化的代码被重排序到进行检测之后,从而导致的重复实例化。其次,对于 result 变量的使用,看起来没什么用处,在 《Effective Java》(第三版)第 83 条中有相关的介绍,目的在于确保 instance 只在已经被初始化的时候访问一次,可以提高性能。之后,第一次的检查是为了防止过多的线程同时进行同步队列,在线程获取锁后的第二次检查则是为了防止原先已经在同步队列的线程在获取锁之后再次进行对象的实例化

总结

单例模式的目的在于防止过多冗余资源的使用,一般会结合 享元模式 使用。在实际使用过程中,如果非特别需要,建议直接使用饿汉式的实现方式,如果不得已需要选择懒汉式的实现,也尽量选择 “延迟初始化” 类的实现方式,最后不得已的情况才考虑 “双重检查锁” 的实现方式


[1] 《设计模式—可复用面向对象基础》

[2] 《Effective Java》(第三版)

posted @ 2023-01-01 09:11  FatalFlower  阅读(21)  评论(0编辑  收藏  举报