单例模式(Singleton Pattern)是指确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。单例模式是创建性模式。单例模式在开发中应用非常广泛,例如,Spring 框架中的 ApplicationContext、数据库的连接池等。

单例模式有如下实现方式:

  • 饿汉式

  • 懒汉式

  • 注册式

单例模式在实现的时候需要考虑如下两个问题:

  • 多线程环境下对单例模式的破坏

  • 反射、序列化对单例模式的破坏

饿汉式单例模式

饿汉式单例是在类加载的时候就立即初始化,并且创建单例对象。它绝对线程安全,在线程还没出现以前就是实例化了,不可能存在访问安全问题。

Spring 中 IOC 容器 ApplicationContext 本身就是典型的饿汉式单例模式。

优点:没有加任何的锁、执行效率比较高,用户体验比懒汉式单例模式更好。

缺点:类加载的时候就初始化,不管用与不用都占着空间,浪费了内存,有可能“占着茅坑不拉屎”。

懒汉式单例模式的写法如下所示:

  1. public class HungrySingleton {
  2. // 1.私有化构造器
  3. private HungrySingleton (){}
  4. // 2.在类的内部创建自行实例
  5. private static final HungrySingleton instance = new HungrySingleton();
  6. // 3.提供获取唯一实例的方法(全局访问点)
  7. public static HungrySingleton getInstance(){
  8. return instance;
  9. }
  10. }

还有另外一种写法,利用静态代码块的机制:

  1. public class HungryStaticSingleton {
  2. // 1. 私有化构造器
  3. private HungryStaticSingleton(){}
  4. // 2. 实例变量
  5. private static final HungryStaticSingleton instance;
  6. // 3. 在静态代码块中实例化
  7. static {
  8. instance = new HungryStaticSingleton();
  9. }
  10. // 4. 提供获取实例方法
  11. public static HungryStaticSingleton getInstance(){
  12. return instance;
  13. }
  14. }

这两种写法都非常的简单,也非常好理解,饿汉式单例模式适用于单例对象较少的情况。下面我们来看性能更优的写法。

懒汉式单例模式

懒汉式单例模式的特点是:被外部类调用的时候内部类才会加载。

简单懒汉式(线程不安全)

下面来看懒汉式单例模式的简单实现 LazySimpleSingleton:

  1. public class LazySimpleSingleton {
  2. private LazySimpleSingleton() {
  3. }
  4. private static LazySimpleSingleton instance = null;
  5. public static LazySimpleSingleton getInstance() {
  6. if (instance == null) {
  7. instance = new LazySimpleSingleton();
  8. }
  9. return instance;
  10. }
  11. }

然后写一个线程类 ExectorThread:

  1. public class ExectorThread implements Runnable {
  2. @Override
  3. public void run() {
  4. LazySimpleSingleton singleton = LazySimpleSingleton.getInstance();
  5. System.out.println(Thread.currentThread().getName() + ":" + singleton);
  6. }
  7. }

客户端测试代码如下:

  1. public class LazySimpleSingletonTest {
  2. public static void main(String[] args) {
  3. Thread t1 = new Thread(new ExectorThread());
  4. Thread t2 = new Thread(new ExectorThread());
  5. t1.start();
  6. t2.start();
  7. System.out.println("End");
  8. }
  9. }

运行结果如下图所示。

image.png

上面的代码有一定概率出现两种不同结果,这意味着上面的单例存在线程安全隐患。我们通过过时运行再具体看一下。这里教大家一种新技能,用线程模式调试,手动控制线程的执行顺序来跟踪内存的变化。先给 LazySimpleSingleton 类打上断点,如下图所示:

image.png

然后鼠标右键单击断点,切换为 Thread 模式,如下图所示。

image.png

开始“Debug”之后,会看到 Debug 控制台可以自由切换 Thread 的运行状态,如下图所示。

image.png

分别选择 Thread-0 和 Thread-1,都执行一步,都进入到 if 判断中。

image.png

这样 LazySimpleSingleton 就被实例化了两次。

简单懒汉式(线程安全)

通过对上面简单懒汉式单例的测试,我们知道存在线程安全隐患,那么,如何来避免或者解决呢?

通过给 getInstance() 方法加上 synchronized 关键字,使这个方法编程线程同步方法:

  1. public class LazySimpleSyncSingleton {
  2. private LazySimpleSyncSingleton() {
  3. }
  4. private static LazySimpleSyncSingleton instance = null;
  5. public synchronized static LazySimpleSyncSingleton getInstance() {
  6. if (instance == null) {
  7. instance = new LazySimpleSyncSingleton();
  8. }
  9. return instance;
  10. }
  11. }

我们再来调试。当执行其中一个线程并调用 getInstance() 方法时,另一个线程在调用 getInstance() 方法,线程的状态有 RUNNING 变成了 MONITOR,出现阻塞。知道第一个线程执行完,第二个线程才恢复到 RUNNING 状态继续调用 getInstance() 方法,如下图所示。

image.png

上图完美地展现了 synchronized 监视锁的运行状态,线程安全的问题解决了。但是synchronized 加锁时,在线程数量比较多的情况下,如果 CPU 分配压力上升,则会导致大批线程阻塞,从而导致程序性能大幅下降。

双重检查锁懒汉式

那么,有没有一种更好的方式,既能兼顾线程安全又能提升程序性能呢?答案是肯定的。我们来看双重检查锁的单例模式:

  1. public class LazyDoubleCheckSingleton {
  2. private LazyDoubleCheckSingleton() {
  3. }
  4. private static LazyDoubleCheckSingleton instance = null;
  5. public static LazyDoubleCheckSingleton getInstance() {
  6. if (instance == null) {
  7. synchronized (LazyDoubleCheckSingleton.class) {
  8. if (instance == null) {
  9. instance = new LazyDoubleCheckSingleton();// error
  10. }
  11. }
  12. }
  13. return instance;
  14. }
  15. }

上面代码的执行顺序如下:

  1. 检查变量是否被初始化(不去获得锁),如果已被初始化则立即返回;
  2. 获取锁;
  3. 再次检查变量是否已经被初始化,如果还没被初始化就初始化一个对象;

执行双重检查是因为,如果多个线程同时了通过了第一次检查,并且其中一个线程首先通过了第二次检查并实例化了对象,那么剩余通过了第一次检查的线程就不会再去实例化对象。这样,除了初始化的时候会出现加锁的情况,后续的所有调用都会避免加锁而直接返回,解决了性能消耗的问题。

上述写法看似解决了问题,但是有个很大的隐患。实例化对象的那行代码(标记为 error 的那行),字节码操作可以分解成以下三个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将对象指向刚分配的内存空间

但是有些编译器为了考虑性能,可能会对字节码命令重排序,将第二步和第三步进行重排序,顺序就成了:

  1. 分配内存空间
  2. 将对象指向刚分配的内存空间
  3. 初始化对象

现在考虑重排序后,两个线程发生了以下情况的调用:

Time Thread A Thread B
T1 检查到 lazyThree 为空
T2 获取锁
T3 再次检查到 lazyThree 为空
T4 为 lazyThree 分配内存空间
T5 将 lazyThree 指向内存空间
T6 检查到 lazyThree 不为空
T7 访问 lazyThree(此时对象还未完成初始化)
T8 初始化 lazyThree

在这种情况下,T7 时刻线程B 对 lazyThree 的访问,访问的是一个初始化未完成的对象。

正确的双重检查锁的写法如下所示:

  1. public class LazyDoubleCheckSingleton {
  2. private LazyDoubleCheckSingleton() {
  3. }
  4. private volatile static LazyDoubleCheckSingleton instance = null;
  5. public static LazyDoubleCheckSingleton getInstance() {
  6. if (instance == null) {
  7. synchronized (LazyDoubleCheckSingleton.class) {
  8. if (instance == null) {
  9. instance = new LazyDoubleCheckSingleton();
  10. }
  11. }
  12. }
  13. return instance;
  14. }
  15. }

为了解决上述问题,需要在 instance 前加入关键字 volatile。使用了 volatile 关键字后,重排序被禁止,所有的写(write)操作都将发生在读(read)操作之前。

静态内部类懒汉式

用到 synchronized 关键字总归要上锁,对程序性能还是存在一定的影响。有没有更好的方案?当然有,我们可以从类初始化的角度来考虑,看下面的代码,采用静态内部类的方式:

  1. public class LazyInnerClassSingleton {
  2. private LazyInnerClassSingleton() {
  3. }
  4. // 注意关键字final,保证方法不被重写和重载
  5. public static final LazyInnerClassSingleton getInstance() {
  6. // 在返回结果之前,会先加载内部类
  7. return LazyHolder.INSTANCE;
  8. }
  9. // 默认不加载
  10. private static class LazyHolder {
  11. private static final LazyInnerClassSingleton INSTANCE = new LazyInnerClassSingleton();
  12. }
  13. }

这种方式兼顾了饿汉式单例模式的内存浪费问题和 synchronized 的性能问题。

我们可以通过如下时序图来看一下调用顺序:

单例模式(Singleton) - 图7

  1. 客户端调用 LazyInnerClassSingleton.getInstance(),此时会先判断 LazyInnerClassSingleton 这个类是否已经加载,如果没有加载则先加载,然后调用 getInstance 方法;
  2. getInstance 方法内调用了 LazyHolder.LAZY,则此时会先判断 LazyHolder 这个类是否已经加载,如果没有加载则先加载,并初始化自身的静态属性,此时 LAZY 通过 new LazyInnerClassSingleton() 完成了初始化;
  3. 返回 LazyHolder 的属性 LAZY 的引用,最终把引用返回到客户端;

从上面的流程逻辑,我们可以看到,内部类是在方法调用之前初始化,如果在 getInstance 方法中没有调用LazyHolder.LAZY,那么 LazyHolder 是不会完成初始化的,巧妙地避免了线程安全问题,同时节省了系统的开销。

注册式单例模式

注册式单例又称为登记式单例,就是将每一个实例都登记到某一个地方,使用唯一的标识获取实例。注册式单例有两种写法:一种为枚举式单例模式,另一种为容器式单例模式。

枚举式单例模式

创建枚举类 EnumSingleton 类:

  1. public enum EnumSingleton {
  2. INSTANCE;
  3. private Object data;
  4. public Object getData() {
  5. return data;
  6. }
  7. public void setData(Object data) {
  8. this.data = data;
  9. }
  10. public static EnumSingleton getInstance(){
  11. return INSTANCE;
  12. }
  13. }

来看测试代码:

  1. public class EnumSingletonTest {
  2. public static void main(String[] args) {
  3. EnumSingleton instance1 = null;
  4. EnumSingleton instance2 = EnumSingleton.getInstance();
  5. instance2.setData(new Object());
  6. try {
  7. //序列化
  8. FileOutputStream fos = new FileOutputStream("EnumSingletonTest.obj");
  9. ObjectOutputStream oos = new ObjectOutputStream(fos);
  10. oos.writeObject(instance2);
  11. oos.flush();
  12. oos.close();
  13. //反序列化
  14. FileInputStream fis = new FileInputStream("EnumSingletonTest.obj");
  15. ObjectInputStream ois = new ObjectInputStream(fis);
  16. instance1 = (EnumSingleton) ois.readObject();
  17. ois.close();
  18. System.out.println(instance1.getData());
  19. System.out.println(instance2.getData());
  20. System.out.println(instance1.getData() == instance2.getData());
  21. } catch (Exception e) {
  22. e.printStackTrace();
  23. }
  24. }
  25. }

运行结果如下图所示。

image.png

没有做任何处理,我们发现运行结果和预期的一样。为什么枚举式单例模式能够避免反射对单例模式的破坏?下面通过分析源码来揭开它的神秘面纱。

下载一个 Java 反编译工具 XJad 打开 EnumSingleton.class 文件,看到有如下代码:

  1. static
  2. {
  3. INSTANCE = new EnumSingleton("INSTANCE", 0);
  4. $VALUES = (new EnumSingleton[] {
  5. INSTANCE
  6. });
  7. }

原来,枚举式单例在静态代码块中就给 INSTANCE 进行了赋值,是饿汉式单例模式的实现。序列化能不能破坏枚举式单例其实在 JDK 源码中也有体现,我们继续回到 ObjectInputStream 的 readObject0() 方法:

  1. private Object readObject0(boolean unshared) throws IOException {
  2. boolean oldMode = bin.getBlockDataMode();
  3. ...
  4. case TC_ENUM:
  5. return checkResolve(readEnum(unshared));
  6. ...
  7. }

我们看到,在 readObject0() 中调用了 readEnum() 方法,来看 readEnum() 方法的代码实现:

  1. private Enum<?> readEnum(boolean unshared) throws IOException {
  2. if (bin.readByte() != TC_ENUM) {
  3. throw new InternalError();
  4. }
  5. ObjectStreamClass desc = readClassDesc(false);
  6. if (!desc.isEnum()) {
  7. throw new InvalidClassException("non-enum class: " + desc);
  8. }
  9. int enumHandle = handles.assign(unshared ? unsharedMarker : null);
  10. ClassNotFoundException resolveEx = desc.getResolveException();
  11. if (resolveEx != null) {
  12. handles.markException(enumHandle, resolveEx);
  13. }
  14. String name = readString(false);
  15. Enum<?> result = null;
  16. Class<?> cl = desc.forClass();
  17. if (cl != null) {
  18. try {
  19. @SuppressWarnings("unchecked")
  20. Enum<?> en = Enum.valueOf((Class)cl, name);
  21. result = en;
  22. } catch (IllegalArgumentException ex) {
  23. throw (IOException) new InvalidObjectException(
  24. "enum constant " + name + " does not exist in " +
  25. cl).initCause(ex);
  26. }
  27. if (!unshared) {
  28. handles.setObject(enumHandle, result);
  29. }
  30. }
  31. handles.finish(enumHandle);
  32. passHandle = enumHandle;
  33. return result;
  34. }

我们发现,枚举乐行其实通过类名和 class 对象找到一个唯一的枚举对象。因此,枚举对象不可能被类加载器加载多次。那么反射是否能破坏枚举式单例模式呢?来看一段测试代码:

  1. private static void reflectionTest() {
  2. try {
  3. Class clazz = EnumSingleton.class;
  4. Constructor constructor = clazz.getDeclaredConstructor();
  5. EnumSingleton singleton = (EnumSingleton) constructor.newInstance();
  6. System.out.println(singleton);
  7. } catch (Exception e) {
  8. e.printStackTrace();
  9. }
  10. }

运行结果如下图所示。

image.png

结果中报的是 java.lang.NoSuchMethodException 异常,意思是没找到无参的构造方法。我们打开java.lang.Enum 的源码,发现只有一个 protected 的构造方法,代码如下:

  1. protected Enum(String name, int ordinal) {
  2. this.name = name;
  3. this.ordinal = ordinal;
  4. }

我们再来做一个下面这样的测试:

  1. private static void reflectionTest() {
  2. try {
  3. Class clazz = EnumSingleton.class;
  4. Constructor constructor = clazz.getDeclaredConstructor(String.class, int.class);
  5. constructor.setAccessible(true);
  6. EnumSingleton singleton = (EnumSingleton) constructor.newInstance("tom", "666");
  7. System.out.println(singleton);
  8. } catch (Exception e) {
  9. e.printStackTrace();
  10. }
  11. }

运行结果如下图所示。

image.png

结果中报错 Cannot reflectively create enum objects,意思是不能通过反射来创建枚举类,关于这个在 JDK 源码中也有说明,我们来看 Constructor 的 newInstance() 方法:

  1. public T newInstance(Object ... initargs)
  2. throws InstantiationException, IllegalAccessException,
  3. IllegalArgumentException, InvocationTargetException
  4. {
  5. if (!override) {
  6. if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
  7. Class<?> caller = Reflection.getCallerClass();
  8. checkAccess(caller, clazz, null, modifiers);
  9. }
  10. }
  11. if ((clazz.getModifiers() & Modifier.ENUM) != 0)
  12. throw new IllegalArgumentException("Cannot reflectively create enum objects");
  13. ConstructorAccessor ca = constructorAccessor; // read volatile
  14. if (ca == null) {
  15. ca = acquireConstructorAccessor();
  16. }
  17. @SuppressWarnings("unchecked")
  18. T inst = (T) ca.newInstance(initargs);
  19. return inst;
  20. }

从上述代码可以看出,在 newInstance() 方法中做了强制性的判断,如果修饰符是 Modifier.ENUM,则直接抛出异常。

枚举式单例也是《Effective Java》书中推荐的一种单例实现写法。JDK 枚举的语法特殊性及反射也为枚举保驾护航,让枚举式单例模式成为一种比较优雅的实现。

容器式单例模式

接下来看注册式单例模式的另一种写法,即容器式单例模式,创建 ContainerSingleton 类:

  1. public class ContainerSingleton {
  2. // 私有的构造方法
  3. private ContainerSingleton(){}
  4. // 存储实例的map,ConcurrentHashMap中线程安全,spring框架的IOC注册中心就是用这种方式实现的
  5. private static Map<String,Object> ioc = new ConcurrentHashMap<String,Object>();
  6. public static Object getBean(String className){
  7. synchronized (ioc){
  8. //如果map中没有这个class实例
  9. if(!ioc.containsKey(className)){
  10. Object obj = null;
  11. try {
  12. obj = Class.forName(className).newInstance();
  13. ioc.put(className, obj);
  14. } catch (Exception e) {
  15. e.printStackTrace();
  16. }
  17. return obj;
  18. }
  19. else
  20. return ioc.get(className);
  21. }
  22. }
  23. }

容器式单例模式适用于实例非常多的情况,便于管理。

线程单例实现 ThreadLocal

线程单例使用 ThreadLocal 来实现。ThreadLocal 不能保证其创建的对象是全局唯一,但是能保证在单个线程中是唯一的,天生的线程安全。下面来看代码:

  1. public class ThreadLocalSigleton {
  2. private static final ThreadLocal<ThreadLocalSigleton> threadLocalInstance = new ThreadLocal<ThreadLocalSigleton>(){
  3. @Override
  4. protected ThreadLocalSigleton initialValue() {
  5. return new ThreadLocalSigleton();
  6. }
  7. };
  8. private ThreadLocalSigleton(){};
  9. public static ThreadLocalSigleton getInstance(){
  10. return threadLocalInstance.get();
  11. }
  12. }

测试代码:

image.png

我们发现,在主线程中无论调用多少次,获得到的实例都是同一个;在多线程环境下,每个线程获取到了不同的实例。

单例模式为了达到线程安全的目的,会给方法上锁,以时间换空间。ThreadLocal 将所有的对象放在 ThreadLocalMap 中,为每个线程都提供一个对象,这实际上是以空间换时间来实现线程间隔离的。

摘录:《Spring 5 核心原理与30个类手写实战》来自文艺界的Tom老师的书籍。

作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/gl0ge2 来源:殷建卫 - 架构笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。