Java 单例模式一文通
在程序开发中我们往往会涉及到设计模式,那么什么是设计模式呢?官方正式的定义是一套被反复使用经过分类编目,且多数人知晓的代码设计经验总结。简单的说设计模式是软件开发人员在软件开发过程中面临问题时所做出的解决方案。常用的设计模式有 23 中,因为篇幅有限在本篇文章中我之讲解 23 中设计模式中最经典的模式:单例模式。
零、什么是单例模式
单例模式是创建型模式的一种,它主要提供了创建类对象的最优方式。在单例模式下每个类负责创建自己的对象,并且要保证每个类只创建一个对象,也就是说每个类只能提供唯一的访问对象的方式。
单例模式的出现是为了解决全局类被频繁的创建和销毁造成的性能开销,以及避免对资源的多重占用。单例模式虽然解决了这些问题但是它存在几个问题,首先在单例模式下没有接口无法继承,其次它还与单一职责原则相互冲突,并且单例模式下的类只关注内部实现逻辑,不关注外部如何实例化。
Java 中实现单例模式的方式有六种,分别是饿汉模式、懒汉模式、加锁懒汉模式、双重判定加锁懒汉模式、内部静态类实现懒汉模式以及枚举懒汉模式。下面我分别对这六种实现单例模式的方法进行一一讲解。
一、饿汉模式
所谓饿汉模式就是在类被加载时就会实例化该类的一个对象,它就像是一个很饥饿人要迫不及待的吃东西一样。以饿汉模式编写的单例模式需要注意如下两点,首先类的构造函数必须定义为 private,这是为防止该类不会被其他类实例化,其次类必须提供了静态实例,并通过静态方法返回给调用方。下面我们就根据这两点来编写饿汉模式的单例模式。
饿汉模式的代码很简单,按照前面所说的两个注意点进行编写代码即可。这种模式可以快速且简单的创建一个线程安全的单例对象,之所以说它是线程安全的,是因为它只在类加载时才会初始化,在类初始化后的生命周期中将不会再次进行创建类的实例。因此这种模式特别适合在多线程情况下使用,因为它不会多个线程中创建多个实例,避免了多线程同步的问题。但是万物不是只有优点没有缺点的,这种模式最大的缺点就是不管你是否用到这个类,这个类都会在初始化的时候被实例化,这样会造成性能的轻微损耗。饿汉模式一般用于占用内存小并且在初始化时就会被用到的时候。
二、懒汉模式
什么是懒汉模式呢?懒汉模式就是只在需要对象时才会生成单例对象,它就像一个很懒的人只有在你叫他的时候他才会动一动。和饿汉模式一样,懒汉模式也有两点需要注意的,首先类的构造函数必须定义为 private,其次类必须提供静态实例对象且不进行初始化,并通过静态方法返回给调用方,在编写返回实例的静态方法时我们需要判断实例对象是否为空,如果为空则进行实例化反之则直接放回实例化对象。下面我们就来看以下代码如何实现懒汉模式。
懒汉模式规避了饿汉模式的缺点,只有在我们需要用到类的时候才会去实例化它,并且通过饿汉模式类中的静态方法(本例中的 getLazyMode),基本上规避了重复创建类对象的问题。到这里就需要注意了我所说的是基本上规避,而不是完全规避,我为什么这么说呢?这是因为懒汉模式并没有考虑在多线程下当类的实例对象没有被生成的时候很有可能存在多个线程同时进入 getLazyMode 方法,并同时生成实例对象的问题。因此我们说在懒汉模式下实现的单例模式是线程不安全的。那么这个问题怎么解决呢?这时我们就可以使用加锁懒汉模式,我们来看一下代码如何实现。
在上面的代码中我们增加了同步锁,这样就避免了前面所说的问题。加锁懒汉模式和懒汉模式的相同点都是在第一次需要时,类的实例才会被创建,再次调用将不会重新创建新的实例对象,而是直接返回之前创建的实例对象。这两种模式都适用于单例类的使用次数少,但消耗资源较多的时候。但是加锁懒汉模式因为涉及到了锁,因此与懒汉模式相比多了一些额外的资源消耗。
三、双重判定加锁懒汉模式
双重判定加锁懒汉模式在 Java 面试中会被经常问到,但是很少有人能够正确的写出双重判定加锁懒汉模式的代码,甚至很少有人会说出来这种模式的问题,以及在 JDK1.5 版本中是如何修正这个问题的。针对这几个问题我在这一小节中进行一一讲解。
双重判定加锁懒汉模式的实现其实是创建线程安全单例模式的老方法,当单例的实例被创建时它会用单个锁进行性能优化,但是因为这个方法实现起来很复杂,因此在 JDK1.4 中实现总是失败。在 JDK1.5 没有修正这个问题前,为什么还需要这个模式呢?这时因为在加锁懒汉模式中虽然解决了线程并发的问题,又实现了延迟加载,但是它存在性能问题。这是因为使用 synchronized 的同步方法执行速度会比普通方法慢得多,如果多次调用获取实例的方法时积累的性能损耗就会很大,因此就出现了双重判定加锁懒汉模式。我们先来看一下具体的代码实现。
在上述代码中我们在同步代码块外层多加了一个 doubleJudgementLockSluggerMode 是否为空的判断,因此在大部分情况下调用 getInstance 方法都不会执行同步代码块,而是直接返回已经实例化的对象,进而提高了代码的性能。下面我们考虑一个问题,如果程序中存在线程一和线程二,当线程一执行了外层的判断语句它发现实例对象没有创建,然而这个时候线程二也执行到了外层判断语句,它同样发现实例对象没有创建,然后这两个线程依次执行同步代码块中的内容,分别创建了连个实例对象,对于单例模式来说这种情况我们必须避免,因此我么们在同步代码块中增加了 if(doubleJudgementLockSluggerMode==null) 判断语句来解决这个问题。
到目前为止虽然实现了延迟加载和线程并发问题,同时也解决了执行效率问题但是在 JDK1.5 之前还存一些问题。首先在 Java 中存在指令重排优化,这个功能会在不改变原有语义的情况下调整指令顺序来让程序运行的更快。但是在 JVM 中并没有规定优化哪些内容,所以 JVM 可以随意的进行指令重排优化。这样就引出了一个问题,因为指令重排优化的存在会导致初始化 DoubleJudgementLockSluggerMode 和将对象地址付给 doubleJudgementLockSluggerMode 的顺序发生改变。如果在创建单例对象时,在构造函数被调用之前就已经给当前对象分配了内存并且还将对象的字段赋予了默认值,那么此时如果将分配的内存地址赋予 doubleJudgementLockSluggerMode 字段,并有一个线程来调用 getInstance 方法,由于该对象有可能尚未初始化,因此程序就会报错。但是在 JDK1.5 中修正了这个问题,我们只需要利用 volatile 关键字来禁止指令重排优化来避免上述问题。增加 volatile 关键字后,代码如下:
四、内部静态类懒汉模式
双重判定加锁懒汉模式实现起来不仅复杂,在 JDK1.4 及其以下版本上还存在指令重排优化的问题,那么有没有既解决了线程安全的问题又可以实现懒加载的单例模式的实现方法呢?答案是有的,我们可以利用静态内部类来实现。我们先来看一下代码如何实现。
这种方式和饿汉模式一样都是利用了类加载机制,因此不存在对线程并发的问题,同时只要不适用内部类 JVM 就不会去创建单例对象,进而实现了与懒汉模式一样的延迟加载。但是这种方式会导致最终生成的 class 文件变大,程序体积变大。
五、枚举懒汉模式
枚举懒汉模式在开发中并不常用,一般来说如果你编写的类既要支持序列化和反射,又要支持单例模式的话可以使用枚举懒汉模式,但是因为使用了枚举因此会造成内存占用过大的问题。下面我们来看以下代码,然后根据代码来详细讲解枚举懒汉模式。
在上述代码中如果要获取 EnumMode 实例对象我们必须这样调用 ModeEnum.INSTAMCE.getEnumMode()
。那么枚举懒汉模式实现的原理是什么呢?首先在枚举中明确了构造方法并设置为私有,当我们访问枚举实例的时候会执行构造方法,同时每个枚举实例是 static final 类型,因此只能被实例化一次。只有在构造方法被调用时单例才会被实例化。
六、总结
这篇文章讲解了 Java 中单例模式的实现方式,这些实现方式中常用的是饿汉模式和双重判定加锁懒汉模式这两种,其他方式我们也需要掌握。最后我总结一下实现单例模式各种方式的线程安全问题。
评论