设计模式(1)——单例模式详解
一、前言
之前过年在家,买了本《Head First设计模式》
来看,看完后一直想写设计模式的系列博客,但是一直没开始。刚好今天看到《Java并发编程的艺术》
这本书上对单例模式的两种多线程形式下的实现方式做了详细的介绍,让我对它们的实现机制有了更深入的了解,所以,借这个机会,来谈一谈单例模式。
二、正文
2.1 什么是单例模式
单例模式是设计模式中比较简单,但是却使用非常广泛的一种。单例模式的定义如下:
确保一个类只有一个对象,并且提供这个对象的全局访问接口。
这个应该很好理解,使用单例模式定义一个类,只能够通过它创建一个对象,并且提供一个接口,让全局都能获得这个对象。需要注意的是,这个类只有一个对象并不是一个约定,而是我们通过一些手段,强制让这个类只能有一个对象。而单例模式有五种经典的实现方式:
- 饿汉式
- 懒汉式
- 双重校验锁
- 静态内部类实现
- 枚举类实现
下面我就来一一介绍这五种实现方式。
2.2 饿汉式
饿汉式应该是单例模式中最简单的一种实现方式了,我们直接看代码:
public class Singleton {
// 单例对象
public static Singleton singleton = new Singleton();
// 构造器被私有
private Singleton() {
}
}
上面的单例实现应该很好理解,在类中,我们直接初始化了一个static
的对象,然后将构造器设为private
,这样就没法在类的外部创建这个类的对象了,于是这个类就有了唯一的对象singleton
。这也是饿汉式这个名字的由来——不等待,立即创建,像饿疯了一样(尬解)。而这个单例对象是public
类型的,这也就保证了它能够被全局访问。
这种实现方式非常简单,而且在多线程的环境下也是安全的,因为类的静态成员是在类加载的时候被初始化,而类加载这个过程已经由JVM
实现了线程同步,也就是说一个类不会被加载两次。但是这种实现方式也有缺陷,即单例对象是在类被加载的时候创建,假设我们需要创建的是一个非常复杂的单例对象,那这将占用大量资源,大大拖慢了类加载的效率。为了解决这个问题,于是出现了懒汉式。
2.3 懒汉式
懒汉式的实现方式是,只有当单例对象第一次被用到的时候,才进行创建。这也是懒汉式名字的由来——懒得只有催它的时候才会动(再次尬解)。下面就来看看懒汉式的代码实现。
(1)线程不安全实现
在单线程中,懒汉式的实现如下:
public class Singleton {
// 单例对象为private
private static Singleton singleton;
// 构造器被私有
private Singleton() {
}
// 获取单例对象的方法
public static Singleton getSingleton() {
// 若单例对象还没创建,则先创建它
if (null == singleton)
singleton = new Singleton();
return singleton;
}
}
上面的代码应该也很好理解,这次我们将单例对象的引用设置为私有,但是提供一个public
的get
方法来获取这个单例对象,也就是全局访问接口。在这个get
方法中,我们先判断这个对象是否被创建,若没有被创建,则先创建它,这也就意味着这个对象将在第一次get
时被创建,之后都会直接获取。同时,因为这个类的构造器是private
的,所以这个类正常情况下无法创建其他对象。
(2)线程安全实现
上面这种实现方式,很好的解决了饿汉式拖慢加载速度的问题。但是它也有缺陷,那就是无法在多线程的情况下使用。在getSingleton
方法中有三行代码,我们考虑在多线程中的情况会如何:假如有两条线程同时运行到了第一句代码,判断对象是否为空,此时对象还没有被创建,于是两条线程都会运行第二句代码,企图创建对象。第一条线程先创建了对象,赋值给了singleton
,接着第二条线程也创建了一个新的对象,再次赋值给singleton
,就将前面的引用给覆盖了。创建了多个对象,这就违反了单例模式的原则,为了解决这种情况,我们需要给这个方法进行多线程同步,也就是如下代码:
public class Singleton {
private static Singleton singleton;
private Singleton() {
}
// 使用synchronized关键字同步方法
public synchronized static Singleton getSingleton() {
if (null == singleton)
singleton = new Singleton();
return singleton;
}
}
我们使用了synchronized
关键字对方法进行了同步,这样一来,每次只会有一个线程执行方法,不会再出现上述情况了。但是,新的问题出现了,每条线程在调用getSingleton
方法前,都需要先获得锁,这将大大影响运行的效率。而且我们发现,只有第一次创建对象时是需要同步的,当对象创建完成之后,这种同步就没有必要了,也就是说,对象创建完成后,这种同步完全就是没有意义的降低效率。为了解决这个问题,就有人想出了下面这种方式。
2.4 双重校验锁
还是一样,先来看双重校验锁的代码:
public class Singleton {
// 需要加上volatile修饰
public volatile static Singleton singleton;
private Singleton() {
}
public static Singleton getSingleton() {
// 1、先判断singleton是否被创建
if (null == singleton) {
// 2、开始创建对象,但是需要先同步
synchronized (Singleton.class) {
// 3、在正式创建前,再次判断
if (null == singleton)
singleton = new Singleton(); // 4、创建对象
}
}
return singleton;
}
}
双重校验锁的的代码和懒汉式类似,只是getSingleton
的实现更加复杂,我们来看看它做了什么事情:
- 第一步和懒汉式一样,先判断单例对象是否已经被创建,若被创建,则直接返回这个对象;否则尝试创建对象;
- 创建对象的代码被
synchronized
同步块包围,目的是防止某个线程在创建对象的过程中,另一个线程也尝试创建对象,而同步对象使用的是Singleton
类的class
对象; - 在
synchronized
代码块中,我们再次判断对象是否为空,若不为空才创建,否则不创建。为什么需要再次判断呢?比如一个线程A
运行到了上方代码的2
位置,此时被CPU
中断,CPU
转而执行另一条线程B
,B
线程运行到1
后,发现对象没有创建,于是开始执行下面的代码,而且没有被CPU
暂停,于是它成功创建对象。之后CPU
重新恢复A
线程的运行,A
从中断的地方恢复,也就是代码2
处,它继续向下运行,如果没有代码3
的判断,A
线程将再次创建对象,而为了防止这种情况的发生,才需要再次判断;
使用上面的代码后,我们可以发现,只有在对象没有被创建时,才会运行synchronized
代码块,当对象成功创建后,将不会再运行到被同步的代码,这就非常巧妙地解决了懒汉式中不必要的同步问题。但是,这个代码虽然看起来解决了问题,但是它并没有我们看起来那么简单,它存在一个非常隐秘的问题。看下面这句代码:
singleton = new Singleton();
看起来这只是一句代码,但是实际上它被分为三条语句执行:
- 为对象分配内存空间;
- 初始化这个对象;
- 将对象的引用赋值给singleton;
这是这条语句的正常执行方式,但是编译器常常会在为了优化执行效率,在不改变最终结果的情况下,修改语句的执行顺序(注意:这里所说的不改变结果,指的是单线程下),而上面这条语句也是如此。编译器有时候会对上面三个步骤进行重排序,将其变为1->3->2,也就是先分配空间,然后赋值,最后初始化。这样做对效率会有一定的提升,在单线程的环境下也不会有影响。但是,在多线程环境下却发生了问题。当语句被重排序后,假设线程A
创建对象,先执行指令1
分配空间,然后由于重排序,执行指令3
对变量赋值,此时CPU
切换线程,线程B
开始执行,而A
暂时停止。B
线程尝试获取单例对象,执行第一条if
语句,发现singleton != null
,因为之前线程A
执行了语句3
后,singleton
已经指向了对象,于是线程B
直接将对象返回,开始使用。这时候就出现问题了,由于A
线程没有执行语句2
,对象没有被初始化,所以线程B
在使用时读取到的内容将是不确定的。为了解决这个问题,需要为singleton的声明加上volatile关键字,被这个关键字修饰的变量,创建对象时,JVM将不会对指令重排序。
2.5 静态内部类实现
下面再来介绍另外一种对懒汉式的优化实现,通过内部类来实现延迟初始化:
public class Singleton {
// 内部类实现单例类
private static class Instance {
private static final Singleton singletion = new Singleton();
}
// 获取单例对象
public static Singleton getSingleton() {
return Instance.singletion;
}
}
上面的代码中,我们在Singleton
类中创建了一个内部类,这个内部类被修饰为private
,也就是只能在Singleton
内部访问。而需要创建的单例对象,被放在了内部类中创建,但是提供了一个全局访问接口getSingleton
方法,访问内部类中的单例对象。这样的实现有什么好处呢?这是一种天然的,或者说由JVM
实现的懒加载模式。类一般是在第一次被使用时才加载,也就是说,只要我们不使用这个内部类,它就不会被加载,自然也就不会创建这个单例对象。只要当第一次调用getSingleton
方法时,方法中执行return Instance.singletion;
语句时,内部类才被加载,单例对象才被创建。而且最精妙的是,我们前面已经说过,类加载的过程本身就是线程安全的,由JVM
帮我们实现了线程同步,所以也不会出现多个线程同时创建单例对象的问题。
2.6 枚举类实现
下面再说说最后一种实现方式——枚举类实现。先看看代码:
// enum枚举类
public enum Singleton {
// 单例对象
SINGLETON;
// 单例对象的熟悉
private String name;
// 单例对象的方法
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
学习过枚举类型的应该都知道,枚举类的对象天然就是单例的,而且枚举类除了创建对象的方式不同,也同样能够有属性和方法,也就是说,我们完全可以通过枚举类实现天然的单例。这个单例对象将会在枚举类被加载的时候创建,也就是说这是一种饿汉式。使用枚举类创建单例的效率要高于普通饿汉式,因为枚举的单例是由JVM
底层实现的,做了优化。而使用枚举类的第二大好处就是:可以避免反射以及序列化对单例模式的破坏。下面就来说一说。
2.7 单例模式的破坏
创建对象除了使用new
关键字外,还有另外两种方式:
- 通过反射创建对象;
- 通过序列化和反序列化创建对象;
先看第一种方式,虽然我们将单例类的构造器设为了private
,但是对于反射来说,这并不是问题,因为反射连私有成员也能访问,所以对于单例类,我们还能通过如下代码创建对象:
public class Singleton {
private Singleton() {
}
public static void main(String[] args) throws Exception {
// 反射创建对象
Singleton singleton = Singleton.class.newInstance();
System.out.println(singleton);
}
}
这种方式,也就破化了单例模式只能创建一个对象的原则。前四种实现单例模式的方式都能通过反射创建对象,但是枚举类型却能够避免这个问题。在Class
对象的newInstance
方法中,若检测到需要创建的对象的类型是enum
类型,就会抛出异常(创建Class
对象也会)。
第二种破坏的方式就是使用序列化,简单来说就是先将对象通过流存入文件中,再通过流从文件中读出,则读出的对象和原来的对象将是两个对象,也就是将原来的对象拷贝了一份。前四种实现单例模式的方式都无法避免这个问题,但是,枚举又可以。枚举类型由JVM
底层实现了单例,就算是使用序列化,读出来的对象还是原来那个,而不是拷贝一份。所以,枚举类型实现单例是一种非常安全,又简单的方式。
三、总结
当我们要实现饿汉式,也就是类加载时立即创建对象,此时最好是使用枚举类实现,简单高效;当要实现懒汉式时,则推荐使用静态内部类实现,实现简单,无线程安全问题,而且效率也较高。
四、参考
- 《Java并发编程的艺术》
- https://www.cnblogs.com/chiclee/p/9097772.html