前言
很多同学都在学习Java的过程中听说过单例模式,或者在面试中遇到过类似的问题。单例模式算是老生常谈的问题了,其实就是一句话能说清楚事情:所谓单例模式,就是实例化过程中只实例化一次。无论有多少线程来访问,都只实例化一次,多个线程调用已经实例化好的对象,而不是重新创建一个。虽说简单但是还是有不少细节要深究一些,比如前面有些文章里说的DCL就是单例模式的实例之一。更多线程知识内容请点击【Java 多线程和锁知识笔记系列】
为什么要有单例模式
既然要说单例模式还是从零开始,设想这样一个场景:远程办公,有一个任务文档要写,很多同事必然要打开这个文档去输入内容,如果给每一个同事分配一个文档实例,那就相当于谁写谁的,想要知道大家都写了什么必须整合在一起,费时费力。因此所有人如果能够使用同一个文档实例,大家都把自己的内容直接整合进去,就能省去很多时间,当然我们这里不考虑文档顺序或者复盖这些问题,只考虑实例对象。这个例子抽象出来就是:多个线程操作同一个对象的时候,要保证对象的唯一性,这个唯一性就是单例模式。
单例模式的通解
一般说道模式,就和模板类似,不仅Java可以实现,任何一种编程语言只要套用这个模板都可以实现这个模式。一般来说这个通解就是:首先要保证有且只有一个实例化的过程,也就说产生实例化对象(new)的过程只能有一次;然后提供一个返回实例化对象的方法供外部使用,常见的getInstance()等等。凡是遵循上述通解模式的代码开发,都叫做单例模式。下面就介绍一些常见的单例模式。
饿汉模式
这种实现模式是最基础的单例模式的实现。所谓的饿汉的意思:就是说我用不用实例对象不管,反正立刻得有。
public class HungryModel {
/**
* 这就是一个标准的饿汉模式,因为static标注的成员变量,
* 会在最开始被加载的时候就产生实例对象。
* 用不用不管,反正得现有吃的再说,这就是饿汉
*/
private static HungryModel instance=new HungryModel();
private HungryModel() { } //构造方法私有,不允许外部使用new创造一个对象出来
//返回实例对象的方法
public static HungryModel getInstance(){
return instance;
}
//用对象的hashcode进行测试
public static void main(String[] args) {
for (int i = 0; i <5 ; i++) {
new Thread(()->{
System.out.println(HungryModel.getInstance().hashCode());
}).start();
}
}
}
打印输出,所有线程使用对象的hashcode相同,说明没有新的对象被创建出来,单例模式成功
2040633862
2040633862
2040633862
2040633862
2040633862
实例出来了,我们分析下饿汉模式的优缺点:
- 线程方面,例子中static变量在类加载(ClassLoader)的时候就会被实例化,因此只会有这一次,不会出现多次实例化的过程,所以是线程安全的。
- 延时加载方面,不会延迟加载,无论用不用都会被加载出来。
- 空间方面,由于会自动加载,无论使用不使用都要占据内存的空间,如果实例对象很大,又很多有概率会导致内存溢出。
- 性能方面,如果实例对象比较小,性能会比较好。
懒汉模式
懒汉模式是对饿汉模式的一种改进。刚才已经分析过,饿汉模式不会在意实例对象是不是使用都会创建出来。相对的懒汉模式,就是当需要使用的时候再进行实例化。不用就不动,所谓的懒也就是从这里来的。
public class LazyModel {
private static LazyModel instance;
private LazyModel() { } //构造方法私有,不允许外部使用new创造一个对象出来
//实例化部分与返回部分合在一起,是懒汉模式的标志特点
public static LazyModel getInstance(){
if (Objects.isNull(instance)){
instance=new LazyModel();
}
return instance;
}
public static void main(String[] args) {
for (int i = 0; i <5 ; i++) {
new Thread(()->{
System.out.println(LazyModel.getInstance().hashCode());
}).start();
}
}
}
打印输出,所有线程使用对象的hashcode相同,说明没有新的对象被创建出来,单例模式成功
1934637030
1934637030
1934637030
1934637030
1934637030
在这个例子中,当调用到getInstance()的时候才会把相应的实例对象给创建出来,所谓的懒也就是指这个步骤。继续分析下懒汉模式的优缺点:
- 线程方面,由于实例是调用时创建,如果有两个线程同时执行到Objects.isNull(instance)这个条件判断的时候,还是有机会创建两个实例对象的,因此线程不安全。多线程下并不能保证实例对象的唯一性,风险很大。
- 延时加载方面,延迟加载,什么时候用什么时候创建。
- 空间方面,延时加载,不调用不占内存空间。
- 性能方面,什么时候用,什么时候加载,加载过以后就不再创建新的实例对象,性能高。
由于多线程有不安全的问题,一票否决,多线程下不要使用这种模式。
懒汉模式 + 同步锁
懒汉模式有线程安全的缺点,可以通过加一个synchronized同步锁解决这个问题。
public class LazySyncModel {
private static LazySyncModel instance;
private LazySyncModel() { } //构造方法私有,不允许外部使用new创造一个对象出来
//实例化部分与返回部分合在一起,是懒汉模式的标志特点
public synchronized static LazySyncModel getInstance(){
if (Objects.isNull(instance)){
instance=new LazySyncModel();
}
return instance;
}
}
基本上代码是一样的,优缺点基本上也是一样的,唯一就是多线程上做了控制:
- 线程方面,由于在调用创建实例的时候加了同步锁,因此当有一个线程访问的时候,其他线程想要访问时会blocked无法访问,因此线程安全。
- 性能方面,由于创建时加了synchronized,因此每到需要使用实例变量的时候,就要排队,执行过成由多线程并行执行变成了串行执行,性能下降。
- 其他方面,同懒汉模式。
Double Checked Locking (DCL)
双重检查锁定模式。懒汉模式+同步锁会导致线程排队,但是我们真正想要做的只是想要创建的时候不能多线程执行而已,因此我们可以直接把锁加在new这个语句上,做下面的修改:
if (Objects.isNull(instance)){
synchronized(LazySyncModel.class){
instance=new LazySyncModel();
}
}
但是这样做还是有问题,和原始的懒汉模式一样:假设有两个线程同时执行到Objects.isNull(instance)这个条件判断的时候,都判断条件为true要进入语句块内。此时线程1拿到了资源去创建了,于是线程2等待线程1释放资源。等线程1运行结束以后,线程2还是要往下走,直接又创建一次。所以这样线程就又不安全了,怎么样才能保证线程安全呢,我们继续修改代码:
public class DCLModel {
private static DCLModel instance;
private DCLModel() { } //构造方法私有,不允许外部使用new创造一个对象出来
public static DCLModel getInstance(){
if (Objects.isNull(instance)){
synchronized(DCLModel.class){
//再次检查是不是真的要创建实例对象
if(Objects.isNull(instance)){
instance=new DCLModel();
}
}
}
return instance;
}
}
这次的修改是在同步锁synchronized块里面再次判断实例对象要不要创建,这种同步锁外面用if语句检查一次,里面再检查一次的方法,就叫做Double Checked Locking (DCL)。所以Double Check双重检测,就是这么来的。接下来我们分析下这种模式的优缺点:
- 线程方面,没问题线程安全,这是一定的。
- 延时加载方面,是延时加载。
- 空间方面,随调用生成,不调用不占空间,也不错。
- 性能方面,比懒汉-同步锁模式有优化,比纯懒汉模式安全。
似乎看起来很完美,但是还要考虑到另一个问题,在说volatile关键字的时候,会有指令重排的问题,那么极小概率情况下,会导致其他线程在使用实例对象的时候报空指针异常。比如,线程1实例化对象结束但是还没有给其中的成员对象赋值完成的时候,线程2就开始使用这些成员变量,就会引起空指针异常,因此最好我们在实例对象声明的地方加上volatile多做一个保险:
public class DCLModel {
//用volatile保证代码执行顺序
private volatile static DCLModel instance;
private DCLModel() { } //构造方法私有,不允许外部使用new创造一个对象出来
public static DCLModel getInstance(){
if (Objects.isNull(instance)){
synchronized(DCLModel.class){
if(Objects.isNull(instance)){
instance=new DCLModel();
}
}
}
return instance;
}
}
此外即使为了阻止别人New一个单例类,把构造方法全部设置为私有的,其实这也只是 “防君子不妨小人” 的措施而已,毕竟Java提供了反射技术可以对这一个过程进行逆向,但是作为开发者只能保证自己的写法相对安全而已。
Holder 模式
上面的例子中所有的实例对象都是作为成员变量存在的,Holder模式则是放到一个内部静态类中,然后通过调用静态内部类的实例对象,提供对外的访问机制。
public class HolderModel {
private HolderModel() { } //构造方法私有,不允许外部使用new创造一个对象出来
//主动调用,才会实例化
public static HolderModel getInstance(){
return InstanceHolder.instance;
}
//使用静态内部类进行实例化
private static class InstanceHolder{
private static HolderModel instance=new HolderModel();
}
}
由于使用静态类,那么在实例化的时候只能实例化一次,因此线程的安全性得到了保证。而且很明显,这样做避免了加锁,因此性能上又有所提高。由于内部类只有主动调用的时候才会实例化,因此也能做到随用随加载。可以说Holder模式结合了懒汉模式和饿汉模式,属于十分高效安全的单例模式。
Enum模式
最后一种是枚举模式,是Effective Java书中中提到方式,主要使用的就是枚举类型的特性。这种特性就是枚举类型都是属于常量,而且只能在加载的时候实例化一次,就是说不能被懒加载。
public enum EnumModel {
INSTANCE;
public static EnumModel getInstance(){
return INSTANCE;
}
}
代码非常的简洁,但是却能够实现单例模式还可以保证线程的安全性,围观Java大神的代码,膜拜一下。由于这种模式无法直接实现懒加载,那么稍微改进一下,把这种Enum模式和Holder模式结合起来就能二次提高性能,所谓站在巨人的肩膀上看的更远。
public class EnumModel {
private EnumModel(){} //构造方法私有,不允许外部使用new创造一个对象出来
private enum EnumHolder{
INSTANCE;
private EnumModel instance=null;
EnumHolder(){
this.instance=new EnumModel();
}
}
//延时加载,当需要的时候调用内部枚举类型创建实例
public static EnumModel getInstance(){
return EnumHolder.INSTANCE.instance;
}
public static void main(String[] args) {
for (int i = 0; i <5 ; i++) {
new Thread(()->{
System.out.println(EnumModel.getInstance().hashCode());
}).start();
}
}
}
在改进过的代码里面,我们将静态内部类替换为枚举类,在枚举类调用的时候进行外部类的实例化,并且通过最终在外部调用枚举类型完成单例模式的实现。到此单例模式基本结束。