单例模式是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 |
可以看到懒汉式每次获取实例都同步,所以效率较差,其他都差不多。