史上最全单例模式的写法以及破坏单例方式

共 8763字,需浏览 18分钟

 ·

2022-01-10 22:07

天跟大家讲一个老生常谈的话题,单例模式是最常用到的设计模式之一,熟悉设计模式的朋友对单例模式都不会陌生。网上的文章也很多,但是参差不齐,良莠不齐,要么说的不到点子上,要么写的不完整,我试图写一篇史上最全单例模式,让你看一篇文章就够了。

单例模式定义及应用场景

单例模式是指确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。单例模式是创建型模式。许多时候整个系统只需要拥有一个全局对象,这样有利于我们协调系统整体的行为。

比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。

我们写单例的思路是,隐藏其所有构造方法,提供一个全局访问点。

1、饿汉式

这个很简单,小伙们都写过,这个在类加载的时候就立即初始化,因为他很饿嘛,一开始就给你创建一个对象,这个是绝对线程安全的,在线程还没出现以前就实例化了,不可能存在访问安全问题。他的缺点是如果不用,用不着,我都占着空间,造成内存浪费。

public class HungrySingleton {

private static final HungrySingleton hungrySingleton = new HungrySingleton();

private HungrySingleton() {
}

public static HungrySingleton getInstance() {
return hungrySingleton;
}
}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

还有一种是饿汉式的变种,静态代码块写法,原理也是一样,只要是静态的,在类加载的时候就已经成功初始化了,这个和上面的比起来没什么区别,无非就是装个b,看起来比上面那种吊,因为见过的人不多嘛。

public class HungryStaticSingleton {

private static final HungryStaticSingleton hungrySingleton;

static {
hungrySingleton = new HungryStaticSingleton();
}

private HungryStaticSingleton() {
}

public static HungryStaticSingleton getInstance() {
return hungrySingleton;
}

}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

2、懒汉式

简单懒汉

为了解决饿汉式占着茅坑不拉屎的问题,就产生了下面这种简单懒汉式的写法,一开始我先申明个对象,但是先不创建他,当用到的时候判断一下是否为空,如果为空我就创建一个对象返回,如果不为空则直接返回。

为什么叫懒汉式,就是因为他很懒啊,要等用到的时候才去创建,看上去很ok,但是在多线程的情况下会产生线程安全问题。

public class LazySimpleSingleton {

private static LazySimpleSingleton instance;

private LazySimpleSingleton() {
}

public static LazySimpleSingleton getInstance() {
if (instance == null) {
instance = new LazySimpleSingleton();
}
return instance;
}

}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

如果有两个线程同时执行到 if (instance==null) 这行代码,这是判断都会通过,然后各自会执行instance = new Singleton(),并各自返回一个instance,这时候就产生了多个实例,就没有保证单例,如下图所示。

怎么解决这个问题呢,很简单,加锁啊,加一下synchronized即可,这样就能保住线程安全问题了。

3、双重校验锁(DCL)

上面这样写法带来一个缺点,就是性能低,只有在第一次进行初始化的时候才需要进行并发控制,而后面进来的请求不需要在控制了,现在synchronized加在方法上,我管你生成没成生成,只要来了就得给我排队,所以这种性能是极其低下的,那怎么办呢?

我们知道,其实synchronized除了加在方法上,还可以加在代码块上,只要对生成对象的那一部分代码加锁就可以了,由此产生一种新的写法,叫做双重检验锁,我们看下面代码。

我们看19行将synchronized包在了代码块上,当 singleton == null 的时候,我们只对创建对象这一块逻辑进行了加锁控制,如果 singleton != null 的话,就直接返回,大大提升了效率。

在21行的时候又加了一个singleton == null,这又是为什么呢,原因是如果两个线程都到了18行,发现是空的,然后都进入到代码块,这里虽然加了synchronized,但作用只是进行one by one串行化,第一个线程往下走创建了对象,第二个线程等待第一个线程执行完毕后,我也往下走,于是乎又创建了一个对象,那还是没控制住单例,所以在21行当第二个线程往下走的时候在判断一次,是不是被别的线程已经创建过了,这个就是双重校验锁,进行了两次非空判断。

我们看到在11行的时候加了 volatile 关键字,这是用来防止指令重排的,当我们创建对象的时候会经过下面几个步骤,但是这几个步骤不是原子的,计算机比较聪明,有时候为了提高效率他不是按顺序1234执行的,可能是3214执行。

这时候如果第一个线程执行了instance = new LazyDoubleCheckSingleton(),由于指令重排先进行了第三步,先分配了一个内存地址,第二个线程进来的时候发现对象已经是非null,直接返回,但这时候对象还没初始化好啊,第二个线程拿到的是一个没有初始化好的对象!这个就是要加volatile的原因。

  • 分配内存给这个对象

  • 初始化对象

  • 设置instance指向刚分配的内存地址

  • 初次访问对象

最后说下双重校验锁,虽然提高了性能,但是在我看来不够优雅,折腾来折腾去,一会防这一会防那,尤其是对新手不友好,新手会不明白为什么要这么写。

4、静态内部类

上面已经将锁的粒度缩小到创建对象的时候了,但不管加在方法上还是加在代码块上,终究还是用到了锁,只要用到锁就会产生性能问题,那有没有不用锁的方式呢?

答案是有的,那就是静态内部类的方式,他其实是利用了java代码的一种特性,静态内部类在主类加载的时候是不会被加载的,只有当调用getInstance()方法的时候才会被加载进来进行初始化,代码如下

/**
* @author jack xu
* 兼顾饿汉式的内存浪费,也兼顾synchronized性能问题
*/
public class LazyInnerClassSingleton {

private LazyInnerClassSingleton() {
}

public static final LazyInnerClassSingleton getInstance() {
return LazyHolder.INSTANCE;
}

private static class LazyHolder {
private static final LazyInnerClassSingleton INSTANCE = new LazyInnerClassSingleton();
}

}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

好,讲到这里我已经介绍了五种单例的写法,经过层层的演进推理,到第五种的时候已经是很完美的写法了,既兼顾饿汉式的内存浪费,也兼顾synchronized性能问题。

那他真的一定完美吗,其实不然,他还有一个安全的问题,接下来我们讲下单例的破坏,有两种方式反射和序列化。

单例的破坏

1、反射

我们知道在上面单例的写法中,在构造方法上加上private关键字修饰,就是为了不让外部通过new的方式来创建对象,但还有一种暴力的方法,我就是不走寻常路,你不让我new是吧,我反射给你创建出来,代码如下

/**
* @author jack xu
*/
public class ReflectDestroyTest {

public static void main(String[] args) {
try {
Class clazz = LazyInnerClassSingleton.class;
Constructor c = clazz.getDeclaredConstructor(null);
c.setAccessible(true);
Object o1 = c.newInstance();
Object o2 = c.newInstance();
System.out.println(o1 == o2);
} catch (Exception e) {
e.printStackTrace();
}
}
}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

上面c.setAccessible(true)就是强吻,你private了,我现在把你的权限设为true,我照样能够访问,通过c.newInstance()调用了两次构造方法,相当于new了两次,我们知道 == 比的是地址,最后结果是false,确实是创建了两个对象,反射破坏单例成功。

那么如何防止反射呢,很简单,就是在构造方法中加一个判断

public class LazyInnerClassSingleton {

private LazyInnerClassSingleton() {
if (LazyHolder.INSTANCE != null) {
throw new RuntimeException("不要试图用反射破坏单例模式");
}
}

public static final LazyInnerClassSingleton getInstance() {
return LazyHolder.INSTANCE;
}

private static class LazyHolder {
private static final LazyInnerClassSingleton INSTANCE = new LazyInnerClassSingleton();
}
}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

在看结果,防止反射成功,当调用构造方法时,发现单例实例对象已经不为空了,抛出异常,不让你在继续创建了。

2、序列化

接下来介绍单例的另一种破坏方式,先在静态内部类上实现Serializable接口,然后写个测试方法测试下,先创建一个对象,然后把这个对象先序列化,然后在反序列化出来,然后对比一下

    public static void main(String[] args) {

LazyInnerClassSingleton s1 = null;
LazyInnerClassSingleton s2 = LazyInnerClassSingleton.getInstance();

FileOutputStream fos = null;
try {

fos = new FileOutputStream("SeriableSingleton.obj");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(s2);
oos.flush();
oos.close();

FileInputStream fis = new FileInputStream("SeriableSingleton.obj");
ObjectInputStream ois = new ObjectInputStream(fis);
s1 = (LazyInnerClassSingleton) ois.readObject();
ois.close();

System.out.println(s1);
System.out.println(s2);
System.out.println(s1 == s2);

} catch (Exception e) {
e.printStackTrace();
}
}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

  • 22

  • 23

  • 24

  • 25

  • 26

  • 27

我们来看结果发现是false,在进行反序列化时,在ObjectInputStream的readObject生成对象的过程中,其实会通过反射的方式调用无参构造方法新建一个对象,所以反序列化后的对象和手动创建的对象是不一致的。

那么怎么避免呢,依然很简单,在静态内部类里加一个readResolve方法即可

    private Object readResolve() {
return LazyHolder.INSTANCE;
}
  • 1

  • 2

  • 3

在看结果就变成true了,为什么加了一个方法就可以避免被序列化破坏呢,这里不在展开,感兴趣的小伙伴可以看下ObjectInputStream的readObject()方法,一步步往下走,会发现最终会调用readResolve()方法。

至此,史上最牛b单例产生,已经无懈可击、无可挑剔了。

3、枚举

那么这里我为什么还要在介绍枚举呢,在《Effective Java》中,枚举是被推荐的一种方式,因为他足够简单,线程安全,也不会被反射和序列化破坏。

大家看下才寥寥几句话,不像上面虽然已经实现了最牛b的写法,但是其中的过程很让人烦恼啊,要考虑性能、内存、线程安全、破坏啊,一会这里加代码一会那里加代码,才能达到最终的效果。

而使用枚举,感兴趣的小伙伴可以反编译看下,枚举的底层其实还是一个class类,而我们考虑的这些问题JDK源码其实帮我们都已经实现好了,所以在 java 层面我们只需要用三句话就能搞定!

public enum Singleton {  
INSTANCE;
public void whateverMethod() {
}
}
  • 1

  • 2

  • 3

  • 4

  • 5

至此,我通过层层演进,由浅入深的给大家介绍了单例的这么多写法,从不完美到完美,这么多也是网上很常见的写法,下面我在送大家两个彩蛋,扩展一下其他写单例的方式方法。

彩蛋

1、容器式单例

容器式单例是我们 spring 中管理单例的模式,我们平时在项目中会创建很多的Bean,当项目启动的时候spring会给我们管理,帮我们加载到容器中,他的思路方式方法如下。

public class ContainerSingleton {
private ContainerSingleton() {
}

private static Map ioc = new ConcurrentHashMap();

public static Object getInstance(String className) {
Object instance = null;
if (!ioc.containsKey(className)) {
try {
instance = Class.forName(className).newInstance();
ioc.put(className, instance);
} catch (Exception e) {
e.printStackTrace();
}
return instance;
} else {
return ioc.get(className);
}
}
}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

这个可以说是一个简易版的 spring 管理容器,大家看下这里用一个map来保存对象,当对象存在的时候直接从map里取出来返回出去,如果不存在先用反射创建一个对象出来,先保存到map中然后在返回出去。我们来测试一下,先创建一个Pojo对象,然后两次从容器中去取出来,比较一下,发现结果是true,证明两次取出的对象是同一个对象。

但是这里有一个问题,这样的写法是线程不安全的,那么如何做到线程安全呢,这个留给小伙伴自行独立思考完成。

2、CAS单例

从一道面试题开始:不使用synchronized和lock,如何实现一个线程安全的单例?我们知道,上面讲过的所有方式中,只要是线程安全的,其实都直接或者间接用到了synchronized,间接用到是什么意思呢,就比如饿汉式、静态内部类、枚举,其实现原理都是利用借助了类加载的时候初始化单例,即借助了ClassLoader的线程安全机制。

所谓ClassLoader的线程安全机制,就是ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。也正是因为这样, 除非被重写,这个方法默认在整个装载过程中都是同步的,也就是保证了线程安全。

那么答案是什么呢,就是利用CAS乐观锁,他虽然名字中有个锁字,但其实是无锁化技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试,代码如下:

/**
* @author jack xu
*/
public class CASSingleton {
private static final AtomicReference INSTANCE = new AtomicReference();

private CASSingleton() {
}

public static CASSingleton getInstance() {
for (; ; ) {
CASSingleton singleton = INSTANCE.get();
if (null != singleton) {
return singleton;
}

singleton = new CASSingleton();
if (INSTANCE.compareAndSet(null, singleton)) {
return singleton;
}
}
}
}
  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

  • 22

  • 23

在JDK1.5中新增的JUC包就是建立在CAS之上的,相对于对于synchronized这种阻塞算法,CAS是非阻塞算法的一种常见实现,他是一种基于忙等待的算法,依赖底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并行度。

虽然CAS没有用到锁,但是他在不停的自旋,会对CPU造成较大的执行开销,在生产中我们不建议使用,那么为什么我还会讲呢,因为这是工作拧螺丝,面试造火箭的典型!你可以不用,但是你得知道,你说是吧。

——————END——————

欢迎关注“Java引导者”,我们分享最有价值的Java的干货文章,助力您成为有思想的Java开发工程师!

浏览 38
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报