你好,我是刘超。
从这一讲开始,我们将一起探讨设计模式的性能调优。在《Design Patterns: Elements of Reusable Object-Oriented Software》一书中,有23种设计模式的描述,其中,单例设计模式是最常用的设计模式之一。无论是在开源框架,还是在我们的日常开发中,单例模式几乎无处不在。
什么是单例模式?
它的核心在于,单例模式可以保证一个类仅创建一个实例,并提供一个访问它的全局访问点。
该模式有三个基本要点:一是这个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。
结合这三点,我们来实现一个简单的单例:
//饿汉模式
public final class Singleton {
private static Singleton instance=new Singleton();//自行创建实例
private Singleton(){}//构造函数
public static Singleton getInstance(){//通过该函数向整个系统提供实例
return instance;
}
}
由于在一个系统中,一个类经常会被使用在不同的地方,通过单例模式,我们可以避免多次创建多个实例,从而节约系统资源。
饿汉模式
我们可以发现,以上第一种实现单例的代码中,使用了static修饰了成员变量instance,所以该变量会在类初始化的过程中被收集进类构造器即<clinit>方法中。在多线程场景下,JVM会保证只有一个线程能执行该类的<clinit>方法,其它线程将会被阻塞等待。
等到唯一的一次<clinit>方法执行完成,其它线程将不会再执行<clinit>方法,转而执行自己的代码。也就是说,static修饰了成员变量instance,在多线程的情况下能保证只实例化一次。
这种方式实现的单例模式,在类初始化阶段就已经在堆内存中开辟了一块内存,用于存放实例化对象,所以也称为饿汉模式。
饿汉模式实现的单例的优点是,可以保证多线程情况下实例的唯一性,而且getInstance直接返回唯一实例,性能非常高。
然而,在类成员变量比较多,或变量比较大的情况下,这种模式可能会在没有使用类对象的情况下,一直占用堆内存。试想下,如果一个第三方开源框架中的类都是基于饿汉模式实现的单例,这将会初始化所有单例类,无疑是灾难性的。
懒汉模式
懒汉模式就是为了避免直接加载类对象时提前创建对象的一种单例设计模式。该模式使用懒加载方式,只有当系统使用到类对象时,才会将实例加载到堆内存中。通过以下代码,我们可以简单地了解下懒加载的实现方式:
//懒汉模式
public final class Singleton {
private static Singleton instance= null;//不实例化
private Singleton(){}//构造函数
public static Singleton getInstance(){//通过该函数向整个系统提供实例
if(null == instance){//当instance为null时,则实例化对象,否则直接返回对象
instance = new Singleton();//实例化对象
}
return instance;//返回已存在的对象
}
}
以上代码在单线程下运行是没有问题的,但要运行在多线程下,就会出现实例化多个类对象的情况。这是怎么回事呢?
当线程A进入到if判断条件后,开始实例化对象,此时instance依然为null;又有线程B进入到if判断条件中,之后也会通过条件判断,进入到方法里面创建一个实例对象。
所以我们需要对该方法进行加锁,保证多线程情况下仅创建一个实例。这里我们使用Synchronized同步锁来修饰getInstance方法:
//懒汉模式 + synchronized同步锁
public final class Singleton {
private static Singleton instance= null;//不实例化
private Singleton(){}//构造函数
public static synchronized Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//当instance为null时,则实例化对象,否则直接返回对象
instance = new Singleton();//实例化对象
}
return instance;//返回已存在的对象
}
}
但我们前面讲过,同步锁会增加锁竞争,带来系统性能开销,从而导致系统性能下降,因此这种方式也会降低单例模式的性能。
还有,每次请求获取类对象时,都会通过getInstance()方法获取,除了第一次为null,其它每次请求基本都是不为null的。在没有加同步锁之前,是因为if判断条件为null时,才导致创建了多个实例。基于以上两点,我们可以考虑将同步锁放在if条件里面,这样就可以减少同步锁资源竞争。
//懒汉模式 + synchronized同步锁
public final class Singleton {
private static Singleton instance= null;//不实例化
private Singleton(){}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){
instance = new Singleton();//实例化对象
}
}
return instance;//返回已存在的对象
}
}
看到这里,你是不是觉得这样就可以了呢?答案是依然会创建多个实例。这是因为当多个线程进入到if判断条件里,虽然有同步锁,但是进入到判断条件里面的线程依然会依次获取到锁创建对象,然后再释放同步锁。所以我们还需要在同步锁里面再加一个判断条件:
//懒汉模式 + synchronized同步锁 + double-check
public final class Singleton {
private static Singleton instance= null;//不实例化
private Singleton(){}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//第一次判断,当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){//同步锁
if(null == instance){//第二次判断
instance = new Singleton();//实例化对象
}
}
}
return instance;//返回已存在的对象
}
}
以上这种方式,通常被称为Double-Check,它可以大大提高支持多线程的懒汉模式的运行性能。那这样做是不是就能保证万无一失了呢?还会有什么问题吗?
其实这里又跟Happens-Before规则和重排序扯上关系了,这里我们先来简单了解下Happens-Before规则和重排序。
我们在第二期加餐中分享过,编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值,比如以下代码,如果没有进行重排序优化,正常的执行顺序是步骤1/2/3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤1/3/2,这样就能减少一次寄存器的存取次数。
int a = 1;//步骤1:加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
int b = 2;//步骤2 加载b变量的内存地址到寄存器中,加载2到寄存器中,CPU通过mov指令把2写入到寄存器指定的内存中
a = a + 1;//步骤3 重新加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
在 JMM 中,重排序是十分重要的一环,特别是在并发编程中。如果JVM可以对它们进行任意排序以提高程序性能,也可能会给并发编程带来一系列的问题。例如,我上面讲到的Double-Check的单例问题,假设类中有其它的属性也需要实例化,这个时候,除了要实例化单例类本身,还需要对其它属性也进行实例化:
//懒汉模式 + synchronized同步锁 + double-check
public final class Singleton {
private static Singleton instance= null;//不实例化
public List<String> list = null;//list属性
private Singleton(){
list = new ArrayList<String>();
}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//第一次判断,当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){//同步锁
if(null == instance){//第二次判断
instance = new Singleton();//实例化对象
}
}
}
return instance;//返回已存在的对象
}
}
在执行instance = new Singleton();代码时,正常情况下,实例过程这样的:
- 给 Singleton 分配内存;
- 调用 Singleton 的构造函数来初始化成员变量;
- 将 Singleton 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)。
如果虚拟机发生了重排序优化,这个时候步骤3可能发生在步骤2之前。如果初始化线程刚好完成步骤3,而步骤2没有进行时,则刚好有另一个线程到了第一次判断,这个时候判断为非null,并返回对象使用,这个时候实际没有完成其它属性的构造,因此使用这个属性就很可能会导致异常。在这里,Synchronized只能保证可见性、原子性,无法保证执行的顺序。
这个时候,就体现出Happens-Before规则的重要性了。通过字面意思,你可能会误以为是前一个操作发生在后一个操作之前。然而真正的意思是,前一个操作的结果可以被后续的操作获取。这条规则规范了编译器对程序的重排序优化。
我们知道volatile关键字可以保证线程间变量的可见性,简单地说就是当线程A对变量X进行修改后,在线程A后面执行的其它线程就能看到变量X的变动。除此之外,volatile在JDK1.5之后还有一个作用就是阻止局部重排序的发生,也就是说,volatile变量的操作指令都不会被重排序。所以使用volatile修饰instance之后,Double-Check懒汉单例模式就万无一失了。
//懒汉模式 + synchronized同步锁 + double-check
public final class Singleton {
private volatile static Singleton instance= null;//不实例化
public List<String> list = null;//list属性
private Singleton(){
list = new ArrayList<String>();
}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//第一次判断,当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){//同步锁
if(null == instance){//第二次判断
instance = new Singleton();//实例化对象
}
}
}
return instance;//返回已存在的对象
}
}
通过内部类实现
以上这种同步锁+Double-Check的实现方式相对来说,复杂且加了同步锁,那有没有稍微简单一点儿的可以实现线程安全的懒加载方式呢?
我们知道,在饿汉模式中,我们使用了static修饰了成员变量instance,所以该变量会在类初始化的过程中被收集进类构造器即<clinit>方法中。在多线程场景下,JVM会保证只有一个线程能执行该类的<clinit>方法,其它线程将会被阻塞等待。这种方式可以保证内存的可见性、顺序性以及原子性。
如果我们在Singleton类中创建一个内部类来实现成员变量的初始化,则可以避免多线程下重复创建对象的情况发生。这种方式,只有在第一次调用getInstance()方法时,才会加载InnerSingleton类,而只有在加载InnerSingleton类之后,才会实例化创建对象。具体实现如下:
//懒汉模式 内部类实现
public final class Singleton {
public List<String> list = null;// list属性
private Singleton() {//构造函数
list = new ArrayList<String>();
}
// 内部类实现
public static class InnerSingleton {
private static Singleton instance=new Singleton();//自行创建实例
}
public static Singleton getInstance() {
return InnerSingleton.instance;// 返回内部类中的静态变量
}
}
总结
单例的实现方式其实有很多,但总结起来就两种:饿汉模式和懒汉模式,我们可以根据自己的需求来做选择。
如果我们在程序启动后,一定会加载到类,那么用饿汉模式实现的单例简单又实用;如果我们是写一些工具类,则优先考虑使用懒汉模式,因为很多项目可能会引用到jar包,但未必会使用到这个工具类,懒汉模式实现的单例可以避免提前被加载到内存中,占用系统资源。
思考题
除了以上那些实现单例的方式,你还知道其它实现方式吗?
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
精选留言
2019-07-23 11:09:36
private static SinletonExample5 instance = null;
// 私有构造函数
private SinletonExample5(){
}
public static SinletonExample5 getInstance(){
return Sinleton.SINLETON.getInstance();
}
private enum Sinleton{
SINLETON;
private SinletonExample5 singleton;
// JVM保证这个方法只调用一次
Sinleton(){
singleton = new SinletonExample5();
}
public SinletonExample5 getInstance(){
return singleton;
}
}
}
2019-07-23 06:11:30
2019-07-23 23:48:31
2.给老师的代码补段骚的:
// 懒汉模式 + synchronized 同步锁 + double-check
public final class Singleton {
private static validate Singleton instance = null;// 不实例化
public List<String> list;//list 属性
private Singleton(){
list = new ArrayList<String>();
}// 构造函数
public static Singleton getInstance(){// 加同步锁,通过该函数向整个系统提供实例
Singleton temp = instance;
if(null == temp){// 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){// 同步锁
temp = instance;
if(null == temp){// 第二次判断
temp = new Singleton();// 实例化对象
instance = temp;
}
}
}
return instance;// 返回已存在的对象
}
}
用临时变量做方法内数据承载(相对于validate修饰的属性,可以减少从内存直接拷贝数据的次数),最后用instance接收临时变量时,因为是validate修饰,所以也不会有指令重排。所以前面临时变量的赋值操作已经完成,这样instance就必然是赋值好的实例。(如有错误请老师指出,仅个人理解的骚操作)
3.极限编程试试就好,业务代码还是尽量优先保证可读性,只有在有性能需求时再采用影响可读性的性能优化。我的这种骚写法和老师的内部类,这种看起来需要想那么一下的东西尽量避免,简单才是王道。
2019-07-23 12:44:03
相比Double Check,以内部类方式实现单例模式,代码简洁,性能相近,在我看来是更优的选择。
2019-07-23 08:25:19
2019-07-23 07:14:42
注册式单例,spring应该是用的这种。这个也不太清楚,超哥有机会讲一下spring的实现方式和枚举方式实现的单例。谢谢😁
2019-10-22 13:58:51
// 饿汉模式
public final class Singleton {
private Singleton instance=new Singleton();// 自行创建实例
private Singleton(){}// 构造函数
public static Singleton getInstance(){// 通过该函数向整个系统提供实例
return instance;
}
}
2020-03-20 23:16:06
我在看极客时间《设计模式之美》专栏中有一篇讲单例的文章,其中讲述了在Java的高版本中,已经不需要增加volatile来禁止类重排序。
我自己查了一下没有找到相关的资料,我想请问这个说法有依据吗?如果是这样的,能不能推荐一些文章的链接或者资料我们看一下?
----
原文如下:
要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁止指令重排序才行。实际上,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。
2019-10-28 18:37:43
2019-07-23 15:33:57
class InstanceManager {
private static Map<String, Object> objectMap = new HashMap<>();
private InstanceManager(){}
public static void registerService(String key,Object instance){
if (!objectMap.containsKey(key)){
objectMap.put(key,instance);
}
}
public static Object getService(String key){
return objectMap.get(key);
}
}
2019-07-23 06:35:44
2020-01-19 11:13:35
2019-09-11 09:30:18
2019-08-15 18:20:30
2019-07-28 11:58:21
2019-07-23 20:32:57
2024-03-11 09:29:56
2024-03-11 09:27:38
public final class Singleton {
private static Singleton instance= null;//不实例化
public List<String> list = null;//list属性
private Singleton(){
list = new ArrayList<String>();
}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//第一次判断,当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){//同步锁
if(null == instance){//第二次判断
instance = new Singleton();//实例化对象
}
}
}
return instance;//返回已存在的对象
}
}
在执行 instance = new Singleton(); 代码时,正常情况下,实例过程这样的:给 Singleton 分配内存;调用 Singleton 的构造函数来初始化成员变量;将 Singleton 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)。如果虚拟机发生了重排序优化,这个时候步骤 3 可能发生在步骤 2 之前。如果初始化线程刚好完成步骤 3,而步骤 2 没有进行时,则刚好有另一个线程到了第一次判断,这个时候判断为非 null,并返回对象使用,这个时候实际没有完成其它属性的构造,因此使用这个属性就很可能会导致异常。在这里,Synchronized 只能保证可见性、原子性,无法保证执行的顺序。这段话中说它无法保证有序性,为啥啊,不是说,锁,可以解决线程安全的三个问题嘛
2021-12-27 21:53:08
难道 这里面的第3步会经过指令排序排到同步锁外面去么? 如果不会那加volatile关键字意义何在?
synchronized (Singleton.class){//同步锁
if(null == instance){//第二次判断
instance = new Singleton();//实例化对象
}
}
2021-04-29 19:18:46
是这样么,老师。谢谢!