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

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

定义

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

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

实现方式

单例模式的实现方式有很多种,按照对象实例化的时机,可以把实现方式粗略分为 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,生活更美好。

相关推荐
bobz96513 分钟前
tcp/ip 中的多路复用
后端
bobz96522 分钟前
tls ingress 简单记录
后端
你的人类朋友2 小时前
什么是OpenSSL
后端·安全·程序员
bobz9652 小时前
mcp 直接操作浏览器
后端
前端小张同学4 小时前
服务器部署 gitlab 占用空间太大怎么办,优化思路。
后端
databook4 小时前
Manim实现闪光轨迹特效
后端·python·动效
用户2018792831675 小时前
Android黑夜白天模式切换原理分析
android
武子康5 小时前
大数据-98 Spark 从 DStream 到 Structured Streaming:Spark 实时计算的演进
大数据·后端·spark
芦半山5 小时前
「幽灵调用」背后的真相:一个隐藏多年的Android原生Bug
android
该用户已不存在5 小时前
6个值得收藏的.NET ORM 框架
前端·后端·.net