欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

单例模式那些坑

程序员文章站 2022-06-03 15:58:10
...

作为GOF黄道23宫的白羊宫,单例模式是所有设计模式初学者首先要跨过的坎。本文不赘述单例模式和它的诸多变种(比如懒加载单例,单例工厂模式等等)的用法,而是想和大家聊聊单例背后的那些坑。

 

第一坑 并发之坑

这个坑相信大部分童鞋都是知道的,毕竟大部分人闭着眼单手也能蹂单例。懒加载是件好事,但是一不小心就犯错了,比如:

public class Foo {
	
	private static Foo INSTANCE;
	
	private Foo() {}
	
	public static Foo getInstance() {
		if (null == INSTANCE) {
			INSTANCE = new Foo();
		}
		return INSTANCE;
	}

}

 首先实例化Foo对象是有时间开销的,不长也不短。在高并发情况下,在Foo完成实例化之前,多个线程完全可能通过INSTANCE为空的判断进入实例化Foo的过程。所以,规范的做法是对该部分加锁

 

 

 

第二坑 反射之坑

私有化构造器的目的是限制用户通过构造器来构建多个实例。它的前提是构筑在私有化构造器后,Java用户就会失去访问该构造器的能力。可是上帝关门的时候总是不关窗。Java的反射机制就是那扇窗(我们暂且借用上文的那个单例类):

 

	public static void main(String[] args) throws Exception {
		Constructor<Foo> c = Foo.class.getDeclaredConstructor();
		c.setAccessible(true);
		c.newInstance();
	}
 悲剧就这么发生了。我相信肯定有人会说怎么可能有人做这样的傻事。别人都私有化构造器了,你还绕个圈子去访问。假设,单例类被封装在jar包里,而我们亲爱的用户对这个包并不熟悉。更悲剧的是,在预编译和编译阶段,使用反射的用户根本不知道接下来会发生什么。很多程序员还习惯图方便,在反射时强制开启访问权限。于是,一个不太容易定位且不太容易重现的Bug就诞生了。

 

解决方法也很简单,当构造器被调用去创建第二个实例的时候,从构造器内部抛个异常出来就行了:
public class Foo {
	
	private static final Foo INSTANCE = new Foo();
	
	private Foo() {
		if (INSTANCE != null) {
			throw new RuntimeException("哥们,犯2了吧...");
		}
                INSTANCE = this;
	}
	
	public static Foo getInstance() {
		return INSTANCE;
	}

}
  
 

 第三坑 序列化之坑

好吧,我承认之前两个坑是深了点,但中奖的几率也着实低了点。但是我保证...接下来这个坑,还是时常有人踩的...我们获取Foo的实例后,把该对象序列化后,再读入:

import java.io.Serializable;

public class Foo implements Serializable {
	
	private static final long serialVersionUID = -3100270281707074474L;
	
	private static final Foo INSTANCE = new Foo();
	
	private Foo() {
		if (INSTANCE != null) {
			throw new RuntimeException("哥们,犯2了吧...");
		}
	}
	
	public static Foo getInstance() {
		return INSTANCE;
	}

}

 

	public static void main(String[] args) throws Exception {
		Foo foo = Foo.getInstance();
		System.out.println(foo);
		
		ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("1.data"));
		os.writeObject(foo);
		os.flush();
		os.close();
		ObjectInputStream is = new ObjectInputStream(new FileInputStream("1.data"));
		Foo foo1 = (Foo) is.readObject();
		
		System.out.println(foo == foo1);
		System.out.println(foo1);
		
		is.close();
	}

 

 OMG,结果显示两个Foo的声明竟然引用了两个不同的Foo实例...如果这逻辑嵌在复杂的应用逻辑中,估计有人立马就凌乱了...

 

解决方法同样非常简洁:

public class Foo implements Serializable {
	
	private static final long serialVersionUID = -3100270281707074474L;
	
	private static transient final Foo INSTANCE = new Foo();
	
	private Foo() {
		if (INSTANCE != null) {
			throw new RuntimeException("哥们,犯2了吧...");
		}
	}
	
	public static Foo getInstance() {
		return INSTANCE;
	}
	
	private Object readResolve() {
		return INSTANCE;
	}

}

 

众所周知,readObject方法会自动创建一个新的实例。而增加readResolve()方法后,反序列化完成之后,新对象上的readResolve()会被调用,该方法返回的对象引用会取代之前新建的对象。所以,我们可以更进一步,既然序列化后的对象会在反序列化后被取代,那么该被序列化的对象其实不必带上任何数据。带了也没意义。所以我们可以把该类的所有域都设上transient。

 

逃生绳

坑掉了一次又一次,代码改了一遍又一遍。一个字烦。如果有大而全的解决方案的话,会省力很多。我们亮出反坑利器——逃生绳:单元素枚举。当然JDK 5或者以后版本才能使用哦~

public enum Foo1 {
	
    INSTANCE;
	
    public static Foo1 getInstance(){  
        return INSTANCE;  
    }  

}

 

这是目前最佳的单例实现了。三防,防反射,防序列化,防并发而且实现简洁。

 

 

反模式

1. 使用抽象类实现单例
由于抽象类不能被实例化,很多人喜欢使用抽象类来实现单例。但是,抽象类是可以被继承的,而它的非抽象子类又可以被实例化。修饰符abstract本身也有很强的迷惑性,它会误导用户以为该类是专为继承而设计的,所以这种使用方式并不优雅。而且,从代码的简练程度来说,枚举也不输抽象类。所以非常不推荐使用抽象类来实现单例。当然情况也不能一概而论,比如org.springframework.core.Assert也是抽象类的实现方式,不过该类是静态方法的集合,本身并没有状态,所以这样的实现也勉强合格。