Java单例模式详解
@author:Jingdai
@date:2021.06.15
单例模式是23种设计模式中最简单的模式,但是在由于多线程的存在,导致还是存在一些细节问题需要注意。
单线程
单例顾名思义就是应用中这个类至多只有一个实例,并提供给外界一个访问到这个实例的入口。既然一个类至多一个实例,那它的构造方法就一定要是私有的,否则外界就可以随意的创建这个对象,导致不是单例。而将构造方法私有后,只有类内可以访问构造方法,所以需要一个类内的方法创建对象,并将对象返回,如此,便可写成如下代码。
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getSingleton() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
如果系统中每次都会使用到这个对象且在运行时创建这个对象会影响系统效率,那么可以在系统开始运行的时候就创建这个对象,这就是饿汉式单例,代码如下。
// eagerly
public class Singleton {
private static Singleton singleton = new Singleton();
private Singleton() {}
public static Singleton getSingleton() {
return singleton;
}
}
与之相对,一般的单例就称为懒汉式单例。
多线程
上述单线程的第一个代码,也就是懒汉式单例在多线程环境下是有问题的,对于方法:
public static Singleton getSingleton() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
在 if 判断时,如果线程1 判断完为空,失去了时间片,线程2 也来到了这里,再判断还是为空,就会导致创建了2个对象,就不是单例了,为了避免这种问题,最简单的解决方式就是直接加锁,代码如下。
// synchronized lazy
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static synchronized Singleton getSingleton() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
这样就简单的解决了问题。但是我们都知道 synchronize 关键字开销是比较大的,而上面代码中,除了第一次创建对象的时候是需要加锁的,后面再次访问的时候其实是没有必要加锁的,这样在并发量大的时候就会影响性能。因此,就出现了double - checked locking 算法,它可以只有在第一次创建的时候才加锁,而之后获取变量的时候就不需要加锁了,代码如下。
// DCL with bug
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
这样看好像是没有什么问题,完美的解决了问题。其实仅仅从上面 Java 代码的角度来看确实是这样,但是上面的代码却是有问题的,原因在于 cpu 的指令重排序。程序在运行过程中,为了提高性能,指令可能不会按照代码的顺序执行,在不影响结果的前提下,可能会发生重排序。
上面代码中的 singleton = new Singleton()
,执行过程中会有 3 步,①.分配空间并赋初值,②.执行构造函数,③.将构造好的对象的地址赋给 singleton,但是实际运行过程中,发生指令重排后就运行顺序就有可能变成了①③②,这样就有很大的问题了,假如线程1抢到了锁,执行了①③,然后失去了时间片,线程②进来后,发现 singleton 不为空,直接返回,但是这个时候的 singleton 还没有执行构造函数,还是一个半初始化的状态,是不能使用的,很可能造成严重的问题。
针对这个问题,解决方法也很简单,在 singleton 变量上加一个 volatile 关键字就行,volatile 可以保证 volatile写之前的操作不会被编译器重排序到volatile写之后,这就解决了这个问题。代码如下。
// DCL
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
至此,单例的讲解就结束了,可以看出虽然单例是最简单的设计模式,但是其实细节还是非常多的,还是值得仔细研究一下的。
其他细节
- 当一个类的有多个类加载器,那一个类就可以有多个版本,一个类加载器对应一个版本,如果这个类是单例的话,就可能会出现多个实例的问题。
- 由于单例的构造函数私有,所以单例不能被继承。
参考
- 深入浅出设计模式