8、单例模式实战:配置管理器、线程池管理器、Spring Bean默认作用域分析

单例模式,说白了就是保证一个类只有一个实例。你可能会想,这有什么难的?new一个对象不就行了?嗯,在单线程下确实简单,但一旦进入多线程环境,事情就变得微妙了。我这些年做架构评审,看到过不下十种「伪单例」的实现,有些甚至在生产环境跑了两年才暴露出问题。

今天咱们就通过三个经典场景,把单例模式彻底吃透。

8.1 配置管理器:最朴素的单例

配置管理器应该是单例模式最典型的应用了。你想,一个系统的配置信息,比如数据库连接串、缓存地址、开关配置,这些数据全局只需要一份。如果每个模块都各自加载一遍,不仅浪费内存,还会出现配置不一致的坑。

我个人习惯用「饿汉式」来实现配置管理器。为什么?因为配置信息通常在系统启动时就必须加载完毕,晚加载不如早加载。

核心要点: 配置管理器必须在类加载时就完成实例化,避免运行时竞态条件。
public class ConfigurationManager {
    // 类加载时就创建实例,JVM保证线程安全
    private static final ConfigurationManager INSTANCE = new ConfigurationManager();
    
    private Properties properties;
    
    private ConfigurationManager() {
        // 私有构造,防止外部new
        this.properties = new Properties();
        loadConfig();
    }
    
    public static ConfigurationManager getInstance() {
        return INSTANCE;
    }
    
    private void loadConfig() {
        // 实际项目中这里会加载配置文件
        System.out.println("加载系统配置...");
    }
    
    public String getProperty(String key) {
        return properties.getProperty(key);
    }
}

我在项目中遇到过一个问题:有个同事觉得饿汉式「不够优雅」,非要用懒汉式。结果在并发环境下,两个线程同时调用getInstance(),创建了两个配置管理器实例。一个线程改了配置A,另一个线程读到的还是旧值。排查了整整一个下午,最后发现是单例没写对。

避坑指南: 我曾经见过有人用双重检查锁(DCL)实现配置管理器,但忘了加volatile关键字。这在JDK 1.5之前是必坑的,1.5之后虽然修复了,但依然建议用静态内部类或枚举方式。

8.2 线程池管理器:全局唯一的线程资源

线程池为什么需要单例?你想想看,如果每个请求都创建一个线程池,系统很快就会被线程资源耗尽。线程池的本质是复用线程,所以全局只能有一个管理者。

这里我推荐用「双重检查锁」的方式。因为线程池的创建可能依赖一些运行时参数,不适合在类加载时就创建。

public class ThreadPoolManager {
    private volatile static ThreadPoolManager instance;
    private ExecutorService executor;
    
    private ThreadPoolManager() {
        // 核心线程数、最大线程数等参数可以从配置中心获取
        this.executor = new ThreadPoolExecutor(
            4,  // 核心线程数
            8,  // 最大线程数
            60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(1000),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }
    
    public static ThreadPoolManager getInstance() {
        if (instance == null) {
            synchronized (ThreadPoolManager.class) {
                if (instance == null) {
                    instance = new ThreadPoolManager();
                }
            }
        }
        return instance;
    }
    
    public void execute(Runnable task) {
        executor.execute(task);
    }
    
    public void shutdown() {
        executor.shutdown();
    }
}

注意这里的volatile关键字,它保证了instance变量的可见性。没有它,一个线程在创建实例后,另一个线程可能看不到这个变化,导致再次创建。

个人经验: 线程池的参数不要写死。我习惯把核心线程数、队列大小这些参数放到配置中心,通过配置管理器读取。这样线上调优时,改个配置就能生效,不用重启服务。

8.3 Spring Bean默认作用域:框架层面的单例

说到单例,就不得不提Spring。Spring中Bean的默认作用域就是singleton。这意味着,在整个Spring容器中,同一个Bean定义只会创建一个实例。

为什么Spring要把单例作为默认作用域?原因很简单:性能。每次请求都创建新对象,对GC的压力太大了。尤其是那些无状态的服务类,比如Service、DAO,它们本身不保存用户数据,完全可以复用。

作用域 说明 适用场景
singleton 整个容器只有一个实例 无状态Bean(Service、DAO)
prototype 每次获取都创建新实例 有状态Bean(如用户会话)
request 每个HTTP请求一个实例 Web应用中的请求上下文
session 每个HTTP会话一个实例 用户登录信息

这里有个容易踩的坑:如果你在单例Bean中注入了原型Bean,那原型Bean实际上也会变成单例。因为单例Bean在初始化时只会注入一次原型Bean,后续就不再注入了。

我曾经踩过的坑: 有个业务场景需要每次调用都生成一个新的随机数工具类,我把它配成了prototype作用域,但在单例的Service中通过@Autowired注入。结果发现随机数一直不变。后来改用ApplicationContext.getBean()手动获取,才解决了问题。

8.4 三种场景对比

说了这么多,咱们来总结一下。这三种场景虽然都用到了单例模式,但侧重点完全不同:

  • 配置管理器:强调「全局唯一」和「早期加载」,用饿汉式最稳妥
  • 线程池管理器:强调「资源复用」和「延迟创建」,用双重检查锁
  • Spring Bean:框架层面的单例,由容器管理生命周期,开发者只需关注作用域选择

我个人觉得,单例模式的核心不在于「怎么写」,而在于「什么时候用」。用对了,系统简洁高效;用错了,反而引入不必要的复杂性。

8.5 知识体系图

下面这张图梳理了单例模式在三个实战场景中的核心逻辑和关键决策点:

单例模式实战场景 单例模式 配置管理器 线程池管理器 Spring Bean 实现方式 • 饿汉式(推荐) • 类加载时实例化 • JVM保证线程安全 • 适合启动时加载 实现方式 • 双重检查锁(推荐) • 必须加volatile • 延迟创建实例 • 参数可动态配置 实现方式 • 默认singleton • 容器管理生命周期 • 注意原型注入陷阱 • 无状态Bean首选

嗯,单例模式就讲到这里。记住一句话:单例不是银弹,但用对地方,它就是架构的基石。下次你在写配置管理器或者线程池的时候,不妨想想今天聊的这些坑和经验。

一句话总结: 单例模式的核心是「全局唯一」,实现的关键是「线程安全」,应用的重点是「场景匹配」。

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