0%

单例模式有几种写法?

懒汉模式(lazy)

一般我们所熟知的单例(Singleton)懒汉模式是如下写法:

public class Singleton {

private static Singleton instance = null;

private Singleton() {}

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

但是这种写法在多线程情况下,有可能一个线程已经执行到了if(instance == null)然后判断为true,此时线程切换,另一个线程也执行到这一步,同样判定为true。接下来两个线程都会分别创建一个实例,这就打破了单例的原则。为了解决这一问题,可以给getInstance()方法添加同步锁。

同步锁

修改后代码变为:

public class Singleton {

private static Singleton instance = null;

private Singleton() {}

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

这就解决了并发场景下的BUG,但是每次线程调用getInstance()都要请求锁,这在高并发场景下性能很差。于是,又有人提出了一种基于双重检查的方案。

双重检查

双重检查可以不用synchronized,每次请求instance不需要请求和释放锁,代码如下:

public class Singleton {

private static Singleton instance = null;

private Singleton() {}

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

当线程A B同时执行到1处,假设线程A获得了锁,接着由A创建了instance,并释放锁。接着线程B获得锁,由于之前B判定instancenull,而此时实际已经完成了实例化,需要再次验证instance(2处)是否是null。由于验证的结果不再是null,B释放锁,直接拿返回的instance

二次验证看似无懈可击,但实际上有一个隐藏了很深的BUG:JVM在优化执行性能的时候,可能会对局部代码重排序。而一般的对象创建方式是:

  • 1.分配内存空间
  • 2.初始化对象成员
  • 3.返回对象引用

由于JVM的代码重排机制,1-2-3的顺序可能变成1-3-2,即对象还没创建完毕,引用却已经获得。线程A正在执行3处,此时线程B执行到1处,造成2处判断instance不为null并返回,而此时内存空间内还没有instance对象,B拿到一个没有初始化的instance

解决方案是添加volatile关键字:给instance添加volatile访问控制符,作用是取消代码重排优化。修改后的代码如下,这是第一个比较正确的写法。

public class Singleton {

private volatile static Singleton instance = null;

private Singleton() {}

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

内部静态类

除此之外,还有一种单例的思路。由于静态内部类的加载是ClassLoader机制实现的,初始化必须只能是单一线程完成,统一时间只有一个线程执行初始化,则不存在同步互斥问题。且在第一次调用getInstance()时,才会触发SingletonHolder<cinit>()方法。

public class Singleton {

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

private Singleton() {}

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

饿汉模式(eager)

简单饿汉模式

同样是采用ClassLoader初始化静态字段,单例的饿汉模式是线程安全的。

public class Singleton {

private static Singleton instance = new Singleton();

private Singleton() {}

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

我们似乎发现了正确且简洁的解决方案,但是,上述的“正确写法”(带volatile的双重检查,内部静态类,饿汉模式)真的是安全的吗?实际上,用反射/序列化可以轻易破防。来看这样一段代码:

Singleton instance1 = Singleton.getInstance();//用静态方法获取实例

Constructor<Singleton> singletonConstructor = Singleton.class.getDeclaredConstructor();//获取构造方法
singletonConstructor.setAccessible(true);//设置访问控制为true
Singleton instance2 = Singleton.newInstance();//实例化

System.out.println(instance1 == instance2);//false

上述代码可以用于之前任何一种单例的实现。先用getInstance()方法获取到一个实例,然后获取他的一个构造方法,默认是空构造。然后setAccessible(true),让构造方法无论是private还是public都可以被调用。之后通过反射创建一个新的实例。打印出结果为false,说明我们成功又实例化了一个对象。这已经违背了单例设计的初衷:在内存中最多存在一个对象。

同理可以用另一种思路:用序列化与反序列化工具。首先让Singleton实现Serializable接口,然后添加系列化反序列依赖:

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.8.1</version>
</dependency>

修改代码如下:
Singleton instance1 = Singleton.getInstance();//用静态方法获取实例
byte[] serialize = SerializationUtils.serialize(instance1); //序列化到二进制数据
Singleton instance2 = SerializationUtils.deserialize(serialize);//反序列化到对象
System.out.println(instance1 == instance2);//false

上述代码先创建一个instance实例,然后将实例序列化到字节数组中,再将数据从字节数组中反序列化为对象。打印判等结果为false,于是凭空又创造出一个实例。这种手段可能产生BUG,还可能造成安全性问题。我们发现之前所有的实现方法在现在看来都不够完美了,哪怕是饿汉模式,可以被反射/反序列化二次构造。终极的解决方案是:枚举类

枚举类

public enum Singleton {

INSTANCE;

public static void doSomething() {
//your code here
System.out.println("do something");
}

private Singleton() {
//object initialized here
}
}

枚举类实现类似静态类,在调用时初始化,执行构造方法,属于饿汉模式,是线程安全的。使用方法如下:

Singleton instance = Singleton.INSTANCE;
instance.doSomething();

不同是,枚举类无法在反射中被实例化,再次用试图用反射实例化枚举类单例:
Singleton instance1 = Singleton.INSTANCE;

Constructor<Singleton> singletonConstructor = Singleton.class.getDeclaredConstructor();
singletonConstructor.setAccessible(true);
Singleton instance2 = singletonConstructor.newInstance();

System.out.println(instance1 == instance2);

这次没有如约输出false,而是运行错误:
Exception in thread "main" java.lang.NoSuchMethodException: Singleton.<init>()
at java.lang.Class.getConstructor0(Class.java:3110)
at java.lang.Class.getDeclaredConstructor(Class.java:2206)
at Main.main(Main.java:26)

执行getDeclaredConstructor()的时候没有成功,没有获得构造方法,而getDeclaredConstructor()会调用native方法getDeclaredConstructor0(),应该是在JVM层面上对枚举类的实例化做了限制,不允许通过反射获得枚举类的构造器。而下面的代码用getDeclaredConstructors()[0]的确可以获得一个参数列表为(java.lang.String,int)的构造器,但依旧无法实例化:
Singleton instance1 = Singleton.INSTANCE;

Constructor<Singleton> singletonConstructor = (Constructor<Singleton>) Singleton.class.getDeclaredConstructors()[0];
singletonConstructor.setAccessible(true);
Singleton instance2 = singletonConstructor.newInstance();

System.out.println(instance1 == instance2);

会有如下报错:
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
at Main.main(Main.java:25)

说明JRE中也有对枚举类反射实例化的检查,在newInstance()中,执行了对ENUM访问标识符的检查,所以就算或得到了构造器,也会抛出IllegalArgumentException异常:
@CallerSensitive
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仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.EnumvalueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObjectreadObjectreadObjectNoDatawriteReplacereadResolve等方法。

普通的Java类的反序列化过程中,会通过反射调用类的默认构造函数来初始化对象。所以,即使单例中构造函数是私有的,也会被反射给破坏掉。由于反序列化后的对象是重新new出来的,所以这就破坏了单例。

综上,普通类的反序列化是通过反射实现的,枚举类的反序列化不是通过反射实现的。所以,枚举类也就不会发生由于反序列化导致的单例破坏问题。

选用更新的JDK

如果你的JDK是9以下,尝试这段代码,他可以绕过构造方法newInstance()对枚举类的检查,直接拿到ConstructorAccessor进行实例化。

Singleton instance1 = Singleton.INSTANCE;

Constructor c = Singleton.INSTANCE.getClass().getDeclaredConstructors()[0];
Method acquireConstructorAccessor = Constructor.class.getDeclaredMethod("acquireConstructorAccessor");
acquireConstructorAccessor.setAccessible(true);
acquireConstructorAccessor.invoke(c);
Field field = Constructor.class.getDeclaredField("constructorAccessor");
field.setAccessible(true);
ConstructorAccessor constructorAccessor = (ConstructorAccessor) field.get(c);
Singleton instance2 = (Singleton) constructorAccessor.newInstance(new Object[]{"INSTANCE", 0});

System.out.println(instance2 == instance1);

而在JDK9+中修复了这一问题,JDK9+对类的访问权限设置了更安全的控制。

相关链接

为什么我墙裂建议大家使用枚举来实现单例

Disqus评论区没有正常加载,请使用科学上网