单例设计模式

单例设计模式

  • 核心概念
  • 常见的应用场景
  • 单例设计模式的特点
  • 单例设计模式实现
    • 饿汉式
    • 懒汉式
    • 双重检测锁式
    • 静态内部类式
    • 枚举式
  • 单例模式优化
    • 反射突破单例限制
    • 序列化反序列化突破单例限制
  • 结束语

核心概念

上一个篇幅中引入了GoF23种设计模式的概念,本篇开始将详细的针对每一个设计模式,进行初步的了解。
本篇的主题是单例设计模式,单例设计模式的核心功能就是保证一个类只有一个实例对象,并且对外提供一个可以访问该实例的全局访问点。通常我们实现单例设计模式需要经过以下几个步骤:

  1. 将构造方法进行私有化,使其它类无法通过new关键字实例化该类的对象。
  2. 在本类中产生一个唯一的实例化对象。
  3. 对外暴露一个静态方法来获取唯一的实例。

常见的应用场景

在我们的日常应用中,单例设计模式可以说是经常见到,下面将罗列出一些日常应用中的单例设计模式应用场景。

  1. Windows系统中的Task Manager(任务管理器)。
  2. Windows系统中的Recycle Bin(回收站)。
  3. 一般应用程序中,用于读取配置文件的类,一般都采用单例设计模式。因为没必要每次读取配置文件都new出一个新的对象。
  4. 网站的计数器一般也是采用单例设计模式,否则容易出现难以同步的情况。
  5. 一般应用程序中的日志应用也会采取单例设计模式,因为日志应用通常情况是随着应用的启动一直运行着,如果存在多个实例,在写入日志的时候,会出现异常。
  6. 操作系统中的文件系统也是一个单例设计模式的经典应用,一个操作系统中有且只有一个文件系统。
  7. Application也是单例模式的典型应用(Servlet编程中会涉及到)。
  8. 在Spring中,每个Bean默认都是单例的,使得更容易进行统一管理。
  9. Servlet编程中,每个Servlet也是遵循单例设计模式的。
  10. 在 Spring MVC/Struts1中,控制器对象也是遵循单例设计模式的。

单例设计模式的特点

上面讲了那么多,那么单例设计模式具体有哪些优点呢?

  1. 因为单例设计模式只生成一个实例,所以减少了系统的性能开销,当一个对象的产生需要消耗比较多的资源时,如读取配置,产生其它依赖对象时,则可以通过在应用启动时,直接产生一个单例对象,然后永久驻留在内存中以供使用。
  2. 单例模式可以在系统中设置全局的访问点,优化了共享资源的访问,如可以用一个单例类来负责所有数据表的映射处理。

单例设计模式实现

描述完单例设计模式的特点后,现在便开始讲讲如何通过代码来实现单例模式,下面的篇幅将逐一描述常见的五种单例设计模式的实现方式。

  1. 饿汉式(线程安全,调用效率高,不能延时加载)
  2. 懒汉式(线程安全,调用效率不高,可以延时加载)
  3. 双重检测锁式(由于JVM底层内部模型原因,偶尔会出现问题,不建议使用)
  4. 静态内部类式(线程安全,调用效率高,可以延时加载)
  5. 枚举式单例(线程安全,调用效率高,不能延时加载)

饿汉式

饿汉式实现,见名思其义,因为是饿汉,所以会第一时间将对象创建出来,所以也就没有了延时加载的功能,所以它的调用效率将会比较高。下面将使用代码来示例饿汉式单例的实现。

饿汉式实现类

public class Singleton01 {
	
	//创建唯一实例对象
	private static final Singleton01 instance = new Singleton01();
	
	//私有化构造函数
	private Singleton01(){}
	
	//对外提供静态方法获取唯一实例对象
	public static /*synchronized*/ Singleton01 getInstance(){
		return instance;
	}
	
}

测试类

public class Client01 {
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					Singleton01 s = Singleton01.getInstance();
					System.out.println(s);
				}
			}).start();
		}
	}
}

运行上述代码后,可以在控制台看到如下打印
Singleton01测试结果
从控制台打印中可以看出,尽管测试代码中通过循环的方式在多线程的环境下获取了10个测试类对象,但本质上都是指向了同一个对象实例,符合单例模式的定义。
上述实现类代码首先利用了static关键字的特性,在类被加载的时候,顺势创建出了实现类的实例对象。因为此时程序还未正式运行业务代码,因此无需考虑并发访问的问题,此时创建出来的实例对象是唯一并且线程安全的。因此在获取实例的getInstance()方法中可以省略synchronized关键字。
但也正是因为利用了该特性,导致了程序运行,一旦类被加载,那么便会创建出该类的实例对象。假使这个类并不会被经常使用,甚至不被使用,那么利用该种方法来实现单例模式将会导致系统资源的浪费。

懒汉式

懒汉式实现,与饿汉式实现相对应,懒汉式实现只有在真实需要使用实例对象的时候才会将实例对象创建出来,而不是随着类的加载便将实例对象创建好,有着延时加载的特点。下面将通过代码来示例懒汉式单例模式的实现。

懒汉式实现类

public class Singleton02 {
	//创建实例对象的引用
	private static Singleton02 instance;
	
	//私有化构造函数
	private Singleton02(){}

	//对外暴露获取唯一实例的公共方法
	public static synchronized Singleton02 getInstance(){
		if(instance == null)
			instance = new Singleton02();
		return instance;
	}
}

测试类

public class Client02 {
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					Singleton02 s = Singleton02.getInstance();
					System.out.println(s);
				}
			}).start();
		}
	}
}

运行上述代码后,可以在控制台看到如下打印
懒汉式单例测试打印
从控制台打印中可以看出,尽管测试代码中通过循环的方式在多线程的环境下获取了10个测试类对象,但本质上都是指向了同一个对象实例,符合单例模式的定义。
从示例代码中可以看出,相比饿汉式实现,懒汉式实现是在调用getInstance()方法之后,才真正创建当前对象的实例。并判断当前是否已经存在对象实例,如果存在就直接返回,否则创建一个新的实例对象并返回。在getInstance()方法上使用了synchronized关键字来确保线程安全,因为调用**getInstance()**方法时需要考虑到并发访问的场景。
相比饿汉式,懒汉式实现带有了延迟加载的特性,但相对的,因为需要考虑线程安全的问题,每次都要进行上锁,释放锁的动作,并且要检查当前对象是否已经存在,所以相对运行效率上要弱于饿汉式实现,不得不感慨万物皆要遵循平衡一道。时间和空间的平衡点便需要根据具体的应用场景来自行判断选择了。

双重检测锁式

双重检测锁(Double Checked Locking,简称DCL)方式本身可以看做是对懒汉式的一种优化。早期java开发中,使用synchronized关键字来保证线程同步是一件非常耗时的操作,因此开发者们就尝试着使用一些技巧,尽可能地去优化这个过程。下面将通过代码来示例双重检测锁式单例模式的实现。

双重检测锁式实现类

public class Singleton03 {
	
	//创建实例对象的引用
	private static /*volatile*/ Singleton03 instance;
	
	//私有化构造函数
	private Singleton03(){}
	
	//对外暴露获取唯一实例的公共方法
	public static Singleton03 getInstance(){
		if(instance == null){
			synchronized(Singleton03.class){
				if(instance == null){
					instance = new Singleton03();
				}
			}
		}
		return instance;
	}
}

测试类

public class Client03 {
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					Singleton03 s = Singleton03.getInstance();
					System.out.println(s);
				}
			}).start();
		}
	}
}

运行上述代码后,可以在控制台看到如下打印

双重检测锁式测试打印
从控制台打印中可以看出,尽管测试代码中通过循环的方式在多线程的环境下获取了10个测试类对象,但本质上都是指向了同一个对象实例,符合单例模式的定义。
从示例代码中可以看出,双重检测锁式跟懒汉式实现的代码非常相似,唯一的区别就是在真正创建实例之前,同步块前后分别判断了两次当前类对象是否存在。第一次判断,如果已经存在本类实例对象,则跳过同步块创建代码,从而提升程序运行的整体效率。第二次判断则是为了保证多线程状态下数据的线程安全。
从逻辑上来看,这个小技巧似乎很好的优化了懒汉式实现,减少了synchronized关键字对程序运行的负担。但遗憾的是,双重检测锁早期在java里并不是一个值得提倡的实现方法。原因则是因为java早期时,编译器、虚拟机、甚至是操作系统,都有可能对你的代码进行优化,尽管这些改变遵循着一些规则,可以尽可能的保证代码的执行结果不变,但指令的重排便会导致程序的执行跟设计者的预计不符,从而得到我们预想之外的结果,这就是所谓的HappenBefore问题了,在多线程开发中是一个需要注意的问题。
在JDK1.5之后,我们可以通过volatile关键字来解决这个问题,这也使得双重检测锁能被更广泛地使用。使用volatile修饰过后的instance对象引用,在创建新对象赋值时,将会禁止指令重排的动作。
我们在创建对象并给对象引用赋值时尽管只是使用了一行代码,但其并不是一个原子操作,可以分为以下几个过程

  1. 在堆中开辟对象所需的空间,并分配内存地址。
  2. 根据类加载的初始化顺序进行类的初始化。
  3. 将内存地址返回给栈中的引用变量。
    发生指令重排的情况,如2,3步骤的执行顺序颠倒了,那么在多线程的情况下,可能出现instance对象不为null,但是本身还没有完全初始化好,当另一个线程来直接使用instance对象时便会出现异常。如果使用了volatile关键字修饰过后,便禁止了指令重排的动作,从而避免了这个问题。
    对于上面描述的具体问题,由于笔者水平有限,因此建议大家前往下面的链接去仔细品味双重检测锁的原理。
    InfoQ-双重检查锁定与延迟初始化

静态内部类式

静态内部类式的单例实现也是懒汉式单例的一种演变,其利用了静态内部类的加载特性对懒汉式单例进行了优化。下面将通过代码来示例静态内部类式单例模式的实现。

静态内部类式实现类

public class Singleton04 {

	// 私有化构造函数
	private Singleton04() {
	}

	// 对外暴露的获取唯一实例的公共方法
	public static Singleton04 getInstance() {
		return Singleton04.Singleton4InnerClass.instance;
	}

	// 静态内部类,利用其加载特性创建唯一实例
	static class Singleton4InnerClass {
		private static Singleton04 instance = new Singleton04();
	}
}

测试类

public class Client04 {
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					Singleton04 s = Singleton04.getInstance();
					System.out.println(s);
				}
			}).start();
		}
	}
}

运行上述代码后,可以在控制台看到如下打印
单例设计模式_第1张图片
从控制台打印中可以看出,尽管测试代码中通过循环的方式在多线程的环境下获取了10个测试类对象,但本质上都是指向了同一个对象实例,符合单例模式的定义。
静态内部类式单例实现巧妙地运用了静态内部类的加载特性来实现了单例模式。静态内部类本身不跟随程序加载而加载,只有真正使用时,即测试代码中调用了静态方法后,静态内部类才会进行加载,从而其中定义的静态属性也会随着加载,因为是静态属性,所以只会加载一次,从而获得唯一的实例对象,实现了延迟加载的功能。

枚举式

枚举式实现在我看来是一种另类的饿汉式实现吧。Java中的枚举本身就是一种单例模式的表现,之前讲述的单例模式的实现中,线程的安全性、是否存在漏洞突破单例限制等,都需要我们自己来通过代码维护,使用枚举式实现则可以非常轻易地避免这些问题,因为枚举类实现单例是从JVM的层面保证了其准确性、安全性。下面将通过代码来示例枚举式单例模式的实现。


***枚举式实现类***

public enum Singleton05 {

	INSTANCE;

	private Singleton05() {
	}

	public static Singleton05 getInstance() {
		return INSTANCE;
	}

	public void otherMethods() {

	}
}

***测试类***

public class Client05 {
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					Singleton05 s = Singleton05.getInstance();
					System.out.println(s.hashCode());
				}
			}).start();
		}
	}
}

运行上述代码后,可以在控制台看到如下打印
单例设计模式_第2张图片
从控制台打印中可以看出,尽管测试代码中通过循环的方式在多线程的环境下获取了10个测试类对象,但本质上都是指向了同一个对象实例,符合单例模式的定义。
枚举式单例的实现充分运用了枚举本身的特性。枚举本身就具有着自由序列化、线程安全、以及单例的特性,这些特性都非常符合单例模式的需求,因此使用枚举来实现单例便变的异常的轻松。
通过XJad反编译工具反编译上面展示的Singleton05测试类可以得到如下代码,下面将以此为参照来说说枚举为什么可以实现单例。

public final class Singleton05 extends Enum
{

	public static final Singleton05 INSTANCE;
	private static final Singleton05 ENUM$VALUES[];

	private Singleton05(String s, int i)
	{
		super(s, i);
	}

	public static Singleton05 getInstance()
	{
		return INSTANCE;
	}

	public void otherMethods()
	{
	}

	public static Singleton05[] values()
	{
		Singleton05 asingleton05[];
		int i;
		Singleton05 asingleton05_1[];
		System.arraycopy(asingleton05 = ENUM$VALUES, 0, asingleton05_1 = new Singleton05[i = asingleton05.length], 0, i);
		return asingleton05_1;
	}

	public static Singleton05 valueOf(String s)
	{
		return (Singleton05)Enum.valueOf(Singleton/Singleton05, s);
	}

	static 
	{
		INSTANCE = new Singleton05("INSTANCE", 0);
		ENUM$VALUES = (new Singleton05[] {
			INSTANCE
		});
	}
}

从上面贴出的代码可以看出,枚举在java中的本质也是通过类来实现的。首先我们可以发现枚举里面的构造函数都是私有化的(如果没有自定义构造函数,jvm会添加一个默认的私有构造函数),这符合这我们单例设计模式的要点。之后我们可以发现,具体的枚举属性初始化时,是通过静态代码块完成的,这跟之前所描述的饿汉式实现异曲同工,这样保证了它的线程安全性,再次符合了单例设计的要点。
最后还有个很重要的特点,那就是自由序列化的特性。其实在枚举类实现之前讲述的4种实现方式,都有着一定的瑕疵,所谓单例模式,最根本的因素就是要能保证创建的对象有且唯一,之前的几种方式都有可能被打破这种限制。单纯的私有化构造器可以被反射技术所突破,如果单例对象需要进行序列化反序列化操作并且没有做特殊处理,那么单例的限制也会被打破。上述的这些问题在枚举式实现中便不会发生,具体原因将在下面的篇幅中继续讲述。

单例模式优化

正如上一段的描述,普通的单例实现,有可能会存在被反射,序列化反序列化等操作突破单例限制的情况,因此还需要对其进行一些优化。

反射突破单例限制

本篇示例代码以之前饿汉式实现的代码为基础进行扩展。

饿汉式实现类

public class Singleton01 {
	
	//创建唯一实例对象
	private static final Singleton01 instance = new Singleton01();
	
	//私有化构造函数
	private Singleton01(){}
	
	//对外提供静态方法获取唯一实例对象
	public static /*synchronized*/ Singleton01 getInstance(){
		return instance;
	}
	
}

单例类并没有采用其它手段来限制反射,因此,尽管单例类中的构造函数采用了私有化,但我们仍然可以通过反射技巧来调用构造函数来创建新的对象,实现代码如下:

public class Client011 {
	public static void main(String[] args) {
		Singleton01 sUsual1 = Singleton01.getInstance();
		Singleton01 sUsual2 = Singleton01.getInstance();
		Singleton01 sReflection1 = null;
		Singleton01 sReflection2 = null;
		Constructor<Singleton01> constructor = null;
		try {
			constructor = Singleton01.class.getDeclaredConstructor();
			constructor.setAccessible(true);
			sReflection1 = constructor.newInstance();
			sReflection2 = constructor.newInstance();
		} catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException
				| IllegalArgumentException | InvocationTargetException e) {
			e.printStackTrace();
		}

		System.out.println(sUsual1);
		System.out.println(sUsual2);
		System.out.println("==========Reflection==========");
		System.out.println(sReflection1);
		System.out.println(sReflection2);
	}
}

运行上述代码后,可以在控制台看到如下代码
反射攻击单例实现
通过控制台打印可以看出,原先的饿汉式实现在未做任何处理的情况下,很轻松地被反射突破了限制。这里我们可以通过抛出异常的方式来简单地优化一下这个问题,修改代码如下

反射优化饿汉式实现类

public class Singleton01 {
	
	//创建唯一实例对象
	private static final Singleton01 instance = new Singleton01();
	
	//私有化构造函数
	private Singleton01(){
		if(null != instance)
			return new RuntimeException("遭受反射攻击");
	}
	
	//对外提供静态方法获取唯一实例对象
	public static /*synchronized*/ Singleton01 getInstance(){
		return instance;
	}
	
}

通过测试类执行修改后的代码后,可以在控制台看到如下打印
单例设计模式_第3张图片
从控制台打印可以看出,当尝试通过反射创建新的对象时,程序会抛出指定异常,避免创建出新的对象实例,打破单例的限制。

序列化反序列化突破单例限制

除了上面说的通过反射来突破单例模式的限制外,还存在单例类需实现序列化功能而导致可以通过反序列化操作来突破单例限制。
本篇示例代码以之前饿汉式实现的代码为基础进行扩展。

public class Singleton01 implements Serializable{

	// 创建唯一实例对象
	private static final Singleton01 instance = new Singleton01();

	// 私有化构造函数
	private Singleton01() {}

	// 对外提供静态方法获取唯一实例对象
	public static synchronized Singleton01 getInstance() {
		return instance;
	}

}

将之前实现的饿汉式单例类实现Serializable类后,再不做其它操作的情况下,可以通过反序列化来突破单例的限制,测试代码如下所示

public class Client012 {
	public static void main(String[] args) throws IOException, ClassNotFoundException {

		Singleton01 s = Singleton01.getInstance();
		ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("data.tmp"));
		oos.writeObject(s);
		oos.flush();
		oos.close();

		FileInputStream fis = new FileInputStream("data.tmp");
		ObjectInputStream ois = new ObjectInputStream(fis);
		Singleton01 s1 = (Singleton01) ois.readObject();
		ois.close();
		
		System.out.println(s);
		System.out.println(s1);
	}
}

通过测试类执行上述代码后,可以在控制台看到如下打印
在这里插入图片描述
通过控制台打印可以看到,通过序列化反序列化的操作,又一次地打破了单例模式的限制。出现这种情况的本质其实是因为反序列化的时候底层是运用到了反射的技巧,这里笔者就不再深入探究了,这属于反射和序列化范畴中的内容,之后有机会的话可以专门针对这两个知识点详细地说一说。
想要避免通过反序列化来突破单例实现也有比较简单的方式,只需在单例类中添加**readResolve()方法返回单一示例即可,这样反序列化时,当检测到当前类存在readResolve()**方法后,便会执行它并返回,优化代码如下

public class Singleton01 implements Serializable{

	// 创建唯一实例对象
	private static final Singleton01 instance = new Singleton01();

	// 私有化构造函数
	private Singleton01() {
//		System.out.println("==");
//		if (null != instance)
//			throw new RuntimeException("遭受反射攻击");
	}

	// 对外提供静态方法获取唯一实例对象
	public static synchronized Singleton01 getInstance() {
		return instance;
	}
	
	private Object readResolve(){
		return instance;
	}

}

通过测试类执行上述代码后,可以在控制台看到如下打印
在这里插入图片描述
通过控制台打印可以看出,在添加**readResolve()**后,反序列化得到的对象依然为我们限定好的唯一实例。
这里笔者推荐另一篇博文,该篇中详细地讲述了序列化和反序列化对单例的影响,写得非常的详细,下面附上传送门:
序列化和反序列化的对单例破坏的防止及其原理
上一章节中之所以说通过枚举来实现单例模式是最简单方便的选择,便是因为这些隐藏的问题可能会导致我们的单例设计被打破,从而与我们的预期不符,枚举本身就帮我们避免了这些问题。
关于枚举如何避免序列化反序列化的印象,建议大家看下这片文章,虽然篇幅不长,但是直击要点,下面附上传送门:
枚举实现单例
有兴趣的朋友们也可以在即看看源码,看看反编译后的class文件,相信都会有所收获。

结束语

到此基本上就是本篇的结束了,简单地讲述了一下单例模式的使用,在实际工作场景中,单例模式也是我们经常会碰到的一种设计模式,至于具体采用何种实现方式去实现,则要根据具体的情况进行分析,主要的区别就是在于是否需要进行延时下载,正常情况推荐使用枚举式实现方式,在我们开发过程中,真正要使用到延时加载的毕竟也是少数(可能是笔者水平有限,接触的东西太少了)。
以上为本篇的全部内容。

你可能感兴趣的:(Java,#,GoF设计模式)