温故而知新—从原理解释单例模式

1,944 阅读10分钟

前言

单例模式,应该是使用频率比较高的一种设计模式了。

关于它,你是否了解的够深呢?比如:

java和kotlin的实现方式? 懒汉饿汉到底啥意思?
饿汉、双重校验、静态内部类模式的实现原理?
涉及到的类初始化、类锁、线程安全、kotlin语法知识?

静态变量实现单例——饿汉

保证一个实例很简单,只要每次返回同一个实例就可以,关键是如何保证实例化过程的线程安全

这里先回顾下类的初始化

在类实例化之前,JVM会执行类加载

类加载的最后一步就是进行类的初始化,在这个阶段,会执行类构造器<clinit>方法,其主要工作就是初始化类中静态的变量,代码块。

<clinit>()方法是阻塞的,在多线程环境下,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>(),其他线程都会被阻塞。换句话说,<clinit>方法被赋予了线程安全的能力。

再结合我们要实现的单例,就很容易想到可以通过静态变量的形式创建这个单例,这个过程是线程安全的,所以我们得出了第一种单例实现方法:

private static Singleton singleton = new Singleton();

public static Singleton getSingleton() {
      return singleton;
}

很简单,就是通过静态变量实现唯一单例,并且是线程安全的。

看似比较完美的一个方法,也是有缺点的,就是有可能我还没有调用getSingleton方法的时候,就进行了类的加载,比如用到了反射或者类中其他的静态变量静态方法。所以这个方法的缺点就是有可能会造成资源浪费,在我没用到这个单例的时候就对单例进行了实例化。

在同一个类加载器下,一个类型只会被初始化一次,一共有六种能够触发类初始化的时机:

  • 1、虚拟机启动时,初始化包含 main 方法的主类;
  • 2、new等指令创建对象实例时
  • 3、访问静态方法或者静态字段的指令时
  • 4、子类的初始化过程如果发现其父类还没有进行过初始化
  • 5、使用反射API 进行反射调用时
  • 6、第一次调用java.lang.invoke.MethodHandle实例时

这种我不管你用不用,只要我这个类初始化了,我就要实例化这个单例,被类比为 饿汉方法。(是真饿了,先实例化出来放着吧,要吃的时候就可以直接吃了)

缺点就是 有可能造成资源浪费(到最后,饭也没吃上,饭就浪费了)

但其实这种模式一般也够用了,因为一般情况下用到这个实例的时候才会去用到这个类,很少存在需要使用这个类但是不使用其单例的时候。

当然,话不能说绝了,也是有更好的办法来解决这种可能的资源浪费

在这之前,我们先看看Kotlin的 饿汉实现

kotlin 饿汉 —— 最简单单例

object Singleton

没了?嗯,没了。

这里涉及到一个kotlin中才有的关键字:object(对象)

关于object主要有三种用法:

  • 对象表达式

主要用于创建一个继承自某个(或某些)类型的匿名类的对象。

window.addMouseListener(object : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent) { /*……*/ }

    override fun mouseEntered(e: MouseEvent) { /*……*/ }
})
  • 对象声明

主要用于单例。也就是我们今天用到的用法。

object Singleton

我们可以通过Android Studio 的 Show Kotlin Bytecode 功能,看到反编译后的java代码:

public final class Singleton {
   public static final Singleton INSTANCE;

   private Singleton() {
   }

   static {
      Singleton var0 = new Singleton();
      INSTANCE = var0;
   }
}

很显然,跟我们上一节写的饿汉差不多,都是在类的初始化阶段就会实例化出来单例,只不过一个是通过静态代码块,一个是通过静态变量。

  • 伴生对象

类内部的对象声明可以用 companion 关键字标记,有点像静态变量,但是并不是真的静态变量。

class MyClass {
    companion object Factory {
        fun create(): MyClass = MyClass()
    }
}

//使用
MyClass.create()

反编译成Java代码:

public final class MyClass {
   public static final MyClass.Factory Factory = new MyClass.Factory((DefaultConstructorMarker)null);
   public static final class Factory {
      @NotNull
      public final MyClass create() {
         return new MyClass();
      }

      private Factory() {
      }

      // $FF: synthetic method
      public Factory(DefaultConstructorMarker $constructor_marker) {
         this();
      }
   }
}

其原理还是一个静态内部类,最终调用的还是这个静态内部类的方法,只不过省略了静态内部类的名称。

要想实现真正的静态成员需要 @JvmField 修饰变量。

优化饿汉,吃饭的时候再去做饭 —— 最优雅单例

说回正题,即然饿汉有缺点,我们就想办法去解决,有什么办法可以不浪费这个实例呢?也就是达到 按需加载 单例?

这就要涉及到另外一个知识点了,静态内部类的加载时机。

刚才说到类的加载时候,初始化过程只会加载静态变量和代码块,所以是不会加载静态内部类的。

静态内部类是延时加载的,意思就是说只有在明确用到内部类时才加载,只使用外部类时不加载。

根据这个信息,我们就可以优化刚才的 饿汉模式,改成静态内部类模式(java和kotlin版本)

    private static class SingletonHolder {
        private static Singleton INSTANCE = new Singleton();
    }

    public static Singleton getSingleton() {
        return SingletonHolder.INSTANCE;
    }
	companion object {
        val instance = SingletonHolder.holder
    }

    private object SingletonHolder {
        val holder = SingletonDemo()
    }

同样是通过类的初始化<clinit>()方法保证线程安全,并且在此之上,将单例的实例化过程向后移,移到静态内部类。所以就变成了当调用getSingleton方法的时候才会去初始化这个静态内部类,也就是才会实例化静态单例。

如此一整,这种方法就完美了...吗?好像也有缺点啊,比如我调用getSingleton方法创建实例的时候想传入参数怎么办呢?

可以,但是需要一开始就设置好参数值,无法通过调用getSingleton方法来动态设置参数。比如这样写:

    private static class SingletonHolder {
        private static String test="123";
        private static Singleton INSTANCE = new Singleton(test);
    }

    public static Singleton getSingleton() {
        SingletonHolder.test="12345";
        return SingletonHolder.INSTANCE;
    }

最终实例化进去的test只会是123,而不是12345。因为只要你开始用到SingletonHolder内部类,单例INSTANCE就会最开始完成了实例化,即使你赋值了test,也是单例实例化之后的事了。

这个就是 静态内部类方法的缺点了。如果不用动态传参数,那么这个方法已经足够了。

可以传参的单例 —— 懒汉

如果需要传参数呢?

那就正常写呗,也就是调用getSingleton方法的时候,去判断这个单例是否已存在,不存在就实例化即可。

    private static Singleton singleton;

    public static Singleton getSingleton() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }

这个倒是看的很清楚,需要的时候才去创建实例,这样的话就保证了在需要吃饭的时候才去做饭,比较中规中矩的一个做法。但是在饿汉的思维里就会觉得这个人好懒啊,都不先准备好饭,吃的时候再煮好麻烦。

因此,这个方法被称为 懒汉式

但是这个方法的弊端也是很明显,就是线程不安全,不同线程同时访问getSingleton方法有可能导致对象实例化出错。

所以,加锁。

双重校验的懒汉

加锁怎么加,也是个问题。

首先肯定的是,我们加的锁肯定是类锁,因为要针对这个类进行加锁,保证同一时间只有一个线程进行单例的实例化操作。

那么类锁就有两种加法了,修饰静态方法和修饰类对象:

//方法1,修饰静态方法
    public synchronized static Singleton getSingleton() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }

//方法2,代码块修饰类对象
    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }

        }
        return singleton;
    }

方法2就是我们常说的双重校验的模式。

比较下两种方式其实区别也就是在这个双重校验,首先判断单例是否为空,如果为空再进入加锁阶段,正常走单例的实例化代码。

那么,为什么要这么做呢?

  • 第一个判断,是为了性能。当这个singleton已经实例化之后,我们再取值其实是不需要再进入加锁阶段的,所以第一个判断就是为了减少加锁。把加锁只控制在第一次实例化这个过程中,后续就可以直接获取单例即可。
  • 第二个判断,是防止重复创建对象。当两个线程同时走到synchronized这里,线程A获得锁,进入创建对象。创建完对象后释放锁,然后线程B获得锁,如果这时候没有判断单例是否为空,那么就会再次创建对象,重复了这个操作。

到这里,看似问题都解决了..吗?

等等,new Singleton()这个实例化过程真的没问题吗?

在JVM中,有一种操作叫做指令重排

JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,会将指令进行重新排序,但是这种重新排序不会对单线程程序产生影响。

简单的说,就是在不影响最终结果的情况下,一些指令顺序可能会被打乱。

再看看在对象实例化中的指令主要有这三步操作:

  • 1、分配对象内存空间
  • 2、初始化对象
  • 3、instance指向刚分配的内存地址

如果我们将第二步和第三步重排一下,结果也是不影响的:

  • 1、分配对象内存空间
  • 2、instance指向刚分配的内存地址
  • 3、初始化对象

这种情况下,就有问题了:

当线程A进入实例化阶段,也就是new Singleton(),刚完成第二步分配好内存地址。这时候线程B调用了getSingleton()方法,走到第一个判空,发现不为空,返回单例,结果用的时候就有问题了,对象都没有初始化完成。

这就是指令重排有可能导致的问题。

所以,我们需要禁止指令重排,volatile 登场。

volatile 主要有两个特性:

  • 可见性。也就是写操作会对其他线程可见。
  • 禁止指令重排。

所以再加上volatile 对变量进行修饰,这个双重校验的单例模式也就完整了。

private volatile static Singleton singleton;

kotlin 版本双重校验

//不带参数
class Singleton private constructor() {
    companion object {
        val instance: Singleton by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
        Singleton() }
    }
}

//带参数
class Singleton private constructor(private val context: Context) {
    companion object {
        @Volatile private var instance: Singleton? = null

        fun getInstance(context: Context) =
                instance ?: synchronized(this) {
                    instance ?: Singleton(context).apply { 
                    	instance = this 
                    }
                }
    }
}

带参数的写法很好理解,和Java差不多。 但这个不带参数的写法也太简便了点吧?Volatile也没有了?确定没问题?

没问题,奥秘就在这个延迟属性lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED)中,我们进去瞧瞧:

public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
    when (mode) {
        LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
        LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
        LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
    }

private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
    private var initializer: (() -> T)? = initializer
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
    // final field is required to enable safe publication of constructed instance
    private val lock = lock ?: this

    override val value: T
        get() {
            val _v1 = _value
            if (_v1 !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST")
                return _v1 as T
            }

            return synchronized(lock) {
                val _v2 = _value
                if (_v2 !== UNINITIALIZED_VALUE) {
                    @Suppress("UNCHECKED_CAST") (_v2 as T)
                } else {
                    val typedValue = initializer!!()
                    _value = typedValue
                    initializer = null
                    typedValue
                }
            }
        }

其实内部还是用到了Volatile + synchronized 双重校验。

总结

今天和大家回顾了下单例模式,希望大家能有温故而知新的收获。

参考

www.kotlincn.net/docs/refere…

拜拜

感谢大家的阅读,有一起学习的小伙伴可以关注下我的公众号——码上积木❤️❤️
每天一个知识点,建立完整知识体系架构。
这里有一群很好的Android小伙伴,欢迎大家加入~