懒汉模式(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 { |
当线程A B同时执行到1处,假设线程A获得了锁,接着由A创建了instance
,并释放锁。接着线程B获得锁,由于之前B判定instance
为null
,而此时实际已经完成了实例化,需要再次验证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 { |
内部静态类
除此之外,还有一种单例的思路。由于静态内部类的加载是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();//用静态方法获取实例 |
上述代码可以用于之前任何一种单例的实现。先用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 { |
枚举类实现类似静态类,在调用时初始化,执行构造方法,属于饿汉模式,是线程安全的。使用方法如下: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
异常:
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();
}
T inst = (T) ca.newInstance(initargs);
return inst;
}
而使用序列化/反序列化来操作枚举类单例,并不会出错,而是反序列化后的对象和原对象引用相同!
枚举类型在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum
的valueOf
方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject
、readObject
、readObjectNoData
、writeReplace
和readResolve
等方法。
普通的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+对类的访问权限设置了更安全的控制。