「谈谈设计模式」之单例模式

单例模式应该是设计模式中最为常见的一种了,今天我们来讨论一下单例模式的几种实现方式,再谈谈笔者主观偏好以及其中的为什么。

定义

单例,顾名思义,就是一个类只有一个对象实例。什么情况下一个类只需要一个实例呢?当一个类代表了一个全局的资源时,或者一个类的实例化成本很高,只需要一个实例就可以满足要求的情况下,我们就会倾向于使用单例模式。比如全局的线程池,对一个数据库连接对象,全局缓存对象等等。

照理来说,我们无需对类做任何特殊的处理,只需要保证使用的时候只实例化一次就可以了。但这种以约定作为保证的方式会随着时间的推进,无心的使用而很容易被破坏,这一点也不安全。这就是单例模式发挥作用的时候了,单例模式就是安全的保障一个类只能被实例化一次的实现方式。

实现方式

单例模式的实现方式有很多种,按照对象实例化的时机,可以把实现方式粗略分为 EagerLazy 两种类型。下面我们就来看看一些常见的实现方式。

Eager(饿汉式)

java 复制代码
public class SingletonEager {
    // 1
    private static final SingletonEager INSTANCE = new SingletonEager();
    // 2
    private SingletonEager() {
    }
    // 3
    public static SingletonEager getInstance() {
        return INSTANCE;
    }
}

代码很简单, part1 声明对象并实例化,part2 把构造函数声明为 private,这样从外部就无法调用构造函数,这样保证了这个类无法在外部被实例化,part3 通过一个方法返回这个实例。这就是著名的饿汉式,Eager 翻译为急切的意思,这个翻译着实有趣。

这种实现方式利用了类的加载机制,JVM 会保证一个类只会被加载一次,在加载一个类的时候,类的 static 成员变量也会被加载,即 part1 就会执行实例化,这样就保证了 INSTANCE 会且仅仅被实例化一次。

Lazy-1(饱汉式/懒汉式)

java 复制代码
public class SingletonLazy1 {
    // 1
    private static SingletonLazy1 INSTANCE;
    // 2
    private SingletonLazy1() {
    }
    // 3
    public static SingletonLazy1 getInstance() {
        if (INSTANCE == null) {
          	// 3.1
            INSTANCE = new SingletonLazy1();
        }
        return INSTANCE;
    }
}

对比上面的 Eager 模式,我们把 part1 的 实例化过程挪到了 part3 里面,这样在加载类的时候并不会在 part1 处就被实例化,而是在切实调用 getInstance() 的时候才会实例化,这也是为什么被称为 lazy 模式,这里的翻译同样有趣,懒汉式可以理解为 lazy 的直译,但饱汉式应该是和饿汉式对应强行套用"饱汉子不知饿汉子饥"的俗语,单独使用这个翻译并不直观。

有经验的同学会指出这里其实并不安全,如果是多线程环境的话这里有可能会出错,如果第一个线程进到 3.1 处时被切走,后面的线程可能会进入到 3.1 处,这样就会实例化多次了,说明这种模式在多线程环境确实是不够健壮的。

Lazy-2

java 复制代码
public class SingletonLazy2 {
    // 1
    private static SingletonLazy2 INSTANCE;
    // 2
    private SingletonLazy2() {
    }
    // 3
    public static synchronized SingletonLazy2 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SingletonLazy2();
        }
        return INSTANCE;
    }
}

在 lazy1 的基础上在 part3 加了同步控制,这样在多线程环境下也安全了。代价是 getInstance 方法被加了锁,调用效率会降低。于是我们又有了如下变种

Lazy-3

java 复制代码
public class SingletonLazy3 {
    // 1
    private static SingletonLazy3 INSTANCE;

    // 2
    private SingletonLazy3() {
    }

    // 3
    public static SingletonLazy3 getInstance() {
        if (INSTANCE == null) {
            // 3.1
            synchronized (SingletonLazy3.class) {
                // 3.2
                if (INSTANCE == null) {
                    // 3.3
                    INSTANCE = new SingletonLazy3();
                }
            }
        }
        return INSTANCE;
    }
}

我们把 lazy2 中的 synchronized 关键字挪到了 3.1,只有当 INSTANCE 还未实例化时,才会进入到 synchronized 块,加入3.2 的双重判断的原因是:如果两个线程同时执行到 3.1,即使第一个线程先获取到锁进入3.3 执行了,另外一个线程在等待完成获得锁之后依然会进入 synchronized 块,如果没有 3.2 的判断,3.3 会被再次执行。而当 INSTANCE 有值后,再调用 getInstance() 都不会进去 synchronized 块,这就是锁粒度细化带来的好处。

性能对比

上面关于加锁的内容很多地方都这么提到过,但是关键的效率具体会降低多少呢?似乎没有人回答,我们就来测测看:首先因为 getInstance() 方法执行都会很快,多个线程同时执行 getInstance() 的几率其实非常之低,所以我们以占实际场景 99.99% 以上的单线程执行来测试一下效率:

kotlin 复制代码
fun main(args: Array<String>) {
    val repeatTimes = 1000_000
  
  	// part1 empty block
    val emptyBlockTime = measureTimeMillis {
        repeat(repeatTimes) {}
    }
    println("emptyBlock:  $emptyBlockTime")
  
  	// part2 none Sync
    val nonSyncTime = measureTimeMillis {
        repeat(repeatTimes) { SingletonLazy1.getInstance() }
    }
    println("nonSyncTime: $nonSyncTime")
  
  	// part3 big Sync
    val syncTime = measureTimeMillis {
        repeat(repeatTimes) { SingletonLazy2.getInstance() }
    }
    println("syncTime:    $syncTime")
  
  	// part4 less Sync
    val syncLessTime = measureTimeMillis {
        repeat(repeatTimes) { SingletonLazy3.getInstance() }
    }
    println("syncLessTime:$syncLessTime")
}

fun printlnWithTime(message: Any) {
    println("${System.currentTimeMillis()}: $message")
}

// log
emptyBlock:  3
nonSyncTime: 6
syncTime:    7
syncLessTime:5

我们 repeat 100w 次,分别执行四个场景,part1 什么都不做,作为基准,part2 为不同步版本,part3 方法同步版本,part4 double check 同步版本。可以看出 nonSyncTime 在 emptyBlock 的基础上增了 3,syncTime 增加了 4,syncLessTime 增加了 5,在 100w 这个量级上可以看出差别并不大,这个结果我执行了多次才得到接近于预期的结果,甚至有可能 syncTime 比 nonSyncTime 还低,只有当 repeat 次数高于 1000w,两者的效率才能拉出数量级的差别,相信在后端的代码能够达成这个量级的调用是有可能的,但在 Android 端这几乎不可能。

ClassLoader Lazy

类会保证只被加载一次,子类在父类被加载的时候而自己没被访问的时候不会被加载,组合这两个特性,我们就能得到一种类加载器的懒加载版本:

java 复制代码
public class SingletonLazyClassLoader {

    // 1
    private SingletonLazyClassLoader() {
    }

    // 2
    public static SingletonLazyClassLoader getInstance() {
        return SingletonHolder.INSTANCE;
    }

    // 3. Static declarations in inner classes are not supported at language level '8',upgrade to jdk16+
    private static class SingletonHolder {
        static SingletonLazyClassLoader INSTANCE = new SingletonLazyClassLoader();
    }
}

和 lazy-2 以及 lazy-3 的版本相比,instance 不再由外层的 SingletonLazyClassLoader 持有,这样在首次加载 SingletonLazyClassLoader 类的时候就不会实例化 instance,做到了懒加载,只有在首次调用 getInstance 方法的时候才会加载内部的 SingletonHolder 类,这时 instance 才会被实例化,而因为内部类能够访问外部类的私有方法,所以能够实例化外部类,这个实例化的过程又由类加载机制帮我们保证了只会实例化一次,足够巧妙。之所以用 static 的内部类是因为在 Java 17 之前普通内部类不能持有 static 的属性,在 Java17 之后无此限制。

Enum - 另一种Eager

枚举类的每一个枚举量都只有一个实例,我们来看一看枚举的实现和细节:

java 复制代码
// define
public enum SingletonEnum {
    ENUM1;
}

// decompile
// access flags 0x4019
public final static enum Lcom/hunter/kotlin/coroutine/learn/other/SingletonEnum; ENUM1

// static 块
static <clinit>()V	
  ...
  ICONST_0
  NEW com/hunter/kotlin/coroutine/learn/other/SingletonEnum // new 对象
  DUP
  LDC "ENUM1" // 赋值给 ENUM1 成员
  ...

可以看到 Enum 枚举量在内部也是一个 static 的成员,并且在 static 块内部做了实例化,所以这其实和我们手动实现的 Eager 方式等价。

Kotlin 中的单例

Eager

kotlin 提供了一个 object 关键字,被此修饰的就是一个单例对象:

kotlin 复制代码
// 1. define
object SingletonObject {}

// decompile
public final class SingletonObject {
   @NotNull
   public static final SingletonObject INSTANCE;

   private SingletonObject() {
   }

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

可以看到 decompile 后的实现与我们在上面的 Eager(饿汉式) 的实现基本等价。人生苦短,用好 kotlin 语法糖。

Lazy

kotlin 可以通过委托方式把对象的获取交给代理对象,下面我们看看如果通过委托实现 Lazy 的单例模式:

kotlin 复制代码
// 1. define
class SingletonLazy private constructor() {
    companion object {
        val INSTANCE: SingletonLazy by lazy { SingletonLazy() }
    }
}

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

// 3. SynchronizedLazyImpl
private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
  	// 3.1
    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
          	// 3.2
            if (_v1 !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST")
                return _v1 as T
            }

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

我们在 part1 中就定义了一个可以懒加载的单例对象 INSTANCE,使用时直接调用 SingletonLazy.INSTANCE。定义的 by lazy 实际调用了 part2 的 lazy 方法,返回了一个 SynchronizedLazyImpl 对象(part3),从名字看我们可以推测内部有同步代码的实现。进入 part3 内部,首先我们可以看到 3.1 的 initializer block,就是我们在 part1 中用来创建 SingletonLazy 的部分,part3.2 做了一个检查,如果已经初始化了,就直接返回,part3.3 则是同步代码块,part3.4 在内部做了第二次判断,part3.5 则是正式做了初始化,这个实现跟我们在 Lazy-3 中的实现基本等价,但是 kotlin 再次大大简化了我们的实现,再次拥抱 kotlin。

如何应用

Eager / Lazy ?

lazy 的方法大多看来不算简单, eager 是最省事的,但代价是什么?内存提前被占用。如果一个单例对象占用的内存较大,那么这种提前占用就是一种浪费,所以我们需要 lazy 模式。但:

  1. 我们的单例对象似乎很多并不算大,一个最简单的对象占用的内存在 16 byte,如果采用内部类持有的方式,一个类被加载到内存中占用的内存数量级在 1kb 左右,虽然两者的内存区域不同
  2. 在上述的单例实现中,一个静态成员变量只有在类被主动加载(一般不会做)或者调用静态方法时才会被加载,所以只要没有其他不需要单例对象就可以调用的静态方法,类就不会被加载,这就意味着上述的 Eager 和 Lazy 其实是等价的

不要过早优化,优化应该基于现状,而不是想象。所以我推荐如果没有非常明确的情况,首先使用 Eager 方式,不要在无谓的地方增加复杂度,只有在根据数据分析出此处产生瓶颈时再做优化。

需要单例吗?

如果你要实现的只是一个工具类,那么你可能只需要一个 static 的类和方法,你甚至不需要实例化一个对象,根据自己的情况灵活处理。

防止反序列化?

关于防止反序列化,笔者曾看到一种说法,重写 readResolve 方法,返回单例对象:

java 复制代码
public class Singleton implements Serializable {
    private static final long serialVersionUID = 1L;
    private static final Singleton instance = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return instance;
    }

    // 确保序列化和反序列化过程中保持单例
    private Object readResolve() throws ObjectStreamException {
        return instance;
    }
}

但这里貌似有一个逻辑的悖论:如果反序列化没有用到序列化的数据,那序列化的意义又在哪里。笔者不曾遇到过需要序列化和反序列化的单例场景,有用过的可以在评论区互动一下。

局部单例

在某个范围内部只有一个对象实例,在全局可能有多个对象实例,我称之为局部单例,比如 DI 框架里的 scope 概念就是基于此的,后面有机会可以展开详解。

总结

单例模式就是保证只有一个对象实例的一种实现方式,按照对象的实例化时机分为 Eager 和 Lazy 模式,保证单例的机制主要有两种:类加载器模式和手动同步控制两种。在多数情况下,Eager 其实跟 Lazy 模式效果类似,所以推荐首先使用 Eager 模式,另外,用好 Kotlin,生活更美好。

相关推荐
大圣数据星球2 小时前
Fluss 写入数据湖实战
大数据·设计模式·flink
思忖小下3 小时前
梳理你的思路(从OOP到架构设计)_设计模式Template Method模式
设计模式·模板方法模式·eit
向前看-6 小时前
验证码机制
前端·后端
超爱吃士力架8 小时前
邀请逻辑
java·linux·后端
拭心8 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
AskHarries10 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
带电的小王11 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡11 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
isolusion11 小时前
Springboot的创建方式
java·spring boot·后端
元争栈道11 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频