引言
单例模式可以说是一种最常见的设计模式了,他的主要作用相信大家都知道,简单说就是单例模式的类在项目运行期间只会产生一个对象,系统每次使用的都是这一个对象,这样做的好处就是可以省略对象创建所需的时间,更好的提升性能,同时可以减少GC的压力。
而本文主要是介绍几种常见的单例模式思路,并从线程安全的角度分析其合理性。
饿汉式单例模式
所谓饿汉式,就是在类加载的时候,就完成对单例模式对象的初始化。我们直接来看代码。
public final class Singleton {
private Singleton(){
}
private static final Singleton INSTANCE = new Singleton();
//通过接口获取是为了有更好的扩展性
public static Singleton getInstance(){
return INSTANCE;
}
}
对于上面的代码实现,我们提出两个问题:
1.在单例对象的创建过程中是否是线程安全的?
从代码中我们可以看到,我们将单例对象的引用设置为静态的,而静态变量的初始化是在类加载过程中就已经完成的,所以是线程安全的。
1.如何保证对象单例?
首先,我们将类设置了final,这就防止后续有子类继承该类而对单例模式造成破坏;另外,我们将构造函数设置为私有,这样就避免了新对象的创建,从而保证单例。
值得注意的是,如果我们为该类加入序列化接口,我们就必须实现readResovle函数,通过该函数我们可以值得反序列化后得到的对象,代码如下:
public final class Singleton implements Serializable{
private Singleton(){
}
private static final Singleton INSTANCE = new Singleton();
public static Singleton getInstance(){
return INSTANCE;
}
public Object readResovle(){
return Instance;
}
}
懒汉式单例模式
所谓懒汉式,就是当我们第一次使用这个对象时,这个对象才会被创建。因为懒汉式涉及的知识较多,所以我们一步步来分析。首先我们来看第一种写法。
public final class Singleton {
private Singleton(){
}
private static final Singleton INSTANCE = null;
public static Singleton getInstance(){
if(INSTANCE==null){
INSTANCE=new Singleton();
}
return INSTANCE;
}
}
我们来分析上面的代码,如果我们不考虑多线程,仅仅是在单线程下执行,这段代码是没有问题的。但是如果是多线程情况呢?显然我们在代码中没有加入任何的锁,我们很容易就可以分析得到,如果线程1第一次获取该对象,运行到INSTANCE=new Singleton()位置,这时候发生线程上下文切换,线程2又一次获取该对象,那么此时由于线程1尚未创建成功,所以INSTANCE依然为空,这时候线程2还会再去创建一次该对象,就会造成重复创建,所以我们做出以下改进。
public final class Singleton {
private Singleton(){
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance(){
if(INSTANCE==null){
INSTANCE=new Singleton();
}
return INSTANCE;
}
}
在上面的代码中,我们为获取实例对象的函数加上了synchronized锁,这样就解决了上面的线程安全问题。但是这样也会有一个很大的弊端,因为其实当我们的实例对象创建好之后,之后的访问就都是线程安全的了,而在这种写法中,即时实例对象已经创建好了,我们依然会为其上锁,而每次加锁对性能的消耗是很大的,所以就会对性能产生影响,所以我们作以下改进:
public final class Singleton {
private Singleton(){
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance(){
if(INSTANCE!=null){
return INSTANCE;
}
synchronized(Singleton.class){
if(INSTANCE==null){
INSTANCE=new Singleton();
}
}
return INSTANCE;
}
}
在这里,我们采用两次null判断。第一次null判断时,如果对象已经创建好了,就直接返回就可以了,不存在线程安全问题。而通过上面的分析我们知道如果INSTANCE为null,不一定没有线程尝试获取过该对象,所以我们要加锁再进行一次null判断,以保证对象创建过程中的线程安全。
到这里,相信很多人都会认为没有问题了(我之前也是这样认为的),但其实,如果我们从java底层来看,还是会发现问题。
学过JVM的都应该知道,JVM在执行指令的时候,会在单线程下不影响结果的前提下,对某些指令进行重排序以提升性能。我们首先对该函数的字节码进行分析
以下是对字节码的解释:
getstatic:获得静态变量
ifnonnull:进行if not null判断,如果判断成功不为空,进入37行(重新获取静态变量)
ldc:获得类对象
dup:把类对象的引用地址复制了一份
astore_0:把类对象指针临时存储,是为了后面解锁用
moitorenter:开始执行同步代码块,底层就是创建moitorer对象,判断owner是否为空,不为空进入阻塞队列等等
getstatic:获得静态变量
ifnonnull:进行if not null判断,如果判断成功不为空,进入27行(把存储的类对象取出)
new:创建实例
dup:复制引用
invokespecial:复制的引用用来进行构造方法
putstatic:赋值给静态变量
aload_0:获取类对象的引用
monitorexit:解锁
实际上我们只需要关注下面四行字节码,也许jvm会进行优化,进行指令重排,24会在21之前执行,先把引用地址赋值给静态变量,再调用构造方法。
那这样就可能会产生问题,如下图,如果在调用构造方法之前,第二个线程执行了getstatic,然后执行ifnonnull判断是否为空,结果确是不为空,然后return返回,然后使用对象,但此时构造方法还没有执行完,那对于线程t2来说就会拿到一个还没有初始化完毕的单例对象,那线程t2那这这个对象去进行操作时就很可能会产生异常。
如下图,如果在调用构造方法之前,第二个线程执行了getstatic,然后执行ifnonnull判断是否为空,结果确是不为空,然后return返回,然后使用对象,但此时构造方法还没有执行完,那对于线程t2来说就会拿到一个还没有初始化完毕的单例对象。
基于此,我们做了以下改进。我们在INSTANCE变量上加上volitile关键字,以保证有序性。(即在putstatic后加入写屏障,该指令上面的指令无法重排序到写屏障下)
public final class Singleton {
private Singleton(){
}
private static volitile Singleton INSTANCE = null;
public static synchronized Singleton getInstance(){
if(INSTANCE!=null){
return INSTANCE;
}
synchronized(Singleton.class){
if(INSTANCE==null){
INSTANCE=new Singleton();
}
}
return INSTANCE;
}
}
枚举类
我们直接来看代码:
public enum Singleton{
INSTANCE;
}
其实对于枚举类中的变量来说,它就是枚举类中的一个静态变量,而静态变量是在类加载阶段完成初始化的,所以枚举类的实现方式其实是一种饿汉式的单例模式,也是线程安全的。而枚举类默认都是实现序列化接口的,但是它已经考虑到反序列化破坏单例的问题,已经做了处理,所以不能被反序列化破坏单例。
静态内部类
同样的,我们先看代码:
public final class Singleton {
private Singleton(){
}
private static class LazeHolder{
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance(){
return LazeHolder.INSTANCE;
}
}
这种实现方式其实是利用了静态内部类的懒汉式类加载,即在第一次访问该类的时候该类才会被加载,所以这是一种懒汉式单例模式。而类加载的过程是由JVM保证其线程安全的,所以该方法是线程安全的。
总结
本文中,我们主要分析了饿汉式、懒汉式、枚举类(本质上也是饿汉式)、静态内部类(本质上也是懒汉式)四种创建单例模式的方式,并分析了其线程安全性。从饿汉式的实现过程来看,尽管单例模式比较容易理解,但是它涉及的知识点还是比较多的。这里也推荐大家多了解一些JVM底层的知识,能够好的帮助我们去理解代码。