1. 享元模式到底在解决什么问题
先给结论:享元模式(Flyweight Pattern)是一种通过共享对象来减少内存中对象数量的设计模式。它的核心思想是"当多个对象需要的属性大部分相同时,就让这些对象共享同一个实例,而不是各自new一个"。这句话听着像废话,但放到真实项目里,能省下来的内存是肉眼可见的。
我最常拿来举例的场景是日志系统。假设一个高并发的消息网关,每秒要处理几万条MQ消息,每条消息都要生成一个包含协议类型、来源渠道、日志级别、时间戳的日志对象。如果每条消息都new一个日志对象,就算每条只占几百字节,一秒钟几万条下来就是几十MB的垃圾对象,频繁触发GC不说,年轻代直接被打满。而如果用享元模式把协议类型、来源渠道、日志级别这类不随消息变化的属性抽成共享对象,每条日志只保存自己的消息ID和内容,内存占用能降一个量级。这就是享元模式解决的痛点:大量相似对象的重复创建与内存浪费。
要注意的是,享元模式不是简单的全局变量或单例,它和"缓存"也有关联,但并不是所有缓存都叫享元。享元模式强调的是"同一份数据被多个逻辑对象复用",关键在于对象身份的透视——从使用方看,它们是独立对象;从内存看,它们其实共用了同一份实例。这个性质必须在设计时显式维护,否则很容易退化成裸的Map缓存。
这篇文章适合这几类读者:正在学Java面试刷题的人——享元模式是高频考点;做后端开发和中间件基建的人——真正能用来优化内存峰值;写客户端或游戏逻辑的人——降内存和对象创建频率的核心手段。接下来我会把概念、JVM里的真实案例、手写可运行的Demo、以及和容易混淆的几种模式全部拉出来讲一遍。
2. 标准的享元模式结构长什么样
2.1 五个角色一张图
享元模式的标准结构分五个角色:
- 享元接口(Flyweight):定义对外暴露的业务方法。
- 具体享元(ConcreteFlyweight):内部状态固定,真正被共享的类。
- 非共享享元(UnsharedFlyweight):有些对象不适合共享,但仍通过接口统一管理。
- 享元工厂(FlyweightFactory):负责创建和管理享元对象,用一个容器(通常是Map)存实例。
- 客户端(Client):通过工厂获取享元,而不是直接new。
这里最关键的是"内部状态"和"外部状态"的拆分。内部状态是对象创建时就固定的、不会变的属性,可以被共享;外部状态是随使用场景变化的属性,必须由客户端在调用时传入。比如围棋棋盘,棋子的颜色是内部状态(只有黑白两种),棋子的坐标是外部状态(每个落子不一样)。一个"黑子"实例可以被棋盘上所有黑子共用,传入不同坐标就行。不拆清楚这两个状态,享元模式基本等于白写。
2.2 一个最标准的代码骨架
直接上代码,这个骨架我用了很多年,改造一下就能适配大部分业务。
// 1. 享元接口 public interface Report { void print(String messageId, String content); } // 2. 具体享元:内部状态在构造时固定 public class MqReport implements Report { private final String protocolType; // 内部状态:协议类型 private final String channel; // 内部状态:渠道 private final String level; // 内部状态:日志级别 public MqReport(String protocolType, String channel, String level) { this.protocolType = protocolType; this.channel = channel; this.level = level; // 模拟耗时初始化 try { Thread.sleep(50); } catch (InterruptedException ignored) {} } @Override public void print(String messageId, String content) { System.out.println(level + " [" + protocolType + "/" + channel + "] msg=" + messageId + " -> " + content); // 外部状态是方法参数,由调用方传进来 } } // 3. 享元工厂:控制实例的创建与复用 public class ReportFactory { private static final Map<String, Report> POOL = new ConcurrentHashMap<>(); public static Report get(String protocolType, String channel, String level) { String key = protocolType + "|" + channel + "|" + level; return POOL.computeIfAbsent(key, k -> { System.out.println("[Factory] create new report for key=" + k); return new MqReport(protocolType, channel, level); }); } public static int poolSize() { return POOL.size(); } } // 4. 客户端使用 public class Client { public static void main(String[] args) { for (int i = 0; i < 100; i++) { Report report = ReportFactory.get("MQ", "OrderChannel", "INFO"); report.print("msg-" + i, "create order success"); } System.out.println("pool size = " + ReportFactory.poolSize()); } }这段代码跑出来,100次调用只创建了1个Report对象。computeIfAbsent这个API在Java 8之后是写享元工厂的标配,自带原子性,省去写双重检查锁的麻烦。
3. 你其实每天都在用:JVM和JDK里的享元式设计
3.1 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原因是Integer.valueOf()内部有个IntegerCache,默认缓存了-128到127之间的Integer对象。在这个区间内的值直接返回缓存对象,区间外的valuesOf就new一个。用==比较的是(因为对象的引用地址可以直接比较),缓存区间内命中同一个引用所以是true,区间外是两个不同对象所以是false。
这个设计就是享元模式的工业级应用——缓存的是对象而非数值。当然,实际编码中用==比较Integer是坏味道,要用equals(),但理解缓存机制能帮你排查很多隐蔽问题。
3.2 String常量池的启发
JVM的字符串常量池也是享元思想的体现,但它比IntegerCache更"失控",因为它是全JVM共享的。同一个字面量字符串在代码中出现100次,常量池只保存一份。
不过从编程实践来看,String.intern()这个方法请谨慎使用。对长字符串做intern,如果字符串本身不重复率高,反而会把常量池搞成一个大HashMap,让StringTable膨胀到触发Full GC。我有一次排查问题,发现某个业务把请求URL做intern,结果常量池涨到上百MB,就是因为URL变化太频繁。享元模式也同理:要不要共享,取决于"重复率是否真的高"。
3.3 线程池和数据库连接池是享元吗
严格说,线程池和连接池是对象池化,和享元模式的"共享不变对象"有本质区别。对象池里每个对象通常是有状态的,拿走一个就独占一个,用完要还回来;享元模式里共享的是同一个不变对象,所有人都同时用,不需要归还。
但你可以把线程池理解为"享元思想的近亲"。它们都是为了消灭"频繁创建重量级对象"的开销——一个消灭的是线程创建开销,一个消灭的是对象重复存储开销。只是共享粒度和生命周期管理方式不同。
4. 手写一个真正能落地的JVM内存对象池缓存
4.1 场景设定:报表模板缓存
理论说完了,来点能动手的东西。我拿一个曾经的业务场景举例:某报表系统需要根据用户选择的报表类型、导出格式、语言环境渲染报表头。这三个维度排列组合出来有几十种模板对象,每个模板对象构建时要加载字体、读取样式配置并解析,大概耗时几百毫秒。如果不做缓存,用户每点一次导出就重新构建一次模板,高峰时段CPU和内存都扛不住。
这个场景就很适合享元模式,因为模板本身的渲染逻辑是固定的,用户ID、报表ID这类信息属于外部状态,由调用时传入。
4.2 核心实现:模板享元工厂
public class ReportTemplate { // 内部状态:完全不可变 private final String reportType; // 报表类型 private final String exportFormat; // 导出格式:PDF / EXCEL / CSV private final Locale locale; // 语言区域 // 模拟重量级初始化 public ReportTemplate(String reportType, String exportFormat, Locale locale) { this.reportType = reportType; this.exportFormat = exportFormat; this.locale = locale; loadFonts(); loadStyleConfig(); } private void loadFonts() { // 模拟加载字体资源,耗时约120ms try { Thread.sleep(120); } catch (InterruptedException ignored) {} } private void loadStyleConfig() { // 模拟解析样式配置,耗时约80ms try { Thread.sleep(80); } catch (InterruptedException ignored) {} } // 外部状态通过方法参数传入 public String render(long userId, String reportId, Map<String, Object> data) { StringBuilder sb = new StringBuilder(); sb.append("[header] ").append(reportType).append(" / ").append(exportFormat).append(" / ").append(locale).append("\n"); sb.append("[user] ").append(userId).append(" report=").append(reportId).append("\n"); data.forEach((k, v) -> sb.append("[data] ").append(k).append("=").append(v).append("\n")); return sb.toString(); } } public class TemplateFactory { private static final Map<String, ReportTemplate> CACHE = new ConcurrentHashMap<>(); private TemplateFactory() {} public static ReportTemplate getTemplate(String reportType, String exportFormat, Locale locale) { String key = reportType + "::" + exportFormat + "::" + locale; return CACHE.computeIfAbsent(key, k -> new ReportTemplate(reportType, exportFormat, locale)); } public static int cacheSize() { return CACHE.size(); } // 注意:这里不提供缓存清理,因为模板数量天然有限——见下文说明 }客户端调用就一行:
ReportTemplate template = TemplateFactory.getTemplate("SALES", "PDF", Locale.CHINA); String html = template.render(1001L, "RP20240001", Map.of("total", "12000.00"));注意我对外不传"内部状态"和"外部状态"的边界,是写死在设计里的。render方法接收到的userId、reportId就是外部状态,每次调用都可能不同。这就是为什么能共享:真正变化的数据根本不放在对象里,而是作为方法参数流动。
4.3 并发下的线程安全设计
很多文章里的Demo不考虑并发,真实项目根本不敢用。这里有两个线程安全问题:
- 工厂的Map并发读写:用
ConcurrentHashMap.computeIfAbsent解决。 - 共享对象的业务方法被多线程同时调用:如果方法内部没有实例变量读写,纯靠参数传入,那就是线程安全的。如果你的共享对象里藏了可变实例字段,那就要么改成不可变,要么加锁,否则共享等于制造了一场并发事故。
注意:
computeIfAbsent里的构造函数如果抛异常,这个key在ConcurrentHashMap里不会留下占位符,因此下次调用还可以重试。但如果构造函数特别慢,并发场景下会有多个线程阻塞在同一个key的初始化上。如果你的初始化极重且高并发概率大,建议用FutureTask做单线程初始化,也就是典型的高性能缓存写法。
4.4 缓存容量与淘汰策略
我故意把工厂写得很单纯,因为报表模板的维度组合是有限的:几十种报表、三四种格式、几个语言,撑死了几百个对象。这种场景不需要淘汰策略,缓存只增不删没有任何问题。
但如果你做的是通用型对象池缓存,那必须考虑容量。比如一个业务动态生成的"查询条件模板",用户每次自定义查询都会产生新的模板组合,这种就属于无限增长场景,建议带上淘汰机制。具体策略有三种:
- 定时清理:后台线程周期性扫描闲置时间超过阈值的对象。
- LRU淘汰:配合LinkedHashMap实现最近最少使用淘汰。
- 最大容量上限:达到上限直接不再缓存,回退到每次新建。
选哪种完全看业务。不要为了用设计模式而用,先想清楚你的重复命中和对象生命周期。
5. 三种最容易被认错的"亲戚":单例、原型、静态工具类
5.1 vs 单例模式
单例模式保证一个类只有一个实例;享元模式保证一组"相似对象"共享有限实例。两者最大的区别是"维度":单例是全局一比一,享元是工厂里一比N但N很小。
举个具象例子:一个日志框架里,Logger是单例;但这个Logger要处理INFO、DEBUG、ERROR三种级别的日志,运行期间真正创建出来的Logger对象只有3个——如果三个级别共用一个对象但用不同内部状态字段区分,那就是享元。区别非常微妙,但不难。单例强调"类级别唯一",享元强调"相同状态的对象唯一"。
5.2 vs 原型模式
原型模式的核心动作是"克隆":用现有对象作为原型,复制出新对象,用于解决构造创建昂贵的问题。注意,原型模式克隆出来的每个新对象都是独立的内存实体,改一个不影响另一个。
享元和原型正好反过来:享元是"尽量不创建新对象,直接用原来的",原型是"可以用最快的速度创建新对象"。两种模式都是为了解决对象创建成本高的痛点,但走的路完全相反。判断方法很简单:看你需不需要修改对象的属性。如果只是只读共享,用享元;如果需要各自持有独立副本并修改部分属性,用原型。
5.3 vs 静态工具类
静态工具类提供一堆方法,内部不保存状态,本身就是无状态的。享元则是有状态的——保存内部状态,只是把外部状态参数化。
这里有一个常见的实践误区:很多人觉得既然是享元模式,那对象就应该设计成完全无状态、所有东西都用参数传入。这是错的。享元的内部状态恰恰是对象的固有属性,是为了让外部状态传入时的操作能根据内部状态产生差异化行为而存在的。如果没有内部状态,所有调用行为完全一样,那这个类就没有做成享元的必要,直接上静态方法更合适。
6. 享元模式真正的成本:对象池引发的性能与内存权衡
6.1 池里的对象不是越多越好
很多刚接触享元的人容易犯一个错误,以为工厂池越大越爽。实际上享元缓存也会反过来影响系统:
- 池里对象太多,占用老年代空间,容易触发Full GC。
- 池对象的存活时间普遍很长,长命对象如果被频繁访问,还可能因为"热点对象"问题给GC带来压力(特别是某些JVM的卡表更新开销)。
- 如果内部状态设计得太细,key维度爆炸,对象数量反而比不缓存还要多。
所以设计享元时,需要先把内部状态的维度数和取值范围统计清楚。如果组合结果是几百以内,闭眼缓存;如果组合是几十万级别,请重新思考是不是应该把部分内部状态降级为外部状态。
6.2 泄漏风险:持有了不该持有的引用
使用享元模式,最怕的是把享元对象塞进普通Map后就再也不清理。比如前面提到的报表模板,如果模板维度是动态的、每次用户配置都会生成新key,那么旧的key和对象就一直留在工厂Map里。这个现象和线程池里线程永远不会被回收是一样的——一旦进入池子,对象生命周期就和应用同寿。
解决方法是给工厂加一个淘汰接口,或者至少把缓存做成有界的。我在实际项目中见过最惨痛的例子:原本想用享元缓解内存,结果因为key设计得太细,缓存对象数量比原来正常new还多,内存翻了一倍。所以享元不是"用了就省内存",用错方向反而更费。
6.3 什么时候绝对不要用享元
- 对象本身就是轻量级的,比如一个只有两个int字段的坐标类,new一个成本极低,共享反而引入线程同步和复杂度。
- 外部状态远多于内部状态,共享率极低,缓存命中率无限趋近于0。
- 对象会被频繁修改且修改影响共享,那应该用原型模式而不是享元。
7. 真实项目中的排查实录
以下问题我在业务系统、中间件、以及帮助别人review代码时都遇到过,整理成速查表:
| 现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 享元对象方法偶发数据错乱 | 共享对象内部有可变字段,被并发修改 | 检查类里是否有非final字段;看是否有多线程在同一实例上写相同字段 | 内部状态全部改为final;可变逻辑放到外部状态参数中 |
| 缓存池越来越大无法释放 | 工厂Map无上限、key维度过细或者包含外部状态 | 打印工厂池size;检查key的组成部分 | 设计有界缓存;收缩内部维度;使用弱引用或LRU |
| 性能反而变差 | 初始化耗时的对象被频繁创建却未命中缓存,或锁竞争严重 | 打点观察get方法耗时;用jstat查看GC频率 | 改用FutureTask单飞初始化;调整key粒度 |
| 某些值在缓存中存在但获取时却是旧数据 | 缓存对象被直接修改了内部状态,或key设计失误导致错共享 | 审查是否有setter;检查equals/hashCode | 对象不可变;工厂内部做防御性复制 |
| 多线程环境下访问享元方法出现阻塞 | 方法内部增加了重量级锁,拖垮并发 | thread dump查看锁状态 | 拆分为无锁只读方法;外部可变数据放方法入参 |
最典型的是第一种。某个订单导出模块用了享元模板,但代码里把导出用户ID作为字段存储到了模板对象内部,多人同时导出时偶发串单。查了两天最后定位到是共享对象中的可变字段。从那以后我给人review享元代码,第一眼看有没有非final字段,有没有public setter。
还有一个隐蔽问题:极小概率遇到类加载器相关的泄漏。如果享元对象被某个类加载器加载,而这个类加载器本身要被GC回收,那么缓存池中持有的强引用会阻止类加载器的卸载。在Tomcat这类容器应用热部署场景下,每次部署都会创建新的类加载器,如果工厂是static字段,老类加载器会被缓存强引用,导致PermGen/Metaspace泄漏。解决方案是把工厂放到容器作用域,或者使用WeakHashMap、弱引用值。
8. 从设计到落地:几个关键经验
分享几个实战中积累的判断准则。
第一,先画内部状态矩阵。把类可能要有的字段全部列出来,按"创建后是否变化"分成两组:不变的进内部状态,变化的进方法参数。别一边写代码一边纠结,画个表一目了然。内部状态维度多的话,宁可多拆几个类,也要维度收敛。
第二,工厂的key分离。不要用自定义对象的toString、不要用拼接字符串的复杂结构。我习惯用分隔符串,但字段本身就是包含分隔符的文本时,这个key会碰撞。用String.join或者内部定义的Key类都可以。保证key生成逻辑稳定最重要,否则相同的内部状态可能生成不同的key,缓存形同虚设。
第三,享元和原型结合。如果你想享元共享共性部分,又希望每个调用者能修改个别属性,可以做一个"半享元半原型"的方案:工厂返回一个不可变的享元,然后基于它拷贝出一个可变副本。和Spring里的prototype作用域很像,兼顾了初始化成本和灵活性。
第四,不要忘了序列化。享元对象一旦被序列化,反序列化回来就是独立对象,池化关系就断了。如果你的项目有缓存Redis或者消息传输,注意享元对象不参与序列化,或者实现readResolve()方法,返回工厂中的原始实例。
第五,日志打点。给工厂加一个cacheSize()和hitCount/missCount计数,上线后观察命中率。如果miss占比超过百分之六十,说明你的共享粒度没设计好,应该考虑调内部状态和外部状态的切分,或者干脆放弃享元。这个建议非常朴素,但非常有效,因为很多"优化"是拍脑袋做出来的,数据一测就露馅。
最后说一个个人体会:享元模式不是那种"学了就要到处用"的模式,它最适合出现的地方是那些性能瓶颈明确、对象创建开销大、属性重复率高的场景。在JVM生态里,它已经融入到语言底层和大量框架的血液里,你可能未必显式写一个工厂类,但一定会在不知不觉中使用它的思想。与其想着写一个大而全的享元框架,不如好好审视你的核心热点对象——哪些字段生来就不曾变化,哪些对象new了一万次其实长得一模一样。找到它们,再动手。