vlambda博客
学习文章列表

强烈建议收藏,单例模式最强总结

单例模式可以说是面试中最常问的一种设计模式了,我觉得原因主要有两点。第一是因为它简单,比较方便单独拿出来来考察一些初级岗位的面试者,第二呢,虽然它简单,但是其中包含了关于线程安全,内存模型,类加载机制等一些比较核心的知识点,比较能够看出面试者的基本功。同样是问单例模式,面试者答得完整透彻和答得零零散散所表现出来的状态是不一样的,所以单例模式值得深入去理解一下。

单例模式顾名思义,就是在整个运行时域,一个类只有一个实例对象。为什么需要单例模式呢?因为有的类的实例对象创建和销毁对资源消耗不大,比如上一个视频中提到的String,有的类比较庞大和复杂,如果频繁创建和销毁对象,并且这些对象完全是可以复用的,那么将会造成一些不必要的性能浪费。

举个例子,比如说我现在需要写一个访问数据库的demo,而创建数据库链接对象是一个耗资源的操作,并且数据库链接对象也是可以进行复用的,那么我可以将这个对象设计成单例的,这样我只要创建一次,并且重复使用这个对象就行了,而不用每当需要访问数据库都去创建一个链接对象,如果那么做是一件非常恐怖的事。

那么在Java中,怎么去实现单例模式呢?

单例模式有很多种写法,有的同学呢不以为然,觉得这没有意义,就是一个茴香豆的茴有几种写法的问题,自己只要会一种就够了。我个人觉得,没有那么简单,每种写法都出于不同的思考方式,我觉得十分巧妙,而其中的思想是值得细品和借鉴的。那么接下来,我就一一道来。

实现单例模式,主要需要考虑三点:1.是不是线程安全;2.是不是懒加载;3.能不能通过反射破坏。如果你对这三点一无所知,不用担心,下面我会循序渐近地介绍,保证大家都听得懂。

首先,先写一种最简单的方式,如下:

public class Singleton { private Singleton() {} // 构造器私有 private static Singleton instance = null; // 初始化对象为null public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }}

这里我写了一个名叫singleton的类,来表示我们需要构建的单例。首先将构造器设置为private,那么其他的类没办法通过new来构造singleton对象实例。而其他类中需要使用Singleton对象的话,只能通过调用getInstance方法,在getInstance方法中,首先判断instance是否被构造过,如果构造过就直接使用,如果没有就当场构造。

一个简单的单例实现就写完了。首先我们可以看到,实例对象是在第一次被调用的时候才真正构建的,而不是程序一启动,它就构建好来等着你调用的。这种滞后构建的方式就叫做懒加载,十分形象。

有的人可能会觉得,这对象这么懒干嘛,早点自己洗白白等着别人调用就好,还要等到别人来催你了,才开始加载。

那么懒加载的好处是什么?因为有的对象构建开销是比较大的,假如这个对象在项目启动就构建,万一从没被调用过,那么就比较浪费了。只有真的需要使用了再去创建,这是更加合理的。就像吃自助餐,盘子就那么大,不要一开始不管想不想吃的都装到盘子里,后面就装不下了。

理解了懒加载,我们再来说说线程安全。不难发现,上面写的这个例子,是不是线程安全的,因为在执行if (instance == null)时,多个不同的线程可能同时进入,这样就会实例化多次。

public class Singleton { private Singleton() {}  private static Singleton instance = null;  public static Singleton getInstance() { if (instance == null) { // 线程A、线程B同时进入 instance = new Singleton(); } return instance; }}

那么怎么才能使其线程安全呢?有的同学就说了,这还不简单?加上 synchronized就好了,确实,这样可以解决问题,如下:

public class Singleton { private Singleton() {}  private static Singleton instance = null;  public static synchronized Singleton getInstance() {  if (instance == null) {  instance = new Singleton(); } return instance; }}

这样,就能保证在同一时刻,只有一个线程能够进入getInstance方法。但是这引入了新的问题,其实我们只想要对象在构建的时候同步线程,而这样现在每次获取对象都要进行同步操作,对性能影响极大,无疑是捡了芝麻丢了西瓜,所以这种写法大多数情况下不可取。

通过以上的思考,我们发现,线程安全问题出现在了构建对象的阶段,那么我们只要在编译期构建了对象,在运行时调用时,就不用考虑线程安全问题了,于是我们可以这么写:

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

聪明的同学一眼就看出了,这是能够保证线程安全,但是这种写法不是懒加载的。

那么,到底有没有,既线程安全又是懒加载的单例写法?答案肯定是有的,但是让我们先来自己尝试一下。回到第二种写法,让我们看看是哪里出了问题。

public class Singleton { private Singleton() {}  private static Singleton instance = null;  public static synchronized Singleton getInstance() { if (instance == null) {  instance = new Singleton(); } return instance; }}

形成低效的原因在于getInstance上加了synchronized,所以每个进入该方法的线程都得首先获取锁,那么我们的设想之前其实也提到了,只需要在构建对象的时候同步,而可以直接使用对象的时候没必要同步,所以我们是不是可以这么写:

public class Singleton {  private volatile static Singleton singleton;  private Singleton (){}  public static Singleton getSingleton() { // 1 if (singleton == null) { // 2 synchronized (Singleton.class) { // 3 singleton = new Singleton(); //4 }  }  return singleton;  } }

在getSingleton时,不需要竞争锁,所有线程可以直接进入,此时进行第二步判断,如果对象实例还没构建,那么多个线程开始争抢锁,抢到锁的那个线程开始创建对象实例,对象创建之后,以后所有线程在执行到第2步时可以直接跳过,返回实例对象来使用,再也不用去争抢锁,这就解决了最开始效率低的问题。

但是这段代码真的没问题吗?细心的同学早已看透了一切,在多个线程执行语句2后,虽然只有一个线程(假设A)能够争抢到锁去执行语句3,但是可能会有其他线程已经进入if代码块的线程(假设B)此时正在等待,一旦线程A执行完,线程B就会立即获得锁,然后再进行对象创建。这个问题不难,你可以发现,其实只要再加一个判空操作就能解决,我们就把代码改成这样:

public class Singleton {  private volatile static Singleton singleton;  private Singleton (){}  public static Singleton getSingleton() {  if (singleton == null) { // 1 synchronized (Singleton.class) {  if (singleton == null) {  singleton = new Singleton(); // 2 }  }  }  return singleton;  } }

这样似乎已经完美了,但是如果是老程序员,会眉头一皱,发现事情并不简单,这就要谈到Java多线程的happens-before原则。happens-before有8条,主要是定义了多线程的可见性。这里不细讲,后面会单独出一期视频讲一下,可以先关注一波。

1.程序顺序规则2.锁定规则3.volatile变量规则4.线程启动规则5.线程结束规则6.中断规则7.终结器规则8.传递性规则

简单来说,这里在多线程环境下没有遵循happens-before原则,因为singleton = new Singleton() 在指令层面,不是一个原子的操作,它分为三步:

在真正执行时,虚拟机为了效率,可能会进行指令重排,比如变成1->3->2,如果按照这个顺序,A线程执行到第3步时,那么此时instance还未初始化,还是null。假设就在此时,有一个B线程执行到if (singleton == null)这一步。

public class Singleton {  private static Singleton singleton;  private Singleton (){}  public static Singleton getSingleton() {  if (singleton == null) { // 1 B线程执行到这一步 synchronized (Singleton.class) {  if (singleton == null) {  singleton = new Singleton(); // A线程在执行这一步,指令重排了 }  }  }  return singleton;  } }

此时在B线程内if (singleton == null)返回false,直接跳过,但是此时A线程内instance对象还未初始化,所以灾难发生了,B线程中调用getSingleton()返回了null,出现了线程不安全情况。

那么怎么解决呢,还是从happens-before原则出发,只要给instance加上volatile修饰,关于volatile,也会在后面专门一期讲Java多线程三大特性时细讲,关注一波哦,这里先带过,你只要知道使用了volatile,就能阻止作用在instance上的指令重排问题。

那么完整的,无bug的写法就是这样:

public class Singleton {  private volatile static Singleton singleton;  private Singleton (){}  public static Singleton getSingleton() {  if (singleton == null) {  synchronized (Singleton.class) {  if (singleton == null) {  singleton = new Singleton();  }  }  }  return singleton;  } }

这种方式是比较常用的,既满足了懒加载,也满足了线程安全,效率也比较高,也是我最喜欢的一种写法。这种方式叫做“双检锁写法“,听起来很nb,其实就是因为两次对对象进行判空检查,所以才被叫做双检锁。

但是,这种写法有个不算缺点的缺点,就是写起来还是有点复杂的,有没有满足满足了懒加载,也满足了线程安全,效率也比较高,但是写起来更简洁的写法?

既然你有这个需求,那肯定有的,如下:

public class Singleton {  private static class SingletonHolder {  private static final Singleton INSTANCE = new Singleton();  }  private Singleton (){}  public static final Singleton getInstance() {  return SingletonHolder.INSTANCE;  } }

这里用到了静态内部类,关于内部类,老样子,还是下次出一期整体讲,在这里,你只需要知道静态内部类在程序启动的时候不会加载,只有第一次被调用的时候才会加载,这种写法算是巧妙地利用了JDK类加载机制的特性,来实现懒加载。

写到这里,不知道还有没有人记得,我在文章一开始说的,实现单例模式,主要需要考虑三点:1.是不是线程安全;2.是不是懒加载;3.能不能通过反射破坏。

第一第二点上文已经详细探讨了,那第三点为什么只字未提呢?因为很遗憾,上述几种方法都是可以通过反射破坏单例的,但是呢,反射操作是一种人为的主动操作,只有故意那样操作才会被破坏。那到底什么是反射?还是老样子,以后单独出一期讲,在这里,你只要知道反射是在程序运行时,动态调用类型信息。

那么我们先写个demo,来看一下单例是如何被破坏的。

Constructor c = null; try { c = Class.forName("com.streambuf.SingletonDemo").getDeclaredConstructor(); } catch (Exception e) { e.printStackTrace(); }  if(c != null) { c.setAccessible(true); SingletonDemo singleton1 = (SingletonDemo)c.newInstance(); SingletonDemo singleton2 = (SingletonDemo)c.newInstance(); System.out.println(singleton1.equals(singleton2)); }

这里,假设SingletonDemo是按照上面任何一种方式写的单例类,我们可以发现返回的值都为false。这就说明以上的单例写法都可以被反射破坏。为什么会这样,关键在于从运行时类型信息中获取了构造器(即使是private的),并通过这个构造器构建了对象。而单例的目的就是阻止外部来构建对象。

顺便再说一句,有人觉得这种方式破坏了Java的类可见权限控制,因为被声明为private的属性或方法本身就不愿对外暴露。但是这种方式也有非常大的作用,比如对于第三方的封装库,我们可以监控它的一些状态值,一些框架也大量的依赖反射机制。

回到正题,那么,到底有没有完全满足三个条件的单例写法?我们想一下,目前面临的问题是如何拒绝JVM来读取类的私有方法。到了这个层面上,应该是无法通过上层代码实现的,不过JVM本身也提供了这种机制,就是枚举类型,对于枚举类型,反射是无法获取它的构造器的,因此反射不能破坏,而且枚举类型本身能够保证线程安全,但是枚举无法满足懒加载,(说明目前没有完全满足三个条件的单例写法)

基于枚举的优点,以及它写起来只要一行代码,非常方便,因此《Effective Java》的作者在第一章就发表观点,它认为使用枚举来构造单例是最优雅的方式。但我个人并不这么认为,我觉得单例从面向对象的角度来看,并不包含枚举这种语义,虽然写起来方便,但缺点是会造成混淆,其次就是它并不具备懒加载的特性。

那么我们现在尝试使用枚举来实现单例:

public enum SingletonEnum { INSTANCE;}

没错就是这么简单,其实它也没有比之前几种写法高级,只是利用了Java的语法糖。

这时候,我们再使用反射来尝试破坏单例,就会发现程序将会抛出如下异常:

java.lang.NoSuchMethodException: com.streambuf.SingletonDemo2.<init>() at java.lang.Class.getConstructor0(Class.java:3082) at java.lang.Class.getDeclaredConstructor(Class.java:2178) at com.streambuf.Main.main(Main.java:21)

可以看到JVM无法获取该枚举类型的构造器,这样就杜绝了通过反射来构造对象。

其实这里说得还不完全准确,如果你想寻根究底的话,这里可以再简单拓展一下,可以看到上面抛出的异常是NoSuchMethodException,我们代码中是想要通过反射获取的是枚举类型的无参构造函数。如果你手动反编译一下枚举类型的class文件,你会发现枚举类型事实上不存在无参构造函数,只override了父类Enum的一个(String,int)的构造函数。

那么既然这样,我们将代码改成这样,获取含参构造函数,是不是就能通过反射来构建实例对象了呢?

public static void main(String[] args) throws IllegalAccessException, InvocationTargetException, InstantiationException { Constructor c = null; try { c = Class.forName("com.streambuf.SingletonDemo2").getDeclaredConstructor(String.class, int.class); } catch (Exception e) { e.printStackTrace(); } if(c != null) { c.setAccessible(true); SingletonDemo2 singleton1 = (SingletonDemo2)c.newInstance("1", 1); SingletonDemo2 singleton2 = (SingletonDemo2)c.newInstance("1", 1); System.out.println(singleton1.equals(singleton2)); } }

然而也不行,我们这时看到抛出的异常为:

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects at java.lang.reflect.Constructor.newInstance(Constructor.java:417) at com.streambuf.Main.main(Main.java:27)

这时,编译器才真正告诉了我们真实原因:不能通过反射创建枚举类型的对象。

关于单例模式,本文介绍了所有最经典的写法以及它背后的思想,一文在手,面试无忧。

配合视频学习更佳:https://www.bilibili.com/video/BV1pt4y1X7kt