单例模式

定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

单例模式结构图:

单例模式结构图

使用单例的优点:

  • 单例类只有一个实例
  • 共享资源,全局使用
  • 节省创建时间,提高性能

  使用单例模式的主要优点就是节约资源,节省时间:

1.由于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级的对象而言,是很重要的

2.因为不需要频繁创建对象,我们的GC压力也减轻了,而在GC中会有STW(stop the world),从这一方面也节约了GC的时间

  单例模式的缺点:简单的单例模式设计开发都比较简单,但是复杂的单例模式需要考虑线程安全等并发问题,引入了部分复杂度

单例模式的七种写法

  单例模式有多种写法各有利弊,现在我们来看看各种模式写法。

1、饿汉式

1
2
3
4
5
6
7
8
public class Singleton {  
private static Singleton instance = new Singleton();
private Singleton (){
}
public static Singleton getInstance() {
return instance;
}
}

  这种方式和名字很贴切,饥不择食,在类装载的时候就创建,不管你用不用,先创建了再说,如果一直没有被使用,便浪费了空间,典型的空间换时间,每次调用的时候,就不需要再判断,节省了运行时间。

Java Runtime就是使用这种方式,它的源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Runtime {
private static Runtime currentRuntime = new Runtime();

/**
* Returns the runtime object associated with the current Java application.
* Most of the methods of class <code>Runtime</code> are instance
* methods and must be invoked with respect to the current runtime object.
*
* @return the <code>Runtime</code> object associated with the current
* Java application.
* 每个java程序在运行时相当于启动了一个JVM进程,每个JVM进程都对应一个RunTime实例。
* 此实例是JVM负责实例化的,所以我们不能实例化一个RunTime对象,
* 只能通过getRuntime() 获取当前运行的Runtime对象的引用。
* 一旦得到了一个当前的Runtime对象的引用,
* 就可以调用Runtime对象的方法去查看Java虚拟机的状态以及控制虚拟机的行为:
* JVM内核数、空闲内存、总内存、最大内存等
*/
public static Runtime getRuntime() {
return currentRuntime;
}

/** Don't let anyone else instantiate this class */
private Runtime() {}

//以下代码省略
}

总结:「饿汉式」是最简单的实现方式,这种实现方式适合那些在初始化时就要用到单例的情况,这种方式简单粗暴,如果单例对象初始化非常快,而且占用内存非常小的时候这种方式是比较合适的,可以直接在应用启动时加载并初始化。

​ 但是,如果单例初始化的操作耗时比较长而应用对于启动速度又有要求,或者单例的占用内存比较大,再或者单例只是在某个特定场景的情况下才会被使用,而一般情况下是不会使用时,使用「饿汉式」的单例模式就是不合适的,这时候就需要用到「懒汉式」的方式去按需延迟加载单例。

2、懒汉式(非线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {  
private static Singleton instance;
private Singleton (){
}
public static Singleton getInstance() {
if (instance == null) {
/*
* 假设在这里线程1等待
* 我们假设有多个线程1,线程2都需要使用这个单例对象。
* 而恰巧,线程1在判断完s==null后突然交换了cpu的使用权,变为线程2执行,
* 由于s仍然为null,那么线程2中就会创建这个Singleton的单例对象。
* 之后线程1拿回cpu的使用权,而正好线程1之前暂停的位置就是判断s是否为null之后,
* 创建对象之前。这样线程1又会创建一个新的Singleton对象。
*/
instance = new Singleton();
}
return instance;
}
}

总结:懒汉模式申明了一个静态对象,在用户第一次调用时初始化,虽然节约了资源,但第一次加载时需要实例化,反应稍慢一些,而且在多线程不能正常工作。在多线程访问的时候,很可能会造成多次实例化,就不再是单例了。

  「懒汉式」与「饿汉式」的最大区别就是将单例的初始化操作,延迟到需要的时候才进行,这样做在某些场合中有很大用处。比如某个单例用的次数不是很多,但是这个单例提供的功能又非常复杂,而且加载和初始化要消耗大量的资源,这个时候使用「懒汉式」就是非常不错的选择。

3、懒汉式(线程安全)

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {  
private static Singleton instance;
private Singleton (){
}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

  这两种「懒汉式」单例,名字起的也很贴切,一直等到对象实例化的时候才会创建,确实够懒,不用鞭子抽就不知道走了,典型的时间换空间,每次获取实例的时候才会判断,看是否需要创建,浪费判断时间,如果一直没有被使用,就不会被创建,节省空间。

  因为这种方式在getInstance()方法上加了同步锁,所以在多线程情况下会造成线程阻塞,把大量的线程锁在外面,只有一个线程执行完毕才会执行下一个线程。

​ Android中的 InputMethodManager 使用了这种方式,我们看看它的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public final class InputMethodManager {

static InputMethodManager sInstance;

/**
* Retrieve the global InputMethodManager instance, creating it if it
* doesn't already exist.
* @hide
*/
public static InputMethodManager getInstance() {
synchronized (InputMethodManager.class) {
if (sInstance == null) {
IBinder b = ServiceManager.getService(Context.INPUT_METHOD_SERVICE);
IInputMethodManager service = IInputMethodManager.Stub.asInterface(b);
sInstance = new InputMethodManager(service, Looper.getMainLooper());
}
return sInstance;
}
}
}

4、双重校验锁(DCL)

  上面的方法「懒汉式(线程安全)」毫无疑问存在性能的问题 — 如果存在很多次getInstance()的调用,那性能问题就不得不考虑了!

​ 让我们来分析一下,究竟是整个方法都必须加锁,还是仅仅其中某一句加锁就足够了?我们为什么要加锁呢?分析一下出现lazy loaded的那种情形的原因。原因就是检测null的操作和创建对象的操作分离了。如果这两个操作能够原子地进行,那么单例就已经保证了。于是,我们开始修改代码,就成了下面的双重校验锁(Double Check Lock):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {
/**
* 注意此处使用的关键字 volatile,
* 被volatile修饰的变量的值,将不会被本地线程缓存,
* 所有对该变量的读写都是直接操作共享内存,从而确保多个线程能正确的处理该变量。
*/
private volatile static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return singleton;
}
}

​ 这种写法在getSingleton()方法中对singleton进行了两次判空,第一次是为了不必要的同步,第二次是在singleton等于null的情况下才创建实例。在这里用到了volatile关键字,不了解volatile关键字的可以查看 Java多线程(三)volatile域 java中volatile关键字的含义 两篇文章,可以看到双重检查模式是正确使用volatile关键字的场景之一。

  「双重校验锁」:既可以达到线程安全,也可以使性能不受很大的影响,换句话说在保证线程安全的前提下,既节省空间也节省了时间,集合了「饿汉式」和两种「懒汉式」的优点,取其精华,去其槽粕。

对于volatile关键字,还是存在很多争议的。由于volatile关键字可能会屏蔽掉虚拟机中一些必要的代码优化,所以运行效率并不是很高。也就是说,虽然可以使用“双重检查加锁”机制来实现线程安全的单例,但并不建议大量采用,可以根据情况来选用。

还有就是在java1.4及以前版本中,很多JVM对于volatile关键字的实现的问题,会导致“双重检查加锁”的失败,因此“双重检查加锁”机制只只能用在java1.5及以上的版本。

这样虽然解决了问题,但是因为用到了synchronized,会导致很大的性能开销,并且加锁其实只需要在第一次初始化的时候用到,之后的调用都没必要再进行加锁。

  双重检查锁(double checked locking)是对上述问题的一种优化。先判断对象是否已经被初始化,再决定要不要加锁。

错误的双重检查锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private static Singleton uniqueSingleton;

private Singleton() {
}

public Singleton getInstance() {
if (null == uniqueSingleton) {
synchronized (Singleton.class) {
if (null == uniqueSingleton) {
uniqueSingleton = new Singleton(); // error
}
}
}
return uniqueSingleton;
}
}

如果这样写,运行顺序就成了:

  • 检查变量是否被初始化(不去获得锁),如果已被初始化则立即返回。

  • 获取锁。

  • 再次检查变量是否已经被初始化,如果还没被初始化就初始化一个对象。

  执行双重检查是因为,如果多个线程同时了通过了第一次检查,并且其中一个线程首先通过了第二次检查并实例化了对象,那么剩余通过了第一次检查的线程就不会再去实例化对象。

  这样,除了初始化的时候会出现加锁的情况,后续的所有调用都会避免加锁而直接返回,解决了性能消耗的问题。
  上述写法看似解决了问题,但是有个很大的隐患。实例化对象的那行代码(标记为error的那行),实际上可以分解成以下三个步骤:

  • 分配内存空间

  • 初始化对象

  • 将对象指向刚分配的内存空间

    但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,顺序就成了:

  • 分配内存空间

  • 将对象指向刚分配的内存空间

  • 初始化对象

  • 现在考虑重排序后,两个线程发生了以下调用:

    TimeThread AThread B
    T1检查到uniqueSingleton为空
    T2获取锁
    T3再次检查到uniqueSingleton为空
    T4为uniqueSingleton分配内存空间
    T5将uniqueSingleton指向内存空间
    T6检查到uniqueSingleton不为空
    T7访问uniqueSingleton(此时对象还未完成初始化)
    T8初始化uniqueSingleton

    在这种情况下,T7时刻线程B对uniqueSingleton的访问,访问的是一个初始化未完成的对象。

正确的双重检查锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton uniqueSingleton;

private static Singleton() {
}

public static Singleton getInstance() {
if (null == uniqueSingleton) {
synchronized (Singleton.class) {
if (null == uniqueSingleton) {
uniqueSingleton = new Singleton();
}
}
}
return uniqueSingleton;
}
}

  为了解决上述问题,需要在uniqueSingleton前加入关键字volatile。使用了volatile关键字后,重排序被禁止,所有的写(write)操作都将发生在读(read)操作之前。

​  至此,双重检查锁就可以完美工作了。

  synchronized的语义保证了整个同步块内变量的可见性,和使用什么锁没有关系。也就是确保线程A在退出同步块前看的变量的值和后来线程B看到的值是相同的。所以volatile只是解决了第一个指令重排序的问题,对于可见性,synchronized已经保证了。

5、静态内部类

  另外,在很多情况下JVM已经为我们提供了同步控制,比如:

  • static {...}区块中初始化的数据
  • 访问final字段时

  因为在JVM进行类加载的时候他会保证数据是同步的,我们可以这样实现:采用内部类,在这个内部类里面去创建对象实例。这样的话,只要应用中不使用内部类 JVM 就不会去加载这个单例类,也就不会创建单例对象,从而实现「懒汉式」的延迟加载和线程安全。

1
2
3
4
5
6
7
8
9
10
public class Singleton { 
private Singleton(){
}
public static Singleton getInstance(){
return SingletonHolder.sInstance;
}
private static class SingletonHolder {
private static final Singleton sInstance = new Singleton();
}
}

  第一次加载Singleton类时并不会初始化sInstance,只有第一次调用getInstance方法时虚拟机加载SingletonHolder 并初始化sInstance ,这样不仅能确保线程安全也能保证Singleton类的唯一性,所以推荐使用静态内部类单例模式。

  然而这还不是最简单的方式,《Effective Java》中作者推荐了一种更简洁方便的使用方式,就是使用「枚举」。

6、枚举

  《Java与模式》中,作者这样写道,使用枚举来实现单实例控制会更加简洁,而且无偿地提供了序列化机制,并由JVM从根本上提供保障,绝对防止多次实例化,是更简洁、高效、安全的实现单例的方式。

1
2
3
4
5
6
7
8
public enum Singleton {
//定义一个枚举的元素,它就是 Singleton 的一个实例
INSTANCE;

public void doSomeThing() {
// do something...
}
}

  使用方法如下:  

1
2
3
4
public static void main(String args[]) {
Singleton singleton = Singleton.instance;
singleton.doSomeThing();
}

  枚举单例的优点就是简单,但是大部分应用开发很少用枚举,可读性并不是很高,不建议用。

7. 使用容器

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SingletonManager { 
  private static Map<String, Object> objMap = new HashMap<String,Object>();
  private Singleton() {
  }
  public static void registerService(String key, Objectinstance) {
    if (!objMap.containsKey(key) ) {
      objMap.put(key, instance) ;
    }
  }
  public static ObjectgetService(String key) {
    return objMap.get(key) ;
  }
}

  这种事用SingletonManager 将多种单例类统一管理,在使用时根据key获取对象对应类型的对象。这种方式使得我们可以管理多种类型的单例,并且在使用时可以通过统一的接口进行获取操作,降低了用户的使用成本,也对用户隐藏了具体实现,降低了耦合度。

联系我

评论