单例模式的优与劣
一、前言
首先来明确一个问题,那就是在某些情况下,有些对象,我们只需要一个就可以了,比如,一台计算机上可以连好几个打印机,但是这个计算机上的打印程序只能有一个,这里就可以通过单例模式来避免两个打印作业同时输出到打印机中,即在整个的打印过程中我只有一个打印程序的实例。
简单说来,单例模式(也叫单件模式)的作用就是保证在整个应用程序的生命周期中,任何一个时刻,单例类的实例都只存在一个(当然也可以不存在)。
下图是单例模式的结构图。
下面就来看一种情况(这里先假设我的应用程序是多线程应用程序),示例代码如下:
public static Singleton GetInstance()
{
if (singleton == null)
{
singleton = new Singleton();
}
return singleton;
}
如果在一开始调用 GetInstance()时,是由两个线程同时调用的(这种情况是很常见的),注意是同时,(或者是一个线程进入 if 判断语句后但还没有实例化 Singleton 时,第二个线程到达,此时 singleton 还是为 null)这样的话,两个线程均会进入 GetInstance(),而后由于是第一次调用 GetInstance(),所以存储在 Singleton 中的静态变量 singleton 为 null ,这样的话,就会让两个线程均通过 if 语句的条件判断,然后调用 new Singleton()了,这样的话,问题就出来了,因为有两个线程,所以会创建两个实例,很显然,这便违法了单例模式的初衷了,那么如何解决上面出现的这个问题(即多线程下使用单例模式时有可能会创建多个实例这一现象)呢?
其实,这个是很好解决的,可以这样思考这个问题:由于上面出现的问题中涉及到多个线程同时访问这个 GetInstance(),那么可以先将一个线程锁定,然后等这个线程完成以后,再让其他的线程访问 GetInstance()中的 if 段语句。示例代码如下:
public static Singleton GetInstance()
{
lock(syncRoot){
if (singleton == null)
{
singleton = new Singleton();
}
}
return singleton;
}
但是如果这样的话,每次调用GetInstance方法时都需要lock操作,影响性能。
下面就来重新改进前面 Demo 中的 Singleton 类,使其在多线程的环境下也可以实现单例模式的功能。
namespace Singleton
{
public class Singleton
{
//定义一个私有的静态全局变量来保存该类的唯一实例
private static Singleton singleton;
//定义一个只读静态对象,且这个对象是在程序运行时创建的。
private static readonly object syncObject = new object();
//构造函数必须是私有的,这样在外部便无法使用 new 来创建该类的实例
private Singleton(){}
//定义一个全局访问点,设置为静态方法,则在类的外部便无需实例化就可以调用该方法
public static Singleton GetInstance()
{
//这里可以保证只实例化一次,即在第一次调用时实例化,以后调用便不会再实例化
//第一重 singleton == null
if (singleton == null)
{
lock (syncObject)
{
//第二重 singleton == null
if (singleton == null)
{
singleton = new Singleton();
}
}
}
return singleton;
}
}
}
上面的就是改进后的代码,可以看到在类中有定义了一个静态的只读对象syncObject,这里需要说明的是,为何还要创建一个 syncObject 静态只读对象呢?
由于提供给 lock 关键字的参数必须为基于引用类型的对象,该对象用来定义锁的范围,所以这个引用类型的对象总不能为 null 吧,而一开始的时候,singleton 为 null ,所以是无法实现加锁的,所以必须要再创建一个对象即 syncObject 来定义加锁的范围。
还有要解释一下的就是在 GetInstance()中,我为什么要在 if 语句中使用两次判断 singleton == null ,这里涉及到一个名词 Double-Check Locking ,也就是双重检查锁定,为何要使用双重检查锁定呢?
考虑这样一种情况,就是有两个线程同时到达,即同时调用 GetInstance(),此时由于 singleton == null ,所以很明显,两个线程都可以通过第一重的 singleton == null ,进入第一重 if 语句后,由于存在锁机制,所以会有一个线程进入 lock 语句并进入第二重 singleton == null ,而另外的一个线程则会在 lock 语句的外面等待。
而当第一个线程执行完 new Singleton()语句后,便会退出锁定区域,此时,第二个线程便可以进入 lock 语句块,此时,如果没有第二重 singleton == null 的话,那么第二个线程还是可以调用 new Singleton()语句,这样第二个线程也会创建一个 Singleton 实例,这样也还是违背了单例模式的初衷的,所以这里必须要使用双重检查锁定。
细心的朋友一定会发现,如果我去掉第一重 singleton == null ,程序还是可以在多线程下完好的运行的,考虑在没有第一重 singleton == null 的情况下,当有两个线程同时到达,此时,由于 lock 机制的存在,第一个线程会进入 lock 语句块,并且可以顺利执行 new Singleton(),当第一个线程退出 lock 语句块时, singleton 这个静态变量已不为 null 了,所以当第二个线程进入 lock 时,还是会被第二重 singleton == null 挡在外面,而无法执行 new Singleton(),所以在没有第一重 singleton == null 的情况下,也是可以实现单例模式的?那么为什么需要第一重 singleton == null 呢?
这里就涉及一个性能问题了,因为对于单例模式的话,new Singleton()只需要执行一次就 OK 了,而如果没有第一重 singleton == null 的话,每一次有线程进入 GetInstance()时,均会执行锁定操作来实现线程同步,这是非常耗费性能的,而如果我加上第一重 singleton == null 的话,那么就只有在第一次,也就是 singleton ==null 成立时的情况下执行一次锁定以实现线程同步,而以后的话,便只要直接返回 Singleton 实例就 OK 了而根本无需再进入 lock 语句块了,这样就可以解决由线程同步带来的性能问题了。
好,关于多线程下单例模式的实现的介绍就到这里了,但是,关于单例模式的介绍还没完。
二、单例的三种实现方式
下面将要介绍的是懒汉式单例和饿汉式单例
2.1 懒汉式单例
何为懒汉式单例呢,可以这样理解,单例模式呢,其在整个应用程序的生命周期中只存在一个实例,懒汉式呢,就是这个单例类的这个唯一实例是在第一次使用 GetInstance()时实例化的,如果不调用 GetInstance()的话,这个实例是不会存在的,即为 null。
形象点说呢,就是你不去动它的话,它自己是不会实例化的,所以可以称之为懒汉。
其实呢,我前面在介绍单例模式的这几个 Demo 中都是使用的懒汉式单例,看下面的 GetInstance()方法就明白了:
// 延迟初始化保证线程安全(禁止重排序)
private volatile static Singleton singleton = null;
private Singleton(){}
public static Singleton GetInstance()
{
if (singleton == null)
{
lock (syncObject)
// synchronized (Singleton.class)
{
if (singleton == null)
{
singleton = new Singleton();
}
}
}
return singleton;
}
从上面的这个 GetInstance()中可以看出这个单例类的唯一实例是在第一次调用 GetInstance()时实例化的,所以此为懒汉式单例。
另外,可以看到里面加了volatile关键字来声明单例对象,既然synchronized已经起到了多线程下原子性、有序性、可见性的作用,为什么还要加volatile呢?见参考文献。
双重检测锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型。内存模型允许所谓的“无序写入”,这也是失败的一个主要原因。因此,为了杜绝“无序写入”的出现,使用voaltile关键字。
2.2 饿汉式单例
上面介绍了懒汉式单例,到这里来理解饿汉式单例的话,就容易多了。懒汉式单例是不会主动实例化单例类的唯一实例的,而饿汉式的话,则刚好相反,他会以静态初始化的方式在自己被加载时就将自己实例化。
下面就来看一看饿汉式单例类
//饿汉式单例类.在类初始化时,已经自行实例化
public class Singleton1 {
//私有的默认构造器
private Singleton1() {}
//已经自行实例化
private static final Singleton single = new Singleton();
//静态工厂方法
public static Singleton getInstance() {
return single;
}
}
上面的饿汉式单例类中可以看到,当整个类被加载的时候,就会自行初始化 singleton 这个静态只读变量。而非在第一次调用 GetInstance()时再来实例化单例类的唯一实例,所以这就是一种饿汉式的单例类。
2.3 登记式单例类(可忽略)
import java.util.HashMap;
import java.util.Map;
//登记式单例类.
//类似Spring里面的方法,将类名注册,下次从里面直接获取。
public class Singleton3 {
private static Map<String,Singleton3> map = new HashMap<String,Singleton3>();
static{
Singleton3 single = new Singleton3();
map.put(single.getClass().getName(), single);
}
//保护的默认构造器
protected Singleton3(){}
//静态工厂方法,返还此类惟一的实例
public static Singleton3 getInstance(String name) {
if(name == null) {
name = Singleton3.class.getName();
System.out.println("name == null"+"--->name="+name);
}
if(map.get(name) == null) {
try {
map.put(name, (Singleton3) Class.forName(name).newInstance());
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
return map.get(name);
}
//一个示意性的商业方法
public String about() {
return "Hello, I am RegSingleton.";
}
public static void main(String[] args) {
Singleton3 single3 = Singleton3.getInstance(null);
System.out.println(single3.about());
}
}
登记式单例实际上维护了一组单例类的实例,将这些实例存放在一个Map(登记薄)中,对于已经登记过的实例,则从Map直接返回,对于没有登记的,则先登记,然后返回。
这里我对登记式单例标记了可忽略,我的理解来说,首先它用的比较少,另外其实内部实现还是用的饿汉式单例,因为其中的static方法块,它的单例在类被装载的时候就被实例化了。
好,到这里,就真正的把单例模式介绍完了,在此呢再总结一下单例类需要注意的几点:
- 一、单例模式是用来实现在整个程序中只有一个实例的。
- 二、单例类的构造函数必须为私有,同时单例类必须提供一个全局访问点。
- 三、单例模式在多线程下的同步问题和性能问题的解决。
- 四、懒汉式和饿汉式单例类。
三、饿汉式与懒汉式的区别
- 从速度和反应时间角度来讲,非延迟加载(又称饿汉式)好;从资源利用效率上说,延迟加载(又称懒汉式)好。
- 饿汉式天生就是线程安全的,可以直接用于多线程而不会出现问题;懒汉式本身是非线程安全的,为了实现线程安全需附加语句。
- 饿汉式在类创建的同时就实例化一个静态对象出来,不管之后会不会使用这个单例,都会占据一定的内存,但是相应的,在第一次调用时速度也会更快,因为其资源已经初始化完成。而懒汉式顾名思义,会延迟加载,在第一次使用该单例的时候才会实例化对象出来,如果要做的工作比较多,性能上会有些延迟,之后就和饿汉式一样了。
四、单例对象作配置信息管理时可能会带来的几个同步问题
1.在多线程环境下,单例对象的同步问题主要体现在两个方面,单例对象的初始化和单例对象的属性更新。
本文描述的方法有如下假设:
- 单例对象的属性(或成员变量)的获取是通过单例对象的初始化实现的。也就是说,在单例对象初始化时,会从文件或数据库中读取最新的配置信息。
- 其他对象不能直接改变单例对象的属性,单例对象属性的变化来源于配置文件或配置数据库数据的变化。
4.1单例对象的初始化
首先,讨论一下单例对象的初始化同步。单例模式的通常处理方式是,在对象中有一个静态成员变量,其类型就是单例类型本身;如果该变量为null,则创建该单例类型的对象,并将该变量指向这个对象;如果该变量不为null,则直接使用该变量。
这种处理方式在单线程的模式下可以很好的运行;但是在多线程模式下,可能产生问题。如果第一个线程发现成员变量为null,准备创建对象;这是第二个线程同时也发现成员变量为null,也会创建新对象。这就会造成在一个JVM中有多个单例类型的实例。如果这个单例类型的成员变量在运行过程中变化,会造成多个单例类型实例的不一致,产生一些很奇怪的现象。例如,某服务进程通过检查单例对象的某个属性来停止多个线程服务,如果存在多个单例对象的实例,就会造成部分线程服务停止,部分线程服务不能停止的情况(此时可考虑使用双重锁安全机制)。
4.2 单例对象的属性更新
通常,为了实现配置信息的实时更新,会有一个线程不停检测配置文件或配置数据库的内容,一旦发现变化,就更新到单例对象的属性中。在更新这些信息的时候,很可能还会有其他线程正在读取这些信息,造成意想不到的后果。还是以通过单例对象属性停止线程服务为例,如果更新属性时读写不同步,可能访问该属性时这个属性正好为空(null),程序就会抛出异常。
下面是解决方法
//单例对象的初始化同步
public class GlobalConfig {
private static GlobalConfig instance = null;
private Vector properties = null;
private GlobalConfig() {
//Load configuration information from DB or file
//Set values for properties
}
private static synchronized void syncInit() {
if (instance == null) {
instance = new GlobalConfig();
}
}
public static GlobalConfig getInstance() {
if (instance == null) {
syncInit();
}
return instance;
}
public Vector getProperties() {
return properties;
}
}
这种处理方式虽然引入了同步代码,但是因为这段同步代码只会在最开始的时候执行一次或多次,所以对整个系统的性能不会有影响。
参照读者/写者的处理方式,设置一个读计数器,每次读取配置信息前,将计数器加1,读完后将计数器减1.只有在读计数器为0时,才能更新数据,同时要阻塞所有读属性的调用。
代码如下:
public class GlobalConfig {
private static GlobalConfig instance;
private Vector properties = null;
private boolean isUpdating = false;
private int readCount = 0;
private GlobalConfig() {
//Load configuration information from DB or file
//Set values for properties
}
private static synchronized void syncInit() {
if (instance == null) {
instance = new GlobalConfig();
}
}
public static GlobalConfig getInstance() {
if (instance==null) {
syncInit();
}
return instance;
}
public synchronized void update(String p_data) {
syncUpdateIn();
//Update properties
}
private synchronized void syncUpdateIn() {
while (readCount > 0) {
try {
wait();
} catch (Exception e) {
}
}
}
private synchronized void syncReadIn() {
readCount++;
}
private synchronized void syncReadOut() {
readCount--;
notifyAll();
}
public Vector getProperties() {
syncReadIn();
//Process data
syncReadOut();
return properties;
}
}
采用"影子实例"的办法。具体说,就是在更新属性时,直接生成另一个单例对象实例,这个新生成的单例对象实例将从数据库或文件中读取最新的配置信息;然后将这些配置信息直接赋值给旧单例对象的属性。
public class GlobalConfig {
private static GlobalConfig instance = null;
private Vector properties = null;
private GlobalConfig() {
//Load configuration information from DB or file
//Set values for properties
}
private static synchronized void syncInit() {
if (instance = null) {
instance = new GlobalConfig();
}
}
public static GlobalConfig getInstance() {
if (instance = null) {
syncInit();
}
return instance;
}
public Vector getProperties() {
return properties;
}
public void updateProperties() {
//Load updated configuration information by new a GlobalConfig object
GlobalConfig shadow = new GlobalConfig();
properties = shadow.getProperties();
}
}
注意:在更新方法中,通过生成新的GlobalConfig的实例,从文件或数据库中得到最新配置信息,并存放到properties属性中。上面两个方法比较起来,第二个方法更好,首先,编程更简单;其次,没有那么多的同步操作,对性能的影响也不大。
五、全局变量和单例模式的区别
首先,全局变量就是对一个对象的静态引用,全局变量确实可以提供单例模式实现的全局访问这个功能。但是,它并不能保证应用程序中只有一个实例。
同时,在编码规范中,也明确指出,应该要少用全局变量,因为过多的使用全局变量,会造成代码难读。
还有就是全局变量并不能实现继承(虽然单例模式在继承上也不能很好的处理,但是还是可以实现继承的)而单例模式的话,其在类中保存了它的唯一实例,这个类,它可以保证只能创建一个实例,同时,它还提供了一个访问该唯一实例的全局访问点。
六、单例模式的优与劣
言归正传,回到“单例模式的利与弊”问题上来。总结如下:
6.1 主要优点
- 提供了对唯一实例的受控访问。
- 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统的性能。
- 允许可变数目的实例。
6.2 主要缺点
- 由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。
- 单例类的职责过重,在一定程度上违背了“单一职责原则”。
- 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。
公司面试中,“观察者模式”(发布者-订阅者模式)也会被经常问到及写出代码,详见博文《大话设计模式(五)观察者模式》。
- 点赞
- 收藏
- 关注作者
评论(0)