Java单例的三种经典实现
双重检查锁(DCL)
volatile关键字在此处起了什么作用?
为何要执行两次instance == null判断?
双重检查模式,进行了两次的判断,第一次是为了避免不要的实例,第二次是为了进行同步,避免多线程问题。由于singleton=new Singleton()对象的创建在JVM中可能会进行重排序,在多线程访问下存在风险,使用volatile修饰signleton实例变量有效,解决该问题。
静态内部类
这种方式是通过什么机制保证线程安全性与延迟加载的?(注意,这是Java单例的两大要点,必须保证)
这个内部类因为被static修饰,代表,这个货是一个类级别的内部类。
如果没有被static修饰,就是一个对象级别的内部类,这种内部类是必须绑定在外部对象实例上的
这种类级别内部类的好处是什么,就是虚拟机JVM的内部机制对它是有保护的,只允许它第一次被加载,其余都是互斥的。
虚拟机会保证一个类的类构造器()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会 有一个线程去执行这个类的类构造器(),其他线程都需要阻塞等待,直到活动线程执行()方法完毕。
特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行()方法的那条线程退出后,其他线程在唤醒之 后不会再次进入/执行()方法,因为 在同一个类加载器下,一个类型只会被初始化一次。如果在一个类的()方法中 有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。
Java枚举的本质是?
这种方式又是通过什么机制保证线程安全性与延迟加载的?
枚举自己处理序列化。为了保证枚举类型像Java规范中所说的那样,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,在枚举类型的序列化和反序列化上,Java做了特殊的规定
在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。
当一个Java类第一次被真正使用到的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的。所以,创建一个enum类型是线程安全的
枚举单例可以有效防御两种破坏单例(即使单例产生多个实例)的行为:反射攻击与序列化攻击。言外之意就是前两种单例方式都会被破坏。
如何破坏一个单例
反射攻击
直接上代码:
这种方法非常简单暴力,通过反射侵入单例类的私有构造方法并强制执行,使之产生多个不同的实例,这样单例就被破坏了。要防御反射攻击,只能在单例构造方法中检测instance是否为null,如果已不为null,就抛出异常。显然双重检查锁实现无法做这种检查,静态内部类实现则是可以的。
注意,不能在单例类中添加类初始化的标记位或计数值(比如boolean flag、int count)来防御此类攻击,因为通过反射仍然可以随意修改它们的值。
序列化攻击
这种攻击方式只对实现了Serializable接口的单例有效,但偏偏有些单例就是必须序列化的。现在假设DoubleCheckLockSingleton类已经实现了该接口,上代码:
枚举单例的防御机制
对反射的防御
JDK反射机制内部完全禁止了用反射创建枚举实例的可能性。
对序列化的防御
如果将serializationAttack()方法中的攻击目标换成EnumSingleton,那么我们就会发现s1和s2实际上是同一个实例,最终会打印出true。这是因为ObjectInputStream类中,对枚举类型有一个专门的readEnum()方法来处理,其简要流程如下:
通过类描述符取得枚举单例的类型EnumSingleton;
取得枚举单例中的枚举值的名字(这里是INSTANCE);
调用Enum.valueOf()方法,根据枚举类型和枚举值的名字,获得最终的单例。
这种处理方法与readResolve()方法大同小异,都是以绕过反射直接获取单例为目标。不同的是,枚举对序列化的防御仍然是JDK内部实现的。
综上所述,枚举单例确实是目前最好的单例实现了,不仅写法非常简单,并且JDK能够保证其安全性,不需要我们做额外的工作。