六、单例模式:定义、结构、实现与实战

单例模式,说白了就是保证一个类只有一个实例。你想想看,有些对象我们只需要一个,比如配置管理器、线程池、数据库连接池。如果搞出多个实例,要么浪费内存,要么数据不一致,要么资源冲突。

我记得刚入行那会儿,有个同事写了个日志工具类,每次调用都 new 一个实例。结果线上日志文件被多个实例同时写,日志全乱了。后来改成单例,问题就解决了。嗯,这就是单例模式最典型的应用场景。

6.1 定义与核心思想

单例模式的定义很简单:确保一个类只有一个实例,并提供一个全局访问点

核心思想就两条:

  • 私有化构造方法——不让外部 new 对象
  • 提供一个静态方法——返回唯一的实例

这两条缺一不可。我见过有人只私有化构造方法,但忘了提供静态方法,结果类根本没法用。也有人提供了静态方法,但构造方法没私有化,外部照样 new 出多个实例。都是坑。

6.2 结构图

先看一张结构图,帮你建立整体认知:

单例模式结构图 Singleton - instance: Singleton - Singleton() + getInstance(): Singleton + businessMethod() Client 调用 getInstance() 关键点: 1. 构造方法私有 2. 静态方法返回唯一实例

6.3 五种实现方式

单例模式的实现方式很多,我挑最常用的五种来讲。每种都有它的适用场景和坑。

6.3.1 饿汉式

饿汉式,就是类加载时就创建实例。简单粗暴,线程安全。

public class Singleton {
    // 类加载时就创建实例
    private static final Singleton INSTANCE = new Singleton();
    
    // 构造方法私有
    private Singleton() {}
    
    // 提供全局访问点
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

优点:实现简单,线程安全,没有锁的性能开销。

缺点:类加载时就创建,如果一直没用这个实例,就白白浪费了内存。

我的建议:如果实例不重,或者确定会被用到,用饿汉式最省心。我在项目中写配置管理器就用的饿汉式,因为配置肯定会被用到。

6.3.2 懒汉式(线程不安全)

懒汉式,就是延迟加载,用的时候才创建。但注意,这个版本是线程不安全的。

public class Singleton {
    private static Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();  // 多线程下可能创建多个实例
        }
        return instance;
    }
}

注意:这个版本在多线程环境下会出问题。两个线程同时判断 instance == null,都会创建实例。我曾经在面试中问过这个问题,很多人答不上来。

6.3.3 懒汉式(同步方法)

给 getInstance 方法加 synchronized,线程安全了,但性能下降了。

public class Singleton {
    private static Singleton instance;
    
    private Singleton() {}
    
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

问题:每次调用 getInstance 都要加锁,性能开销大。其实只有第一次创建时需要同步,后面读操作不需要。

6.3.4 双重检查锁(DCL)

双重检查锁,既保证了延迟加载,又保证了线程安全,还减少了锁的开销。这是我最常用的方式。

public class Singleton {
    // volatile 防止指令重排序
    private static volatile Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {  // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {  // 第二次检查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

注意:instance 必须用 volatile 修饰。为什么?因为 new Singleton() 不是原子操作,它分三步:分配内存、初始化对象、将引用指向内存。如果没有 volatile,JVM 可能重排序,导致其他线程拿到一个未初始化的对象。我曾经踩过这个坑,排查了好久才发现是 volatile 的问题。

6.3.5 静态内部类

静态内部类方式,既实现了延迟加载,又线程安全,还没有锁。我个人最喜欢这种方式。

public class Singleton {
    private Singleton() {}
    
    // 静态内部类,只有在调用 getInstance 时才会加载
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

原理:JVM 在加载外部类时不会加载内部类,只有调用 getInstance 时才会加载 SingletonHolder,从而创建实例。JVM 的类加载机制保证了线程安全。

我的建议:如果项目没有特殊要求,优先用静态内部类方式。代码简洁,性能好,线程安全。我在好几个生产项目里都用这种方式,从来没出过问题。

6.4 优缺点总结

实现方式 延迟加载 线程安全 性能 推荐指数
饿汉式 ⭐⭐⭐⭐
懒汉式(不安全)
懒汉式(同步方法) ⭐⭐
双重检查锁 ⭐⭐⭐⭐
静态内部类 ⭐⭐⭐⭐⭐

6.5 应用场景

单例模式的应用场景其实挺多的,我挑几个常见的说说:

  • 配置管理器——全局只需要一份配置,多个实例会导致配置不一致
  • 日志记录器——多个实例写同一个日志文件会冲突
  • 线程池——全局共享一个线程池,避免资源浪费
  • 数据库连接池——连接池本身是共享资源,多个实例会创建多余连接
  • 缓存——全局缓存只需要一份,多个实例会缓存重复数据

核心原则:当系统中某个对象只需要一个实例,或者多个实例会导致资源浪费、数据不一致时,就用单例模式。

6.6 避坑指南

最后,分享几个我踩过的坑:

  • 序列化破坏单例:如果单例类实现了 Serializable,反序列化时会创建新实例。解决方案是添加 readResolve 方法。
  • 反射破坏单例:通过反射调用私有构造方法可以创建新实例。解决方案是在构造方法中加判断,如果实例已存在就抛出异常。
  • 类加载器不同:不同类加载器会加载出不同的单例。这个在 Web 容器中要注意。

我曾经在写一个缓存工具时,忘了处理序列化问题。结果缓存数据在集群间同步时,每个节点都创建了自己的单例,缓存完全失效。排查了两天才找到原因。嗯,从那以后我写单例都会检查序列化和反射的问题。

单例模式虽然简单,但细节不少。选对实现方式,注意边界情况,才能写出靠谱的单例。

公众号:蓝海资料掘金营,微信deep3321