剑指Offer对答如流系列 - 实现Singleton模式

Posted jefferychenxiao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了剑指Offer对答如流系列 - 实现Singleton模式相关的知识,希望对你有一定的参考价值。

面试题2:实现Singleton模式

题目:设计一个类,我们只能生成该类的一个实例。

由于设计模式在面向对象程序设计中起着举足轻重的作用,在面试过程中很多公司都喜欢问一些与设计模式相关的问题。在常用的模式中,Singleton是唯一一个能够用短短几十行代码完整实现的模式。因此,写一个Singleton的类型是一个很常见的面试题。

技术图片

如果你看过我之前写的设计模式专栏,那么这道题思路你会很开阔。

单例模式的要点有三个:一是某个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例

我们下面来看一下它的实现

一、懒汉式写法

public class LazySingleton {

    private static LazySingleton lazySingleton = null;

    private LazySingleton() {

    }

    public static LazySingleton getInstance() {
        if(lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

关键就是将构造器私有,限制只能通过内部静态方法来获取一个实例。

但是这种写法,很明显不是线程安全的。如果多个线程在该类初始化之前,有大于一个线程调用了getinstance方法且lazySingleton == null 判断条件都是正确的时候,这个时候就会导致new出多个LazySingleton实例。可以这么改一下:

这种写法叫做DoubleCheck。针对类初始化之前多个线程进入 if(lazySingleton == null) 代码块中情况

这个时候加锁控制,再次判断 if(lazySingleton == null) ,如果条件成立则new出来一个实例,轮到其他的线程判断的时候自然就就为假了,问题大致解决。

public class LazyDoubleCheckSingleton {

    private static LazyDoubleCheckSingleton lazySingleton = null;

    private LazyDoubleCheckSingleton() {

    }

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

但是即使是这样,上面代码的改进有些问题还是无法解决的。

因为会有重排序问题。重排序是一种编译优化技术,属于《编译原理》的内容了,这里不详细探讨,但是要告诉你怎么回事。

正常来说,下面的这段代码

lazySingleton = new LazyDoubleCheckSingleton();

执行的时候是这样的:
1.分配内存给这个对象
2.初始化对象
3.设置LazyDoubleCheckSingleton指向刚分配的内存地址。

但是编译优化后,可能是这种样子
1.分配内存给这个对象
3.设置LazyDoubleCheckSingleton指向刚分配的内存地址。
2.初始化对象

2 步骤 和 3 步骤一反,就出问题了。(前提条件,编译器进行了编译优化)
比如说有两个线程,名字分别是线程1和线程2,线程1进入了 if(lazySingleton == null) 代码块,拿到了锁,进行了new LazyDoubleCheckSingleton()的执行,在加载构造类的实例的时候,设置LazyDoubleCheckSingleton指向刚分配的内存地址,但是还没有初始化对象。线程2判断 if(lazySingleton == null) 为假,直接返回了lazySingleton,又进行了使用,使用的时候就会出问题了。

画两张图吧:

重排序的情况如下:

技术图片
再看出问题的地方

技术图片
当然这个很好改进,从禁用重排序方面下手,添加一个volatile。不熟悉线程安全可以参考这篇文章【Java并发编程】线程安全性详解

    private volatile static LazyDoubleCheckSingleton lazySingleton = null;

方法不止一种嘛,也可以利用对象初始化的“可见性”来解决,具体来说是利用静态内部类基于类初始化的延迟加载,名字很长,但是理解起来并不困难。(使用这种方法,不必担心上面编译优化带来的问题)

类初始化的延迟加载与JVM息息相关,我们演示的例子的只是被加载了而已,而没有链接和初始化。

我们看一下实现方案:
定义一个静态内部类,其静态字段实例化了一个单例。获取单例需要调用getInstance方法间接获取。

public class StaticInnerClassSingleton {

    private static class InnerClass{
        private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
    }

    public static StaticInnerClassSingleton getInstance() {
        return InnerClass.staticInnerClassSingleton;
    }
}

如果对内部类不熟悉,可以参考这篇文章【Java核心技术卷】深入理解Java的内部类

技术图片
懒汉式的介绍就到这里吧,下面再看看另外一种单例模式的实现


二、饿汉式写法

演示一下基本的写法

public class HungrySingleton {

    // 类加载的时候初始化
    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    /*
    也可以在静态块里进行初始化
      private static HungrySingleton hungrySingleton;

     static {
        hungrySingleton = new HungrySingleton();
     }
     */
    private HungrySingleton() {

    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }

}

饿汉式在类加载的时候就完成单例的实例化,如果用不到这个类会造成内存资源的浪费,因为单例实例引用不可变,所以是线程安全的

同样,上面的饿汉式写法也是存在问题的

我们依次看一下:

首先是序列化破坏单例模式

先保证饿汉式能够序列化,需要继承Serializable 接口。

import java.io.Serializable;

public class HungrySingleton implements Serializable {

    // 类加载的时候初始化
    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    /*
    也可以在静态块里进行初始化
      private static HungrySingleton hungrySingleton;

     static {
        hungrySingleton = new HungrySingleton();
     }
     */
    private HungrySingleton() {

    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }

}

我们测试一下:

import lombok.extern.slf4j.Slf4j;

import java.io.*;

@Slf4j
public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
       HungrySingleton hungrySingleton = HungrySingleton.getInstance();
       ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton"));
       oos.writeObject(hungrySingleton);

       File file = new File("singleton");
       ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));

        HungrySingleton newHungrySingleton = (HungrySingleton) ois.readObject();

        log.info("结果 {}",hungrySingleton);
        log.info("结果 {}",newHungrySingleton);
        log.info("对比结果 {}",hungrySingleton == newHungrySingleton);
    }
}

结果:
技术图片
结果发现对象不一样,原因就涉及到序列化的底层原因了,我们先看解决方式:

饿汉式代码中添加下面这段代码

private Object readResolve() {
        return hungrySingleton;
    }

重新运行,这个时候的结果:

技术图片
原因出在readResolve方法上,下面去ObjectInputStream源码部分找找原因。(里面都涉及到底层实现,不要指望看懂)

在一个读取底层数据的方法上有一段描述
就是序列化的Object类中可能定义有一个readResolve方法。我们在二进制数据读取的方法中看到了是否判断

技术图片
private Object readOrdinaryObject()方法中有这段代码,如果存在ReadResolve方法,就去调用。不存在,不调用。联想到我们在饿汉式添加的代码,大致能猜到怎么回事了吧。

技术图片
***

另外一种情况就是反射攻击破坏单例

演示一下

import lombok.extern.slf4j.Slf4j;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

@Slf4j
public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class objectClass = HungrySingleton.class;

        Constructor constructor = objectClass.getDeclaredConstructor();
        constructor.setAccessible(true); // 强行打开构造器权限
        HungrySingleton instance = HungrySingleton.getInstance();
        HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

        log.info("结果{}",instance);
        log.info("结果{}",newInstance);
        log.info("比较结果{}",newInstance == instance);
    }
}

技术图片
这里强行破开了private的构造方法的权限,使得能new出来一个单例实例,这不是我们想看到的。

解决方法是在构造方法中抛出异常

   private HungrySingleton() {
        if( hungrySingleton != null) {
            throw new RuntimeException("单例构造器禁止反射调用");
        }
    }

这个时候再运行一下

技术图片
其实对于懒汉式也是有反射破坏单例的问题的,也可以采用类似抛出异常的方法来解决。

饿汉式单例与懒汉式单例类比较

  • 饿汉式单例类在自己被加载时就将自己实例化。单从资源利用效率角度来讲,这个比懒汉式单例类稍差些。从速度和反应时间角度来讲,则比懒汉式单例类稍好些。
  • 懒汉式单例类在实例化时,必须处理好在多个线程同时首次引用此类时的访问限制问题,特别是当单例类作为资源控制器在实例化时必然涉及资源初始化,而资源初始化很有可能耗费大量时间,这意味着出现多线程同时首次引用此类的机率变得较大,需要通过同步化机制进行控制。

三、枚举

除此之外还有一种单例模式的实现就是枚举

使用枚举的方式实现单例模式是《Effective Java》作者力推的方式,在很多优秀的开源代码中经常可以看到使用枚举方式实现单例模式的地方,枚举类型不允许被继承,同样是线程安全的且只能被实例化一次,但是枚举类型不能够懒加载,对Singleton主动使用,比如调用其中的静态方法则INSTANCE会立即得到实例化。

//枚举类型本身是final的,不允许被继承
public enum Singleton
{
    INSTANCE;
    //实例变量
    private byte[] data = new byte[1024];

    Singleton()
    {
        System.out.println("I want to follow Jeffery.");
    }

    public static void method()
    {
        //调用该方法则会主动使用Singleton,INSTANCE将会被实例化
    }

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

在实际面试中,我们为了展现枚举单例模式,可以写成这样:

public enum Singleton
{
    INSTANCE;
  
    public static Singleton getInstance()
    {
        return INSTANCE;
    }
}

Java中的枚举其实是一种语法糖,换句话说就是编译器帮助我们做了一些的事情,我们将字节码反编译成Java代码,看看编译器帮我们做了什么,以及探讨为什么使用枚举的方式实现单例模式是《Effective Java》作者力推的方式?

原始代码如下:

public enum EnumClass {
    SPRING,SUMMER,FALL,WINTER;
}

反编译后的代码

public final class EnumClass extends Enum
{

    public static EnumClass[] values()
    {
        return (EnumClass[])$VALUES.clone();
    }

    public static EnumClass valueOf(String name)
    {
        return (EnumClass)Enum.valueOf(suger/EnumClass, name);
    }

    private EnumClass(String s, int i)
    {
        super(s, i);
    }

    public static final EnumClass SPRING;
    public static final EnumClass SUMMER;
    public static final EnumClass FALL;
    public static final EnumClass WINTER;
    private static final EnumClass $VALUES[];

    static 
    {
        SPRING = new EnumClass("SPRING", 0);
        SUMMER = new EnumClass("SUMMER", 1);
        FALL = new EnumClass("FALL", 2);
        WINTER = new EnumClass("WINTER", 3);
        $VALUES = (new EnumClass[] {
            SPRING, SUMMER, FALL, WINTER
        });
    }
}

对于静态代码块不了解的参考 : Java中静态代码块、构造代码块、构造函数、普通代码块

结合前面的内容,是不是很容易理解了? 除此之外,我们还可以看出,枚举是继承了Enum类的,同时它也是final,即不可继承的。

枚举类型的单例模式的玩法有很多,网上传的比较多的有以下几种:

内部枚举类形式

1.构造方法中实例化对象(上面提到了 注意了吗)

public class EnumSingleton {
    private EnumSingleton(){}
    
    public static EnumSingleton getInstance(){
        return Singleton.INSTANCE.getInstance();
    }

    private enum Singleton{
        INSTANCE;
        
        private EnumSingleton singleton;
        
        //JVM会保证此方法绝对只调用一次
        Singleton(){
            singleton = new EnumSingleton();
        }
        public EnumSingleton getInstance(){
            return singleton;
        }
    }
}

2.枚举常量的值即为对象实例

public class EnumSingleton {
    private EnumSingleton(){}
    
    public static EnumSingleton getInstance(){
        return Singleton.INSTANCE.getInstance();
    }

    private enum Singleton{
        INSTANCE(new EnumSingleton());
        private EnumSingleton singleton;
        
        //JVM会保证此方法绝对只调用一次
        Singleton(EnumSingleton singleton){
            this.singleton = singleton;
        }
        public EnumSingleton getInstance(){
            return singleton;
        }
    }
}

接口实现形式
对于一个标准的enum单例模式,最优秀的写法还是实现接口的形式:

// 定义单例模式中需要完成的代码逻辑
public interface MySingleton {
    void doSomething();
}

public enum Singleton implements MySingleton {
    INSTANCE {
        @Override
        public void doSomething() {
            System.out.println("I want to follow Jeffery. What about you ?");
        }
    };

    public static MySingleton getInstance() {
        return Singleton.INSTANCE;
    }
}

我就问!单例模式的面试,你还怕不怕?

以上是关于剑指Offer对答如流系列 - 实现Singleton模式的主要内容,如果未能解决你的问题,请参考以下文章

剑指Offer对答如流系列 - 数值的整数次方

剑指Offer对答如流系列 - 序列化二叉树

剑指Offer对答如流系列 - 复杂链表的复制

剑指Offer对答如流系列 - 对称的二叉树

剑指Offer对答如流系列 - 翻转字符串

剑指Offer对答如流系列 - 二进制中 1 的个数