枚举单例

单例模式在我们书写代码中是最经常使用的一种设计模式,但是这种设计模式真的安全吗?如果不安全的话,我们有没有安全的单例模式?其实这也是大厂面试的时候可能会问道的面试题,本篇我们来研究下这个问题。

一、引出问题

  1. 双重锁定单例和静态内部类单例安全吗?
  2. 枚举单例使用过吗?它为什么是安全的?
  3. Kotlin 中的单例有使用过吗?
1. 双重锁定单例

首先我们快速回忆一下双重锁定单例和静态内部类单例,首先双重锁定单例如下:

public class Manager {
    private volatile static Manager INSTANCE = null;

    private Manager() {
    }

    public static Manager getInstance() {
        if (INSTANCE == null) {//第一次判空
            synchronized (Manager.class) {
                if (INSTANCE == null) {//第二次判空
                    INSTANCE = new Manager();
                }
            }
        }
        return INSTANCE;
    }

    public static void main(String[] args) {
        Manager instance = Manager.getInstance();
        Manager instance2 = Manager.getInstance();
        System.out.println(instance);
        //com.oman.forward.pattern.create.singleton.Manager@5ca881b5
        System.out.println(instance2);
        //com.oman.forward.pattern.create.singleton.Manager@5ca881b5
        System.out.println(instance == instance2);
        //true
    }
}

为什么要两次判空呢?因为假如两个线程都走到了锁的地方,第一个线程持有了锁之后,内部判断为空,创建实例成功后释放锁。此时第二个线程获取到了锁,假如没有第二个判空条件,很明显就又会再次实例化一次对象,那么所谓的单例就不是全局唯一的了。

为什么加上 volatile 关键字?主要为了避免指令排序导致的问题,因为对象的创建最少需要三个步骤:

  1. 在堆内存开辟内存控件;
  2. 内存控件的初始化零值;
  3. 将实例化的对象指向栈中的符号引用。

如果不加上 volatile 的话,可能会导致步骤 2 和 3 的执行顺序得不到保证,假如线程 1 先执行了步骤 1 和步骤 3,此时线程 2 在外部第一次判断不为空,就去直接使用单例对象了,就会出现问题。

2. 静态内部类单例

下面看静态内部类的方式创建的单例:

public class Manager2 {
    private Manager2() {
    }

    private static class SingletonHolder {
        private static final Manager2 INSTANCE = new Manager2();
    }

    public static Manager2 getInstance() {
        return SingletonHolder.INSTANCE;
    }

    public static void main(String[] args) {
        Manager2 instance = Manager2.getInstance();
        Manager2 instance2 = Manager2.getInstance();
        System.out.println(instance);   
        //com.oman.forward.pattern.create.singleton.Manager2@5ca881b5
        System.out.println(instance2);
        //com.oman.forward.pattern.create.singleton.Manager2@5ca881b5
        System.out.println(instance == instance2);
        //true
    }
}

静态内部类方式创建单例也是通过懒加载的方式创建的单例,因为静态内部类不属于 JVM 规定的必须进行初始化的情况,只有在调用 getInstance 静态方法的时候才会进行内部类的初始化,保证了类的延迟加载,不浪费资源。

并且虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时很长的操作,就可能造成多个进程阻塞(需要注意的是,其他线程虽然会被阻塞,但如果执行 <clinit>() 方法后,其他线程唤醒之后不会再次进入 <clinit>() 方法。同一个加载器下,一个类型只会初始化一次)。

所以可以看出 INSTANCE 在创建过程中是线程安全的,所以说静态内部类形式的单例可保证线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

但是静态内部类单例有一个很大的问题就是不能传递参数,如果需要传递参数的话,就不能使用这种方式创建单例了。

3. 以上两种单例是安全的吗?

下面我们通过代码来演示:

public static void main(String[] args) throws Exception {
        Manager instance = Manager.getInstance();
        Manager instance2 = Manager.getInstance();
        System.out.println(instance);
      //com.oman.forward.pattern.create.singleton.Manager@5ca881b5
        System.out.println(instance2);
      //com.oman.forward.pattern.create.singleton.Manager@5ca881b5
        System.out.println(instance == instance2);
      //true

        Constructor<Manager> constructor 
            = Manager.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Manager manager = constructor.newInstance();
        System.out.println(manager);
      //com.oman.forward.pattern.create.singleton.Manager@24d46ca6
        System.out.println(instance == manager);
      //false
}

从代码可以看出,我们通过反射又可以重新创建一个单例对象的实例,并且和原来的地址值不是同一个(静态内部类的单例可以通过同一种方式验证),那这样的话单例还安全吗?

4. 枚举:安全的单例

我们使用枚举来创建单例,看看效果如何,是否安全

public enum EnumSingle {
    INSTANCE;

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

    public static void main(String[] args) throws Exception {
        EnumSingle single = EnumSingle.INSTANCE;
        EnumSingle single2 = EnumSingle.INSTANCE;
        System.out.println(single);//INSTANCE
        System.out.println(single2);//INSTANCE
        System.out.println(single == single2);//true
    }
}

上面的结果并不能说明什么,我们也使用反射获取一下单例,看看效果如何?

public static void main(String[] args) throws Exception {
    Constructor<EnumSingle> enumSingleConstructor = EnumSingle.class.getDeclaredConstructor();
    enumSingleConstructor.setAccessible(true);
    EnumSingle enumSingle = enumSingleConstructor.newInstance();
    System.out.println(enumSingle);
}

上述代码在运行的时候会报错如下:

Exception in thread "main" java.lang.NoSuchMethodException: com.oman.forward.pattern.create.singleton.EnumSingle.<init>() at java.lang.Class.getConstructor0(Class.java:3082) at java.lang.Class.getDeclaredConstructor(Class.java:2178) at com.oman.forward.pattern.create.singleton.EnumSingle.main(EnumSingle.java:13)`

在 debug 的时候发现,只有一个参数为(String.class,int.class)构造器,其实看下父类 Enum 源码就明白,这两个参数是 name 和 ordial 两个属性: 在这里插入图片描述

那么我们使用这两个参数的构造方法使用反射获取一下,看看效果,于是代码改成了下面这样:

 public static void main(String[] args) throws Exception {
    Constructor<EnumSingle> enumSingleConstructor = EnumSingle.class.getDeclaredConstructor(String.class,int.class);
    enumSingleConstructor.setAccessible(true);
    EnumSingle enumSingle = enumSingleConstructor.newInstance("name",1);
    System.out.println(enumSingle);
}

运行一下看看效果如何?

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects at java.lang.reflect.Constructor.newInstance(Constructor.java:417) at com.oman.forward.pattern.create.singleton.EnumSingle.main(EnumSingle.java:15)

从上面我看到还是会报错,不过报错的内容变为了 Cannot reflectively create enum objects,查看源码 newInstance,如下:

public T newInstance(Object ... initargs)
        throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException
    {
        if (!override) {
            if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
                Class<?> caller = Reflection.getCallerClass();
                checkAccess(caller, clazz, null, modifiers);
            }
        }
        if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        ConstructorAccessor ca = constructorAccessor;// read volatile
        if (ca == null) {
            ca = acquireConstructorAccessor();
        }
        @SuppressWarnings("unchecked")
        T inst = (T) ca.newInstance(initargs);
        return inst;
}

我们能看到报错是源自于下面这一行,说明了 java 虚拟机不允许对枚举进行反射。从而说明枚举是安全的单例。

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

说到这里,其实我们可以完全按照 java 虚拟机的这种思想,对我们的单例设置安全性,代码如下:

public class Manager {
    private volatile static Manager INSTANCE = null;

    private Manager() {
        if ((this.getClass().getModifiers() & Modifier.PUBLIC) != 0) {
            throw new IllegalArgumentException("Cannot reflectively create this objects");
        }
    }

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

    public static void main(String[] args) throws Exception {
        Constructor<Manager> constructor = Manager.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Manager manager = constructor.newInstance();
    }
}

这样在运行上述代码的时候,就会报错,不允许反射创建实例了,保证了单例的全局唯一并且安全性。

其实单例模式在序列化的时候也会有一些问题: 首先看单例的序列化和反序列化问题:

public class Manager implements Serializable {

    private static final long serialVersionUID = 36249882076318126L;

    private volatile static Manager INSTANCE = null;

    private Manager() {
    }

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

    public static void main(String[] args) throws Exception {
        Manager instance = Manager.getInstance();
        Manager instance2 = Manager.getInstance();
        System.out.println(instance);
        //com.oman.forward.pattern.create.singleton.Manager@5ca881b5
        System.out.println(instance2);
        //com.oman.forward.pattern.create.singleton.Manager@5ca881b5
        System.out.println(instance == instance2);
        //true

        ObjectOutputStream outputStream
            = new ObjectOutputStream(new FileOutputStream("obj"));
        outputStream.writeObject(instance);
        outputStream.flush();
        outputStream.close();

        ObjectInputStream objectInputStream 
            = new ObjectInputStream(new FileInputStream("obj"));
        Manager m = (Manager) objectInputStream.readObject();
        objectInputStream.close();
        System.out.println(m);
        //com.oman.forward.pattern.create.singleton.Manager@4edde6e5
        System.out.println(instance == m);
        //false
    }
}

我们看到反序列化后的对象和原来对象不一致。我们再来看看枚举的序列化和反序列化:

public enum EnumSingle {
    INSTANCE;

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

    public static void main(String[] args) throws Exception {
        EnumSingle single = EnumSingle.INSTANCE;
        EnumSingle single2 = EnumSingle.INSTANCE;
        System.out.println(single);//INSTANCE
        System.out.println(single2);//INSTANCE
        System.out.println(single == single2);//true

        ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("single"));
        outputStream.writeObject(single);
        outputStream.flush();
        outputStream.close();

        ObjectInputStream objectInputStream 
            = new ObjectInputStream(new FileInputStream("single"));
        EnumSingle m = (EnumSingle) objectInputStream.readObject();
        objectInputStream.close();
        System.out.println(m);//INSTANCE
        System.out.println(single == m);//true
    }
}

从运行结果来看,序列化前和序列化后是同一个对象。

那么我们需要思考一下,为什么枚举能够保证线程安全?

其实枚举反编译后是继承自 Enum 类的,当一个 Java 类第一次被真正使用到的时候静态资源被初始化、Java 类的加载和初始化过程都是线程安全的(因为虚拟机在加载枚举的类的时候,会使用 ClassLoader 的 loadClass 方法,而这个方法使用同步代码块保证了线程安全)。所以,创建一个 enum 类型是线程安全的。

另外枚举如何保证序列化后还是同一个对象的?

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

普通的 Java 类的反序列化过程中,会通过反射调用类的默认构造函数来初始化对象。所以,即使单例中构造函数是私有的,也会被反射给破坏掉。但是,枚举的反序列化并不是通过反射实现的。所以,也就不会发生由于反序列化导致的单例破坏问题。

Kotlin 中的单例(这里只简单介绍两种)

饿汉式单例: kotlin 中的饿汉式单例只需要一个 object 关键字就可以实现,不信的话我们反编译看看字节码:

object Single

编译后的 class 文件如下, 说明确实是饿汉式的单例。

public final class Single {
   public static final Single INSTANCE;

   private Single() {
   }

   static {
      Single var0 = new Single();
      INSTANCE = var0;
   }
}

懒加载单例, 其实这里使用的是 kotlin 的延迟属性 lazy, 关于这个属性的具体内容大家可以自行学习。

class Single private constructor() {
    companion object {
        val instance: Single by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
            Single()
        }
    }
}