第十八章:享元模式——共享对象、池化思想、实战:线程池与连接池
享元模式,说白了就是「共享复用」。
你想想看,一个系统里如果每个请求都 new 一个线程、每个数据库操作都 new 一个连接,那内存和性能早就崩了。我早年接手过一个老项目,每次查询都新建数据库连接,高峰期直接连爆连接数,DBA 半夜打电话把我叫醒……嗯,从那以后,我对「复用」这两个字就特别敏感。
18.1 什么是享元模式
享元模式(Flyweight Pattern)是一种结构型设计模式。它的核心思想是:通过共享已有对象,减少大量细粒度对象的创建,从而节省内存、提升性能。
享元模式通常包含这几个角色:
- 享元接口(Flyweight):定义对外接口,接受外部状态
- 具体享元(ConcreteFlyweight):实现享元接口,存储内部状态
- 享元工厂(FlyweightFactory):管理享元对象的创建与共享
- 客户端(Client):使用享元对象,传入外部状态
关键区分:
- 内部状态:对象固有的、可共享的(比如线程池的核心线程数、连接池的URL)
- 外部状态:随上下文变化、不可共享的(比如具体执行的SQL、当前任务)
18.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 什么时候该用享元模式
我总结了几条判断标准,你写代码时可以对照一下:
- 对象数量巨大:系统里存在大量相似对象,内存吃紧
- 对象可拆分为内部/外部状态:内部状态可以共享,外部状态由客户端传入
- 对象创建/销毁成本高:比如线程、数据库连接、网络连接
- 对象需要频繁复用:而不是每次都用完就丢
我的建议:不要为了用模式而用模式。如果你的系统只有几十个对象,享元模式带来的复杂度可能超过收益。但如果你在处理高并发、高吞吐的系统,享元模式几乎是必选项。
18.8 享元模式的优缺点
| 优点 | 缺点 |
|---|---|
| 大幅减少内存占用 | 增加了系统复杂度(需要区分状态) |
| 提升性能(减少创建/销毁) | 需要额外管理共享对象的生命周期 |
| 便于统一管理资源 | 如果外部状态过多,享元优势减弱 |
说白了,享元模式就是用「共享」换「性能」。代价是你要多写一些管理代码,但这点代价在高并发场景下完全值得。
嗯,享元模式就讲到这里。线程池和连接池的实战代码,建议你亲自跑一遍,看看内存和性能的变化。只有亲手试过,才能真正理解「共享」的力量。
公众号:蓝海资料掘金营,微信 deep3321