设计模式篇——单例模式详解

单例模式的概念

单例模式(Singleton Pattern)的定义为:确保一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

单例模式是创建型模式。单例模式分为饿汉式单例和懒汉式单例,接下来我们对这两种类型做详细介绍。

饿汉式

饿汉式单例模式就是在类加载的时候就立即初始化,并且创建单例对象。不管你有没有用到,都先建好了再说。它绝对线程安全,在线程还没出现以前就实例化了,不可能存在访问安全问题。

优点:线程安全,没有加任何锁、执行效率比较高。

缺点:类加载的时候就初始化,不管后期用不用都占着空间,浪费了内存。

饿汉式单例的写法很简单,看下面代码:

还可以通过静态代码块的机制来实现:

这两种写法都很简单,都是创建了一个饿汉式的单例类。

饿汉式单例适合用在单例类比较少的情况下,在实际项目中,有可能会存在很多的单例类,如果我们都使用饿汉式单例的话,对内存的浪费会很大,所以,我们要学习更优的写法。

懒汉式

懒汉式,顾名思义就是实例在用到的时候才去创建,“比较懒”,用的时候才去检查有没有实例,如果有则直接返回,没有则新建。

下面看懒汉式的简单实现:

上面这种写法有一定概率会生成不同的对象,意味着这种写法不是线程安全的。原因如下:

假如有两个线程,线程A和线程B同时走到“if(lazy == null)”这个判断,因为lazy还没有没实例化过,所以两个线程判断的结果都是true,然后同时进入 if 代码块执行 new 操作,这时,线程A创建了一个实例,线程B也创建了一个实例,最后 return 的 lazy 肯定不是同一个对象,所以,这种写法是线程不安全的。

那我们要如何解决这个线程不安全的问题呢?最容易想到的方法就是加锁。我们把 getInstance() 方法进行加锁,看代码:

由于我们给该方法加上了 synchronized 锁,所以当线程A进入 getInstance() 方法的时候,线程B就只能在方法外等到线程A执行完这个方法之后才能进入该方法。由于线程A已经执行完该方法,所以此时 lazy 是不为null的,线程B就不会进入 if 代码块,最后返回的肯定是线程A创建的实例。

上面这种方式成功解决了线程安全问题,但在线程数量比较多的情况下,大量线程会阻塞在方法外部,导致程序性能下降。为了兼顾性能和线程安全问题,我们可以通过双重检查锁的方式来创建懒汉式的单例:

当第一个线程调用 getInstance()方法时,第二个线程也可以调用。当第一个线程执行到 synchronized 时会上锁,第二个线程就会变成 MONITOR 状态,出现阻塞。此时,阻塞并不是基于整 个 LazySimpleSingleton 类的阻塞,而是在 getInstance()方法内部的阻塞,只要逻辑不太复杂,对于 调用者而言感知不到。

但是,用到 synchronized 关键字总归要上锁,对程序性能还是存在一定影响的。难道没有更好的方案吗?显示是有的,我们可以从类初始化的角度来考虑,采用静态内部类的方式。看下面的代码:

这种方式兼顾了饿汉式单例模式的内存浪费问题和 synchronized 的性能问题。内部类一定是要在 方法调用之前初始化,巧妙地避免了线程安全问题。

反射破坏单例

饿汉式单例和懒汉式单例都是将构造方法私有化,防止在外部通过 new 来创建对象实例,以达到保证全局只有一个实例的效果。那我们思考一个问题:如果我们通过反射来调用其构造方法,在调用 getInstance() 方法得到 new 出来的实例,应该会存在两个不同的实例。现在来看一段测试代码,以上面的 LazyInnerClassSingleton 类为例:

为了防止这种情况的发生,我们在其构造方法中做一些限制,一旦出现多次创建,则直接抛出异常:

至此,最牛B的单例模式的实现就完成了!

序列化破坏单例

一个单例对象创建好后,有时候我们需要将对象序列化然后写入磁盘,下次使用时再从磁盘中读取对象 并进行反序列化,将其转化为内存对象。反序列化后的对象会重新分配内存,即重新创建。如果序列化 的目标对象为单例对象,就违背了单例模式的初衷,相当于破坏了单例。来看一段代码:

看下测试代码:

运行结果为false。从运行结果可以看出,反序列化后的对象和手动创建的对象是不一致的,实例化了两次,违背了单例模式的设计初衷。那么,我们如何保证在序列化的情况下也能够实现单例模式呢?其实很简单,只需要增加 readResolve() 方法即可。来看优化后的代码:

这时,再执行上面的测试代码,输出结果就是 true 了。这是什么原因呢?我们就要看JDK反序列化的源码了。我们进入 ObjectInputStream 类的 readObject()方法, 代码如下:

我们发现,在 readObject() 方法中调用了 readObject0() 方法。进入 readObject0() 方法,代码如下:

我们看到 TC_OBJECT 中调用了 ObjectInputStream 的 readOrdinaryObject()方法,看源码:

我们发现调用了 ObjectStreamClass 的 isInstantiable()方法,而 isInstantiable()方法的代码如下:

上述代码非常简单,就是判断一下构造方法是否为空,构造方法不为空就返回 true。这意味着只要 有无参构造方法就会实例化。这时候其实还没有找到加上 readResolve()方法就避免了单例模式被破坏的真正原因。再回到 ObjectInputStream 的 readOrdinaryObject()方法,继续往下看:

判断无参构造方法是否存在之后,又调用了 hasReadResolveMethod() 方法,来看代码:

上述代码逻辑非常简单,就是判断 readResolveMethod 是否为空,不为空就返回 true。那么 readResolveMethod 是在哪里赋值的呢?通过全局查找知道,在私有方法 ObjectStreamClass()中给 readResolveMethod 进行了赋值,来看代码:

上面的逻辑其实就是通过反射找到一个无参的 readResolve()方法,并且保存下来。现在回到 ObjectInputStream 的 readOrdinaryObject()方法继续往下看,如果 readResolve()方法存在则调用 invokeReadResolve()方法,来看代码:

我们可以看到,在 invokeReadResolve()方法中用反射调用了 readResolveMethod 方法。最终返回我们添加的 readResolve() 方法中的实例。

虽然增加 readResolve()方法返回实例解决了单例模式被破坏的 问题,但是实际上实例化了两次,只不过新创建的对象没有被返回而已。

注册式单例模式

注册式单例模式又叫登记式单例模式,就是将每一个实例都登记到某一个地方,使用唯一的标识 获取实例。注册式单例模式有两种:一种为枚举式单例模式,另一种为容器式单例模式。

枚举式单例模式

先来看枚举式单例模式的写法:

使用序列化与反序列化,看两次拿到的对象是否一样:

运行之后,看到结果返回时true,说明序列化不会对枚举类型的单例产生破坏。那发射呢?我们看下测试反射破坏枚举单例的代码:

运行之后,会报一个异常:java.lang.NoSuchMethodException。意思是没找到无参的构造方法。这时候, 我们打开 java.lang.Enum 的源码,查看它的构造方法,只有一个 protected 类型的构造方法,代码如下:

那我们再来做一个下面这样的测试:

运行之后,会报一个这样的错:“Cannot reflectively create enum objects”,即不能用反射来创建枚举类型。其原因是JDK源码中,newInstance() 方法中做了判断,如果修饰符是 Modifier.ENUM 枚举类型,则直接抛出异常:

容器式单例

容器式单例的写法如下:

容器式单例模式适用于实例非常多的情况,便于管理。但它是非线程安全的。

点个关注吧,我会持续更新更多干货~~

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20200301A0KB7S00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券