预计阅读 14 分钟5238 字1 次浏览

设计模式修炼之路 ② 单例模式——一个实例,六种写法

一个类在整个应用里只允许存在一个实例——听着是一句话需求,做起来是一路打怪:懒汉式、饿汉式、双重校验锁、静态内部类、枚举,六种写法沿着"线程安全"这条主线层层演进。

目录

一个类在整个应用里只允许存在一个实例——听着是一句话需求,做起来是一路打怪:懒汉式、饿汉式、双重校验锁、静态内部类、枚举,六种写法沿着"线程安全"这条主线层层演进。

上一篇把地图摊开了,这一篇正式上路。创建型家族的第一个:单例模式,GoF 23 式里最简单的一个,也是面试出场率最高的一个。

它要解决的问题只有一句话:一个类在整个应用里只允许存在一个实例。

听着不像个问题?想想这些场景:配置管理器造出两份,一边改了配置另一边毫不知情;连接池造出两份,连接数直接翻倍;日志对象造出两份,两个句柄抢同一个文件。有些对象,天然只该有一份。

一、什么是单例模式

定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

落到实现上是三件事:

  1. 构造器私有——别人 new 不出来;
  2. 自己持有唯一实例——静态字段;
  3. 对外只开一扇门——静态的 getInstance()。

三件事里,第 1、3 件是照抄的,真正千变万化的是第 2 件:这个唯一的实例,什么时候造、怎么造得安全。六种写法的全部差异,都在这一个问题里。

二、优缺点

优点:

  1. 内存里只有一个实例。对频繁创建销毁、或创建成本高的对象(大配置、重连接),省内存也省 CPU;
  2. 避免对资源的重复占用。写文件、连数据库这类独占型操作,两份实例就是事故现场;
  3. 访问点严格受控。什么时候拿、怎么拿、拿的是不是同一个,全收在一个方法里,全局行为可预期。

缺点:

  1. 没有接口,不能继承(常见类式写法都如此)。想换实现只能改源码——上一篇速览的开闭原则在这里直接顶牛:想"对修改关闭",它不给你关;
  2. 一身二职。单例类既当"实例管理员"又当业务类,单一职责原则亮红灯;
  3. 本质是受控的全局变量。用爽了就是全局状态满天飞,模块之间长出看不见的依赖,谁动了状态全靠猜;
  4. 单元测试不友好。全局状态在测试之间互相污染,想换成替身(mock)也得先破开它。

三、应用场景

  • 有状态的管理类:配置管理、日志、连接池、线程池、缓存、ID 生成器;
  • JDK 里的活例子:Runtime.getRuntime() 是饿汉式单例,每个 Java 应用天生配一个运行时;Spring 容器里 Bean 的默认作用域也是单例;
  • 反过来提醒:无状态的纯工具类,直接 static 方法就够,不必强行单例——"只许一份"对无状态对象没有意义。

四、实现方式:六种写法

先交代裁判。光靠嘴说"线程不安全"太空洞,我写了个 SingletonTest:起 20 个线程同时去拿实例,把各自拿到的对象丢进 Set 里数一数——Set 的大小,就是实际造出来的实例个数。想让竞态稳定复现,难点在把"同时"做实,关键就一个字段加一段自旋:

private static volatile boolean go = false;
new Thread(() -> {
    while (!go) {
    }
    instances.add(supplier.get());
    doneGate.countDown();
}).start();

主线程 sleep 100 毫秒,等 20 个线程全部在 while (!go) 上原地自旋"各就各位",再把 go 置为 true——一声发令枪,20 个线程才真正同时冲进 getInstance()。(为什么不用 CountDownLatch 放行?await() 的唤醒自带先后顺序,20 个线程会被排着队一个个醒,竞态反而复现不出来。别问我怎么知道的。)

singleton 包里的全家福:

singleton
├── Singleton1.java    // 懒汉式(线程不安全)
├── Singleton2.java    // 懒汉式(方法加锁)
├── Singleton3.java    // 饿汉式
├── Singleton4.java    // 双重校验锁
├── Singleton5.java    // 静态内部类
├── Singleton6.java    // 枚举
└── SingletonTest.java // 20 线程的裁判

1. 懒汉式(线程不安全)

"懒"指用到才造(懒加载):第一次调 getInstance() 时 instance 还是 null,才去 new。

public class Singleton1 {

    private static Singleton1 instance;

    private Singleton1() {
        System.out.println("调用构造函数创建对象");
    }

    public static Singleton1 getInstance() {
        if (instance == null) {
            instance = new Singleton1();
        }
        return instance;
    }
}

构造函数里的打印是故意的,好从运行结果里看清对象被造了几次。裁判上场:

========== 第一版 懒汉式(线程不安全):20 个线程同时起跑 ==========
调用构造函数创建对象
调用构造函数创建对象
调用构造函数创建对象
调用构造函数创建对象
调用构造函数创建对象
拿到 5 个不同的实例

问题出在那个 if 上——判断和赋值不是原子的。线程 A 刚看到 null 准备 new,线程 B 也看到 null 也去 new,各造各的。同一份代码连跑四次,分别拿到过 7 个、3 个、11 个、5 个实例:竞态的结果是随机的。

这恰恰是最危险的地方:不是每次都炸。开发环境自测时也许 20 个线程挤成 1 个实例,上了高峰期一压测就冒出好几个。每次都触发的 bug 早就被修了;偶尔才触发的 bug,活得最久。

2. 懒汉式(方法加锁)

最直白的修法:给整个方法加上 synchronized,20 个线程排队过独木桥。

public class Singleton2 {

    private static Singleton2 instance;

    private Singleton2() {
        System.out.println("调用构造函数创建对象");
    }

    public static synchronized Singleton2 getInstance() {
        if (instance == null) {
            instance = new Singleton2();
        }
        return instance;
    }
}

安全是安全了,裁判也确实只数出 1 个实例。但代价一眼可见:锁把所有调用都挡住了。真正需要锁的只有第一次创建那一下,之后哪怕几百万次纯读的 getInstance() 也得排队抢锁——为了 1 次写,把 999999 次读全赔了进去。能用,但亏。

3. 饿汉式

换个思路,不躲了:类加载的时候直接造好。"饿"就是等不及,见到就造。

public class Singleton3 {

    private static Singleton3 instance = new Singleton3();

    private Singleton3() {
        System.out.println("调用构造函数创建对象");
    }

    public static Singleton3 getInstance() {
        return instance;
    }
}

线程安全是白送的——JVM 保证类的初始化是排他的,一个类只初始化一次,连竞态窗口都没有。裁判数出 1 个实例。

代价两个字:浪费。不管用不用得上,加载即占用;构造函数里若有重活(读配置、连远程),还会拖慢启动。反过来,如果这个实例大概率会用、构造也便宜,饿汉式是最省心的选择。

4. 双重校验锁(DCL)

前三版的合体改良:要懒加载(学第一版),要锁(学第二版),但只锁创建那一下(补第二版的亏)。这是面试高频题,两个问题必须答得上来。

public class Singleton4 {

    private volatile static Singleton4 instance;

    private Singleton4() {
        System.out.println("调用构造函数创建对象");
    }

    public static Singleton4 getInstance() {
        //标记1
        if (instance == null) {
            //标记2
            synchronized (Singleton4.class) {
                //标记3
                if (instance == null) {
                    instance = new Singleton4();
                }
            }
        }
        //标记4
        return instance;
    }
}

第一问:为什么要判两次 null?

  • 标记 1 的外层判断是性能闸门:实例造好之后,绝大多数调用在这里就返回了,根本碰不到锁;
  • 标记 3 的内层判断是安全闸门:两个线程可以同时通过外层判断(都看到了 null),先到的抢到锁、造好实例、释放锁,后到的这时才进锁——如果不再检查一次 null,它会再造一个。

一句话:外层挡重复读,内层挡重复造,一层都不能少。

第二问:为什么要 volatile?

instance = new Singleton4() 不是一步到位,大致三步:分配内存、执行构造初始化对象、把引用赋给 instance。编译器和 CPU 可能把后两步重排成"先赋引用、再初始化"。重排之后,另一个线程在外层判断处看到 instance 已经非 null,兴冲冲拿走——拿到的是还没初始化完的半成品。volatile 禁掉这个重排,保证别人看到引用时,对象一定造完了。

5. 静态内部类

DCL 又难写又难讲。有没有安全 + 懒加载兼得、还不碰锁的写法?有——把实例挪进静态内部类:

public class Singleton5 {

    public static class SingletonInner {
        public static final Singleton5 INSTANCE = new Singleton5();
    }

    private Singleton5() {
        System.out.println("调用构造函数创建对象");
    }

    public static Singleton5 getInstance() {
        return SingletonInner.INSTANCE;
    }
}

两步看懂:

  • 外部类加载时,SingletonInner 不会跟着初始化(JVM 的类加载按需触发)——懒加载达成;
  • 第一次调 getInstance() 才触发内部类加载,而类初始化的排他性由 JVM 保证——线程安全白送,靠山和饿汉式是同一个。

等于借 JVM 的类加载机制,把 DCL 手写的整套逻辑免费领了一遍,代码还短一半。要懒加载又怕写错 DCL,选它。

6. 枚举

《Effective Java》推荐的写法,一共三行:

public enum Singleton6 {
    INSTANCE
}

写法最短,保险却最厚,一口气三重:

  1. 线程安全:枚举实例在类初始化时创建,JVM 保证;
  2. 防反射攻击:JVM 明确禁止反射调用枚举构造器,newInstance() 直接抛异常;
  3. 防序列化破坏:普通单例序列化再反序列化会造出新实例(得额外实现 readResolve() 才堵得住),枚举天然免疫,反序列化拿到的还是原来那个。

反射和序列化这两个"高级破坏手段",前五种写法全是裸奔,只有枚举全防住了。

**六种写法之外,再看一个细节。**回头看六版 getInstance() 的返回类型:始终是具体的 Singleton1、Singleton2……调用方 import 的是具体类,依赖的也是具体类。哪天想换实现、或测试时想塞个替身进去,就得把每个调用点翻出来改——上一篇速览的六大原则里,依赖倒置(依赖抽象,不依赖具体实现)在这里轻轻响了声警报。单例自己解决不了这个问题,先把这句话记在小本本上,㉞ 收官篇回头细算。

五、六种写法怎么选

写法 懒加载 线程安全 高并发性能 防反射/序列化 一句话点评
懒汉式(不安全) ✓ ✗ ✗ ✗ 反面教材,别进生产
懒汉式(方法加锁) ✓ ✓ ✗ ✗ 能用,但每次都排队
饿汉式 ✗ ✓ ✓ ✗ 简单粗暴,构造不重就选它
双重校验锁 ✓ ✓ ✓ ✗ 面试必考的工业级懒加载
静态内部类 ✓ ✓ ✓ ✗ 常规首选,DCL 的平替
枚举 ✗ ✓ ✓ ✓ Effective Java 之选

裁判对 2~5 版的判决是一致的,构造函数都只跑一次:

========== 第二版 懒汉式(方法加锁):20 个线程同时起跑 ==========
调用构造函数创建对象
拿到 1 个不同的实例

第六版直接比较两个引用:

========== 第六版 枚举 ==========
两次获取是否同一个实例:true

实战建议很朴素:不介意饿、构造便宜——第三版;要懒加载——第五版;担心反射或序列化搞破坏——第六版。第一版永远别进生产代码;第二版留给性能无所谓的老代码;第四版——把它当成一道必须会做的面试题。

写在最后

六种写法翻完,主线只有一条:线程安全。从第一版随机的 7、3、11、5 个实例,到 DCL 的两层判断加 volatile,再到静态内部类和枚举干脆把安全外包给 JVM——所有演进都在回答同一个问题:怎么让"只有一个实例"在并发下依然成立。

示例代码在 design-pattern 项目的 singleton 包里:Singleton1 到 Singleton6 是六种写法,SingletonTest 是那个 20 线程的裁判,跑一次 main 就能看到竞态现场——顺便说,每次跑出来的数都不一样。

单例管的是"一个类有几个实例",但创建型家族真正的主场是另一件事:实例从哪来。一堆同族对象,怎么造才不至于让调用方把 new 写得到处都是、改一个产品名就全局翻车?下一篇——工厂模式。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论