今天我们一起来探讨一下单例模式

可以说单例模式呢,是面试常客。如果考察你对设计模式的理解程度,那么有很大的可能会考察到。

因为:单例模式,虽然看起来简单,也许每个人都可以写出来,但如果深入挖掘,又能考查应试者对于并发、对于类加载序列化等重要知识的掌握程度和水平。

image-20210223231727499

单例模式有很多种写法,那么哪种写法更好呢?为什么呢?

Which? Why ?

1.什么是单例模式

首先,我们需要知道什么是单例模式。

单例模式指的是保证一个类只有一个实例,并且提供一个全局可以访问的入口。

image-20210223231807436

举个例子,这就好比是分身术,每一个分身其实都对应同一个真身:

image-20210223231826229

2.为什么需要单例呢

我们为什么需要单例呢?

2.1 节省内存节省计算

image-20210223231846544

其中一个理由,是为了节省内存节省计算。

许多时候,我们只需要一个实例就够了,如果出现了更多的实例,反而属于浪费。

举个例子:我们就拿一个初始化比较耗时的类来说:

image-20210223231911258

expensive
美 [ɪk'spensɪv]
英 [ɪk'spensɪv]
adj.昂贵的;花钱多的;价格高的
网络价格高贵的;高价的;奢侈的

在这个类构造的时候呢,需要查询数据库,并对查到的数据做大量的计算。

所以在第一次构造的时候,我们花了很多时间来初始化这个对象。

  • Y: 但是假设我们数据库里的数据是不变的,并且把这个对象保存在了内存中,那么以后啊就可以用同一个实例了。

  • N:如果每一次都重新生成新的实例,实在是没有必要。

2.2 保证结果的正确

image-20210223231954671

接下来我们再来看一看需要单例的第二个理由,那就是为了保证结果的正确。

比如我们需要一个全局的计数器用来统计人数,那么如果有多个实例的话,反而会造成混乱。

2.3 方便管理

image-20210223232019255

另外呢,就是为了方便管理

很多工具类啊,我们只需要一个实例,那么我们通过一个统一的入口,比如通过getInstance()方法就可以获取到这个单例,这是很方便的。太多的实例呢,不但没有帮助,反而会让人眼花缭乱。

3.适用场景

了解了单例模式的好处之后,我们接下来就来探讨一下单例模式有哪些适用场景。

3.1无状态的工具类

先来看一看无状态的工具类,比如日志工具类,不管是在哪里使用,我们需要的只是让他帮我们记录日志信息,除此之外呢,并不需要在他的实例对象上存储任何状态。

image-20210223232056003

3.2全局信息类

image-20210223232125717

这时候我们就只需要一个实例对象就可以了,再来看一看全局信息类。

比如我们在一个类上记录网站的访问次数,而且不希望有的访问记录被记录在对象A身上,而有的呢,却被记录在对象B上。

这时我们就可以让这个类成为单例,在需要计数的时候拿出来用就可以了。

对于全局的环境,变量类也是如此。

4.常见的写法

image-20210223232213465

那接下来我们看一下常见的写法又有哪些呢?

我认为有这么五种:

  • 饿汉式
  • 懒汉式
  • 双重检查式
  • 静态内部类式
  • 以及枚举式

我们按照写法的难易程度来逐层递进。

4.1.1饿汉式

首先来看一看相对简单的饿汉式的写法具体是什么样的:

image-20210224000957162

用static修饰我们的实例,并且能把构造函数用private修饰。

  • 优点:这种写法比较简单,在类装载的时候就完成了实例化避免了线程同步的问题
  • 缺点: 缺点在于类装载的时候就完成了实例化,而有达到懒加载的效果。所以,如果从始至终都没有使用过这个实例,就可能会造成内存的浪费。

代码例子:

private static HungryMode sHungryMode = new HungryMode();
public class HungryMode {
    private static HungryMode sHungryMode = new HungryMode();

    private HungryMode() {
        System.out.println("create " + getClass().getSimpleName());
    }

    public static void fun(){
        System.out.println("call fun in HungryMode");
    }

    public static HungryMode getInstance(){
        return sHungryMode;
    }

    public static void main(String[] args) {
        HungryMode.fun();
    }
}
// https://blog.csdn.net/hikobe8/article/details/79477853

4.1.2静态代码块式-饿汉式的变种

接下来呢,我们再来看一看,饿汉式的变种,也就是静态代码块的形式:

image-20210223232323100

这种写法和最开始的饿汉式的方式类似,只不过把类加载的过程放在了静态代码块中。

也是在类装载的时候就执行了静态代码块中的代码,完成了实例的初始化,所以优缺点也是一样的。

static {
  instance = new MyObject();
}
public class MyObject {
 
	private static MyObject instance = null;
 
	private MyObject() {
	}
 
	static {
		instance = new MyObject();
	}
 
	public static MyObject getInstance() {
		return instance;
	}
 
}

对比:

image-20210223232333653

4.2.1 懒汉式

在了解了饿汉式的写法后,接下来我们来看一下第二种写法,懒汉式。

image-20210223232401963

public class LazyMode {

    private static LazyMode sLazyMode;

    private LazyMode() {
        System.out.println("create " + getClass().getSimpleName());
    }

    public static LazyMode getInstance(){
        if (sLazyMode == null) {
            sLazyMode = new LazyMode();
        }
        return sLazyMode;
    }

    public static void main(String[] args) {
        LazyMode.getInstance();
    }

}

这种写法在getInstance方法,被调用的时候才去实例化,我们的对象起到了懒加载的效果。但是只能在单线程下使用。

如果在多线程下一个线程进入了if(null == sigleton)的判断语句块,还没来得及往下执行,另一个线程的也通过了这个判断语句,这时就会多次创建实例。所以这里需要注意在多线程环境下不能使用这种方式,这是错误的写法。

4.2.2 线程安全的懒汉式

为了避免发生线程安全问题,我们可以对前面的写法进行升级。

那么线程安全的懒汉式的写法是怎么样的呢?

image-20210223232502084

我们在,getInstance()方法上加了synchronized关键字。

这么做的目的就是为了解决刚才的现场安全问题,不过缺点就是效率太低了,每个线程在想获得类的实例的时候执行getInstance()方法都要进行同步。—》》》导致:多个线程不能同时访问。然而,这在大多数的情况下是没有必要的。

那么,为了提高效率,缩小同步范围,就把synchronized关键字从方法上移除了,然后再把synchronized关键字放到了我们的方法内部,采用代码块的形式来保护现场安全。

image-20210223232542794

不过这种写法是有问题的:

image-20210223232603509

假如一个线程进入了 if(null == singleton),判断语句块还没来得及往下执行,而另一个线程的也通过了这个判断语句,此时就会产生多个实例。

4.3双重检查模式

所以啊,为了解决这个问题,在这基础上就有了双重检查模式。

image-20210223232641846

public class Singleton {

    //volatile保证,当uniqueInstance变量被初始化成Singleton实例时,多个线程可以正确处理uniqueInstance变量
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public static Singleton getInstance() {
       //检查实例,如果不存在,就进入同步代码块
        if (uniqueInstance == null) {
            //只有第一次才彻底执行这里的代码
            synchronized(Singleton.class) {
               //进入同步代码块后,再检查一次,如果仍是null,才创建实例,防止多线程都通过第一次校验
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

我们重点来看一下getInstance()的方法,我们进行了两次 if(null == singleton)的判断就可以保证现场安全了。这样实例化代码只用调用一次,后面再次访问的时候,只会判断第一次的if就可以了,然后会跳过整个if块直接return实例化对象,这种写法的优点就是不仅线程安全,而且延迟加载效率也更高。

image-20210223232715828

这里涉及到一个常见的问题,面试官可能会问你为什么要double-check,去掉第二次的check行不行呢?

image-20210223232755287

这时你需要考虑这样的一种情况,有两个线程同时调用了getInstance()方法,并且由于singleton是空的,所以呢,两个线程都可以通过第一重的一次判断。然后由于锁机制的存在,会有一个线程先进入同步语句,并进入第二层的if判断,而另一个现成的就会在外面等待,不过呀,当第一个线程执行完了new Singleton()这个语句之后,就会退出sychronized的保护的区域,这时,如果没有第二重 null == singleton 的话,那么第二个线程也会创建一个实例,这就破坏了单例,这肯定是不行的。

而对于第一个check而言,如果去掉它,那么所有的线程都会串行,执行效率低下。

所以两个check都是需要保留的。

相信细心的你可能看到了我们在双重检查模式中给singleton这个对象加了volatile关键字,那为什么要加volatile呢?

image-20210223232859897

主要就在于singleton = new Singleton()这句,这并非是一个原子操作。

原子操作:一个或多个操作在CPU执行过程中不被中断的特性

本质在于多个资源之间有一致性的要求,操作的中间态对外不可见

Java使用锁和自旋CAS实现原子操作

JDK5提供AtomicReference保证引用对象间的原子性,它可将多个变量放在一个对象中来进行CAS操作。

JDK5之后Atomic包中提供了AtomicStampedReference#compareAndSet来解决ABA问题

https://blog.csdn.net/qq_30118563/article/details/90106741

image-20210223232923724

事实上,在JVM中,这句语句至少做了三件事:第一步就是给singleton,分配内存空间;然后第二步就是调用singleton的构造,函数等来初始化这个singleton实例;最后第三步就是将singleton对象指向分配的内存空间,而执行完这部之后singleton就不是null了。

image-20210223233000085

这里需要留意一下123的顺序,因为存在着重排序的优化,也就是说,第二部和第三部他的顺序是不能保证的,最终的执行顺序可能是123也可能是132。

image-20210223233037826

如果是132的话,那么在第三步执行完以后,singleton就不是null了,可是此时第二步并没有执行。假设此时线程二进入了getInstance()的方法,由于此时的singleton已经不是null了,所以呢,它就会通过第一重的检查,并且直接返回,那其实这个时候的singleton并没有完全的完成初始化,所以呢,使用这个实例的时候会报错。

详细的流程,我们一起来看一下

image-20210223233155144

线程一首先执行新建实例的第一步,也就是分配单例对象的内存空间;然后呢,线程一因为被重排序了,所以呢,去执行了新建实例的第三步,也就是把singleton,指向之前的内存地址;在这之后啊,对象就不是null了,此时,我们假设线程二进入了getInstance()方法判断singleton这个对象不是null,紧接着的线程二就返回了这个singleton对象并且使用,由于这个对象还没有被完全初始化,所以使用的时候就报错了;

image-20210223233225969

最后啊,线程一才姗姗来迟,才开始执行新建实例的第二步也就是初始化对象,可是这个时候的初始化已经晚了,因为前面已经报错了。

到这里,关于为什么要用volatile的问题就讲完了。使用volatile的意义,我认为主要就在于它可以防止刚才讲到的那种重排序的发生,也就避免了拿到未完成初始化的对象。

4.4 静态内部类

接下来要讲的这种方法静态内部类的写法。

image-20210223233302066

它跟饿汉式的方法采用的机制类似,都采用了类装载的机制来保证我们初始化实例时只有一个线程,所以在这里JVM帮助我们保证了现场的安全性。

image-20210223233332125

不过呢,饿汉式的方式有一个特点,就是只要这个类被加载了就会实例化这个单例对象,而静态内部类的方式在singleton类被装载时并不会立刻实例化,而是在需要实例时也就是调用getinstance()方法的时候才会去完成对singleton,这个实例的实例化。

这里简单做个小总结,静态内部类的写法与双重检查模式的优点是一样的,都是避免了线程不安全的问题,并且延迟加载效率高。可以看出呢,静态内部类和双重检查这两种写法都是不错的写法,但是他们不能防止被反序列化生成多个实例。

4.5枚举方式

那么,有没有更好的写法呢,最后,我们就来看一看枚举方式的写法。

借助JDK 1.5 中添加的枚举类来实现单例模式,这不仅能够避免多线程同步的问题,而且还能防止反序列化和反射创建新的对象来破坏单例的情况的出现。

image-20210223233453758

public class EnumSingleton {

    public static EnumSingleton getInstance() {
        return Elvis.INSTANCE.getInstance();
    }

    private enum Elvis {
        INSTANCE;
        private EnumSingleton singleton;
		//构造方法
        Elvis() {
            singleton = new EnumSingleton();
        }

        private EnumSingleton getInstance() {
            return singleton;
        }
    }
}

//外部调用
EnumSingleton enumSingleton = EnumSingleton.getInstance();
// https://blog.csdn.net/mycsdn6/article/details/106140291?utm_medium=distribute.pc_relevant.none-task-blog-baidujs_title-11&spm=1001.2101.3001.4242

5.怎么选择呢

image-20210223232213465

前面我们讲了饿汉式、懒汉式、双重检查式、静态内部类式和枚举类这五种写法。

有了这么多方法可以实现单例,此时你可能会问了,那我应该怎么选择呢?用哪种单例去实现最好呢?

我认为,最好的方式就是利用枚举

image-20210223233545194

Joshua Bloch 在 《Effective Java》一书中明确表达过一个观点,就是使用枚举去实现单例的方法,虽然还没有被广泛采用,但是呢,单元素的枚举类型已经成为了实现singleton的最佳方法。

6.枚举写法的优点

为什么他会更加推崇枚举模式的单例呢,这就不得不回到枚举写法的优点上来,枚举写法的优点有这么几个:

6.1 简洁

首先就是写法简单

image-20210223233618505

image-20210223233635286

枚举的写法不需要我们自己去考虑懒加载线程安全等问题,同时呢,代码也比较短小精悍,比其他任何的写法都更简洁,更优雅。

6.2 线程安全

第二个优点是线程安全有保障。

image-20210223233651090

通过反编译一个枚举类,我们可以发现枚举中的各个枚举项是通过static代码块来定义和初始化的,他们会在类被加载时完成初始化,而Java类的加载由JVM保证线程安全,所以呢,创建一个Enum类型的枚举是线程安全的。

image-20210223233714416

6.3 防止破坏单例

前面几种实现单例的方式其实是存在问题的,那就是可能被反序列化破坏,反序列化生成的新的对象,从而产生了多个实例。

image-20210223233749315

接下来要说的这个枚举类的第三个优点,它恰恰解决了这些问题。

image-20210223233813761

Java专门对枚举的序列化做了规定,在序列化时呢,仅仅是将枚举对象的name属性输出到结果中,在反序列化时,就是通过,java.lang.Enum的valueOf()方法来根据名字查找对象,而不是新建一个新的对象,所以这就防止了反序列化导致的,单例破坏问题的出现。

对于通过反射破坏单例而言,枚举类同样有防御措施反射。

image-20210223233846172

在通过newInstance创建对象时,会检查这个类是否是枚举类,如果是的话,那就抛出IllegalArgumentException(“Cannot reflectively create enum objects”),这样的异常反射,创建对象失败。可以看出枚举这种方式能够防止序列化和反射破坏,单例在这一点上与其他的实现方式相比有很大的优势。

安全问题不容小视,一旦生成了多个实例,单例模式就彻底没用了。

所以结合讲到的这三个优点,写法简单,线程安全以及防止反序列化和反射破坏单例枚举写法最终胜出。

7. 总结

image-20210223233951852

最后我来总结一下,今天我讲解了单例模式是什么,它的作用,用途以及五种经典写法,其中包含了恶汉式懒汉式双重检查,式静态内部类式和枚举的方式。最后呢,我们还经过了对比,看到了枚举方式,在写法上线程安全以及避免序列化反射攻击上都有优势。

这里也跟大家强调一下,如果使用线程不安全的错误的写法,在并发的情况下可能会产生多个实例,那么不仅会影响到性能,更可能会造成数据错误等严重的后果。

如果是在面试中遇到这个问题,那么你可以从一开始的饿汉式懒汉式说起一步一步的分析每一种的优缺点,并且对写法进行演进,然后重点要讲一下双重检查模式,为什么需要两次检查,以及为什么需要volatile关键字,最后再说枚举类的写法的优点和背后的原理,相信这一定会为你的面试加分。

另外,在工作中,要是遇到了全局信息类,无状态,工具类等场景的时候,推荐使用枚举的写法来实现单例模式好。

image-20210223234054811

https://time.geekbang.org/dailylesson/detail/100044001
徐隆曦