GoF设计模式——单例模式

前言

为什么需要单例模式?

想象一个场景:一个应用需要读取配置文件,如果每次读配置都 new 一个配置管理器出来,就会出现两个问题:

  1. 浪费资源:配置数据都一样,创建多个对象毫无意义
  2. 数据不一致:A 模块改了配置,B 模块的配置对象还是旧的

这时候需要的就是单例模式------保证一个类在整个应用中只有一个实例,并提供一个全局访问点。

一句话总结:有些东西,整个应用有一份就够了。

概念

单例的意思就是单个实例

单例模式是一种创建型设计模式 , 它的核心思想是保证一个类只有一个实例,并提供一个全局访问点来访问这个实例。

  • 只有一个实例:在整个应用程序中,只存在该类的一个实例对象,而不是创建多个相同类型的对象。
  • 全局访问点:为了让其他类能够获取到这个唯一实例,该类提供了一个全局访问点(通常是一个静态方法),通过这个方法就能获得实例。

实现

实现单例,必须满足三个条件:

  1. 构造函数私有化 :禁止外部 new 对象
  2. 自己持有自己的实例:用一个静态变量保存
  3. 提供公开的获取方法:通过静态方法返回这个实例

饿汉式(最简单)

在类加载的时候就创建好实例,不管会不会被使用。

饿汉的意思是:等不及被调用,先把实例创建好。像一个饿汉一样,不管好不好吃,先填饱肚子再说。

csharp 复制代码
public class Singleton {
    private static final Singleton instance = new Singleton();
    private Singleton() {}
    public static Singleton getInstance() {
        return instance;
    }
}

懒汉式

只有在请求实例时才会创建,如果在首次请求时还没有创建,就创建一个新的实例,如果已经创建,就返回已有的实例。

懒汉的意思是:被调用时才创建实例,不调用就不创建。像一个懒人一样,不催就不做。

csharp 复制代码
public class Singleton {
    private static Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

懒汉式+双重检查锁(最常用)

懒汉式写法会有一个问题------多线程下可能创建出多个实例。当有多个请求同时调用Singleton.getInstance()的时候,此时instance为空,则每个请求都可能new一个实例出来。

为了避免这种情况,可以通过加锁来确保只会有一个请求创建出实例:

csharp 复制代码
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:多个线程同时过了外层检查,排队进入锁,第一个创建后,第二个进来发现已经有了就不再创建

扩展:为什么加 volatile
new Singleton() 这一行不是原子操作,实际分三步:

  1. 分配内存
  2. 初始化对象
  3. 引用指向内存
    JVM可能把顺序重排为1→3→2。线程A执行到第3步但还没初始化完,线程B进来发现 instance != null,直接返回了一个没初始化完的对象 ,程序就崩了。volatile 禁止这种重排序。

静态内部类(推荐)

结合了饿汉式和懒汉式的优点:线程安全 + 懒加载,而且没有锁的性能损耗。像把东西放在保险箱(内部类)里,不开箱东西就不会被拿出来。需要的时候一开箱,发现东西早就准备好了,而且只有一份。

csharp 复制代码
public class Singleton {
    private Singleton() {}
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

原理SingletonHolder 这个内部类只有在调用 getInstance() 时才会被JVM加载,才会创建Singleton实例,而JVM保证类加载过程是线程安全的。所以既实现了懒加载,又保证了线程安全,还不用加锁。

枚举(最安全)

为什么枚举最安全?

  • 天然防止反射攻击(枚举不允许通过反射创建实例)
  • 天然支持序列化(反序列化不会产生新实例)
  • 代码最简洁,JVM保证唯一性

像身份证号,天生就唯一,不需要额外做任何保证。

csharp 复制代码
public enum Singleton {
    INSTANCE;
    public void doSomething() {
        // 业务方法
    }
}

// 使用
Singleton.INSTANCE.doSomething();

总结

优缺点

说明
优点
节省资源 全局只有一个实例,避免重复创建和内存浪费
数据一致 多处代码共享同一份状态,不会出现数据不同步的问题
全局访问 提供统一的访问入口,使用方便
缺点
难以测试 全局状态会污染测试环境,单测时需要特殊处理(如重置实例)
隐藏依赖 调用方通过 getInstance() 直接获取,依赖关系不透明,违反依赖注入原则
并发风险 单例的成员变量被多线程修改时,需要额外保证线程安全

实现方式对比

实现方式 线程安全 懒加载 防反射 防序列化 优点 缺点
饿汉式 实现最简单,没有锁开销 类加载就创建,浪费资源(如果用不到)
懒汉式+双重检查锁 懒加载,资源利用率高 写法复杂,需要 volatile,有锁开销
静态内部类 懒加载 + 无锁,写法简洁 无法传参给构造函数
枚举 最安全,天然防反射和序列化 不能继承其他类,写法不够直观

怎么选

  • 日常开发 → 静态内部类(简单、安全、懒加载)
  • 需要序列化 → 枚举(最安全)
  • Spring 项目 → 直接用 @Service,框架管理单例

简单记忆 :不确定就用静态内部类,它是综合最优的选择。

练习题目

银行叫号系统

题目描述:某银行大厅有一台叫号机,两个窗口共用这台叫号机为客户取号。请用单例模式设计叫号机,确保所有窗口取到的号码连续且不重复。

输入描述:第一行输入一个整数n,表示客户数量。接下来n行,每行包含窗口编号(1或2)和客户姓名,用空格隔开。

输出描述:为每个客户分配排队号码,输出格式为"客户姓名 号码"。

输入示例

复制代码
5
1 张三
2 李四
1 王五
2 赵六
1 钱七

输出示例

复制代码
张三 1
李四 2
王五 3
赵六 4
钱七 5

解题思路 :两个窗口各自获取叫号机实例,但必须是同一个实例------否则各拿各的计数器,号码就会重复。这就是单例模式解决的真正问题:多处代码需要共享同一个状态时,确保只有一个实例存在。

ini 复制代码
import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = Integer.parseInt(sc.nextLine());

        // 两个窗口各自获取叫号机
        TicketMachine window1 = TicketMachine.getInstance();
        TicketMachine window2 = TicketMachine.getInstance();

        for (int i = 0; i < n; i++) {
            String[] parts = sc.nextLine().split(" ");
            int window = Integer.parseInt(parts[0]);
            String name = parts[1];

            int number;
            if (window == 1) {
                number = window1.getNextNumber();
            } else {
                number = window2.getNextNumber();
            }
            System.out.println(name + " " + number);
        }
    }
}

class TicketMachine {
    private int currentNumber;

    private TicketMachine() {
        currentNumber = 0;
    }

    private static class Holder {
        private static final TicketMachine INSTANCE = new TicketMachine();
    }

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

    public int getNextNumber() {
        return ++currentNumber;
    }
}

扩展:实际项目中的单例

学完了实现方式,更重要的是知道在真实项目中什么时候会用到。以下场景大概率已经接触过,只是没意识到它们是单例。

Spring中的Bean(最常见)

Spring容器中的Bean默认就是单例@Service@Component 等注解标记的类,默认 scope = "singleton")。整个应用只有一个实例,所有请求共享,所以Spring应用处理请求很快------不用每次都创建新对象。

注意 :单例Bean如果有成员变量被多线程修改,需要用 ThreadLocal 或加锁保证线程安全。

数据库连接池

数据库连接创建很慢(TCP握手、认证、分配资源),所以项目一定会用连接池(如 HikariCP、Druid),而连接池本身就是单例------整个应用只维护一个池子,所有模块共用。

本地缓存管理器

高频查询的数据(如字典表、省市区、热门商品)缓存在本地,减少数据库压力。缓存管理器通常用单例,保证整个应用共享一份缓存,避免内存浪费。

线程池管理器

线程池创建成本高,整个应用通常只维护几个线程池。通过单例统一管理,异步发邮件、推送消息、导出报表等耗时操作都通过同一个线程池执行,避免每个模块各自创建线程池导致资源浪费。

配置中心客户端

微服务中从 Nacos、Apollo 等配置中心拉取配置,这个客户端通常用单例------整个应用只需要一个连接来拉取和监听配置变更。

日志管理器

每天用的 LoggerFactory.getLogger() 背后也是单例。日志框架的核心组件(LoggerContext)是单例的,所有 Logger 共享同一个输出器,日志级别、格式等配置全局统一。

相关推荐
0xDevNull1 小时前
JDK多版本切换安装与配置
java·后端
流年似水~1 小时前
Java新手5分钟接AI:Spring AI Alibaba实战
java·人工智能·spring
DarkAthena2 小时前
【YaShanDB】给YaShanDB开发R2DBC驱动
java·yashandb·r2dbc
014-code2 小时前
布隆过滤器:判断“可能存在“和“一定不存在“
java·redis
兔小盈2 小时前
多线程篇-(二)线程创建、中断与终止
java·开发语言·多线程
jnrjian2 小时前
Library Cache Load Lock library cache pins are replaced by mutexes
java·后端·spring
abcnull2 小时前
传统的JavaWeb项目Demo快速学习!
java·servlet·elementui·vue·javaweb
risc1234562 小时前
【lucene】PostingsEnum跟TermsEnum 的区别是啥?
java·lucene