设计模式之单例模式

设计模式之单例模式

单例模式(Singleton Pattern)是一个比较简单的模式,定义如下:

Ensure a class has only one instance, and provide a global point of access to it.

确保一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

以下是单例模式的几种写法:

懒汉式–线程不安全

1
2
3
4
5
6
7
8
9
10
11
public class Singleton{
private static Singleton instance;
private Singleton(){}

public static Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
}

代码简洁明了,使用了懒加载模式,但是在多个线程并行调用getInstance()的时候,会创建多个实例,在多线程下不能正常工作。

懒汉式–线程安全

为了解决上述问题,最简单的方法就是将整个getInstance()方法设为同步(synchronized).

1
2
3
4
5
6
public static synchronized Singleton getInstance(){
if(instance == null) {
instance = new Singleton();
}
return instance;
}

这种方式做到了线程安全,但是并不高效,任何时候都只能有一个线程调用getInstance()方法,同步操作只有第一次调用时才需要。于是有人引出了双重检验锁。

双重检验锁

双重检验锁(double checked locking pattern)是一种使用同步块加锁的方法。我们称其为双重检验锁,因为会有两次检查instance == null,一次是在同步块外,一次是在同步块内。

为什么要检查两次,因为可能会有多个线程一起进入同步块的if,如果不进行二次检验就会生成多个实例

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

这段代码看上去完美,但是还是有问题,主要在于instance = new Singleton()并非是一个原子操作,事实上在JVM中这句话大概做了3件事情:

  1. 给instance分配内存
  2. 调用Singleton 的构造函数来初始化成员变量
  3. 将instance对象指向分配的内存空间

但是在JVM的即时编译器中存在指令重排序的优化,即上述第二步和第三步的顺序是不能保证的,最终执行顺序可能是1-2-3也可能是1-3-2。如果是后者,则在3执行完毕,2未执行之前被线程二抢占,这时instance 已经是非null(没有初始化),线程2会直接返回instance,然后使用时报错。

我们只需要将instance变量声明为volatile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton{
//声明成volatile
private volatile static Singleton instance;
private Singleton(){}

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

volatile关键字的作用:

  1. 保证变量在内存中的可见性
  2. 保证程序执行的顺序,volatile关键字禁止指令重排

这里其实用到的是volatile关键字的有序性,确保JVM执行时不会进行指令重排。

饿汉式–static final field

直接将单例的实例声明成static和final变量,这样在类第一次加载时就在内存中初始化,所以实例本身是线程安全的。

1
2
3
4
5
6
7
8
9
public class Singleton{
//类加载时就初始化
private static final Singleton instance = new Singleton();
private Singleton(){}

public static Singleton getInstance(){
return instance;
}
}

这种写法的缺点在于单例会在加载类一开始就被初始化,这导致了饿汉式在某些场景中无法使用,比如Singleton实例的创建依赖参数或者配置文件的,在getInstance()之前必须调用某个方法设置参数给它,这样这种单例写法无效了。

静态内部类 static nested class

这种方法是《effective Java》上所推荐的。

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

public static final Singleton getInstance(){
return SingletonHolder.INSTANCE;
}
}

这种写法使用JVM本身的机制来保证了线程安全的问题,由于SingletonHolder是私有的,除了getInstance()方法没有办法访问到他,同时读取实例的时候不会进行同步,没有性能缺陷,不依赖于JDK版本。

枚举 Enum

用枚举写单例实在太简单了!这也是它最大的优点。下面这段代码就是声明枚举实例的通常做法。

1
2
3
public enum EasySingleton{
INSTANCE;
}

我们可以通过EasySingleton.INSTANCE来访问实例,这比调用getInstance()方法简单多了。创建枚举默认就是线程安全的,所以不需要担心double checked locking,而且还能防止反序列化导致重新创建新的对象。

几种创建方式的总结

一般来说,单例模式有五种线程安全的写法:懒汉,饿汉,双重检验锁,静态内部类,枚举。

单例模式的使用场景

在以下场景适合单例模式:

  1. 有频繁实例化然后销毁的情况。
  2. 创建对象耗时多或者消耗资源多而又使用频繁。
  3. 频繁访问IO资源的对象,例如数据库连接池或者访问本地文件。

比如说:

  1. 网站在线人数统计

    这其实就是一个全局计数器,所有用户在同一时刻获取的在线人数数量都是一致的,这里包含分布式场景。下面代码简单实现一个计数器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class Counter{
    private static class CounterHolder{
    private static final Counter counter = new Counter();
    }
    private Counter(){
    System.out.println("init...");
    }
    private static final Counter getInstance(){
    return CounterHolder.counter;
    }

    private AtomicLong online = new AtomicLong();

    public long getOnline(){
    return online.get();
    }

    public long add(){
    return online.increasementAndGet();
    }
    }
  1. 配置文件访问类

    项目种经常需要一些环境相关的配置文件,比如短信通知相关,邮件相关。比如使用Spring,可以使用@PropertySource注解实现,默认就是单例模式,不用单例的话,每次都要new对象,读取配置文件。

    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
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    public class SingleProperty{
    private static Properties prop;

    private static class SinglePropertyHolder{
    private static final SingleProperty singleProperty = new SingleProperty();
    }

    /**
    * config.properties 内容是 test.name=kite
    */
    private SingleProperty(){
    System.out.println("构造函数执行");
    prop = new Properties();
    InputStream stream = SingleProperty.class.getClassLoader()
    .getResourceAsStream("config.properties");
    try {
    prop.load(new InputStreamReader(stream, "utf-8"));
    } catch (IOException e) {
    e.printStackTrace();
    }
    }

    public static SingleProperty getInstance(){
    return SinglePropertyHolder.singleProperty;
    }


    public String getName(){
    return prop.get("test.name").toString();
    }

    public static void main(String[] args){
    SingleProperty singleProperty = SingleProperty.getInstance();
    System.out.println(singleProperty.getName());
    }
    }
  2. 数据库连接池的实现

    做池化的原因就是新建连接十分耗时,一般做法是在应用内维护一个连接池,这样当任务进来时,如果有空闲连接可以直接拿来用,省去了初始化的开销。所以单例模式正好实现一个应用内只有一个线程池,所有的连接任务都需要从连接池里获取连接。

参考文献

如何正确地写出单例模式

单例模式的使用场景