一. 单例模式概述
单例模式是一种常用的软件设计模式, 该模式的主要目的是确保某一个类在内存中只能有一个实例对象, 通过单例模式的方法创建的类在当前进程中只有一个实例对象.
常见的单例模式有两种:
- 饿汉式, 这里的 “饿” 意义表述不够清晰, 用 “急” 来表述意义更加容易联想一些, 饿汉式就是类一加载就创建对象, 给人一种 “特别急切” 的感觉.
- 懒汉式, 在使用时才去创建对象.
单例模式有 3 个特点:
- 单例类只有一个实例对象.
- 该单例对象必须由单例类自行创建.
- 单例类对外提供一个访问该单例的全局访问点.
我们知道static
修饰的成员是属于类的, 也就是只有一份, 所以我们可以使用static
修饰的成员变量保存实例对象的引用.
二. 单例模式的实现
1. 饿汉模式
单例模式中, 一个类只能有一个实例对象, 我们可以使用static
成员变量来保存这个唯一实例对象的引用, 同时, 为了防止类被创建多个实例对象, 需要将类构造方法封装为private
修饰的私有方法, 在使用该类时必须要得到该类的这个实例对象, 所以我们还需要创建一个对外公开的获取该唯一实例的方法getInstance
.
class Singleton {
//使用static变量存储实例对象,类加载时创建对象.
private static Singleton intance = new Singleton();
//对外提供公开的方法获取该类的唯一实例.
public static Singleton getInstance() {
return intance;
}
//封装构造方法,防止该类被实例出新的对象
private Singleton() {}
}
单线程情况下不存在线程安全的问题, 我们需要考虑多线程情况下上面的代码是否存在线程安全问题, 重点要看读写情况下的线程安全问题, 上面的代码只有getInstance
涉及到读操作, 所以上面的代码是线程安全的.
2. 懒汉模式
懒汉式与饿汉明显的区别就是对象创建的时机不同, 懒汉式要等到第一次使用实例对象的时候才会创建对象, 所以我们将上面的代码稍做修改写出下面的代码.
class SingletonLazy {
//在使用的时候创建对象,这里不初始化实例对象
private volatile static SingletonLazy instance = null;
//对外提供公开的方法获取该类的唯一实例,如果没有就创建
public static SingletonLazy getInstance() {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
//封装构造方法,防止该类被实例出新的对象
private SingletonLazy() {}
}
那么再来考虑上面的代码在多线程情况下是否有线程安全问题存在, 代码中的getInstance
方法中存在两次读操作(一次比较一次返回)和一次写操作(修改intsance
变量的值),第一次创建对象时(即instance=null
)可能会存在多个线程进入判断语句, 此时就可能会创建出多个实例对象, 看下图理解:
所以上面写出来的代码可能会触发多次new
操作, 显然就不是单例了, 也就是说上面写出来的懒汉式的代码是线程不安全的.
导致上面的线程安全问题的主要原因是读, 比较和写这三个操作不是原子的, 这就导致了t2
读到的值可能是t1
还没来得及写的(脏读), 所以我们只需要将代码中的读写操作加锁即可这里的问题, 得到下面的代码:
class SingletonLazy {
private volatile static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
synchronized (SingletonLazy.class) {
if(instance == null) {
instance = new SingletonLazy();
}
}
return instance;
}
private SingletonLazy() {}
}
但代码写到这里还是有问题的, 上述的线程安全问题只出现在instance
没有初始化的时候, 如果instance
已经有了实例对象, 后续调用getlnstance
时instance
的值一定是非空的, 因此就会直接触发return
, 相当于一个是比较操作, 一个是返回操作, 这就都是读操作了, 此时就不涉及线程安全问题了.
而上面的代码, 不论instance
是否已经初始化, 每次调用getlnstance
, 都需要进行加锁操作, 要知道加锁操作是有开销的, 在instance
已经有了实例对象以后就完全没有必要去加锁了, 所以代码还需要修改, 在加锁前需要先判断一下instance
是否已经初始化, 如果还没初始化就进行加锁, 如果实例对象创建了, 就不加锁了.
调整后的获取实例对象的代码如下:
public static SingletonLazy getInstance() {
if (instance == null) {
synchronized (SingletonLazy.class) {
if(instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
到这里其实还没完, 还要考虑编译器优化造成的内存可见性和指令重排序的问题, 上面的代码中是有判断操作的(instance==null
), 假设有很多线程, 都去进行getlnstance
, 就会进行频繁的判断, 这时候就会有被优化的风险, 可能只有第一次读才是真正读了内存, 后续都是读寄存器/cache中的值, 这时候instance
值变化时, 线程就感知不到了, 所以这里的内存可见性问题需要解决.
还有要注意这里的instance = new Singleton();
操作, 拆分成三个步骤就是:
- 申请内存空间.
- 调用构造方法, 把这个内存空间初始化成一个合理的对象.
- 把内存空间的地址赋值给instance引用.
正常情况下,是按照123
这个顺序来执行的, 但你防不住编译器的优化操作, 也就是指令重排序问题, 编译器为了提高程序效率, 可能就调整了代码执行顺序, 123
这个顺序就可能变成132
, 如果是单线程,123和132没有本质区别, 但是多线程环境下, 就会有问题了;
假设t1
是按照132
的步骤执行的, t1
执行到13
之后, 执行2
之前,被切出cpu了, 然后t2
来执行;
当t1
执行完3
之后, 再从t2
角度来看, 此处的引用就非空了, 此时此刻, t2
就相当于直接返回了instance
引用并且可能会尝试使用引用中的属性, 但是由于t1
中的2
操作还没执行完呢, t2
拿到的是非法的对象(还没构造完成的不完整的对象), 这就可能会造成一些问题.
为了解决上述问题需要使用关键字volatile
修饰instance
变量来防止编译器优化, 从而保证内存可见性, 禁止指令重排序, 完整代码如下:
class SingletonLazy {
private volatile static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
synchronized (SingletonLazy.class) {
if(instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {}
}