定义
保证一个类有且仅有一个实例,并且提供一个可以访问它的全局访问点。
三个基本步骤
实现单例模式有三个基本的步骤:
- 私有构造方法用来限制外部类对其直接实例化
- 提供一个私有静态当前类的对象作为变量
- 提供一个公有静态方法返回类的实例
实现单例模式的几种方式
懒汉式
懒汉式单例模式在类创建的时候不会创建实例,因此类的加载速度比较快。
懒汉式单例模式在第一次调用的时候才进行初始化,这样做避免了内存的浪费。
使用懒汉式方法创建的 LazySingleton
类实例代码:
1 | /** |
这种方式其实是线程不安全的。
在单线程环境下,这种方式可以很好地进行处理。但是在多线程的环境下,这种方式就会出现问题。
假设现在有两个线程,一个线程A,另一个线程B。当线程A到达 instance = new LazySingleton();
这一行但没有执行这一行的同时,线程B到达了 if (instance == null)
这个判断。此时由于线程A还未执行当前行,线程B的判断结果为 true
,所以线程B依旧可以执行 instance = new LazySingleton();
这一行语句。此时这两个线程会获得单例类 LazySingleton
的两个不同实例,从而进一步破坏了单例模式。
改进方式一 (加同步锁)
在 getInstance()
方法上添加 synchronized
关键字,使得当前的方法变成同步方法。
1 | /** |
synchronized
加在静态方法上,相当于锁的是当前类的class文件。
改进方式二 (加同步锁)
在 getInstance()
方法中添加 synchronized
块。
1 | /** |
这种方式和上述改进方式一的效果实际上是一样的。
在多线程资源共享当中,使用 synchronized
同步锁的方式,其实就是以时间换空间的方式。
双重检查懒汉式
同步锁是比较消耗资源的,会存在加锁和解锁的开销。而且上述两改进方式 synchronized
锁的是class文件,锁的粒度范围较大。
为了兼顾性能和线程安全,使用双重检查的方式来改进懒汉式,代码如下:
1 | /** |
外层 if (instance == null)
不加锁,如果不满足则程序直接返回,如果满足则也只会有一个线程进入到 synchronized
当中。大幅度地降低了将 synchronized
加在方法上时带来的性能开销。
但是目前这种方式依旧存在隐患。主要原因在于外层 if (instance == null)
分支判断和 instance = new LazySingleton()
语句这两处。
外层 if (instance == null)
在进行分支判断的时候, instance
对象有可能是不为空的,虽然不为空,但是很有可能 instance
这个对象还未完成初始化,也就是说我们的 instance = new LazySingleton()
语句还没有执行完成。
Q:如何理解?
我们可以先来看下 instance = new LazySingleton()
这语句。虽然看似简单的一行语句,但是在程序中这条语句实际上进行了三个操作:
- 给当前对象分配内存
- 初始化对象
- 设置变量
instance
指向刚分配的内存地址
在程序执行的过程当中,JVM有可能会发生指令重排序。比如发生指令重排序后当前语句的执行顺序变为132,即先给对象分配内存地址,然后将变量指向刚分配的内存地址,最后再进行对象的初始化操作。
上述操作在单线程环境下也没什么大问题。然后我们再来看看,在多线程环境下,当前程序存在隐患的可能。
时间线 | 线程A | 线程B |
---|---|---|
1 | 给当前对象分配内存 | |
2 | 设置变量 instance 指向刚分配的内存地址 |
|
3 | 外层分支判断 instance 是否为null |
|
4 | 初次访问对象 | |
5 | 初始化对象 | |
6 | 初次访问对象 |
按照时间线的推移,我们可以从表格中看到在时间线4中线程B访问的对象是线程A中还未初始化完成的对象,这个时候就有可能发生异常。
这里实际上有两种方式可以解决以上问题:
- 防止指令重排序
- 让其余的线程无法观察到当前线程的指令重排序
Q:如何避免指令重排序?
使用 volatile
关键字来声明 instance
变量,这样的话重排序就会被禁止。
1 | public class LazySingleton { |
CPU也有共享内存。在加了 volatile
关键字之后,所有的线程都能观察到共享内存的最新状态,保证了内存的可见性。
静态内部类模式
这种方式,其实就是让其余的线程无法观察到当前线程的指令重排序。
1 | /** |
JVM在类的初始化阶段(即Class在被类加载器加载之后,在被线程使用之前这一期间)会去执行类的初始化。
在类的初始化期间,JVM会去获取Class对象的初始化锁(同步多个线程对一个类的初始化)。
初始化一个类,包括执行类的静态初始化,初始化类中声明的静态变量。
根据Java语言规范主要有五种情况在首次发生时,一个类(包括接口)将被立刻初始化。以 Clazz
类为例:
- 有一个
Clazz
类型的实例被创建 Clazz
类中声明的一个静态方法被调用Clazz
类中声明的一个静态成员被赋值Clazz
类中的静态成员被使用,并且这个成员不是一个常量成员Clazz
类是一个顶级类,并且Clazz
类中有嵌套的断言语句
通过改动双重检查懒汉式中线程A和线程B的流程,我们得到下图。
通过上图,我们可以分析下:当线程A和线程B尝试获取 Class
对象的初始化锁,假设线程A获取到了这个锁,此时线程A执行静态内部类的初始化操作。由于 Class
对象初始化锁的存在,线程B是无法看到类初始化操作中的指令重排序的。
饿汉式
饿汉式单例模式正好与懒汉式单例模式相反。
饿汉式单例模式在类加载的时候就完成了初始化操作,避免了线程同步的问题。所以类在加载的时候比较缓慢,而在运行时获取对象的速度相对较快。
代码如下:
1 | /** |
饿汉式单例模式的问题就在于类在加载的时候就进行初始化操作,没有达到懒加载的效果。如果类从始至终都未曾使用过,那么就造成了资源的浪费。
当然也可以把对象实例化的过程放入到静态块当中,效果也是一样的。
1 | /** |
枚举模式
枚举方式是《Effective Java》作者Josh Bloch推荐的方式。
这种方式不仅能避免多线程同步的问题,而且还能防止序列化和反射破坏单例创建新的对象。
1 | /** |
以上的两种方式均可,效果一样。
容器单例
这种单例模式和享元模式有点类似。利用容器来管理多个单例对象。
1 | /** |
这种方式比较适合程序在初始化期间多个单例存放至容器进行统一管理,使用时通过键值从容器中获取单例对象。
这里的容器使用的是 HashMap
,很显然是线程不安全的。但是对上述用例(程序在初始化期间多个单例存放至容器进行统一管理,使用时通过键值从容器中获取单例对象)这样使用也是可以的。
为了线程安全,我们可以改用 HashTable
。但是 HashTable
会影响性能,在频繁地存取时都会有同步锁。也可以考虑使用 ConcurrentHashMap
。
ThreadLocal线程单例
这种方式产生的单例并不能保证在整个应用中全局唯一,但是它可以保证在同个线程当中唯一。
1 | /** |
ThreadLocal
会为每个线程提供一个独立的变量副本。ThreadLocal
是基于 ThreadLocalMap
这个类来实现的,它维持了线程间的隔离。当调用 ThreadLocal
的 get()
方法时,我们不用指定键值,默认走的就是 ThreadLocalMap
。
ThreadLocal
隔离了多个线程对数据访问的冲突。在多线程资源共享当中,使用 ThreadLocal
的方式,其实就是以空间换时间的方式。
总结
适用场景
- 在应用场景中,某类只要求生成一个实例的时候。例如一个班中的班长、每个人的身份证号等。
- 当对象需要被共享的场合。由于单例模式只允许创建一个对象,共享该对象可以节省内存,并且加快对象访问速度。例如Web中的配置对象、数据库的连接池等。
- 当某类需要频繁进行实例化操作,而创建的对象又频繁被销毁的时候。例如多线程的线程池、网络连接池等。
优点
- 在内存中只存在一个实例,减少了内存开销。
- 避免对共享资源的多重占用。
- 设置全局访问点,严格控制访问。
缺点
- 没有抽象层,扩展困难。
- 单例类的职责过重,在一定程度上违背了“单一职责原则”。
- 不适用于变化的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。
- 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。
参考
- 《Head First 设计模式》
- 《大话设计模式》
- 《Effective Java》
- 维基百科-单例模式
- 使用容器实现单例模式
- 容器单例和ThreadLocal单例
- 多线程之ThreadLocal单例模式
- ThreadLocal-单例模式下高并发线程安全