您的位置:  首页 > 技术杂谈 > 正文

美团面试官问:写一个你认为最好的单例模式?于是我写了7个

2021-11-04 18:00 https://my.oschina.net/u/862741/blog/5293840 跟着Mic学架构 次阅读 条评论

图怪兽_7c12286ae7cc540a8fdd027147869cc9_18238

面试题:写一个你认为最好的单例模式

面试考察点

考察目的: 单例模式可以考察非常多的基础知识,因此对于这种问题,很多面试官都会问。 小伙伴要注意,在面试过程中,但凡能够从多个维度考察求职者能力的题目,一定不会被抛弃,特别是比较泛的问题,比如: ”请你说说对xxx的理解“之类。

考察范围: 工作1到5年经验,随着经验的提升,对于该问题的考察深度越深。

背景知识

单例模式,是一种软件设计模式,属于创建型模式的一种。

它的特性是:保证一个类只有唯一的一个实例,并提供一个全局的访问点。

基于这个特性可以知道,单例模式的好处是,可以避免对象的频繁创建对于内存的消耗,因为它限制了实例的创建,总的来说,它有以下好处:

  1. 控制资源的使用,通过线程同步来控制资源的并发访问;

  2. 控制实例产生的数量,达到节约资源的目的。

  3. 作为通信媒介使用,也就是数据共享,它可以在不建立直接关联的条件下,让多个不相关的两个线程或者进程之间实现通信。

在实际应用中,单例模式使用最多的就是在Spring的IOC容器中,对于Bean的管理,默认都是单例。一个bean只会创建一个对象,存在内置map中,之后无论获取多少次该bean,都返回同一个对象。

下面来了解单例模式的设计。

单例模式设计

既然要保证一个类在运行期间只有一个实例,那必然不能使用new关键字来进行实例。

所以,第一步一定是私有化该类的构造方法,这样就防止了调用方自己创建该类的实例。

接着,由于外部无法实例化该对象,因此必须从内部实例化之后,提供一个全局的访问入口,来获取该类的全局唯一实例,因此我们可以在类的内部定义一个静态变量来引用唯一的实例,作为对外提供的实例访问对象。基于这些点,我们可以得到如下设计。

public class Singleton {
    // 静态字段引用唯一实例:
    private static final Singleton INSTANCE = new Singleton();

    // private构造方法保证外部无法实例化:
    private Singleton() {
    }
}

接着,还需要给外部一个访问该对象实例INSTANCE的方法,我们可以提供一个静态方法

public class Singleton {
    // 静态字段引用唯一实例:
    private static final Singleton INSTANCE = new Singleton();

    // 通过静态方法返回实例:
    public static Singleton getInstance() {
        return INSTANCE;
    }

    // private构造方法保证外部无法实例化:
    private Singleton() {
    }
}

这样就完成了单例模式的设计,总结来看,单例模式分三步骤。

  1. 使用private私有化构造方法,确保外部无法实例化;
  2. 通过private static变量持有唯一实例,保证全局唯一性;
  3. 通过public static方法返回此唯一实例,使外部调用方能获取到实例。

单例模式的其他实现

既然单例模式只需要保证程序运行期间只会产生唯一的实例,那意味着单例模式还有更多的实现方法。

  • 懒汉式单例模式
  • 饿汉式单例模式
  • DCL双重检查式单例
  • 静态内部类
  • 枚举单例
  • 基于容器实现单例

懒汉式单例模式

懒汉式,表示不提前创建对象实例,而是在需要的时候再创建,代码如下。

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    // synchronized方法,多线程情况下保证单例对象唯一
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

其中,对getInstance()方法,增加了synchronized同步关键字,目的是为了避免在多线程环境下同一时刻调用该方法导致出现多实例问题(线程的并行执行特性带来的线程安全性问题)。

优点: 只有在使用时才会实例化单例,一定程度上节约了内存资源。 缺点: 第一次加载时要立即实例化,反应稍慢。每次调用getInstance()方法都会进行同步,这样会消耗不必要的资源这种模式一般不建议使用。

DCL双重检查式单例

DCL双重检查式单例模式,是基于饿汉式单例模式的性能优化版本。

/**
 * DCL实现单例模式
 */
public class Singleton {
    private static volatile Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // 两层判空,第一层是为了避免不必要的同步
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {// 第二层是为了在null的情况下创建实例
                    instance = new Singleton();
                }
            }

        }
        return instance;
    }
}

从代码中可以看到,DCL模式做了两处改进:

  1. getInstance()方法中,把synchronized同步锁的加锁范围缩小了。

    缩小锁的范围能够带来性能上的提升,不妨思考一下,在原来的懒汉式模式中,把synchronized关键字加载方法级别上,意味着不管是多线程环境还是单线程环境,任何一个调用者需要获得这个对象实例时,都需要获得锁。但是加这个锁其实只有在第一次初始化该实例的时候起到保护作用。后续的访问,应该直接返回instance实例对象就行。所以把synchroinzed加在方法级别,在多线程环境中必然会带来性能上的开销。

    而DCL模式的改造,就是缩小了加锁的范围,只需要保护该实例对象instance在第一次初始化即可,后续的访问,都不需要去竞争同步锁。因此它的设计是:

    • 先判断instance实例是否为空,如果是,则增加synchronized类级别锁,保护instance对象的实例化过程,避免在多线程环境下出现多实例问题。
    • 接着再synchronized同步关键字范围内,再一次判断instance实例是否为空,同样也是为了避免临界点时,上一个线程刚初始化完成,下一个线程进入到同步代码块导致多实例问题。
  2. 在成员变量instance上修饰了volatile关键字,该关键字是为了保证可见性。

    之所以要加这个关键字,是为了避免在JVM中指令重排序带来的可见性问题,这个问题主要体现在instance=new Singleton()这段代码中。我们来看这段代码的字节码

     17: new           #3                  // class org/example/cl04/Singleton
     20: dup
     21: invokespecial #4                  // Method "<init>":()V
     24: putstatic     #2                  // Field instance:Lorg/example/cl04/Singleton;
     27: aload_0
     28: monitorexit
     29: goto          37
     32: astore_1
     33: aload_0
    
    

    关注以下几个指令

    • new #3 : 这行指令是说在堆上的某个地址处开辟了一块空间作为Singleton对象

    • invokespecial #4 :这行指令是说将对象里的成员变量进行赋值操作

    • astore_1 :这行指令是说将栈里的Singleton instance与堆上的对象建立起引用关联

    invokespecial #4指令,和astore_1指令,是允许重排序的(关于重排序问题,就不再本篇文章中说明,后续的面试题中会分析到),就是说执行顺序有可能astore_1先执行, invokespecial #1后执行。

    重排序对于两个没有依赖关系的指令操作,CPU和内存以及JVM,为了优化程序执行性能,会对执行指令进行重排序。也就是说两个指令的执行顺序不一定会按照程序编写顺序来执行。

    因为在堆上建立对象开辟地址以后,地址就已经定了,而“将栈里的Singleton instance与堆上的对象建立起引用关联” 和 “将对象里的成员变量进行赋值操作” 是没什么逻辑关系的。

    所以cpu可以进行乱序执行,只要程序最终的结果是一致的就可以。

    这种情况,在单线程下没有问题,但是多线程下,就会出现错误。

    试想一下,DCL下,线程A在将对象new出来的时,刚执行完new #4指令,紧接着没有执行invokespecial #4指令,而是执行了astore_1,也就是说发生了指令重排序。

    此时线程B进入getInstance(),发现instance并不为空(因为已经有了引用指向了对象,只不过还没来得及给对象里的成员变量赋值),然后线程B便直接return了一个“半初始化”对象(对象还没彻底创建完)。

    所以DCL里,需要给instance加上volatile关键字,因为volatile在JVM层有一个特性叫内存屏障,可以防止指令重排序,从而保证了程序的正确性。

关于DCL模式的优缺点:

优点:资源利用率高,既能够在需要的时候才初始化实例,又能保证线程安全,同时调用getInstance()方法不进行同步锁,效率高。 缺点:第一次加载时稍慢,由于Java内存模型的原因偶尔会失败。在高并发环境下也有一定的缺陷,虽然发生概率很小。

DCL模式是使用最多的单例模式实现方式,除非代码在并发场景比较复杂,否则,这种方式基本都能满足需求。

饿汉式单例模式

在类加载的时候不创建单例实例。只有在第一次请求实例的时候的时候创建,并且只在第一次创建后,以后不再创建该类的实例。

/**
 * 饿汉式实现单例模式
 */
public class Singleton {
    private static Singleton instance = new Singleton();

    private Singleton() {
    }

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

由于static关键字修饰的属性,表示这个成员属于类本身,不属于实例,运行时,Java 虚拟机只为静态变量分配一次内存,在类加载的过程中完成静态变量的内存分配。

所以在类加载的时候就创建好对象实例,后续在访问时直接获取该实例即可。

而该模式的优缺点也非常明显。

优点:线程安全,不需要考虑并发安全性。

缺点:浪费内存空间,不管该对象是否被使用到,都会在启动是提前分配内存空间。

静态内部类

静态内部类,是基于饿汉式模式下的优化。

第一次加载Singleton类时不会初始化instance,只有在第一次调用getInstance()方法时,虚拟机会加载SingletonHolder类,初始化instanceinstance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。

/**
 * 静态内部类实现单例模式
 */
public class Singleton {
  private Singleton() {
  }

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

  /**
     * 静态内部类
     */
  private static class SingletonHolder {
    private static Singleton instance = new Singleton();
  }
}

这种方式既保证线程安全,单例对象的唯一,也延迟了单例的初始化,推荐使用这种方式来实现单例模式。

静态内部类不会因为外部内的加载而加载,同时静态内部类的加载不需要依附外部类,在使用时才加载,不过在加载静态内部类的过程中也会加载外部类

知识点:如果用static来修饰一个内部类,那么就是静态内部类。这个内部类属于外部类本身,但是不属于外部类的任何对象。因此使用static修饰的内部类称为静态内部类。静态内部类有如下规则:

  • 静态内部类不能访问外部类的实例成员,只能访问外部类的类成员。
  • 外部类可以使用静态内部类的类名作为调用者来访问静态内部类的类成员,也可以使用静态内部类对象访问其实例成员。

静态内部类单例优点

  • 对象的创建是线程安全的。
  • 支持延时加载。
  • 获取对象时不需要加锁。

这是一种比较常用的模式之一。

基于枚举实现单例

用枚举来实现单例,是最简单的方式。这种实现方式通过Java枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。

public enum SingletonEnum {

    INSTANCE;

    public void execute(){
        System.out.println("begin execute");
    }

    public static void main(String[] args) {
        SingletonEnum.INSTANCE.execute();
    }
}

基于枚举实现单例会发现它并不需要前面描述的几个操作

  1. 构造方法私有化
  2. 实例化的变量引用私有化
  3. 获取实例的方法共有

这类的方式实现枚举其实并不保险,因为私有化构造并不能抵御反射攻击.

这种方式是Effective Java作者Josh Bloch提倡的方式,它不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象,可谓是很坚强的壁垒啊。

基于容器实现单例

下面的代码演示了基于容器的方式来管理单例。

import java.util.HashMap;
import java.util.Map;
/**
 * 容器类实现单例模式
 */
public class SingletonManager {
    private static Map<String, Object> objMap = new HashMap<String, Object>();

    public static void regsiterService(String key, Object instance) {
        if (!objMap.containsKey(key)) {
            objMap.put(key, instance);
        }
    }

    public static Object getService(String key) {
        return objMap.get(key);
    }
}

SingletonManager可以管理多个单例类型,在程序的初始化时,将多个单例类型注入到一个统一管理的类中,使用时根据key获取对象对应类型的对象。这种方式可以通过统一的接口获取操作,隐藏了具体实现,降低了耦合度。

关于单例模式的破坏

前面在分析枚举类实现单例模式时,有提到一个问题,就是私有化构造,会被反射破坏,导致出现多实例问题。

public class Singleton {

    private static volatile Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // 两层判空,第一层是为了避免不必要的同步
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {// 第二层是为了在null的情况下创建实例
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) throws Exception{
        Singleton instance=Singleton.getInstance();
        Constructor<Singleton> constructor=Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton refInstance=constructor.newInstance();
        System.out.println(instance);
        System.out.println(refInstance);
        System.out.println(instance==refInstance);

    }
}

运行结果如下

org.example.cl04.Singleton@29453f44
org.example.cl04.Singleton@5cad8086
false

由于反射可以破坏private特性,所以凡是通过private私有化构造实现的单例模式,都能够被反射破坏从而出现多实例问题。

可能有人会问,我们没事干嘛要去破坏单例呢? 直接基于这个入口访问就不会有问题啊?

理论上来说是这样,但是,假设遇到下面这种情况呢?

下面的代码演示的是通过对象流实现Singleton的序列化和反序列化。

public class Singleton implements Serializable {

    private static volatile Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // 两层判空,第一层是为了避免不必要的同步
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {// 第二层是为了在null的情况下创建实例
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) throws Exception {
        Singleton instance=Singleton.getInstance();
        ByteArrayOutputStream baos=new ByteArrayOutputStream();
        ObjectOutputStream oos=new ObjectOutputStream(baos);
        oos.writeObject(instance);
        ByteArrayInputStream bais=new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois=new ObjectInputStream(bais);
        Singleton ri=(Singleton) ois.readObject();
        System.out.println(instance);
        System.out.println(ri);
        System.out.println(instance==ri);
    }
}

运行结果如下

org.example.cl04.Singleton@36baf30c
org.example.cl04.Singleton@66a29884
false

可以看到,序列化的方式,也会破坏单例模式。

枚举类单例的破坏测试

可能有人会问,枚举难道就不能破坏吗?

我们可以试试看,代码如下。

public enum SingletonEnum {

    INSTANCE;

    public void execute(){
        System.out.println("begin execute");
    }

    public static void main(String[] args) throws Exception{
        SingletonEnum instance=SingletonEnum.INSTANCE;
        Constructor<SingletonEnum> constructor=SingletonEnum.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        SingletonEnum refInstance=constructor.newInstance();
        System.out.println(instance);
        System.out.println(refInstance);
        System.out.println(instance==refInstance);
    }
}

运行结果如下

Exception in thread "main" java.lang.NoSuchMethodException: org.example.cl04.SingletonEnum.<init>()
	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)
	at org.example.cl04.SingletonEnum.main(SingletonEnum.java:15)

从错误来看,似乎是没有一个空的构造函数?这里并没有证明 反射无法破坏单例。

下面是Enum这类的源码,所有枚举类都继承了Enum这个抽象类。

public abstract class Enum<E extends Enum<E>>
        implements Comparable<E>, Serializable {
    /**
     * The name of this enum constant, as declared in the enum declaration.
     * Most programmers should use the {@link #toString} method rather than
     * accessing this field.
     */
    private final String name;

    /**
     * Returns the name of this enum constant, exactly as declared in its
     * enum declaration.
     *
     * <b>Most programmers should use the {@link #toString} method in
     * preference to this one, as the toString method may return
     * a more user-friendly name.</b>  This method is designed primarily for
     * use in specialized situations where correctness depends on getting the
     * exact name, which will not vary from release to release.
     *
     * @return the name of this enum constant
     */
    public final String name() {
        return name;
    }

    /**
     * The ordinal of this enumeration constant (its position
     * in the enum declaration, where the initial constant is assigned
     * an ordinal of zero).
     *
     * Most programmers will have no use for this field.  It is designed
     * for use by sophisticated enum-based data structures, such as
     * {@link java.util.EnumSet} and {@link java.util.EnumMap}.
     */
    private final int ordinal;

    /**
     * Returns the ordinal of this enumeration constant (its position
     * in its enum declaration, where the initial constant is assigned
     * an ordinal of zero).
     *
     * Most programmers will have no use for this method.  It is
     * designed for use by sophisticated enum-based data structures, such
     * as {@link java.util.EnumSet} and {@link java.util.EnumMap}.
     *
     * @return the ordinal of this enumeration constant
     */
    public final int ordinal() {
        return ordinal;
    }

    /**
     * Sole constructor.  Programmers cannot invoke this constructor.
     * It is for use by code emitted by the compiler in response to
     * enum type declarations.
     *
     * @param name - The name of this enum constant, which is the identifier
     *               used to declare it.
     * @param ordinal - The ordinal of this enumeration constant (its position
     *         in the enum declaration, where the initial constant is assigned
     *         an ordinal of zero).
     */
    protected Enum(String name, int ordinal) {
        this.name = name;
        this.ordinal = ordinal;
    }
}

该类有一个唯一的构造方法,接受两个参数分别是:nameordinal

那我们尝试通过这个构造方法来创建一下实例,演示代码如下。

public enum SingletonEnum {

    INSTANCE;

    public void execute(){
        System.out.println("begin execute");
    }

    public static void main(String[] args) throws Exception{
        SingletonEnum instance=SingletonEnum.INSTANCE;
        Constructor<SingletonEnum> constructor=SingletonEnum.class.getDeclaredConstructor(String.class,int.class);
        constructor.setAccessible(true);
        SingletonEnum refInstance=constructor.newInstance("refinstance",2);
        System.out.println(instance);
        System.out.println(refInstance);
        System.out.println(instance==refInstance);
    }
}

运行上述代码,执行结果如下

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
	at org.example.cl04.SingletonEnum.main(SingletonEnum.java:17)

从错误信息来看,我们成功获取到了Constructor这个构造器,但是在newInstance时报错。

定位到出错的源码位置。

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        ConstructorAccessor ca = constructorAccessor;   // read volatile

从这段代码:(clazz.getModifiers() & Modifier.ENUM) != 0说明:反射在通过newInstance创建对象时,会检查该类是否ENUM修饰,如果是则抛出异常,反射失败,因此枚举类型对反射是绝对安全的。

既然反射无法破坏?那序列化呢?我们再来试试

public enum SingletonEnum {

    INSTANCE;

    public void execute(){
        System.out.println("begin execute");
    }
    public static void main(String[] args) throws Exception{
        SingletonEnum instance=SingletonEnum.INSTANCE;
        ByteArrayOutputStream baos=new ByteArrayOutputStream();
        ObjectOutputStream oos=new ObjectOutputStream(baos);
        oos.writeObject(instance);
        ByteArrayInputStream bais=new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois=new ObjectInputStream(bais);
        SingletonEnum ri=(SingletonEnum) ois.readObject();
        System.out.println(instance);
        System.out.println(ri);
        System.out.println(instance==ri);
    }
}

运行结果如下.

INSTANCE
INSTANCE
true

因此,我们可以得出一个结论,枚举类型是所有单例模式中唯一能够避免反射破坏导致多实例问题的设计模式。

综上,可以得出结论:枚举是实现单例模式的最佳实践。毕竟使用它全都是优点:

  1. 反射安全

  2. 序列化/反序列化安全

  3. 写法简单

问题解答

面试题:写一个你认为最好的单例模式

对于这个问题,相比大家都有答案了,枚举方式实现单例才是最好的。

当然,回答的时候要从全方面角度去讲解。

  1. 单例模式的概念
  2. 有哪些方式实现单例
  3. 每种单例模式的优缺点
  4. 最好的单例模式,以及为什么你觉得它是最好的?

问题总结

单例模式看起来简单,但是学到极致,也还是有很多知识点的。

比如涉及到线程安全问题、静态方法和静态从成员变量的特征、枚举、反射等。

多想再回到从前,大家都只用jsp/servlet,没有这么多乱七八糟的知识,我们只想做个简单的程序员。 关注[跟着Mic学架构]公众号,获取更多精品原创

  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接