设计模式与生活①:你的脸,为什么不能有第二个?

设计模式与生活①:你的脸,为什么不能有第二个?

Scroll Down

上个月,小王的一个朋友差点被骗走 40 万。

那天下午他接到"老板"的视频电话:脸是老板的脸,声音是老板的声音,说有个项目要紧急打款,让他先走流程。

他看了三秒,觉得哪里不对——老板的额头,在视频里的反光有点怪。他借口信号不好挂断,回拨了老板的私人号码。老板说:我没给你打过电话。

AI 换脸诈骗。各地警方通报过不止一起,这个剧本正被批量复用。

scene-call

视频里的"老板",脸是真的吗?

今天不是聊反诈,是聊这场骗局里的一个技术问题:骗子从头到尾只干了一件事——复制了一个"本该唯一"的东西。

而"保证某个东西只有一个,谁也别想弄出第二份",正是设计模式的第一课:单例模式(Singleton Pattern)。系列第一篇,从你的脸开始。

01 · 你的脸,是天然的单例

你的脸,天生带着三条规矩:

  • 规矩一:全世界只有一张。你的身份、账户、信用,全押在这张脸的唯一性上
  • 规矩二:谁也不能凭空造出第二张——骗子的全部工作,就是想"造"出来一张
  • 规矩三:验你是不是你,必须走官方核身系统,不是"视频里那张脸说自己是你就行"

这三条规矩翻译成 Java,语义一行都不会走样——对照看(右列的写法,下面一步步实现):

rule-map

左边是你的脸的规矩,右边是 Java 的写法

最直接的写法叫饿汉式——核身系统一上线,身份档案库就初始化好了,不管今天有没有人来办业务:

public class Identity {

    // 程序一启动就创建好,像银行开业前就把核身台搭好
    private static final Identity INSTANCE = new Identity();

    // 私有构造:这个档案库,谁也别想 new 出第二个
    private Identity() {}

    // 官方验证入口:要验身份,只走这里
    public static Identity getInstance() {
        return INSTANCE;
    }
}

饿汉式:开业前先搭好,随时可验

02 · 能不能等要用时,再建档?

能。核身是重服务——占内存、耗算力,但可能一整天都没几个人用。平时不启动,第一次有人来核身才拉起来,用到才创建,不浪费,这就是懒汉式:

public class Identity {

    private static Identity instance;  // 先不建档

    private Identity() {}

    public static Identity getInstance() {
        if (instance == null) {         // 查档案:还没建过?
            instance = new Identity();  // 这才建一份
        }
        return instance;
    }
}

懒汉式:第一次用到,才发起核身建档

看起来很聪明:没生意就不开门,一分冤枉钱不花。

03 · 两个渠道同时给"你"建档,会怎样?

骗子最喜欢的就是这种"用到才验证"的系统。想象一个画面:你本人前脚在手机银行提交了人脸建档,骗子后脚用伪造渠道也提交了一份——两个请求同时去查档案,查到的都是"还没建过"。

于是,两个渠道各自建了一份"你"的档案。系统里凭空出现两个"你"——其中一个,是骗子的。

race
多线程下,懒汉式会建出两份"唯一档案"

现实里不会这样——你的脸只有一张,档案建得再乱,本体不会变成两份。但代码里,new 一个对象就像批量印刷:零门槛、彼此看不见,两个线程各自 new,系统里就真的有两个"你"了。

"检查"和"行动"之间的时间差,就是并发 bug 的温床——也是骗子的空档。

补救办法:同一身份的建档请求,排成一队,一次只处理一个。对应到代码就是加锁:

// 排队版:安全,但建档完成后,所有人还得陪着排队
public static synchronized Identity getInstance() {
    if (instance == null) {
        instance = new Identity();
    }
    return instance;
}

synchronized 版:安全但慢

更聪明的做法是静态内部类,把线程安全这件事直接交给 JVM:

public class Identity {

    private Identity() {}

    // 内部类:第一次用到 getInstance() 才加载,
    // JVM 保证类只加载一次 —— 天然线程安全
    private static class Holder {
        private static final Identity INSTANCE = new Identity();
    }

    public static Identity getInstance() {
        return Holder.INSTANCE;
    }
}

静态内部类:JVM 替你把锁管好了

懒加载有了,线程安全有了,性能也没损失,《Effective Java》认可的经典写法。日常开发,写到这就够了。

还有个更狠的:AI 换脸,本质是一次"反射攻击"——不走常规入口(不偷你的证件、不破你的密码),直接把"造对象"这事绕过去。而枚举写法,是 JVM 层面从根上禁止"再造一个"的方案,反射和反序列化都撬不开。面试聊到这,就到顶了。

04 · 这些"唯一档案",你天天在用

  • 🧩 JDK 自带的 Runtime:Runtime.getRuntime(),整个 JVM 就一份;
  • 🌱 Spring 的 Bean 默认单例:容器里同一个 Bean 只有一个实例。注意,Spring 的单例是"每个容器一份",跟你手写的单例不是一个层面的事;
  • 💧 数据库连接池、线程池:创建成本高,必须全局一份,谁都能来借,用完还回去;
  • 🖥 Windows 任务管理器:你永远开不出第二个。

05 · 什么时候,别用这层"保险"

说点掏心窝的边界:

单例的本质是全局状态。全局状态是单元测试的噩梦——mock 掉一个全局单例,比想象中难得多,用例之间还会互相污染。

另外,带可变状态的单例是并发事故重灾区。只读的单例(配置、工具类)才让人放心。在 Spring 项目里,别手写单例,交给容器管理。

用不用之前,先分清一件事:你要的是"一人一份",还是"全局就这一份"?

这个原则在生活里也成立——涉及转账的视频电话,一律挂断后用你自己的通讯录回拨。你只信那个唯一的官方入口,不信对方递过来的新入口。反诈的第一原则,和单例的使用第一原则,是同一句话。

带走一句话:单例模式 = 给系统里的"你的脸"上保险——构造器私有(别人 new 不出第二份),唯一官方入口(验证只走 getInstance)。

你的脸之所以可信,是因为它不可复制。

参考资料

  1. Erich Gamma 等,《设计模式:可复用面向对象软件的基础》(GoF 原著)——单例模式的原始出处
  2. Joshua Bloch,《Effective Java(第 3 版)》——第 3 条:怎么写单例,才不会被人造出第二份(本文代码的出处);第 83 条:懒加载怎么写才线程安全(静态内部类写法的依据)
  3. Java 官方文档 · Runtime.getRuntime()
  4. Spring Framework 官方文档 · Singleton scope