设计模式:单例模式

设计模式:单例模式

目录

单例模式是什么

单例模式是后端面试中的一个热点:一个类在整个应用中只能有一个实例,并提供一个全局访问点。

单例模式要做的就是这件事:用代码保证某个对象全局唯一,谁来拿都是同一个。

为什么需要单例

假设你写了一个数据库连接池管理器,每个模块各自 new 了一个实例:

// 订单模块
ConnectionPool poolA = new ConnectionPool();

// 用户模块
ConnectionPool poolB = new ConnectionPool();

// 支付模块
ConnectionPool poolC = new ConnectionPool();

三个模块各自初始化了一个连接池,数据库连接数直接翻了三倍。更严重的是,它们各自维护自己的连接状态,互相不知道对方用了多少连接,连接泄漏了也没法统一排查,。

多个实例意味着资源被浪费、状态不一致、管理失控。 连接池、线程池、配置中心、日志记录器,这些东西天然就应该是全局唯一的。单例模式就是用代码强制保证这种唯一性。

五种写法全览

写法一:饿汉式

最直白的写法:类加载的时候就创建实例,不管你用不用。

public class Singleton {
    // 类加载时就创建实例
    private static final Singleton INSTANCE = new Singleton();

    // 私有构造,禁止外部 new
    private Singleton() {}

    // 全局访问点
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

如何做到线程安全? JVM 的类加载机制保证了 static 字段的初始化只会执行一次,这个过程是线程安全的。我们在使用的时候就不需要加锁,因为JVM 帮我们兜底了。

缺点也很明显:不管你用不用,类一加载实例就创建好了。如果这个对象很重(比如初始化时要加载大量配置文件),而应用启动后可能很长时间都用不到它,那这块资源就会被一直白白占着。

写法二:懒汉式

为了解决"用不到就别创建"的问题,延迟到第一次调用 getInstance() 时再创建:

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

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

看起来没问题,但多线程下会出事。两个线程同时调 getInstance(),都判断 instance == null,于是各自创建了一个实例,单例就被打破了。

写法三:懒汉式 + synchronized

加锁,让同一时刻只有一个线程能进入创建逻辑:

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

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

线程安全了,但性能差。synchronized 加在方法上,意味着每次调用 getInstance() 都要拿锁,哪怕实例早就创建好了,读操作也要排队。在高并发场景下,这里会成为瓶颈。

写法四:双重检查锁(DCL)

既然只有第一次创建时需要加锁,后续读取不需要,那我们可以把锁的粒度缩小:

public class Singleton {
    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;
    }
}

第一个 if 是为了性能:实例已经创建了,直接返回,不用排队抢锁。

第二个 if 是为了安全:可能有两个线程同时通过了第一个 if,进入 synchronized 块后,必须再确认一次,避免重复创建。

关键点:instance 必须用 volatile 修饰。这一点我们在后文中单独拎出来讲。

写法五:静态内部类

这是兼顾延迟加载和线程安全的最优写法之一:

public class Singleton {
    private Singleton() {}

    // 静态内部类,只有在被引用时才会加载
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

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

原理:Java 的内部类只有在真正被使用时才会被类加载器加载。当getInstance() 第一次被调用时,Holder 类才会初始化,INSTANCE 才会被创建。在此之前,Holder 类根本不存在于内存中。

线程安全同样由 JVM 的类加载机制保证,不需要 synchronized,不需要 volatile,代码简洁,性能最优。

写法六:枚举

《Effective Java》作者 Joshua Bloch 最推荐的写法:

public enum Singleton {
    INSTANCE;

    public void doSomething() {
        System.out.println("执行业务逻辑");
    }
}

就这么多代码。枚举天然是单例的,JVM 保证枚举实例的唯一性。更重要的是,枚举能防御反射和序列化攻击,这是其他写法都做不到的。

为什么 DCL 必须加 volatile

问题出在 instance = new Singleton() 这一行。maybe你以为它是一个原子操作,实际上 JVM 执行它分了三步:

1. 分配内存空间
2. 初始化对象(执行构造方法)
3. 将 instance 指向分配的内存

如果没有 volatile,JVM 可能会进行指令重排序,把步骤 2 和步骤 3 调换顺序:

1. 分配内存空间
2. 将 instance 指向分配的内存    ← 先指过去了
3. 初始化对象                    ← 但对象还没初始化完

这会导致什么问题?线程 A 执行到第 2 步,instance 已经不是 null 了,但对象还没初始化完成。此时线程 B 调用 getInstance(),第一个 if 判断 instance == nullfalse,直接返回了一个尚未初始化完成的对象。用这个对象去调方法,轻则数据错误,重则直接 NullPointerException

volatile 用来禁止指令重排序。加了 volatile 之后,JVM 会保证 instance = new Singleton() 的三步严格按顺序执行,不会出现"指针先到位、对象还没好"的情况。

实际应用场景

JDK 中的单例: Runtime.getRuntime() 就是一个经典的饿汉式单例。每个 Java 应用只有一个 Runtime 实例,通过它获取 JVM 内存信息、执行系统命令。Desktop.getDesktop() 也是单例,用于打开浏览器、邮件客户端等桌面操作。

Spring Bean: Spring 容器中的 Bean 默认就是单例模式。你在代码里注入的 @Autowired UserService userService,整个应用里拿到的都是同一个实例。Spring 通过 IoC 容器管理 Bean 的生命周期,本质上就是一个单例注册表。你可以通过 @Scope("prototype") 改成多例,但绝大多数 Service、DAO 都是单例,因为它们本身无状态,单例既节省资源又不影响并发。

数据库连接池: Druid 等连接池在应用中通常只有一个实例,全局共享,统一管理连接的创建、回收、监控。

小结

单例模式的核心:保证一个类全局只有一个实例。 但围绕这个简单的目标,衍生出了线程安全、延迟加载、指令重排序等一系列问题。每种写法都是在这些维度之间做取舍,没有完美的方案,只有最适合你场景的方案。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值