java 初探单例模式

讨论一下单例模式的实现。

参考:http://blog.csdn.net/u013256816/article/details/50966882

 

一、是什么

单例模式是Java中最简单的设计模式之一。单例模式属于创建型模式,它提供了一种创建对象的方式(即单例模式用于创建对象)。

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

(1)单例模式的特点

换句话说,就是单例模式必须遵守的法则(违反任何一条都不能算是单例模式):

1、单例类只能有一个实例(单例类只允许有一个对象)。

2、单例类必须自己创建自己的唯一实例(单例类必须自己创建自己的对象,不能给别的类实例化)。

3、单例类必须给所有其他对象提供这一实例(其他类不能实例化单例类。如果要使用单例类,就需要调用单例类中提供的方法获取单例类的唯一对象)。

在下面提到的各种实现方法必须贯彻以上三点,所以就会遇到各种各样的问题。举个例子:如何防止反射/序列化等方式破坏(没有做相关处理的)单例模式。如果单例模式中不做同步处理,在并发条件下可能创建多个实例。

关于如何防止单例模式被攻击,可以参考:

java 如何防止单例模式被攻击?

这些问题我都会尝试讨论。如果篇幅太大,只能另起文章来进行研究。

(2)单例模式的一些概念介绍

1.饿汉与懒汉

1、概念

“饿汉”和“懒汉”是实现单例模式的两种方式(风格?)。如果你刚接触这两个名词,肯定很难理解这两个“汉”是什么意思,有些什么区别。这时候就要发挥想象力。

“饿汉”是一个很容易饥饿的家伙,每时每刻都急着吃饭,一定要提前把饭准备好。

不管现在饿不饿(就算现在不需要使用单例类的对象),“饿汉”都要提前准备好食物(提前创建好单例类的对象(在类加载阶段就进行实例化)),饿的时候直接开吃(因为对象已经创建好了,所以获取对象的速度很快)。

“懒汉”是一个很懒的家伙,从来不提前做准备。

当你需要他的时候(需要使用单例类的对象),“懒汉”才会开始做准备(调用获取对象的方法时才开始创建对象,然后return)。因为调用时才创建对象,“懒汉”方式也被成为“延迟初始化”(虽然听上去很高端,但是“延迟初始化”就是“懒汉”)。

2、区别

现在我们已经知道“饿汉”和“懒汉”的大致实现思路,那么可以预估一下这两种方式有什么区别:

“饿汉”在类加载阶段实例化对象(static对象在类加载阶段就需要实例化),所以类加载的时间比较长。因为早已创建对象,所以获取对象的速度比较快。

如果一个单例类被使用的概率很高(或者频率很高),而实例化这个单例类的开销很大,使用“懒汉”方式(在实际使用前才进行实例化)效率就会很差。在这种情况下我们宁愿在类加载阶段多花时间和内存先把这个单例类创建好,加快获取对象的速度。

有这样一种说法:如果实例化的开销比较大,希望用到时才创建,或者初始化时需要借助某些外部资源(比如网络或存储设备),就要考虑延迟实例化。我个人认为要根据实际情况进行取舍。

因为“饿汉”在类加载阶段就实例化了对象,所以不需要关注线程安全问题(不用担心在多线程环境下实例化多个单例类,导致不再“单例”)。

“懒汉”在获取单例类对象时才实例化对象,所以类加载速度比较快,但是获取对象的速度就会比较慢。

如果一个单例类被使用的概率较低/这个单例类实例化的开销不大,那么就没有必要太快实例化这个单例类,不需要使用”饿汉“方式。反正效率不会低到难以接受,干脆偷偷懒,直到获取对象时才开始实例化。

但是,“懒汉”在多线程条件下要注意同步问题(如果多条线程同时调用了获取单例类对象的方法,就有可能同时实例化多个单例类,导致不再“单例”)。

2.双检锁(DCL)

双检锁是“懒汉的具体实现方式”,也被称为“双重检查锁”。我们刚才提到“懒汉”在多线程条件下要注意同步问题,双检锁就是一种解决方案。

从字面上看,可能误解为“使用了两个检查锁”,实际上应该理解为“检查了两次,使用了一次锁实现同步”。

大概是这样实现:

双检锁在早期java版本中可能存在重排序的问题(在java 5之后的版本可以使用volitale关键字解决这个问题),这个问题在下面继续讨论。

3.内部类式

同为“懒汉的具体实现方式”,也被称为“占位符式”。使用内部类的方式获取单例类的对象,也可以解决多线程条件下的同步问题。

具体实现方法在下面继续讨论。

4.枚举类式

同为“懒汉的具体实现方式”,使用枚举类的方式获取单例类的对象。这种方式不仅可以解决多线程条件下的同步问题,而且可以防止被反射/序列化破坏。

具体实现方法在下面继续讨论。

(3)单例模式的应用

单例设计模式我们已经接触得太多了,已经成为了顺理成章的事情(但是我们未必留意过)。一些经典的应用如下:

1.Windows的任务管理器。监控性能也是要耗费资源的,不需要同时存在两个任务管理器实例(网上有评论认为:一些早期的Windows版本可以启动多任务管理器。后来的版本对此进行了优化)

2.操作系统的文件系统和回收站。都是不需要多个实例的典范。

3.网站的计数器。因为要实现同步,所以一般采用单例模式实现。

4.数据库链接池。数据库链接池是“数据库链接的管理系统”,不需要多个实例。

二、单例模式的实现

(1)饿汉式

EagerSingleton.java

之所以说“饿汉”不需要考虑线程安全问题,是因为jvm的类加载机制保证了单例。具体原因可以参考:http://blog.csdn.net/gavin_dyson/article/details/69668946

写个例子来求证:

Test.java

可以发现每条线程输出的单例对象的hash值都是一致的。

(2)线程不安全的懒汉式(错误用法)

UnsafeLazySingleton.java

在getInstance方法中没有加锁,在多线程环境下,可能同时有多条线程调用这个方法,从而生成多个对象,违反了“只能有一个对象”的原则。

有人可能认为:“如果我不使用多线程,这种写法不就是安全的吗”?确实是这样没错。但是,既然不使用多线程,那么系统应该不会太复杂,也许new一个对象使用就已经足够了,没必要特意使用单例模式(应反思是否有过度设计的嫌疑)。

(3)线程安全的懒汉式(解决上面的问题)

SafeLazySingleton.java

在SafeLazySingleton方法处加上锁就可以了。

我们加锁的目的,是为了防止同时有多条线程执行到new SafeLazySingleton()处创建多个对象。问题是,synchronized修饰了整个getInstance方法,当一条线程调用getInstance方法时,它就锁住这个方法不让别的线程调用,导致同一时间内只能有一条线程获取单例对象(单例类对象已经创建好了,理应允许所有线程使用),效率很低。

如果有多条线程频繁调用getInstance方法(频繁获取单例类对象),就需要对这种方式进行优化。

(4)双检锁式(解决上面的效率问题)

双检锁通过部分加锁的形式提高了getInstance方法的效率,我个人认为有种“读写锁”的思想。

DoubleCheckedLockingSingleton.java

这里采用了一个很聪明的设计,先检查单例类对象是否已经被实例化(检查步骤不需要加锁),如果没有被实例化,那么使用synchronized锁住这个单例类,保证实例化过程是线程安全的,最后再返回单例类对象。如果单例类对象已经被实例化了,那么直接返回对象(返回对象不需要加锁)。

在java的早期版本,双检锁会引起重排序的问题,导致未初始化完全的对象发布。

具体问题可以参考:http://www.infoq.com/cn/articles/double-checked-locking-with-delay-initialization

在jdk 1.5之后,可以通过volatile(避免编译器将变量缓存在寄存器里 ,避免编译器调整代码执行的顺序(引起重排序问题的主要原因))解决这个问题。

参考:https://www.zhihu.com/question/56606703/answer/149894860

volatile是禁止重排序,初始化一个实例(SomeType st = new SomeType())在java字节码中会有4个步骤:

1.申请内存空间。

2.初始化默认值(区别于构造器方法的初始化)。

3.执行构造器方法。

4.连接引用和实例。

这4个步骤后两个有可能会重排序,1234 1243都有可能,造成未初始化完全的对象发布。volatile可以禁止指令重排序,从而避免这个问题。

(5)内部类式

LazyInitHolderSingleton.java

根据jvm规范,当某对象第一次调用LazyInitHolderSingleton.getInstance()时,LazyInitHolderSingleton类被首次主动使用,jvm对其进行初始化(此时并不会调用LazyInitHolderSingleton()构造方法),然后LazyInitHolderSingleton调用getInstance()方法。

该方法中,又首次主动使用了SingletonHolder类,所以要对SingletonHolder类进行初始化,初始化中,INSTANCE常量被赋值时才调用了LazyInitHolderSingleton的构造方法LazyInitHolderSingleton(),完成了实例化并返回该实例。

当再有对象(也许是在别的线程中)再次调用LazyInitHolderSingleton.getInstance()时,因为已经初始化过了,不会再进行初始化步骤,所以直接返回INSTANCE常量即同一个LazyInitHolderSingleton实例。

(6)枚举类式

SingletonClass.java

可以这样获取单例对象:

TestEnum.java

使用枚举类实现单例可以有效防止反射/序列化攻击。

三、总结

通过这次总结,对单例模式有了更深的理解。应继续研究细节问题。

发表评论

电子邮件地址不会被公开。 必填项已用*标注