单例模式
文章目录
定义:一个类只能有一个实例,且这个类能自行创建这个实例的一种模式。
1. 单例模式的优缺点
1.1 优点
- 由于单例模式在内存中只存在一个实例,减少了内存开支,特别是一个对象需要频繁地创建、销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显了;
- 由于单例模式只生成一个实例,所以减少了系统的性能开销,当一个对象的产生需要比较多的资源时,如读取配置、产生其他依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后用永久驻留内存的方式来解决(在JavaEE中采用单例模式时需要注意JVM垃圾回收机制);
- 单例模式可以避免对资源的多重占用;
- 单例模式可以在系统设置全局的访问点,优化和共享资源访问;
1.2 缺点
- 单例模式一般没有接口,扩展很困难,如果要扩展,除了修改代码基本上没有第二种途径可以实现。那么为什么单例模式不能增加接口呢?因为接口对单例模式时没有任何意义的,它要求“自行实例化”,并且提供单一实例、接口或抽象类是不能被实例化的。当然在特殊情况下,单例模式可以实现接口、被继承等,需要在系统开发中根据环境来进行判断。
- 单例模式对测试是不利的。在并行开发环境中,如果单例模式没有完成,是不能进行测试的,没有接口也不能使用mock的方式虚拟一个对象;
- 单例模式与单一职责原则有冲突。一个类应该只实现一个逻辑,而不关心它是否是单例得到,是不是要单例取决于环境,单例模式把“要单例”和业务逻辑融合在一个类中了;
2. 单例模式的使用场景
在一个系统中,要求一个类有且仅有一个对象,如果出现多个对下个就会出现“不良反应”,可以采用单例模式,具体的场景如下:
- 要求生成唯一序列化的环境;
- 在整个项目中需要一个共享访问点或共享数据。例如一个web页面上的计数器,可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保线程是安全的;
- 创建一个对象需要消耗的资源过多。如要访问IO和数据库等资源;
- 需要定义大量的静态常量和惊天方法(如工具类)的环境,可以采用单例模式(当然,也可以直接声明为static的方式)。
3. 单例模式的实现
3.1 分析
我们都知道,一个类想要实例化那肯定是要调用这个类的构造方法的,那么我们可以通过将构造方法给私有化,这样子别人就无法使用它的构造方法来实例化了。问题又来了,别人用不了这个类的构造方法,又该怎么对他进行实例化呢?我们都知道面向对象有三大特性:封装、继承与多态。那思路就很明确了,我们可以使用封装的方法将构造方法与实例的属性私有化,将实例化这个工作交给这个类自己,对外提供一个公开的获取这个实例的方法。但是还有一个问题,我都不能把这个类实例化,那该怎么调用这个类的方法呢?聪明的朋友们应该已经想到了,我们可以将这个方法写成静态方法,就不需要进行实例化就能拿到了,将实例属性也写成静态的,并且使用构造方法对其赋值。至此,我们已经分析完了单例模式的实现了。以上全是文字分析,太枯燥了,大家估计都看得很懵了,下面我们结合代码来看一下。
3.2 代码实现
3.2.1 饿汉模式
class Singleton {
// 静态属性在程序一运行时就初始化,从而实例化Singleton的对象
private static Singleton singleton = new Singleton();
// 构造方法私有化,禁止别人使用,从而保证只有一个实例
private Singleton(){}
// 提供外界可以获得该实例的方法
public static Singleton getInstance() {
return singleton;
}
}
饿汉模式在类加载的时候就创建了单例类的对象。
如果创建单例对象会消耗大量资源,那么采用懒汉模式就不是一个明智的选择了。
3.2.2 懒汉模式
懒汉模式与饿汉模式的区别在于,懒汉模式并不是在类加载时创建对象,而是在第一次获取这个对象时创建,也就是延迟加载的意思。
3.2.2.1 不加锁实现
class Singleton {
private static Singleton singleton = null;
private Singleton(){}
public static Singleton getInstance() {
// 判断对象是否创建
if(null == singleton) {
// 创建对象
singleton = new Singleton();
}
return singleton;
}
}
这种实现有一个明显的问题,就是没有考虑线程安全问题,在多线程并发的情况下,会并发调用 getInstance() 方法,从而导致系统同时创建多个实例类对象,这显然不符合单例模式的要求。
3.2.2.2 加锁实现
添加 synchronized 锁
class Singleton {
private static Singleton singleton = null;
private Singleton(){}
public static sychronized Singleton getInstance() {
// 判断对象是否创建
if(null == singleton) {
singleton = new Singleton();
}
return singleton;
}
}
添加 sychronized 锁虽然可以保证线程安全,但是每次调用 getInsance() 方法的时候,都会有加锁和解锁操作,同时 synchronized 锁是添加在方法上的,锁的范围过大,而单例类是全局唯一的,锁的操作会成为系统的瓶颈。因为,需要对代码再进行优化,所以就有了==“双重校验锁”==的方式。
class Singleton {
private static Singleton singleton = null;
private Singleton(){}
public static Singleton getInstance() {
// 第一次校验
if(null == singleton) {
sychronized(Singleton.class) {
// 第二次校验
if(null == singleton) {
// 创建对象,非原子操作
singleton = new Singleton();
}
}
}
return singleton;
}
}
双重校验锁会出现指令重排的问题,所谓指令重排就是值 JVM 为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。" singleton = new Singleton() "看似原子操作,但其实并不是。
" singleton = new Singleton() "实际上剋抽象为以下几条 JVM 指令:
// 1:分配对象的内存空间
memory = allocate();
// 2:初始化对象
ctorInstance(memory);
// 3:设置 instance 指向刚分配的内存地址
singleton = memory;
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重新排序的,经过重新排序后如下:
// 1:分配对象的内存空间
memory = allocate();
// 3:设置 instance 指向刚分配的内存地址
singleton = memory;
// 2:初始化对象
ctorInstance(memory);
可以看到,指令重排以后,singleton 指向分配好的内存放到了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给 singleton 引用,恰好 B 线程进入方法判断 singleton 引用不为 null,然后就将其返回使用,导致程序出错。
为了解决指令重排的问题,可以使用 volatile 关键字修饰 singleton 字段。volatile 关键字的一个语义就是禁止指令的重新排序优化,阻止 JVM 对其相关代码进行指令重排,这样就能够按照既定的顺序执行指令。
修改后的代码如下:
class Singleton {
private static volatile Singleton singleton = null;
private Singleton(){}
public static Singleton getInstance() {
if(null == singleton) {
sychronized(Singleton.class) {
if(null == singleton) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
3.2.3 最佳实现
静态内部类单例模式
class Singleton {
// 私有的静态内部类,类加载器负责加锁
private static class SingletonHolder {
private static Singleton singleton = new Singleton();
}
// 私有化构造方法
private Singleton(){}
// 提供外界可以获得该实例的方法
private static Singleton getInstance() {
return SingletonHolder.singleton;
}
}
当第一次访问类中的静态字段时,会触发类加载,并且同一个类只加载一次。静态内部类也是如此,类加载过程由类加载器负责加锁,从而保证了线程的安全。这种写法相对于双重校验锁的写法,更加简洁明了,也更加不容易出错。