第十八章:享元模式——共享对象、池化思想、实战:线程池与连接池

享元模式,说白了就是「共享复用」。

你想想看,一个系统里如果每个请求都 new 一个线程、每个数据库操作都 new 一个连接,那内存和性能早就崩了。我早年接手过一个老项目,每次查询都新建数据库连接,高峰期直接连爆连接数,DBA 半夜打电话把我叫醒……嗯,从那以后,我对「复用」这两个字就特别敏感。

18.1 什么是享元模式

享元模式(Flyweight Pattern)是一种结构型设计模式。它的核心思想是:通过共享已有对象,减少大量细粒度对象的创建,从而节省内存、提升性能

享元模式通常包含这几个角色:

  • 享元接口(Flyweight):定义对外接口,接受外部状态
  • 具体享元(ConcreteFlyweight):实现享元接口,存储内部状态
  • 享元工厂(FlyweightFactory):管理享元对象的创建与共享
  • 客户端(Client):使用享元对象,传入外部状态

关键区分:

  • 内部状态:对象固有的、可共享的(比如线程池的核心线程数、连接池的URL)
  • 外部状态:随上下文变化、不可共享的(比如具体执行的SQL、当前任务)

18.2 享元模式的结构图

下面这张图,是我自己画的一个享元模式核心结构。你看一眼就能明白它的运作方式:

客户端 享元工厂 享元接口 具体享元 对象池 [共享对象1] [共享对象2] ... (内部状态相同) 请求对象 返回共享对象 外部状态(上下文) 传入

你看,客户端不直接 new 对象,而是通过工厂去「借」。工厂内部维护一个池,池里的对象内部状态相同,可以共享。客户端只需要传入外部状态即可。

18.3 享元模式 vs 对象池

很多人把享元模式和对象池混为一谈。我简单说一下区别:

对比维度 享元模式 对象池
核心目的 减少对象数量,共享内部状态 复用对象,减少创建/销毁开销
状态管理 区分内部/外部状态 通常不区分,对象用完重置
典型场景 字符串常量池、Integer缓存 线程池、数据库连接池
对象是否可变 通常不可变(内部状态固定) 可变,用完后归还

我的经验:在实际项目中,享元模式和对象池经常配合使用。比如线程池里的 Worker 线程,本身是享元(内部状态固定),但线程池本身又是一个对象池。两者不冲突,反而互补。

18.4 实战:线程池中的享元思想

Java 的 ThreadPoolExecutor 就是享元模式的最佳实践。我带你拆解一下:

// 核心线程 —— 内部状态:线程本身
// 外部状态:每次提交的 Runnable 任务

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    5,              // 核心线程数(内部状态)
    10,             // 最大线程数
    60, TimeUnit.SECONDS,  // 空闲回收时间
    new LinkedBlockingQueue<Runnable>(100)  // 任务队列
);

// 客户端传入外部状态
executor.submit(() -> System.out.println("任务1"));
executor.submit(() -> System.out.println("任务2"));

这里,线程池里的 5 个核心线程就是享元对象。它们内部状态(线程本身)不变,每次执行的任务(外部状态)不同。线程池工厂负责创建、复用、回收这些线程。

避坑指南:我曾经见过一个团队,每次请求都 new 一个线程池,结果内存里堆了几十个线程池,每个池里又有几十个线程。系统直接 OOM。记住:线程池本身也应该是全局共享的,而不是每个请求建一个。

18.5 实战:数据库连接池中的享元思想

数据库连接池是另一个经典案例。比如 HikariCP、Druid,底层都是享元模式。

// 连接池 —— 享元工厂
public class ConnectionPool {
    private List<Connection> pool = new ArrayList<>();
    private int maxSize = 10;

    public Connection getConnection() {
        if (pool.isEmpty()) {
            if (pool.size() < maxSize) {
                // 创建新连接(内部状态:数据库URL、用户名、密码)
                Connection conn = DriverManager.getConnection(URL, USER, PASS);
                pool.add(conn);
            } else {
                // 等待或抛出异常
            }
        }
        // 从池中取出一个连接
        return pool.remove(0);
    }

    public void releaseConnection(Connection conn) {
        // 归还连接(重置外部状态)
        pool.add(conn);
    }
}

连接池里的每个连接,内部状态(数据库地址、账号、密码)是固定的。客户端每次拿连接执行 SQL,SQL 就是外部状态。用完归还,下次复用。

注意:连接池不是无限大的。我遇到过有人把 maxSize 设成 1000,结果数据库那边最大连接数才 200,直接导致数据库拒绝连接。连接池的大小要根据数据库的承载能力来设置,不是越大越好。

18.6 享元模式在 Java 标准库中的应用

其实你每天都在用享元模式,只是没意识到。我举几个例子:

  • String 常量池:相同的字符串字面量只存一份,JVM 自动享元
  • Integer 缓存Integer.valueOf(-128 ~ 127) 返回缓存对象,不新建
  • Boolean 常量Boolean.TRUE / FALSE 是静态常量,全局共享
// 享元模式在 Integer 中的体现
Integer a = Integer.valueOf(100);
Integer b = Integer.valueOf(100);
System.out.println(a == b); // true,同一个对象

Integer c = Integer.valueOf(200);
Integer d = Integer.valueOf(200);
System.out.println(c == d); // false,超出缓存范围,新建对象

你看,Java 设计者早就把享元模式融入语言底层了。我们写代码时,也要有这种「能复用就不新建」的意识。

18.7 什么时候该用享元模式

我总结了几条判断标准,你写代码时可以对照一下:

  1. 对象数量巨大:系统里存在大量相似对象,内存吃紧
  2. 对象可拆分为内部/外部状态:内部状态可以共享,外部状态由客户端传入
  3. 对象创建/销毁成本高:比如线程、数据库连接、网络连接
  4. 对象需要频繁复用:而不是每次都用完就丢

我的建议:不要为了用模式而用模式。如果你的系统只有几十个对象,享元模式带来的复杂度可能超过收益。但如果你在处理高并发、高吞吐的系统,享元模式几乎是必选项。

18.8 享元模式的优缺点

优点 缺点
大幅减少内存占用 增加了系统复杂度(需要区分状态)
提升性能(减少创建/销毁) 需要额外管理共享对象的生命周期
便于统一管理资源 如果外部状态过多,享元优势减弱

说白了,享元模式就是用「共享」换「性能」。代价是你要多写一些管理代码,但这点代价在高并发场景下完全值得。

嗯,享元模式就讲到这里。线程池和连接池的实战代码,建议你亲自跑一遍,看看内存和性能的变化。只有亲手试过,才能真正理解「共享」的力量。


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