目录
一、单例模式概述
二、懒汉模式和饿汉模式
1.饿汉模式
1.1代码实现
1.2实现细节
1.3模式优劣
2.懒汉模式
2.1代码实现
2.2实现细节
2.3模式优劣
三、多线程下的线程安全问题
1.懒汉和饿汉线程安全问题分析
1.1安全的饿汉模式
1.2不安全的懒汉模式
2.懒汉线程安全实现
2.1代码实现
2.2实现细节
单例模式是一种创建型模式,它的目的是确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。单例模式通常用于需要频繁创建和销毁同一对象的场景,通过单例模式可以减少系统性能开销,提高系统性能。
基础的单例模式分为两种,懒汉模式和饿汉模式。
例如对于相当大的对象(假设其管理10G的数据),使用一次创建一次或是过多创建该对象都会造成较大的系统性能开销,那我们能不能规定整个这个类只能创建出一个实例,即每次创建实例返回的都是同一个对象,这样不但避免了使用一次就创建一次的性能开销,也能避免创建多个对象对空间资源的浪费。
可以结合下例进一步理解:
对于一个“自助调料区”对象,在火锅店中需要他的“客人”线程可以在任何时间任何地区,同时同地的前来操作“自助调料区”对象,获取需要的内容。
在这一场景下,多个“自助调料区”对象无疑是没有必要的,正是单例模式大显身手的地方,采用了单例模式的火锅店就像是下达了“禁止多设调料区,需要调料都到这一个调料区”的指令,避免了资源的浪费。
说了这么多,单例模式怎么使用,又是怎么实现的呢?
且看下文。
饿汉模式是单例模式的一种简单实现,‘式如其名’,饿汉模式主打的就是一个饿死鬼投胎,即类加载阶段就已经把实例创建出来了,相当于程序已启动就有这个实例了,总之非常迫切的感觉。
在饿汉模式中,类加载的时候就已经实例化对象,即“饿汉”在类加载时就完成了初始化,因此可以保证只有一个实例存在。
虽然Java标准库没法直接规定类所能创建实例的数量,但我们依然可以通过一些方法限制实例的创建,间接达到只能创建一个实例的效果。
饿汉模式的代码具体实现如下:
class Singleton {
//实例为static修饰的类属性,类加载阶段创建
private static Singleton instance = new Singleton();
//通过getInstance方法获取唯一实例
public static Singleton getInstance() {
return instance;
}
//私有构造方法,无法通过new创建该类实例
private Singleton() {};
}
第二行代码“private static Singleton instance = new Singleton();”
instance变量是Singleton类创建的唯一实例,分别被‘private’关键字和‘static’关键字修饰。private保证了该变量为类所私有,外界无法直接访问和修改,只能通过下面的getInstance方法获取该实例。static表示类属性,即instance作为Singleton类的属性在类加载阶段就被创建出来,且具有唯一性。
第三~五行代码“getInstance()”
作为获取唯一实例的唯一方法存在,需要由public和static修饰,使外界可以通过类直接调用该方法。
第六行代码“private Singleton() {};”
private关键字使Singleton类的构造方法私有,这样外界就没法new该类了。
这种方式实现简单,但会导致类加载时就创建对象,如果不需要使用该对象则会造成资源浪费。同时,由于实例化对象在类加载时完成,因此无法在运行时改变实例状态。
不同于饿汉模式的急不可耐,懒汉模式采用的是摆烂策略,就像博主暑假在家一样,不喊我我就绝不出门,妥妥的宅男。
在懒汉模式中,类加载的时候不会实例化对象,而是在第一次调用getInstance方法时才实例化对象。
class SingletonLazy {
private static SingletonLazy instance = null;
//通过getInstance方法获取唯一实例
public static SingletonLazy getInstance() {
if(instance == null){
instance = new SingletonLazy();
}
return instance;
}
//私有构造方法,无法通过new创建该类实例
private SingletonLazy() {};
}
上述实现和饿汉模式的实现差别不大,只不过并没有在类加载阶段直接创建实例,而是在第一次调用getIntance方法时才创建出实例,即调用getIntance且instance=null未初始化时。
只有在需要时才创建对象,节省了系统资源,只是实现上要比饿汉模式要更加复杂。由于在多线程环境下可能导致多个线程同时实例化对象,因此需要加锁来保证线程安全。
上述懒汉模式和饿汉模式的实现针对的是单线程情况的代码,多线程下代码实现是否会出现问题还需要具体分析。
我们知道,产生线程安全的原因可能是内存可见性、锁竞争、优化策略和线程调度策略,及其它。具体产生问题的原因可能是多个线程对同一空间读写操作产生的。
再看饿汉模式的代码实现
很显然Singleton类中唯一的可调用方法getIntance只涉及到读操作,并不会产生线程安全问题。而由于不涉及到锁,更不会因为锁竞争陷入死锁。所以,饿汉模式是线程安全的。
再看懒汉模式,getIntance方法中不仅涉及了读操作同时也涉及了写操作,这就为线程安全问题的产生埋下了隐患。
由于线程调度的随机性,当两个线程在同一时间调用该方法时,错落的执行顺序可能导致if语句出现不可避免的错判,进而导致最终创建了两个SingletonLazy实例,如下图:
①T1线程执行完if语句,因为第一次调用getIntance方法,intance==null,所以T1线程接下来将要创建SingletonLazy实例,并将其赋值给intance。
②轮到T2线程执行,由于T1线程中尚未进行实例创建,此时仍旧是instance==null,所以if语句判断通过。接下来创建实例、赋值一气呵成,最后还将创建的Singleton对象返回。
③再次轮到T1线程,继续执行,创建了一个和T2线程不同的Singleton实例,引用赋给instance。最后,这个引用又被返回。
综上所述,在多线程情况下竟然出现了两个懒汉实例,这不符合单例模式下一个类只能创建一个实例的原则,很可能产生无法预估的错误,妥妥的bug代码。所以单线程下实现的懒汉模式不是线程安全的。
上文分析懒汉模式代码线程不安全的原因是进程的随即调度问题,这一点我们可以通过引入锁来保证代码的原子性(一个整体)。同时,还要注意其他线程安全问题。
class SingletonLazy {
//锁对象
private static Object lock = new Object();
//唯一实例,新增的volatile关键字是为了禁止指令重排序导致bug
private static volatile SingletonLazy instance = null;
public static SingletonLazy getInstance() {
//当instance不为空时不进行加锁,提高代码效率
if(instance == null) {
//通过锁保证创建实例代码的原子性,不会因为线程的随即调度而产生多个实例
synchronized(lock) {
//多线程情况下可能由于锁竞争陷入阻塞,所以其他线程可能创建过实例了
if(instance == null){
//虽然new SingletonLazy可以分解为三个指令,
// 但instance受volatile保护不会指令重排序
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {};
}
通过锁保证代码块的原子性,进而克服系统随机调度的问题:
①T1线程执行。实例未创建,if判断通过。
②轮到T2线程。实例未创建,if判断通过。T2线程首先获取到锁资源,synchronized代码块执行完毕后才释放锁。if判断通过,实例创建成功后,锁释放。
③锁释放,T1线程解除阻塞,获取到锁资源。由于T2线程已经创建实例成功,if判断不通过,不创建新的实例,解锁,返回instance。
④轮到T2线程,返回instance的值。
上述过程返回的是同一个实例,成功克服系统随机调度的问题。
通过额外的if嵌套提高代码效率:
对于单线程代码来说,两个完全一样的if判断就是在脱裤子放屁,多此一举。但对于多线程代码来说,由于线程的随机调度,线程阻塞等问题,紧邻的两行代码执行时间相隔的可能是海枯石烂。
就我们的代码来说,外层if的作用是在实例已经创建的情况下,如果再调用本方法,只需经过该if语句就可以直接返回值,结束方法。像较于加锁解锁,if作为跳转语句效率相对非常之高,可以提高代码的运行效率。
而内层if则是判断线程阻塞时其他线程没有创建实例,确保只创建出一个实例。
通过volatile关键字保障创建操作不会因为代码优化(指令重排序)产生问题:
指令重排序是因为编译器会在保持“代码逻辑不发生变化”这一前提下对我们的代码进行优化,举个形象的例子:
1.去楼下超市买菜
2.回家
3.下楼倒垃圾
假如我们的代码执行逻辑为1->2->3,代码优化过后可能执行逻辑就变为1->3->2,两种执行逻辑效果相同,但效率却大大提高了。
而在多线程代码中,代码优化却可能会导致bug的出现。例如当线程频繁对同一个变量进行读值,在代码优化过后可能就不会再从主内存中读值,而是直接从线程的寄存器中读值,这时如果修改主内存的值,线程是感知不到的,从而导致线程安全问题的出现。
new Singleton()可以分解为以下三个指令:
1.申请一段内存空间
2.在这个内存上调用构造方法,创造出这个实例
3.把这个内存地址赋值给instance引用变量
指令重排序会在保持“代码逻辑不发生变化”这一前提下对我们的代码进行优化。对于逻辑而言,上述三个指令的顺序123和132都是没有区别的,因此执行顺序可能被优化成132。
程序执行是一条指令一条指令执行的,因此三条指令执行过程中线程可能就会被调度走了,如下:
①执行完毕后instance不为空,但引用指向的空间还未初始化,因为指令2还未执行。
②因为instance不为空,外层if判断未通过,返回未初始化空间的引用instance,方法结束。因为实列未初始化,而初始化的时间又无法确定(随机调度,T1要和其他线程竞争),这时候使用这个实例就可能产生问题。
③执行时间不确定,可能产生问题。
想要避免上述情况的出现,就必须保证指令的执行顺序保持不变为123,想达到这一效果可以使用volatile关键字修饰instance,利用其禁止指令重排序的特性。
博主是Java新人,每位同志的支持都会给博主莫大的动力,如果有任何疑问,或者发现了任何错误,都欢迎大家在评论区交流“ψ(`∇´)ψ