防止单例被序列化破坏

为什么会破坏

序列化的过程是通过ObjectOutputStream将类写入文件(序列化),通过ObjectInputStream将类序列化文件从硬盘读出生成一个对象。

在单例的序列化中,被反序列化的单例对象会通过显式或者默认的readObject方法去获取一个指向新的实例的引用INSTANCE,原理是利用反射构建了一个新的对象,所以私有构造器是没有用的,readObject方法等于是一个面向反序列化的、参数是字节流的公有构造器(readObject的其他作用暂且不提),这样就会破坏掉单例类中的INSTANCE指向同一个对象的原则。

怎么去避免

1、避免单例被破坏的最简单的方式,就是使用枚举去实现单例

public enum Singleton {
    INSTANCE;

    otherMethods();
    ....

}

利用枚举实现,完全解决了保证单例,线程安全,*序列化的问题。

枚举的实例的底层编译实现是一个final static class,这样的实现类似于单例中静态常量模式的实现(饿汉式),无法实现lazy-loading但保证了单例。类在被加载时是线程安全的,所以解决了线程问题。各种序列化方法,如writeObject、readObject、readObjectNoData、writeReplace和readResolve等会造成单例破坏的方法都是被禁用的,所以在JVM中,枚举类和枚举量都是唯一的,这就实现了*序列化。

虽然枚举实现无法lazy-loading,但还是有很大的优势的,比如代码简洁,最适用需要序列化的场景等等。

public final static class T extends Enum {
    ...;
}

 

2、通过序列化中的readResolve方法去保证序列化中的单例

public class Singleton {
    private final static Singleton INSTANCE;
    
    private Singleton() {
    }

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

    // 在反序列化时,用这个方法返回的对象引用替代readObject新建的引用
    private Object readResolve() {
        return INSTANCE;
    }

}

前面说过,readObject会在反序列化中,为通过对象的字节流参数去新建一个对象,但是反序列化中readResolve方法可以去替换掉readObject新建的实例,这就实现了单例传递,而被新建的实例就会因为失去引用被GC线程回收。

这里还有一个关键词transient,在单例序列化过程中,所有带对象引用的实例域都应该使用transient进行修饰,这个“瞬时”修饰符的作用就是序列化时,不会去保存这个引用的状态,可以理解为,反序列化的时候,不会去为这个引用新建一个实例,而是将引用保持为null,如果没有transient修饰,有一些攻击者可以通过“盗用“的方式获取在反序列化中新建的单例对象,这样攻击者就可以创建出一个内容与你的单例不同的一个单例对象。

 

参考书:Effective Java 中文第二版

如有错误、不足之处,欢迎指正。下面给出测试代码。

Singleton没有readResolve方法

import java.io.Serializable;

public class Singleton implements Serializable{
	public static final Singleton INSTANCE = new Singleton();
	
	private Singleton() {
	
	}
	
	
}

测试类

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

public class testSingleAndSer {
	public static void main(String[] args) {
		Singleton s = Singleton.INSTANCE;

		try {
			System.out.println(s.toString());
			ObjectOutputStream o = new ObjectOutputStream(new FileOutputStream("F:/single.txt"));
			o.writeObject(s);
			o.flush();
			o.close();
		} catch (FileNotFoundException e) {

			e.printStackTrace();
		} catch (IOException e) {

			e.printStackTrace();
		}

		try {
			ObjectInputStream is = new ObjectInputStream(new FileInputStream("F:/single.txt"));
			s = (Singleton) is.readObject();
			is.close();

			System.out.println("\nread after Serializable: ");
			System.out.println(s.toString());
		} catch (FileNotFoundException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} catch (ClassNotFoundException e) {
			e.printStackTrace();
		}
	}
}

返回结果

防止单例被序列化破坏可以看出两个实例的地址不同,也就是单例对象被新建了。

 

Singleton有readResolve方法

import java.io.Serializable;

public class Singleton implements Serializable{
	public static final Singleton INSTANCE = new Singleton();
	
	private Singleton() {
	
	}
	
	private Object readResolve() {
		return INSTANCE;
	}
}

测试方法同上

返回结果

防止单例被序列化破坏这时候的单例引用指向的地址是一个,表示、反序列化之后的引用还是指向原来的单例对象。但实际上已经有新建一个对象了,接下来我们去验证一下。

 

笔者水平有限,只能通过追踪断点的方法去观察是否新建实例

防止单例被序列化破坏

防止单例被序列化破坏

从这张反序列化断点过程中,可以看出确实是有新建一个Singleton实例对象,但在之后又通过判断readResolve方法将引用指向的实例修改为readResolve返回的实例。

 

逻辑梳理:

防止单例被序列化破坏

 

 

总结:

防止单例被序列化破坏的最好解决方法就是利用枚举去编写单例,readResolve虽然可以解决反序列化中引用指向新的对象的问题,但新的对象还是生成了。

反射调用单例中的私有构造器也可以破坏传统单例,解决办法是在构造器中编写如果创建第二个对象,就抛出错误,使用枚举就没有这些烦恼了。