单例模式作为非常重要的一种设计模式,其实现方式多种多样,本文介绍懒汉,饿汉单例,内部类单例,枚举单例以及它们可能存在的失效场景分析.
先说结论:单例模式需要注意的是线程安全,序列化和反序列化安全及防止反射攻击,懒加载模式在处理这几个问题时比较麻烦甚至无解(反射攻击),枚举单例则简洁与安全.
接下来对这些内容举例.依旧通过代码分析.
首先看看懒加载单例
public class LazySingleton implements Serializable {
private static LazySingleton instance;
private LazySingleton(){
}
public static LazySingleton getInstance(){
//线程不安全
if (instance==null){
instance=new LazySingleton();
}
return instance;
}
}
上述代码线程不安全,通过一个测试方法来看看现象,这里说个题外话,使用spring的@Test注解调用测试会抛出异常,原因在于JUnit调用方法是要求被调用的方法必须只有一个公共构造方法(Test class should have exactly one public constructor),然而单例的构造方法是私有的.
//测试线程安全
public static void main(String[] args) {
for (int i=0;i<10;i++){
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"-----------"+ LazySingleton.getInstance());
}
}).start();
}
}
调试过程中可以看到不同的线程都能进入,并且获取单例
得到的打印结果也可以看得出,不同的线程获取到的单例对象不是同一个
那么如何解决这个问题,第一反应自然是加上锁,虽然是解决了线程安全问题,但同时也影响到了效率
public static LazySingleton getInstance(){
//影响效率
synchronized (LazySingleton.class){
if (instance==null){
instance=new LazySingleton();
}
}
return instance;
}
再进一步,可以使用双检锁,尽可能的在保证线程安全的情况下再提高效率,这是这种写法的优势,从目前的角度看似乎已经很完善了,其实这里面依旧存在问题,这些问题,下文会继续说明.
public static LazySingleton getInstance() {
//双检锁
if (instance == null) {
synchronized (LazySingleton.class) {
if (instance == null) {
instance = new LazySingleton();
}
}
}
return instance;
}
JDK 1.6之后 锁的代价已经不大,双检锁的意义在1.6之后不大
接下来看看饿汉模式的单例
public class HungerSingleton implements Serializable{
//初始化的时候就创建对象,消耗内存空间
private static HungerSingleton instance=new HungerSingleton();
private HungerSingleton(){
}
public static HungerSingleton getInstance(){
return instance;
}
}
饿汉模式的好处在于不存在线程安全的问题,因为初始化的时候就将单例给创建出来了,这就导致了另一个问题,会消耗内存空间,无论这个单例需不需要被使用,统统都会被创建.如何让单例跳过线程安全又能延迟加载,这里就能再进一步,使用静态内部类来实现单例
public class StaticInnerClassSingleton implements Serializable{
//静态内部类 可以延迟加载
private static class InnerClass{
private static StaticInnerClassSingleton instance=new StaticInnerClassSingleton();
}
private StaticInnerClassSingleton(){
}
public static StaticInnerClassSingleton getInstance(){
return InnerClass.instance;
}
public void doSome(){
System.out.println(Thread.currentThread().getName()+"-----------"+ StaticInnerClassSingleton.getInstance()+"----------");
}
}
通过一段代码测试一下
//测试线程安全
public static void main(String[] args) {
for (int i=0;i<10;i++){
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"-----------"+ StaticInnerClassSingleton.getInstance());
}
}).start();
}
}
得到的结果是线程安全,所以线程获得的单例都是同一个,打印结果如下
似乎内部静态类来实现单例是完美的?先不要过早下结论.至于原因,下文会继续说.
接下来看看枚举类型的单例实现
public enum EnumSingleton {
INSTANCE {
public void doSome() {
System.out.println("----doSome----");
}
};
public static EnumSingleton getInstance(){
return EnumSingleton.INSTANCE;
}
public abstract void doSome();
}
用枚举实现单例非常简洁非常推荐使用,那么对不懒汉和饿汉,枚举类型单例有什么优势呢?这里就涉及到反序列化和反射攻击使得单例实现的问题了,接下来一一论述.
反序列化问题,懒汉和饿汉都一样,这里用加了双检锁的懒汉模式来举例,写一段代码测试一下
public static void main(String[] args) throws Exception {
//反序列化使得单例失效
LazySingleton lazySingleton=LazySingleton.getInstance();
//序列化
ObjectOutputStream out=new ObjectOutputStream(new FileOutputStream("lazySingleton"));
out.writeObject(lazySingleton);
ObjectInputStream in=new ObjectInputStream(new FileInputStream("lazySingleton"));
//反序列化
LazySingleton lazySingletonNew= (LazySingleton) in.readObject();
System.out.println(lazySingleton);
System.out.println(lazySingletonNew);
}
打印结果如下:
通过反序列化,拿到了两个不同的"单例",这是不符单例原则的.原因在于LazySingleton lazySingletonNew= (LazySingleton) in.readObject();这行代码.
如何防止这种问题产生?这里先说结论,在单例中实现readResolve()方法,将其改造,代码如下
public class LazySingletonThree implements Serializable{
private static LazySingletonThree instance;
private LazySingletonThree(){
}
public static LazySingletonThree getInstance() {
//双检锁
if (instance == null) {
synchronized (LazySingleton.class) {
if (instance == null) {
instance = new LazySingletonThree();
}
}
}
return instance;
}
//防止反序列化生成单例
public Object readResolve(){
return instance;
}
}
那么问题来了,为什么?
跟入代码,来看看原因所在.
首先进入ObjectInputStream 反序列化获取的object对象在readObject0这个方法中,继续跟入
进入到checkResolve方法,注意这里进入条件时TC_OBJECT
进入该方法后,通过反射获取对象,之后做了一个判断desc.hasReadResolveMethod(),如果返回为true则会走desc.invokeReadResolve(obj);这行代码
而后来分析这个desc.hasReadResolveMethod()里面到底做了什么,源码是这样的
接下来去找readResolveMethod,发现
这便是实现readResolve()方法可以防止反序列化造成单例失效的原因,有readResolve这个方法,则在序列化的时候会用该方法覆盖掉反序列化生成的对象.
接下来再说说反射攻击导致单例失效的问题,拿到对象的class,哪怕对象的构造方法是私有的,反射也有调用.这可能会导致单例失效,写一段代码具体表现,这里先用饿汉模式举例
public static void main(String[] args) throws Exception {
//反射构建单例使得单例失效
HungerSingleton hungerSingleton=HungerSingleton.getInstance();
Class clazz= HungerSingleton.class;
//构造器
Constructor constructor=clazz.getDeclaredConstructor();
//设置可访问
constructor.setAccessible(true);
//创建一个对象
Object hungerSingletonNew=constructor.newInstance();
System.out.println(hungerSingleton);
System.out.println(hungerSingletonNew);
}
运行结果如下:
这里又出现了两个"单例",由此可以看出饿汉模式虽然没有线程安全问题,通过实现readResolve()方法可以防止反序列化造成单例失效,但是无法防止反射攻击,该如何解决?这里讲饿汉模式改造一下
public class HungerSingletonTwo implements Serializable {
//初始化的时候就创建对象,消耗内存空间
private static HungerSingletonTwo instance=new HungerSingletonTwo();
private HungerSingletonTwo(){
if (instance!=null){
throw new RuntimeException("不允许反射创建单例对象");
}
}
public static HungerSingletonTwo getInstance(){
return instance;
}
}
在反射调用构造方法的时候先判断instance是否存在,如果存在,那么意味着单例已经创建了,抛出运行时异常即可.这样就解决了反射攻击的问题,再次测试,运行结果如下:
这是对于饿汉模式的解决办法,但这个办法却无法对懒汉模式单例起作用,那懒汉举例说明来看
public static void main(String[] args) throws Exception {
//懒汉模式无法通过构造器抛异常的方式避免反射攻击
//先反射,再调用懒汉单例
Class clazz= LazySingletonTwo.class;
Constructor constructor=clazz.getDeclaredConstructor();
constructor.setAccessible(true);
Object lazySingletonNew=constructor.newInstance();
LazySingletonTwo lazySingletonTwo=LazySingletonTwo.getInstance();
System.out.println(lazySingletonTwo);
System.out.println(lazySingletonNew);
}
运行结果如下:
即使在懒汉模式的构造方法中加入了判断
也无法保证该条件成立,因为调用反射的时候如果懒汉模式单例之前没有被调用,那么意味着该单例本身不存在,这样就会导致出现两个"单例",而这个问题,目前是没有解决办法的.所以懒汉单例并不推荐使用(当然具体情况需要结合自身相关业务逻辑).
那么回过头来看,枚举类型的单例是否能防止反序列化和反射攻击使得单例失效呢?答案是:可以.
看一个例子
public static void main(String[] args) throws Exception {
EnumSingleton enumSingleton= EnumSingleton.getInstance();
ObjectOutputStream out=new ObjectOutputStream(new FileOutputStream("enumSingleton"));
out.writeObject(enumSingleton);
ObjectInputStream in=new ObjectInputStream(new FileInputStream("enumSingleton"));
//反序列化
EnumSingleton enumSingletonNew= (EnumSingleton) in.readObject();
System.out.println(enumSingleton);
System.out.println(enumSingletonNew);
}
运行结果如下:
这又是为什么?依旧进入反序列化的代码看看,和上文中不同的是,这一次进入的是TC_ENUM条件中的readEnum方法
而在该方法中,name这个值依旧是"INSTANCE",最后返回的也是这个对象.这是枚举的天然优势.
从上面看来,枚举天然优势,可以防止反序列化造成单例失效,至于反射,枚举对象无法通过反射来创建.所以通过枚举来实现单例是比较理想的方式,推荐使用.
说完这些,再看看容器创建单例的方式,这里举一个spring源码的例子,在spring的bean单例注册器中就是通过容器来实现单例的
这里的singletonObjects是一个ConcurrentHashMap类型(后续有机会再说说这种类型).