新锅炒冷饭,Kotlin单例那些事儿

单例设计模式有着非常广泛的应用,而平时我们接触的都是些Java的实现方式,关于Kotlin的单例模式则很少被提及,所以我们就来聊聊Kotlin单例的那些事儿。

1、单例模式的相关概念

定义:

确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

简单解读一下就是:单例的类负责创建自己的对象,同时确保只有一个对象被创建;这个类提供一种访问其唯一对象的方式,可以直接访问,不需要实例化该类的对象。

使用场景:

确保某个类有且只有一个对象的场景,避免产生多个对象消耗过多的资源,或者某些类型的对象有些只能有一个。
举个栗子:当我们访问IO和数据库等资源时,每创建一个对象需要消耗很多资源,这时就要考虑使用单例模式。

UML类图:

单例模式UML类图(图片来源于网络)

总的来说,实现单例模式有下面几个关键点:

  • 构造函数私有化,使外部不能直接构造单例对象;
  • 暴露公共静态方法或枚举,返回单例对象;
  • 确保线程安全,保证多线程环境下也仅有一个实例对象;
  • 确保单例对象在反序列化时不会创建新的对象。

真实使用中不一定要同时满足所有条件,尤其在Android对并发性要求不高的时候,仅满足条件的前两条也是可以。

2、单例模式的实现方式

2.1、饿汉式单例:

饿汉式单例就是声明一个静态对象,在类被第一次加载的时候,就完成静态对象的实例化。

Java篇:
public class SingletonJava {
    // 在类内部实例化一个私有的实例
    private static final SingletonJava INSTANCE = new SingletonJava();

    // 构造函数私有,外部无法访问
    private SingletonJava() { }

    // 共有的静态函数,对外暴露获取单例对象的接口
    public static SingletonJava getInstance() {
        return INSTANCE;
    }
}

这种写法比较容易理解,也应该都有接触过,我们直接看一下如何在Kotlin实现类似效果。

Kotlin篇:
object SingletonKotlin { }

没开玩笑,这真的是Kotlin的饿汉式单例,爽到难以置信。这种写法在Kotlin中有自己的称呼:对象声明

  • 何为对象声明,我们在object关键字后面跟一个名称,就可以获取一个单例对象;
  • 像变量声明一样,对象声明不是一个表达式,不能用在赋值语句的右边;
  • 而且对象声明的初始化过程是线程安全的。

如此神奇的效果,我们怎么能忍住不一探究竟,所以我们把Kotlin的自节码进行了反编译。
查看Kotlin的字节码:ToolsKotlinShow Kotlin Bytecode,然后点击字节码的DECOMPILE进行反编译:

public final class SingletonKotlin {
   public static final SingletonKotlin INSTANCE;

   private SingletonKotlin() { }

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

愣了,这分明就是我们饿汉式单例的另一种写法!难怪可以实现相同的单例效果,原来是Kotlin通过语法糖为我们做了简化封装。

2.2、懒汉式单例:

懒汉式单例就是声明一个静态对象,并且在用户第一次调用的时进行初始化。

Java篇:

public class SingletonJava {

    private static SingletonJava INSTANCE;

    private SingletonJava() { }

    public static SingletonJava getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SingletonJava();
        }
        return INSTANCE;
    }
}

Kotlin篇:

// 构造函数私有化
class SingletonKotlin private constructor() {

    // 伴生对象,类似于Java的静态代码块
    companion object {
        // 声明私有对象,并重写get方法
        private var mInstance: SingletonKotlin? = null
            get() {
                // 如果对象为空,则进行实例化
                field = field ?: SingletonKotlin()
                return field
            }

        // 对外暴露获取单例对象的接口
        fun getInstance(): SingletonKotlin = mInstance!!
    }
}

Java的写法大家都应该很熟悉了,而Kotlin的写法则有些不同,毕竟Kotlin也有它自己的语言特性:

companion标记的这块代码,在Kotlin中被称为伴生对象:类似于Java的静态代码块,这样它就与外部类关联在一起,我们可以直接通过外部类访问到对象的内部元素。

而用过Kotlin的都知道,Kotlin的属性自带GetterSetter(对于var属性,下同)。

所以我们在内部声明一个私有的mInstance,自定义它的Getter;当对象为空是进行实例化,当对象不为空时,直接返回实例对象。这样我们既满足了构造函数的私有化,有对外暴露了对象的获取方法。

小结一下:
  • 懒汉式单例只有在使用时才会进行实例化,在一定程度上节约了资源;
  • 但第一次加载时需要及时进行实例化,反应稍慢;
  • 而且这种写法是非线程安全的,适用于单线程环境,不推荐使用。

2.3、线程安全的懒汉式:

为了解决上述线程安全性的问题,我们使用同步锁适应多线程的环境。

Java篇:

public class SingletonJava {

    private static SingletonJava INSTANCE;

    private SingletonJava() { }

    public static synchronized SingletonJava getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SingletonJava();
        }
        return INSTANCE;
    }
}

Kotlin篇:

class SingletonKotlin private constructor() {

    companion object {
        private var mInstance: SingletonKotlin? = null
            get() {
                field = field ?: SingletonKotlin()
                return field
            }

        @Synchronized
        fun getInstance(): SingletonKotlin = mInstance!!
    }
}

这种写法和上面的懒汉式写法类似,仅仅是多了一层同步锁,从而保证在多线程环境下的线程安全。由于每次对象的获取都会对整个类进行加锁,所以运行效率不高,实际使用中并不推荐。


2.4、双重校验锁式(DCL):

DCL单例既能在需要时进行初始化单例,又能保证线程安全,且单例对象实例化之后,不再进行同步锁。

Java篇:

public class SingletonJava {

    private volatile static SingletonJava INSTANCE;

    private SingletonJava() { }

    public static SingletonJava getInstance() {
        if (INSTANCE == null) {
            synchronized (SingletonJava.class) {
                if (INSTANCE == null) {
                    INSTANCE = new SingletonJava();
                }
            }
        }
        return INSTANCE;
    }
}

这段代码的精髓就是getInstance方法的两层非空判断:

  • 第一层为了避免不必要的同步,只需在第一次创建实例时同步;
  • 第二层为了在对象为null的情况下创建实例,防止别的线程抢先初始化了。

可是仅靠getInstance方法就能解决并发线程安全性的问题吗?
我们一起看下INSTANCE = new SingletonJava()这行代码,这行代码都干了些什么呐,可基本拆分为三个步骤:

  1. INSTANCE单例对象分配内存空间;
  2. 调用SingletonJava()构造函数,初始化成员对象;
  3. INSTANCE对象指向分配的内存空间(此时INSTANCE不为null)。

由于Java内存模型(JVM)允许指令重排,执行顺序不一定是1 → 2 → 3,也可能是1 → 3 → 2
虽然单线程环境下,指令重排并不会影响最终结果,但会影响到多线程并发执行的正确性:

  • 如果线程A按1 → 3执行,还没有执行到2
  • 此时被切换带线程B,由于INSTANCE已经不为null了,会被线程B直接取走;
  • 而此时INSTANCE是未完全初始化的,线程B直接使用将会出错。

结合并发线程安全的三要素:原子性、可见性、有序性。
我们知道这种写法是有缺陷的,可是怎么解决上述问题呐?答案就是我们上述代码用到的volatile关键字。

  • volatile可以禁止指令重排,保证有序性;
  • volatile还可以保证可见性,强制INSTANCE对象每次从主存中读取。

这里为什么不提原子性呐,因为原子性我们已经通过synchronized来保证了。关于volatile的更多内容,大家可以参考:Java并发编程:volatile关键字解析

Kotlin篇:

class SingletonKotlin private constructor() {

    companion object {
        @Volatile
        private var mInstance: SingletonKotlin? = null
            get() {
                field = field ?: synchronized(this) { field ?: SingletonKotlin() }
                return field
            }

        fun getInstance() = mInstance!!
    }
}

看到这里你可能会觉得:Kotlin也不过如此,好像除了语法糖也没什么了。要是这么想,结论就下早了,其实针对DCL的写法Kotlin是有杀手锏的。

Kotlin之:延迟属性 lazy

class SingletonKotlin private constructor() {

    companion object {
        val instance: SingletonKotlin by lazy { SingletonKotlin() }
    }
}

如果告诉你这个lazy关键字就是DCL单例,你会不会觉得香?不过要说lazy我们还是先来聊聊委托属性

委托属性:

val/var <属性名>: <类型> by <表达式>by 后面的表达式就是该属性的委托。

属性的委托不需要实现接口,但是需要提供一个getValue()函数与 setValue()函数(对于var属性,下同)。效果类似于自定义的GetterSetter,因为属性对应的get()set()方法会被委托给它的getValue()setValue() 方法。

百闻不如一见,我们还是写个简单的委托属性,大家一起看下:

class MyStringDelegate {

    private var value = "默认值2333..."

    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return value
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        this.value = value
        println("我被赋值:$value")
    }
}

getValue()setValue()方法的参数比较拗口,为了避免手写出现错误,也可以通过继承ReadWriteProperty实现其中的方法,效果是一样的。

  • thisRef:为进行委托的类的对象,必须与属性所有者类型(对于扩展属性——指被扩展的类型)相同或者是它的超类型;
  • property:为进行委托属性的对象本身,必须是类型KProperty<*>或其超类型;
  • value(setValue):必须与属性同类型或者是它的子类型。
fun main(args: Array<String>) {
    // 将 a 的值委托给 MyStringDelegate
    var a: String by MyStringDelegate()
    println(a)
    a = "Hello World"
    println(a)
}

运行上面的代码,我们得到了如下结果:

默认值2333...
我被赋值:Hello World
Hello World

以上结果符合预期,因为在每个委托属性的实现的背后,Kotlin 编译器都会生成辅助属性并委托给它:取值时将调用getValue()方法,赋值时将调用getValue()方法,这一点在查看反编译的字节码也可以证实。

lazy则是Kotlin标准库中提供惰性值的工厂方法,下面我们将针对lazy实现单例效果的过程进行分析。

lazy源码分析:

lazy是可以指定线程安全模式的,这里我们将对默认模式LazyThreadSafetyMode.SYNCHRONIZED进行分析。点击查看lazy,我们得到:

public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

initializer: () -> T 表示lazy可以接收一个带T型返回值的lambda表达式,这里有疑问可以参考:高阶函数与 lambda 表达式

我们继续追踪SynchronizedLazyImpl,发现它实现了Lazy接口:

public interface Lazy<out T> {

    // 当前实例的延迟初始化值,一旦实例化将不能更改
    public val value: T

    // 当前实例是否已经初始化,如果实例化了将会一直返回true,并且将不再初始化
    public fun isInitialized(): Boolean
}

观察Lazy接口,我们发现它提供了单例的指导思想,保证只有一个实例。那就让我们看一下SynchronizedLazyImpl是如何具体实现的:

private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {

    // 由于Kotlin的形参是val类型,定义一个var类型的参数方便使用
    private var initializer: (() -> T)? = initializer
    // 定义一个默认结果,并指定默认值
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
   
    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 {
                    // 执行lambda表达式,计算结果
                    val typedValue = initializer!!()
                    // 将结果赋值,下次可直接返回结果,无需再次计算
                    _value = typedValue
                    initializer = null
                    typedValue
                }
            }
        }
    // 部分代码省略。。。
}

上述代码重写了Lazy接口value属性的getter在第一次调用get()方法会执行lambda表达式并记录结果,后续调用get()方法只是返回记录的结果,实现逻辑是DCL一致的。

而作为委托属性的一份子,lazy为属性赋值也需要提供getValue()方法。所以lazyLazy接口中提供了getValue()的扩展函数,将计算结果value返回:

@kotlin.internal.InlineOnly
public inline operator fun <T> Lazy<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value

至此就是lazy实现DCL单例的全过程,大大简化了我们对DCL单例的使用。


2.5、静态内部类模式:

Java篇:

public class SingletonJava {

    // 私有的构造方法
    private SingletonJava() { }

    // 对外提供获取实例的静态方法
    public static SingletonJava getInstance() {
        return SingletonHolder.INSTANCE;
    }

    // 在静态内部类中初始化实例对象
    private static class SingletonHolder {
        private static final SingletonJava INSTANCE = new SingletonJava();
    }
}

Kotlin篇:

class SingletonKotlin private constructor() {

    companion object {
        val instance = SingletonHolder.INSTANCE
    }

    private object SingletonHolder {
        val INSTANCE = SingletonKotlin()
    }
}

两种写法没什么差别,只是Kotlin看着更精简一些,那这种写法到底有什么好处呐:

首先,在第一次加载Singleton类时并不会初始化INSTANCE,只有在第一次调用getInstance()方法时才会进行初始化操作。而由于静态内部类的特性,在第一次调用getInstance()方法时,虚拟机会去加载SingletonHolder类;这种方式不仅能够保证线程安全,也能够保证单例对象的唯一性,同时也延迟了单例的实例化。

总得来说,静态内部类的写法优点和DCL写法类似;而且不会被反射入侵,因为反射不能从外部类获取内部类的属性。不足就是需要两个类去做到这一点,虽然不会创建静态内部类的对象,但是其Class对象还是会被创建,而且是属于永久的对象。瑕不掩瑜,这种写法依旧是种比较推荐的单例实现方式。


2.6、Enum单例:

Java篇:

public enum SingletonEnumJava {

    INSTANCE;

    public void doSomething() {
        System.out.println("doSomething: Java");
    }
}

Kotlin篇:

enum class SingletonEnumKotlin {

    INSTANCE;

    fun doSomething() {
        println("doSomething: Kotlin")
    }
}

使用枚举来实现单例效果,确实是一种很骚的操作。枚举类与普通的类一样,也可以有自己的字段、方法,甚至实现一个或多个接口(Interface)。但枚举类不能作为子类继承其他类,也不能被继承,因为枚举反编译是final类型的。

使用枚举的究竟有何种优势呐,首先写法简单算一个;重要的是枚举实例的创建是线程安全的;而且在任何情况下它都是一个单例,即使反序列化也不会创建新的对象,而且JVM 还会阻止通过反射获取枚举类的私有构造方法。


2.7、容器单例模式:

Java篇:

public class SingletonManagerJava {

    private static Map<String, Object> map = new HashMap<>();

    public SingletonManagerJava() { }

    public static void registerService(String key, Object instance) {
        if (!map.containsKey(key)) {
            map.put(key, instance);
        }
    }

    public static Object getService(String key) {
        return map.get(key);
    }
}

Kotlin篇:

class SingletonManagerKotlin() {

    private val map = mutableMapOf<String, Any>()

    fun registerService(key: String, instance: Any) {
        if (!map.containsKey(key)) {
            map[key] = instance
        }
    }

    fun getService(key: String) = map[key]
}

在程序的初始,将多种单例类型注入到统一的管理类中,使用时根据key值获取对应的单例对象。使用这种方式我们可以管理多种类型的单例,并且可以通过统一的接口进行获取操作,降低了用户的使用成本,也对用户隐藏了具体实现,降低了耦合度。

此种方式一般用于系统层面,如Android的系统服务就是通过此种方式进行管理,使用时可以通过getSystemService()方法获取具体的服务对象。


2.8、补充:

虽然单例的实现方式已经介绍完了,但前面我们有提到反序列化相关的问题。枚举的内部实现了Serializable接口,并且自己处理了序列化相关操作,但是其他实现方式做不到啊。

如果需要序列化相关的操作,首先单例类需要实现Serializable接口,并且重写readResolve()方法,由我们自己控制对象的反序列化。不然反序列化时会通过特殊的途经创建一个新的实例,相当于调用了该类的构造函数。

为了避免这种情况,我们要做的就是在readResolve()方法中将mInstance对象返回:

// Java篇
private Object readResolve() throws ObjectStreamException{
    return mInstance;
}

// Kotlin篇
@Throws(ObjectStreamException::class)
private fun readResolve() = mInstance

3、总结

无论使用那种形式实现单例模式,核心原理都是将构造函数私有化,并且通过静态方法获取唯一的实例。而在方案选取时我们会从:线程安全性、延迟初始化、反序列化、使用便利性等几方面进行考虑。

在Java中,我们使用比较频繁的有饿汉式、DCL、静态内部类的实现方式。而在Kotlin中由于语法糖的存在,objectlazy的实现方式将成为我们的首选,静态内部类的写法会略显繁琐。

而如果考虑到序列化的情况,我们也可以使用枚举的实现方式,枚举在两种语言的使用可谓是无差别的。

内容参考:
Android 源码设计模式解析与实战
Kotlin下的5种单例模式
Java并发编程:volatile关键字解析

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 210,914评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,935评论 2 383
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,531评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,309评论 1 282
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,381评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,730评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,882评论 3 404
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,643评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,095评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,448评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,566评论 1 339
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,253评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,829评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,715评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,945评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,248评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,440评论 2 348

推荐阅读更多精彩内容