26 | 单例模式:如何创建单一对象优化系统性能

在这里插入图片描述

从这一讲开始,我们将仪器探讨设计模式的性能调优。在《Design Patterns:Elements of Reusable Object-Orinented Software》(书见下图)一书中,有23种设计模式的描述,其中,单例设计模式是常用的设计模式之一。无论是在开源框架,还是在我们的日常开发中,单例模式几乎无处不在。
在这里插入图片描述

什么是单例模式?

它的核心在于,单例模式可以保证一个类仅创建一个示例,并提供一个访问它的全局访问点。
由于在一个系统中,一个类经常会被使用在不同的地方,通过单例模式,我们可以避免多次创建多个实例,从而节约系统资源。
该模式有三个基本要点:一是这个类只能有一个示例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。
结合这三点,我们来实现一个简单的单例:

//饿汉模式
public class HungrySingleton {
    
    
	//我太饿了,必须看到就吃
	private static final HungrySingleton singleton = new HungrySingleton();
	//我只允许外部通过我提供的渠道调用我
	public static HungrySingleton getInstance() {
    
    
		return singleton;
	}
	//隐藏自我,禁止拷贝,提供我想让别人看到的我
	private HungrySingleton() {
    
    

	}

饿汉模式
我们可以发现,以上第一种实现单例的代码中,使用了static修饰了成员变量singleton,所以该变量会在类初始化的过程中被收集进类构造器<clinit> 1方法中,其它线程将会被阻塞等待。

等到唯一的一次<clinit> 1方法执行完成,其它线程将不会再执行<clinit> 1方法,转而执行自己的代码。也就是说,static修饰了成员变量singleton,再多线程的情况下能保证只实例化一次。
这种方式实现的单例模式,再类加载阶段就已经再堆内存中开辟了一块内存,用于存放实例化对象,所以也称为饿汉模式。
饿汉模式实现的单例的优点是,可以保证多线程情况下实例的唯一性,而且getInstance()直接返回唯一实例,性能非常高。
然而,在类成员变量比较多,或变量比较大的情况下,这种模式可能会在没有使用类对象的情况下,一直占用内存。试想下,如果一个第三方开源框架中的类都是基于饿汉模式实现的单例,这将会初始化所有单例类,无疑是灾难性的。
懒汉模式
懒汉模式就是为了避免直接加载类对象时提前创建对象的一种单例设计模式。该模式使用懒加载方式,只有当系统使用到类对象时,才会将实例加载到堆内存中。通过以下代码,我们可以简单地了解懒加载的实现方式:

/*
懒汉 */
public class LazySingleton {
    
    
	//我很懒,什么都不想做,来活了在工作
	private static LazySingleton singleton;
	//官方对外提供的对象
	public static LazySingleton getInstance() {
    
    
		if (singleton == null) {
    
    
			singleton = new LazySingleton();
		}
		return singleton;
	}
	///我不允许通过其它方式暴露
	private IdleSingleton() {
    
    
	}
}

上面代码在单线程在单线程运行是没有问题的,但要运行在多线程下,就会出现实例化多个类对象的情况。这是怎么回事呢?

当线程 A 进入到 if 判断条件后,开始实例化对象,此时 singleton 依然为 null;又有线程
B 进入到 if 判断条件中,之后也会通过条件判断,进入到方法里面创建一个实例对象。

所以我们需要对该方法进行加锁,保证多线程情况下仅创建一个实例。这里我们使用Synchronized同步来修饰getInstance方法:

//方法增加同步锁,通过
public static synchronized LazySingleton getInstance()

但我们前面讲过,同步锁会增加锁竞争,带来系统性能开销,从而导致系统性能下降,因此,这种方式也会降低单例模式的性能。
还有,每次请求获取类对象时,都会通过 etInstance() 方法获取,除了第一次为 null,其它每次请求基本都是不为 null 的。在没有加同步锁之前,是因为 if 判断条件为 null 时,才导致创建了多个实例。基于以上两点,我们可以考虑将同步锁放在 if 条件里面,这样就可以减少同步锁资源竞争。

/*
懒汉 */
public class LazySingleton {
    
    
	//我很懒,什么都不想做,来活了在工作
	private static LazySingleton singleton;
	//官方对外提供的对象
	public static LazySingleton getInstance() {
    
    
		if (singleton == null) {
    
    
			//在判断为null后再增加同步锁,减少锁的竞争
			synchronized (LazySingleton.class) {
    
    
				singleton = new LazySingleton();
			}
		}
		return singleton;
	}
	///我不允许通过其它方式暴露
	private IdleSingleton() {
    
    }
}

看到这里,你是不是觉得这样就可以了呢?答案是依然会创建多个实例。这是因为当多个线程进入到if判断条件里,虽然有同步锁,但是进入到判断条件里的线程依然会依次获取到锁创建对象,然后再释放同步锁。所以我们还需要在同步锁里面再加一个判断条件:

public static LazySingleton getInstance() {
    
    
		if (singleton == null) {
    
    
			//在判断为null后再增加同步锁,减少锁的竞争
			synchronized (LazySingleton.class) {
    
    
				//双重检测,避免获取锁后再次初始化对象
				if (singleton == null) {
    
    
					singleton = new LazySingleton();
				}
			}
		}
		return singleton;
	}

以上这种方式,通常被称为Double-Check,它可以大大提高支持多线程的懒汉模式的运行性能。那这样做是不是就能保证万无一失了呢?还会有什么问题吗?
其实这里又跟Happens-Before规则和指令重排序扯上关系了,这里我们先来简单了解下Happens-Before规则和重排序。
我们在第二期加餐中分享过,编译器为了尽可能地减少寄存器的读取、存储次数,会充分服用寄存器的存储值,比如如下代码,如果没有进行重排序优化,正常的执行顺序是步骤1\2\3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤1/3/2,这样就能减少一次寄存器的存取次数。

	//加餐:汇编指令 mov a,x ->mov 目地地址, 源地址
	//步骤1:加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1赋值到a的内存地址。
	int a = 1;
	//步骤2:加载b变量的内存地址到俱存其中,加载2到寄存器中,CPU通过mov指令把2的值赋值到b的内存地址
	int b=2;
	//步骤3:重新加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把a的值进行加1赋值,把值赋值到a的内存地址
	a=a+1;

在JMM中,重排序是十分重要的一环,特别是在并发编程中。如果JVM可以对它们进行任意排序以提高程序性能,也可能会给并发编程带来一系列的问题。例如,我上面讲到的Double-Check的单例问题,假设类中有其它的属性也需要实例化,这个时候,除了要实例化单例类本身,还需要对其它属性也进行实例化:

//懒汉模式 + synchronized 同步锁 + double-check
public final class LazySingleton{
    
    
private static LazySingleton singleton=null;//不实例化
public List<String> list =null;//属性
public static LazySingleton getInstance() {
    
    
		if (singleton == null) {
    
    //第一次判断,当 instance 为 null 时,则实例化对象,否则直接返回
			//在判断为null后再增加同步锁,减少锁的竞争
			synchronized (LazySingleton.class) {
    
    
				//双重检测,避免获取锁后再次初始化对象
				if (singleton == null) {
    
    //第二次判断
					singleton = new LazySingleton();//实例化对象
				}
			}
		}
		return singleton;//返回已经存在的对象
	}
	}

我们先说下singleton = new LazySingleton在CPU中的实例创建过程:
1)给LazySingleton分配内存空间
2)调用LazySingleton的构造函数,执行构造方法,初始化成员变量对象
3)将LazySingleton对象指向分配的内存空间(执行完这步singleton就为 !null 了)
如果虚拟机发生了重排序优化,这个时候步骤3可能发生在步骤2之前。如果初始化线程刚好完成步骤3,而步骤2没有进行时,则刚好有另一个线程到了第一次判断,这个时候判断为非null,并返回对象使用,这个时候实际没有完成其它属性的构造,因此使用这个属性就很可能会导致异常。在这里,Synchronized只能保证可见性、原子性,无法保证执行的顺序。
这个时候,就体现出Happens-Before规则的重要性了。通过字面意思,你可能会误以为时前一个操作发生在后一个操作之前。然而真正的意思时,前一个操作的结果可以被后续的操作获取。这条规则规范了编译器堆程序的重排序优化。
我们指导volatile关键字可以保证线程间变量的可见性,简单地说就是当线程A对变量X进行修改后,在线程A后面执行的其它线程就能看到变量X的变动。除此之外,volatile在JDK1.5之后还有一个作用就是组织局部重排序的发生,也就是说,volatile变量的操作指令都不会被重排序。所以使用volatile修饰singleton之后,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修饰了成员变量singleton,所以该变量会在类初始化的过程中被收集进类构造器即<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;// 返回内部类中的静态变量
    }
}

看起来静态内部类已经是最完美的方法了,其实不是,可能还存在反射攻击或者反序列化攻击。且看如下代码:

public static void main(String[] args) throws Exception {
    
    
        Singleton singleton = Singleton.getInstance();
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton reflectSingleton = constructor.newInstance();
        System.out.println(singleton == reflectSingleton);
        System.out.println(singleton.equals(reflectSingleton));
    }

运行结果如下:
反射场景不安全通过结果看,这两个实例不是同一个,这就违背了单例模式的原则了。
那么有没有一种真正完美的方式做到真正的单例呢?
一个具有注脚的文本。

Joshua Bloch2大神在《Effective Java》中明确表达过的观点,最佳的单例实现模式就是枚举模式。利用枚举的特性,让JVM来帮我们保证线程安全和单一实例的问题。除此之外,写法还特别简单。

使用枚举实现单例的方法虽然还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。

通过枚举实现单例模式

public enum Singleton {
    
    

    INSTANCE;

    public void doSomething() {
    
    
        System.out.println("I·M Single Instance, doSomething");
    }

}

使用方式

public static void main(String[] args) {
    
    
        Singleton.INSTANCE.doSomething();
    }

使用起来简单方便安全。唯一缺点可能是类似于饿汉式加载,会导致枚举实例会长期存在于内存当中。

总结

单例的实现方式其实有很多,但总结起来就两种:饿汉模式和懒汉模式,我们可以根据自己的需求来做选择。
如果我们在程序启动后,一定会加载到类,那么用饿汉模式实现的单例简单又实用;如果我们是写一些工具类,则优先考虑使用懒汉模式,因为很多项目可能会引用到jar包,但未必会使用到这个工具类,懒汉模式实现的单例可以避免提前被加载到内存中,占用系统资源。


  1. <clinit> 说明:属于JVM类初始化阶段的一个方法。该方法是由编译器自动收集的,包括所有类变量(静态非final)赋值和静态语句块(static{})。该收集行为顺序由语句在源码文件中出现的顺序决定,具体来说,静态语句块只能访问到定义在语句块之前的变量。 ↩︎ ↩︎ ↩︎

  2. Joshua Bloch:Java 集合框架创办人,Joshua Bloch 领导了很多 Java 平台特性的设计和实现,包括 JDK 5.0 语言增强以及屡获殊荣的 Java 集合框架。2004年6月他离开了SUN公司并成为 Google 的首席 Java 架构师。此外他还因为《Effective Java》一书获得著名的 Jolt 大奖。 ↩︎

猜你喜欢

转载自blog.csdn.net/lxn1023143182/article/details/113306819
26