详解单例模式

单例模式是java中广泛运用的一种设计模式。单例模式的基本原则是一个类对外只提供一个实例,单例对象只会被初始化一次。

实现单例的基本思想是构造函数私有化,自己构造一个实例,对外暴露实例的get方法。它的写法多种多样,下面就介绍单例模式的饿汉式、懒汉式、双重检测锁、静态内部类、枚举这5种写法。并从线程安全、反射漏洞、反序列化漏洞三个方面进行分析优化。最后测试各写法的性能。

一、单例的5种写法

饿汉式

public class HungrySingleton {
	private static HungrySingleton instance =new HungrySingleton();
	private HungrySingleton(){}
	public static HungrySingleton getInstance(){
		return instance;
	}
}

饿汉式写法很简单,类加载时就初始化一个实例,私有化构造器,然后对外提供getInstance方法获取实例。所谓饿汉式,就是说很饥饿,刚刚初始化就生成实例,不管你访不访问,实例都已经生成。没有实现延时加载。

因为同一个类加载器加载同一个类只会加载一次,所以饿汉式单例是线程安全的。因为没有同步,所以调用效率也比较高;缺点是没有实现延时加载,也就是没有实现需要的时候才创建实例。一般需要实现单例的对象都是比较占用资源的对象,饿汉式写法就比较消耗资源。

为了实现延时加载,于是就有了懒汉式写法。

懒汉式

public class LazySingleton {
	private static LazySingleton instance;
	private LazySingleton(){}
	public static synchronized LazySingleton getInstance(){
		if(null==instance){
			instance=new LazySingleton();
		}
		return instance;
	}
}

所谓懒汉式,就是懒得创建实例,等需要的时候再去创建。私有化造器的基本思想是一样的,懒汉式单例在构建实例是在调用getInstance方法时,实现了延时加载。通过synchronized关键字实现线程安全。

懒汉式同步了整个getInstance方法,不管唯一实例有没有被创建都同步,调用效率自然就比较低。为了优化这一问题,就有了双重检测锁(double check lock)写法。

双重检测锁

public class DCLSingleton {
	private static DCLSingleton instance;
	private DCLSingleton(){}
	public static DCLSingleton getInstance(){
		if(null==instance){
			synchronized (DCLSingleton.class) {
				if(null==instance){
						instance=new DCLSingleton();
					}
				}
			}
		return instance;
	}
}

双重检测锁写法有两处对实例是否已经被创建的检测。取消懒汉式的对整个方法同步。如果实例被创建,直接返回实例,不会进入同步代码,否则加锁创建实例。

双重检测锁通过锁细化,保证线程安全的同时又提升了效率。但是代码较为复杂。

静态内部类

public class StaticSingleton {
	private static class SingletonClassInstance{
		private static final StaticSingleton instance=new StaticSingleton();
	}
    private StaticSingleton(){}
	public static StaticSingleton getInstance(){
		return SingletonClassInstance.instance;
	}
}

静态内部类写法是我个人比较喜欢的写法,代码比较简单,类加载时不会初始化静态内部类,所以实现延时加载,并且始终只有一个实例,线程安全。调用效率也比较高。

枚举

public enum EnumSingleton {
	//这个枚举元素本身就是单例对象
	INSTANCE;
}

枚举里的元素天然就是单例,线程安全,效率高,不能延时加载。jdk 1.5才出现枚举。

二、单例模式的漏洞

单例模式的语义是用户获取的实例永远是同一个。正常情况下,只要是线程安全的写法,这一点都能得到保证。

但是java中创建对象有多种方式,通过反射和反序列化获取的对象还是同一个对象吗?

通过以下代码测试一下(以饿汉式为例):

反射

//通过反射的方式直接调用私有构造器
	@Test
	public void testReject() throws Exception {
		Class<HungrySingleton> clazz=(Class<HungrySingleton>) Class.forName("com.youzi.singleton.HungrySingleton");
		Constructor<HungrySingleton> c=clazz.getDeclaredConstructor(null);
		c.setAccessible(true);
		HungrySingleton instance1=c.newInstance();
		HungrySingleton instance2=c.newInstance();

		System.out.println("原对象的hashcode:"+instance1.hashCode());
		System.out.println("反射对象的hashcode:"+instance2.hashCode());
	}

结果:

原对象的hashcode:580024961
反射对象的hashcode:2027961269

反序列化:

//通过反序列化的方式构造多个对象
	@Test
	public void testSerialize() throws Exception {
		HungrySingleton instance1= HungrySingleton.getInstance();
		ObjectOutputStream oos= new ObjectOutputStream(new FileOutputStream("D:/temp/ab.txt"));
		oos.writeObject(instance1);
		oos.close();
		ObjectInputStream ois=new ObjectInputStream(new FileInputStream("D:/temp/ab.txt"));
		HungrySingleton instance2=(HungrySingleton) ois.readObject();
		ois.close();
		System.out.println("原对象的hashcode:"+instance1.hashCode());
		System.out.println("反序列化对象的hashcode:"+instance2.hashCode());
	}

注意:测试反序列化必须让被序列化的对象的类实现Serializable接口。

测试结果:

原对象的hashcode:1642360923
反序列化对象的hashcode:1451270520

经过测试发现,除了枚举写法,其他四种单例写法均存在反射漏洞和反序列化漏洞。即通过这两种方式可以生成多个实例。枚举写法天然不存在反射漏洞和反序列化漏洞。

针对这两个漏洞我们再做一些优化,基于DCL写法解决这两个漏洞的写法:

public class SafeSingleton implements Serializable {
	private static SafeSingleton instance;
	private SafeSingleton(){
		if(instance!=null){
			throw new RuntimeException("不允许反射调用构造方法");
		}
	}
	public static SafeSingleton getInstance(){
		if(null==instance){
			synchronized (SafeSingleton.class) {
				if(null==instance){
					instance=new SafeSingleton();
				}
			}
		}
		return instance;
	}
	
	//反序列化时直接调用此方法返回instance
	private Object readResolve(){
		return instance;
	}
}

存在反射漏洞是因为通过反射可以调用类的私有方法,在调用私有构造器时我们再判断一下实例是否已经存在,如果存在就抛出异常,不让创建新的实例。

反序列化时会调用readResolve()方法,我们直接在该方法返回实例,就可以防止反序列化生成新的实例。

三、测试各写法的效率

其实根据各写法是否有同步,以及同步粒度就可以判断他们的效率优劣。这里通过以下代码测试一下,开10个线程同时访问单例,每个线程访问100万次,所花的时间。

public class TestEfficiency {
	public static void main(String[] args) throws Exception {
		long start = System.currentTimeMillis();
		int threadNum=10;
		final CountDownLatch countDownLatch=new CountDownLatch(threadNum);
		
		for(int i=0;i<threadNum;i++){
			new Thread(() -> {
				for (int j = 0; j < 1000000; j++) {
					Object o= HungarySingleton.getInstance();
				}
				countDownLatch.countDown();
			}).start();
		}
		countDownLatch.await();//main方法阻塞,直到计数器变为0才会继续执行
		long end=System.currentTimeMillis();
		System.out.println("总耗时:"+(end-start)+"ms");
	}
}

测试结果:

单例类型 平均耗时(ms)
HungrySingleton 98
LazySingleton 484
DCLSingleton 94
StaticSingleton 108
EnumSingleton 97
SafeSingleton 107

可以看到懒汉式每次获取实例都同步,所以效率较差,其他都差不多。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章