在前面的文章(设计模式-单例模式)中,我们分别介绍了四种单例设计模式,包括普通恶汉式单例、双重检查锁单例(DCL)、静态内部类单例以及枚举单例。但是,这四种模式还有一些问题我们没有仔细分析,以至于我们无法深入分析他们的优点以及可能存在的问题,更无法确定我们应该在什么场景下使用,在使用的时候我们有应该注意哪些方面。这些重要问题包括如下(但肯定不止,欢迎读者评论补充):
- JVM是如何保证并发加载类时只加载一次?
- DCL为什么需要使用volatile关键字修饰私有静态成员变量?
- 依赖私有构造方法为什么无法控制对象为单例?
- 枚举单例是如何保证绝对单例的?
一、类加载-创建唯一性
在普通饿汉式单例以及静态内部类单例,我们都利用JVM在并发加载类时肯定只会加载一次的特性,来保证单例对象有且仅有一次初始化动作。那有个问题是JVM类的加载真的只会加载一次吗?要回答这个问题,我们先回顾下类加载的过程。
类加载,简单来说就是根据字节流创建类型的过程。更加Javanic的可描述为将类的.class文件中的二进制数据读入内存中,将其放在方法区内存中(类信息),并且在堆中创建了该类的Class对象。JVM是对class信息是按需加载的,当使用到该类时,jvm才会将其对应的class文件加载到内存中(具体什么叫“使用”,本文不展开)。
JVM使用什么加载类呢?类加载器!JVM有以下几种类加载器:① 启动类加载器、② 扩展类加载器、③ 应用类加载器、④ 自定义类加载器。既然是回顾,这里也不会对类加载器过细介绍,这里仅点出对本文比较重要的点:
- 启动类加载器是C++实现的,没有Java对象。其余类加载器均为java.lang.ClassLoader的子类。
- 四个类加载器是由等级的,等级按序号顺序依次降低。根据双亲委派机制,类加载有核心的两点:自底向上检查类是否已加载、自顶向下尝试加载类。
- 每个类加载器都有命名空间来保存已加载的类。即,类加载器命名空间+类全限定名唯一确立了JVM中的一个类型。
启动类加载器一般用于加载rt.jar下的核心类,基本不涉及加载单例类。其他类加载器加载类的过程可以通过其父类ClassLoader的loadClass方法进行加载,源代码贴图如下:
可以看到,类加载器加载类是会根据要加载的类名获取同步锁。获取锁的过程实际上是通过ClassLoader的私有变量维护了一个ConcurrentHashMap,其中Key就是加载的类名,Value是Object对象。代码这里就不贴了,要说的是不同类加载器的并发锁Map是不一样的,因此这里的synchronized不会影响多个类加载器之间的并发加载。即使如此,由于双亲委派机制、类加载锁机制基本能够保证类只会被某个类加载器加载一次。
也就是说,类只会被类加载器加载一次是由双亲委派机制、类加载锁机制共同实现的。那我们自然能够想到,利用打破了双亲委派机制的自定义加载器就能够获取不同的单例对象。测试代码如下:
/**
* 饿汉式单例模式
*/
public class HungrySingleton {
// 类初始化时对象实例化
private static final HungrySingleton hungrySingleton = new HungrySingleton();
static {System.out.println("HungrySingleton, 饿汉式单例初始化....");}
// 构造器私有化
private HungrySingleton() {}
// 向外暴露获取单例对象的方法
public static HungrySingleton getInstance() {
return hungrySingleton;
}
}
/**
* 客户端场景类
*/
public class Client {
public static void main(String[] args) {
/* 普通饿汉式单例获取对象实例 start */
System.out.println(HungrySingleton.getInstance());
System.out.println(HungrySingleton.getInstance());
/* 普通饿汉式单例获取对象实例 end */
CustomClassLoader CustomClassLoader = new CustomClassLoader("{项目路径}\\target\\classes\\");
try {
Class<?> clazz = CustomClassLoader.loadClass("com.design.单例模式.hungry.HungrySingleton");
Constructor<?> constructor = clazz.getDeclaredConstructor(null);
constructor.setAccessible(true);
Object obj = constructor.newInstance(); // 不能强转为HungrySingleton
System.out.println(obj);
} catch (Exception e) {
System.out.println("自定义加载失败, msg=" + e.getMessage());
}
}
}
/**
* 自定义类加载器-打破双亲委派机制
*/
public class CustomClassLoader extends ClassLoader {
private final String baseUrl;
public CustomClassLoader(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public Class<?> loadClass(String name) {
try {
String className = name.replace('.', '\\');
String fileName = this.baseUrl + className + ".class";
InputStream is = null;
try {
is = new FileInputStream(fileName);
} catch (Exception ex) {
// no handle
}
if (is == null) { // 必须委派给应用加载器
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (Exception ex) {
System.out.println("加载失败");
}
return null;
}
}
运行Client.main方法,运行结果:
从上面结果上来看,破坏了双亲委派机制的自定义类加载器可以破坏JVM的类加载的唯一性,继而也破坏了饿汉式的单例性。因此,在我们使用饿汉式单例的时候,也需注意自定义类加载器对其唯一性的影响,以免出现及其难以排查的问题。
二、DCL之Volatile思考
双重检查锁单例模式(DCL)可能是我们日常开发中经常会使用的用于创建单例对象的方案。前面在讲解DCL时,我们说到需要使用volatile关键字来解决指令重排带来的影响。这里我会先详细分析下使用volatile关键字的比较性,再发散分析其他可能存在的问题以及使用注意事项。
/**
* 懒汉式单例模式-双重检查锁
*/
public class LazyDoubleCheckSingleton {
private volatile static LazyDoubleCheckSingleton instance;
private LazyDoubleCheckSingleton() {}
public static LazyDoubleCheckSingleton getInstance() {
if (instance == null) { // 第一重检查:保证性能不受锁的影响
synchronized (LazyDoubleCheckSingleton.class){ // 不存在单例对象时,才进入同步区
if(instance == null) { // 第二重检查:进入同步区再次检查保证确实不存在单例对象
instance = new LazyDoubleCheckSingleton();
}
}
}
return instance;
}
}
如上为DCL经典代码示范&核心流程示意图,为了保证多线程环境下高效获取单例对象的需求,DCL设计了两重检查来实现单例模式,具体有四个步骤:
- 判断单例对象是否存在?存在则返回
- 不存在则进入同步代码区
- 判断单例对象是否存在?存在则返回
- 不存在,则进行初始化对象,并返回
要理解DCL这四个步骤的意义,本文将从对象状态进行分析,由于是懒加载过程,对象的创建&初始化过程将会在大量流量请求getInstance方法中进行,因此getIntance的处理逻辑必然要和对象的状态相关。一个对象的创建大致可以分为以下几个状态,① 对象尚未被创建、② 对象已创建但未初始化、③ 对象已创建且被初始化。
我们先看第①个状态,对象尚未被创建,大量请求getInstance尝试获取单例对象,为防止多线程环境下对象被创建多次,必须使用synchronized关键字来控制对象的创建过程同时间只能有一个线程执行。另外,虽然同一时间不能有多个线程进入同步代码区,但这也并不意味着仅有一个线程执行同步区代码。只要同步锁不被占用就会有线程执行同步区代码,因此在同步区内部也需要判断单例对象是否已经存在。
再看第③个状态,对象已创建并且初始化完成。此时对象处理直接可用状态,请求getInstance方法获取对象可以直接返回,并不需要等待synchronized同步锁,影响服务性能。因此,我们需要在进入同步区之前通过对象是否已存在来判断直接返回OR尝试进去同步区。
先看前面这两个状态,你是否已经发现问题了呢。明显地,DCL并没有考虑到对象还有第②个状态,第②个状态实际是对象不可用状态。这就意味着第③状态的判断要以对象可不可用为准,而不是仅仅通过instance==null来判断。
好,如果你认同上面一点,我们就继续看下去。在大部分的DCL分析资料中,大家对于DCL中使用volatile关键字的原因是由于new关键字非原子性且存在指令重拍的可能。我认为是但并非简单如此,加上volatile关键字就没有问题吗?确实,还是有问题的。对象可不可用状态并非能够以私有成员变量是否为null、或对象构造函数执行完成为准绳。如下所示单例对象初始化过程,你猜测下是否存在问题?
/**
* 懒汉式单例模式-双重检查锁
*/
public class LazyDoubleCheckSingleton {
private volatile static LazyDoubleCheckSingleton instance;
@Getter@Setter
private Object obj;
private LazyDoubleCheckSingleton() {}
public static LazyDoubleCheckSingleton getInstance() throws InterruptedException {
if (instance == null) { // 第一重检查:保证性能不受锁的影响
synchronized (LazyDoubleCheckSingleton.class){ // 不存在单例对象时,才进入同步区
if(instance == null) { // 第二重检查:进入同步区再次检查保证确实不存在单例对象
instance = new LazyDoubleCheckSingleton();
instance.setObj(new Object());
}
}
}
return instance;
}
}
如上示例代码,投入生产线上使用会不会存在问题?存在,在特殊并发场景下还是会出现获取的单例对象的属性obj为null,后续流程仍然会有出现空指针异常的问题。所以原罪并非new关键字的指令重拍,原罪其实是流程、方案!
怎么改正呢?回归本质,第一重检查应该是对象可不可用状态的判断,可用才能直接返回!因此问题找到了,第一重判断的含义存在误解。如何判断对象可用状态?看看你的同步区如何初始化的,哪些初始化步骤未完成会带来严重业务问题,那这些初始化状态在第一重就一定要检查。【题外话,防御性编程的重要性,以后获取的单例对象你还能完全信赖嘛】
public static LazyDoubleCheckSingleton getInstance() throws InterruptedException {
if (!checkForSingletonStatus(instance)) { // 第一重检查:保证性能不受锁的影响
synchronized (LazyDoubleCheckSingleton.class){ // 不存在单例对象时,才进入同步区
if(instance == null) { // 第二重检查:进入同步区再次检查保证确实不存在单例对象
instance = new LazyDoubleCheckSingleton();
instance.setObj(new Object());
}
}
}
return instance;
}
// 检查单例对象的可用状态-用于第一重检查
private static boolean checkForSingletonAccessStatus(LazyDoubleCheckSingleton singleton) {
if(singleton == null) { // 对象实例化检查
return false;
}
if(singleton.getObj() == null) { // obj属性检查
return false;
}
// ...其他核心属性检查
return true;
}
以上为改动后的DCL部分代码(volatile关键字也不需要使用),这才是双重检查锁单例模式完全正确理解。
三、私有构造-防君子设计
在大部分的单例模式设计思路上,都是利用了私有构造方法来保障类不会被在其他地方实例化。因为私有了嘛,只能我自己用,其他人不会用。大部分人应该也都知道了,这个前提是不正确的,私有实在是防君子不防小人呐。这个问题也就导致了私有构造的枚举类也可以被一些小手段给实例化了。这些手段包括反射、序列化等。
3.1 反射
下面给出一个反射破坏单例的示例:
public static void main(String[] args) {
// 使用单例破坏了私有构造类型单例
try {
Class<LazyStaticInnerClassSingleton> clazz = LazyStaticInnerClassSingleton.class;
// 通过反射获取类的私有构造方法
Constructor constructor = clazz.getDeclaredConstructor(null);
constructor.setAccessible(true); // 强制访问
Object objV1 = constructor.newInstance();
Object objV2 = constructor.newInstance();
System.out.println(objV1 == objV2); // false
} catch (Exception e) {
e.printStackTrace();
}
}
如上,通过简单的反射就实例化了多个单例类。针对这个问题,也有人提出在构造方法中判断对象是否已经存在,如果已经存在就抛出异常,避免类型的再次实例化。这是个很好的方案,仅仅对静态内部类或饿汉式单例来说是可以的,对于其他类型私有构造单例来说是无效的。
为什么这么说呢,静态内部类单例模式中,单例类的私有构造方法是供静态内部类使用的,内部判断对象是否存在来决定构造方法是否抛出异常,这是非常合适的。对象存在,那就说明静态内部类已经被加载过了不允许再次实例化,相反对象不存在,那就说明本次实例化动作是由静态内部类加载并初始化产生的。饿汉式单例而言,类加载过程已经初始化了单例对象,直接在构造方法中抛出异常即可,不需要判断。而对于DCL懒加载单例模式,只要在懒加载之前使用反射调用构造方法获取实例仍然是没有问题的。
3.2 反序列化
获取对象的另外一种方法也可以是通过反序列化的方式。如果单例类实现了Serializable接口,那么该单例就会被反序列化破坏。理解起来应该十分简单,这里给出测试代码:
public static void main(String[] args) {
LazyStaticInnerClassSingleton instance = LazyStaticInnerClassSingleton.getInstance();
try {
String fileName = "SerializableSingleton.obj";
FileOutputStream fos = new FileOutputStream(fileName);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(instance);
oos.flush();oos.close();fos.close();
FileInputStream fis = new FileInputStream(fileName);
ObjectInputStream ois = new ObjectInputStream(fis);
LazyStaticInnerClassSingleton o = (LazyStaticInnerClassSingleton) ois.readObject();
ois.close();fis.close();
System.out.println(o == instance); // false
} catch (Exception e) {
// skip
}
}
因此,在使用单例类时需注意尽可能不要实现Serializable接口,否则就需要考虑反序列化带来的影响。然而,我们也可以给单例类增加readResolve方法强制返回单例对象,这时反序列化出来的对象就符合预期了。
public class Singleton implements Serializable {
...
public static Singleton getInstance() {
...
}
private Object readResolve() {
return getInstance();
}
}
为什么增加了readResolve方法就可以呢,我们来看下ObjectInputStream#readObject()方法是如何反序列化的。
readObject()方法会根据类型标记来判断是普通对象还是其他,比如枚举等,并分别用不同的方法来进行处理。进入readOrdinaryObject()方法中,这个方法内部会进行实例化对象。
从上述源码上来看,反序列化流程中还是会创建一个新的对象,但是后续会判断是否存在resolveObject方法,如果存在则调用返回并覆盖之前创建的新对象。
返序列化是如何判断是否存在readResolve()方法呢?答案是在ObjectOutputStream#writeObject时会找到方法名为readResolve、返回值为Object类型且不含入参的方法。找到后记录在readResolveMethod属性中,反序列化时会判断是否存在这个属性,即resolve方法。
四、枚举单例
前面提到了很多可以破坏单例模式的方法,比如自定义类加载器、反射、序列化等方式均可以破坏单例。这个章节我们就一起来看下枚举是如何保证绝对单例的。
4.1 枚举本质
先来看看枚举类的本质。枚举实际上还是一个类,只不过由于其特殊性,在写法上进行了简化。如下是一个普通枚举类字节码信息:
从字节码信息中,我们看到枚举实际上还有一些我们看不到的东西,比如这里有2个成员变量,有values、valueOf、类静态方法等。我们通过jad工具反编译其class文件得到内容如下:
public final class EnumSingleton extends Enum {
public static final EnumSingleton INSTANCE;
private static final EnumSingleton $VALUES[];
static
{
INSTANCE = new EnumSingleton("INSTANCE", 0);
$VALUES = (new EnumSingleton[] {
INSTANCE
});
}
public static EnumSingleton[] values()
{
return (EnumSingleton[])$VALUES.clone();
}
public static EnumSingleton valueOf(String name)
{
return (EnumSingleton)Enum.valueOf(EnumSingleton, name);
}
private EnumSingleton(String s, int i)
{
super(s, i);
}
public static EnumSingleton getInstance()
{
return INSTANCE;
}
}
上面就是编译后的枚举类,从其内容上来看有以下几个特点:
- 枚举类使用final修饰,因此该类不会被继承
- 枚举类都继承自Enum抽象类。
- 枚举类的实例都是在枚举类加载时就初始化,因此枚举本身也属于懒加载多单例对象(不同对象区别且唯一)
- 枚举类的所有实例都存储在其数组类型的类变量中。
4.2 反射无效
自定义类加载器也是通过反射来获取实例化对象的,因此这里一起说明。反射是通过获取构造函数,然后通过newInstance方法创建实例。根据编译后的枚举类,我们知道枚举是有一个有参构造方法的,第一个参数为枚举实例名称,第二个参数为枚举实例序号。既然有构造方法为啥不会被反射出实例呢?答案就在newInstance方法中。
4.3 序列化无效
枚举类也无法序列化出实例吗?我们在前文讲解ObjectInputStream#readObject()方法是如何反序列化时截图中给出不同对象实际反序列化的方法。对于枚举类,实际是通过readEnum()方法来进行反序列化的,其内部逻辑如下:
从代码可以看出,枚举类的反序列化实际上是通过valueOf()方法获取枚举类变量中的实例的,这个函数返回的就是之前枚举类加载时初始化的对象,因此能够保证序列化出来的对象也是之前的同一对象。