浅谈单例模式

单例模式简介

单例模式作为GOF 23种常见设计模式的一种,在J2EE不断发展和逐渐成熟中有着广泛的应用。其中Java Web的核心组件servlet在tomcat容器中便是单例存在的。同样,目前主流Web架构广泛使用的Spring框架,在其IoC容器中,Bean实例同样也是单例存在的。
实现单例模式,我们需要首先需要保证两个最基本的要求,即构造方法私有化和给外界一个可以访问实例对象的public方法。构造方法私有化可以保证这个实例由其内部自行创建,这能够保证其能够创建自身的唯一实例;给外界一个public方法可以使得应用方可以使用这一实例,这二者构成了最基本的单例模式的思想。下面给出一个最基本的单例模式的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Single {
private static final Single INSTANCE = new Single();
private Single() {
}
public static Single getInstance() {
return INSTANCE;
}
public void print() {
System.out.println("I'm in the single instance");
}
public static void main(String[] args) {
Single.getInstance().print();
}
}
Output:
I'm in the single instance

由上例可以看出,我们通过最简单的方式,即创建静态对象,使其在类加载期进行初始化,完成对象的实例化;同时通过将构造方法私有,导致外界无法调用构造方法来构造此类的实例对象,这便保证了单例。同时,我们提供给外界一个公有方法可以获取该实例,并进行方法的调用。
以上这种方法仅是一种容易理解的单例模式的实现;虽然其实现了单例模式,但是由于是静态对象持有,在类加载的时候便会构建实例,所以如果类加载后一段时间不用该实例的话,就会导致内存的浪费;这必定不是一种很好的方案,下面就让我们了解几个单例模式构建中的最佳实践(其实也不算最佳啦= =根据实际场景各取所需才是王道嗯!)

懒汉与饿汉

区分懒汉与饿汉

所谓 懒汉式饿汉式,指的是单例实例加载的时间。由之前举的例子,可知上例维护了一个静态对象引用的持有,该单例对象在类创建时就已加载,故属于 饿汉式 加载;这种方法好处是在初始化时候加载,不许在后续创建时保证线程安全;但如果该类加载后一段时间不使用,则会出现内存占用的情况,故饿汉式单例模式不能应用去全部需要单例的场景。

懒汉式单例模式的实现

相对而谈,懒汉式 加载方式则是在真正第一次使用的时候进行实例的创建,这样有效的避免了 饿汉式 方式带来的内存浪费。下面列举一个懒汉式单例模式的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Single {
private static Single instance;
private Single() {
}
//提供给外部访问实例的方法
public static Single getInstance() {
if(instance == null) {
instance = new Single();
}
return instance;
}
}

在这个例子中我们可以看到,我们没有像 饿汉 模式一样在类加载的时候就构建实例,而是等到实际第一次调用的时候进行单例的构建;这样的做法在单线程环境下没有问题,效率也比较高。但是如果我们有两个线程同时要调用获取单例对象的方法,就会可能出现构造出两个对象的情况,这样就不是一个合法的单例模式了,所以该实现方法并不适合于多线程环境下。

单例模式与线程安全

单例模式中的线程安全情况

目前在主流应用中,都采用分布式、多线程的方式进行工作,这就造成了我们在很多时候编程中应该保证线程安全,这样才能保证程序运行的正确性。所以我们需要在多线程开发环境下保证线程安全。
首先来观察上文展示的饿汉模式实现的单例模式,在上例中我们可以看出由于在类加载后便创建完单例对象并持有该对象的引用,所以在使用的过程中不会出现线程安全问题。而上例的懒汉式单例模式的实现中,由于可能同时出现在第一次调用getInstance()方法进入if分支的时候,另一个线程得到时间片,也进入该分支,所以在这种情况下就会出现构造出两个实例,违反了单例模式的要求,所以其不能在多线程环境下满足单例的需求。
继续进行思考,我们可以通过 加锁 的方式对 getInstance() 方法的调用进行限制,这样就可以防止多线程并发环境下传统懒汉模式可能出现的问题。可以采用的加锁模式有两种:

  • 用synchronized对方法进行限制加锁
  • 采用双检锁
    这两种方法均可用于解决单例模式的线程安全问题,其中第一种方法中,由于用 synchronized 对方法修饰之后,该方法会变成互斥访问的,任何时刻都只能有一个线程调用该方法;也就意味着只要需要获得实例,便需要排队等待上一个调用者释放锁之后才可进行获取,这样的做法并不高校。

采用双检索实现线程安全的单例模式

仔细思考我们的需求,我们实际上需要加锁控制的,是创建单例对象的唯一,即只能有一次创建该单例对象的过程。所以我们只需要保证创建单例对象的过程加锁就可以。 双检 的概念就是一在同步块外判引用是否为空,二在同步块内判引用是否为空。
下面是一个利用 双检索 保证线程安全的懒汉模式的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Single {
private static Single instance;
private Single() {
}
//提供给外部访问实例的方法
public static Single getInstance() {
if(instance == null) {
synchronized (Single.class) {
if(instance == null) {
instance = new Single();
}
}
}
return instance;
}
}

完成上例,博主觉得已经达到绝对的线程安全了。但是在学习的过程中发现很多双检索实现单例模式的过程中都将内部实例 instance 用volatile进行修饰,于是博主去调查了原因,这是由于JVM指令重排序造成的。在JVM中,创建一个对象实例的时候大概有三步:分配内存、调用构造函数和将对象引用指向该对象的内存空间。而JVM在执行的时候可能会将后两个过程进行指令重排序,下面我们想象这样一种情况:A线程进行了单例的创建,但是由于指令重排序,导致将引用已经指向该空间,但是还没有调用构造函数;这时另外一个B线程调用 getInstance() 进行单例的获取,由于引用已经不为空,所以会直接返回该对象引用,但是由于该对象还未进行构造,所以会出现错误。在我们的程序中,应该避免这种情况的发生。

volatile的使用

由于此篇不是对于 volatileJMM 相关的介绍,所以在此不展开介绍JMM、内存屏障相关知识,只简单介绍volatile在双检锁模式中的作用。
volatile 关键字,在目前的Java版本中主要有两个语义:

  • 保证此变量对所有线程的可见性
  • 禁止指令重排序
    在双检锁模式的实现中,主要使用volatile关键字的第二个语义,通过将单例对象引用以volatile修饰,达到禁止该变量操作的指令重排序。从而保证线程安全。具体实现很简单,只需要将上例的private对象加volatile修饰即可,代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class Single {
    private volatile static Single instance;
    private Single() {
    }
    //提供给外部访问实例的方法
    public static Single getInstance() {
    if(instance == null) {
    synchronized (Single.class) {
    if(instance == null) {
    instance = new Single();
    }
    }
    }
    return instance;
    }
    }

总结

单例模式的实现不仅有懒汉模式、饿汉模式,同时还可以使用静态内部类以及枚举来实现单例模式。其中,静态内部类实现单例模式也是线程安全的,同时也满足懒加载。根据不同的需求,可以进行不同方式的构建。