面向对象语言讲究的是万物皆对象。通常流程是先定义可实例化类,然后再通过各种不同的方式创建对象,因此类一般可以实例化出多个对象。但是实际项目开发时,我们还是希望保证项目运行时有且仅包含一个实例对象。这个需求场景的出发点包括但不限于以下几个方面:数据源对象(创建连接池、权限验证等均十分消耗资源)、线程池对象(同一个线程池)、日志对象(防止日志覆盖)等等。
单例模式(Singleton Pattern)是指在一个系统应用中只有一个实例,并且是自行实例化的。其完整表述为:
Ensure a class has only one instance, and provide a global point of access to it.
Java单例模式是一种广泛使用的设计模式,单例模式有很多好处,他能够避免实例对象的重复创建,不仅可以减少每次创建对象的时间开销,还可以节约内存空间;
为解决项目中有且仅存在唯一对象的需求,下面给出常见的几种单例模式的实现方案,并分析其优缺点。
饿汉式单例是指在项目启动加载类时进行初始化单例对象,并提供向外暴露单例对象的方法。代码如下:
/**
* 饿汉式单例模式
*/
public class HungrySingleton {
// 类初始化时对象实例化
private static HungrySingleton hungrySingleton = new HungrySingleton();
// 构造器私有化
private HungrySingleton() {}
// 向外暴露获取单例对象的方法
public static HungrySingleton getInstance() {
return hungrySingleton;
}
}
饿汉式单例模式优点:
虽然如此,但需要注意到,由于单例对象在类加载时即初始化完成,因此有可能此时初始化的对象并不会被业务逻辑使用,造成内存的浪费,而且无法被GC回收,造成内存利用率低。
因此,这种饿汉式单例模式的应用场景一般是单例对象被业务逻辑强依赖,即单例对象会被频繁的使用。
为解决饿汉式单例的对象浪费内存空间问题,对于不会被频繁使用的单例对象可以考虑使用懒汉式单例模式来初始化。懒汉式单例模式就是将对象初始化时机延迟到请求对象中,如果单例对象已经存在,直接返回,如果不存在单例对象,再进行初始化。初步代码如下:
/**
* 懒汉式单例模式-非线程安全
*/
public class LazySimpleSingleton {
private static LazySimpleSingleton instance;
private LazySimpleSingleton() {}
// 向外暴露获取单例对象的方法
public static LazySimpleSingleton getInstance() {
if (instance == null) { // 如果单例对象为null,则初始化单例对象
instance = new LazySimpleSingleton();
}
return instance;
}
}
类图和饿汉式基本无变化,区别仅在于单例对象的初始化时机。然而,饿汉式单例由于初始化是在类加载时进行,JVM已经保证了在多线程下只会被执行一次加载逻辑,但是懒汉式此例中无法保证在多线程下是否会被初始化多次,即产生了多个对象。
因此,获取单例对象时必须考虑线程安全问题。线程安全我们可以使用JVM提供的synchronized关键字修饰getInstance()保证获取实例对象方法时线程安全的,但是,这种方案也是存在问题,在高并发下,大量的请求同时需要获取单例对象时,就会由于同步锁竞争的原因使得服务性能变差。那还有没有其他方案既能保证线程安全,也能保证性能不受影响呢?答案是双重检查锁(Double Check Lock, DCL)。
双重检查锁大概描述为,第一重检查单例对象是否存在,存在就返回,不存在则进入synchronized同步代码块,在锁内部并进行第二重检查单例对象是否存在,存在即返回,不存在则初始化单例对象。这里面,首先第一重检查保证了大部分请求均不会收到锁的性能影响,第二重检查保证单例对象只会被初始化一次。实际代码如下:
/**
* 懒汉式单例模式-双重检查锁
*/
public class LazyDoubleCheckSingleton {
private volatile static LazyDoubleCheckSingleton instance;
private LazyDoubleCheckSingleton() {}
public static LazyDoubleCheckSingleton getInstance() {
if (instance == null) { // 第一重检查:保证性能不受锁的影响
synchronized (LazyDoubleCheckSingleton.class){ // 不存在单例对象时,才进入同步区
if(instance == null) { // 第二重检查:进入同步区再次检查保证确实不存在单例对象
instance = new LazyDoubleCheckSingleton();
}
}
}
return instance;
}
}
这种双重检查锁的单例实现方式就是既能够保证性能也能够保证延迟初始化对象的唯一性。需要说明的一点,你或许注意到了单例对象使用volatile关键字修饰,这是为什么呢?
首先volatile关键字有两个主要作用① 保证共享变量的可见性;② 禁止指令重排。前一个作用其实还好,因为synchronized本身就能够保证内存可见性,因此主要是由于第二个作用相关原因。继续我们再看new创建一个对象的实际过程主要分为三步:
cpu为了优化程序执行,可能会优化指令的执行顺序,如有可能第3步在第2步之前执行。如果线程A先执行了第3步,而没有执行第2步,此时CPU时间片轮转到线程B上,线程B会在第一次检查判断后返回单例对象,由于这里返回的对象实际并没有初始化完成,可能会导致线程B出现空指针或其他异常。
因此,使用volatile关键字修饰单例对象禁止CPU指令重拍优化,保证不会出现不成熟单例对象的出现和误用。
双重检查锁单例模式特点:
双重检查锁单例模式特点:
前面在使用双重检查锁的方式实现单例模式时,我们既要考虑性能、又要考虑线程安全,还需要考虑CPU指令重排的问题,实现起来相对十分复杂。并且另外一方面,synchronized同步锁在流量初期可能会出现性能尖刺问题,常常可能出现于线上发布器,也给系统稳定性带来了一定影响。有没有一种其他更好的方案呢?静态内部类单例可以算是一个答案。
如同饿汉式一样,静态内部类单例模式也还是利用JVM加载类能够保证线程安全的特性,但是又不能在单例类加载时就初始化单例对象,还是想这用到的时候才去初始化。解决这个问题的方法就是增加静态内部类,单例对象及其初始化维护在静态内部类中。而静态内部类的加载时机由getInstance()方法所决定。因此,静态内部类单例代码如下:
/**
* 静态内部类单例模式
*/
public class LazyStaticInnerClassSingleton {
private LazyStaticInnerClassSingleton() {}
public static LazyStaticInnerClassSingleton getInstance() {
return LazyHolder.INSTANCE; // 这里才会加载静态内部类LazyHolder
}
private static class LazyHolder {
// 加载时会初始化单例对象
private static final LazyStaticInnerClassSingleton INSTANCE = new LazyStaticInnerClassSingleton();
}
}
JVM将推迟LazyHolder的初始化操作,直到开始使用这个类时才初始化【因此,这也属于懒加载单例模式】,并且由于通过一个静态初始化来初始化单例对象,因此不需要额外的同步。当任何一个线程第一次调用getInstance时,都会使LazyHolder 被加载和被初始化,此时静态初始化器将执行单例对象的初始化操作。
静态内部类单例模式特点:
静态内部类单例模式特点:
枚举单例是《Effective Java》作者Joshua Bloch推荐使用的方式。以往的单例模式都有如下3个特点:
但是这种实现方式的问题就在于“私有化构造器并不保险”,因为私有构造方法仍然可以通过反射获取另外一个实例,继而破坏了单例模式。为了能保证在反射、序列化场景下创建对象的唯一性,因此我们可以借助枚举来实现单例模式【为什么枚举不能被序列化和反序列化?详见下一篇文章】。
public enum EnumSingleton {
INSTANCE;
public static EnumSingleton getInstance() {
return INSTANCE;
}
}
枚举单例模式实际上也类似于静态内部类单例,枚举单例模式属于饿汉式单例,内部枚举被JVM加载时会初始化单例对象。之所以使用枚举来加载单例对象,就是因为枚举能够防止用户通过反射、序列化等方式破坏对象的唯一性。
单例模式特点:
枚举单例模式特点:
【参考资料】