news 2026/10/21 16:51:45

享元模式详解:用共享技术解决对象爆炸与内存优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
享元模式详解:用共享技术解决对象爆炸与内存优化

写代码写久了,尤其是做后端或者图形渲染这类重对象业务,会经常遇到同一个问题:内存不受控。我印象很深的一次排查,是一个批量导出模块在高峰期内存飙升,堆占用肉眼可见地往上涨,最后只能靠重启扛过去。用转储文件分析的时候,发现里面挤满了重复对象,光一个样式字段就散落在几万个对象里。当时我脑子里立刻冒出来一个设计模式:享元模式(Flyweight)。

这个模式特别适合回答一个问题:当系统里需要成千上万个对象,而这些对象的大部分属性都一样时,能不能只保留一份公共数据,剩下的个性化数据由外部自己带?能。这就是享元模式的核心思想。这篇文章我会从问题出发,把内部状态、外部状态、工厂设计、线程安全这些关键点拆开讲,再给你一个可以直接抄的字符渲染场景示例,最后把实战中容易踩的坑也一起列出来。无论你是刚接触设计模式,还是已经在项目里被内存问题折磨过,这篇都值得花十分钟看完。

1. 享元模式到底在优化什么问题:先看一个对象爆炸的案例

1.1 对象数量是怎么失控的

很多人写面向对象代码,习惯性给每个业务元素都 new 一个对象。这个习惯在对象少的时候没什么问题,但一旦数量上去了,内存就像漏水一样往外流。

举个很常见的场景:一个表格展示组件,1000 行乘 200 列,就是 20 万个单元格。每个单元格为了独立控制样式,都带一个格式化对象,里面包含字体、字号、颜色、边框、对齐方式。如果按最直接的方式写,20 万个单元格就是 20 万个格式化对象。但你只要看一眼数据就会发现,这 20 万份样式里,真正不同的可能只有十几种。绝大多数单元格共用同一套“默认样式”,只是坐标不同而已。

这个现象就是“对象爆炸”:同样的信息被复制了很多份,每一份在 JVM 里都是独立对象,占着独立内存。等对象数量到百万级,GC 压力会成倍增加,因为垃圾回收器要一遍遍扫描这些内容几乎一样的对象。更麻烦的是,这种重复无法靠 GC 自动解决,因为从引用关系上看,它们确实属于不同单元格,都被列表持有。

1.2 享元模式的核心定义

享元模式要解决的就是这个问题:把一大批对象里可以共用的部分抽出来,做成共享对象;把那些必须随场景变化的部分留在外面,由调用方自己保存。这样从表面看,还是有 20 万个单元格,但底层的样式对象可能只有 15 个。

在经典设计模式书籍里,享元模式的定义是“运用共享技术有效地支持大量细粒度对象”。这里的“享元”可以理解成“共享的单元”。你看 JVM 自带的Integer.valueOf(),就是很典型的享元实现。它把 -128 到 127 的整数对象提前缓存好,调用方拿到的是同一个对象。如果你比较这两个对象的引用指针,会发现它们完全相等。再比如字符串常量池,本质也是一种享元思想的落地,把字面值相同的字符串在池子里复用一个实例。

要特别强调一点:享元模式不是为了炫技,也不是把所有对象都改成共享就万事大吉。它是个很实在的内存优化手段,使用前要先确认业务里确实存在大量重复数据,而且这些重复数据可以被安全共享。

2. 内部状态与外部状态:享元的命门

2.1 内部状态:可以共享的那部分

享元模式里有一个非常关键的概念:状态分离。所有对象属性要分成两大类,第一类叫内部状态,英文是 Intrinsic State。

内部状态的特点是,它不随使用场景变化,由享元对象自己持有,所有调用方看到的都一样。比如字符渲染里的字符本身、字体名称、字号、是否加粗,这些都属于内部状态。一篇文章里出现一万次字母A,只要字体和字号相同,这个A的字形信息就是完全一样的,完全可以共享同一个对象。

内部状态必须满足一个前提:稳定、不会变化。享元对象一旦创建,这些字段最好永远不变。我用 final 字段强制约束,不允许外部再修改。如果内部状态可以被修改,共享就变得非常危险,因为在多线程环境里,一个线程改了字段,所有使用者都会受影响。

2.2 外部状态:必须由调用方持有的那部分

第二类叫外部状态,英文是 Extrinsic State。

外部状态的特点是,它会随使用环境变化,同一个享元对象在不同场景下需要搭配不同的外部状态。还是拿字符渲染说,字符出现在画布的哪个位置,用红色还是绿色渲染,这些信息不能放在共享对象里。如果放进去了,共享对象就必须为每种坐标、颜色都单独创建一个实例,共享率立刻崩掉。

外部状态由客户端保存,通常放在一个上下文对象里,调用享元对象时把它传进去。这样一来,共享对象只负责“怎么画”的公共逻辑,外部状态负责“画在哪、画成什么颜色”的差异逻辑。

我用一个表格把这两类状态说清楚:

状态类型是否随场景变化保存位置典型例子是否参与共享
内部状态不变化享元对象内部字符、字体名、字号、粗体是
外部状态随场景变化客户端上下文坐标、颜色、旋转角否

可以这样理解:内部状态是“这对象是什么”,外部状态是“这东西现在放在哪、以什么方式展示”。两者分开,享元模式才真正成立。

2.3 判断一个业务是否适合享元模式

不是所有场景都值得上享元。我在实际项目里一般按三个条件判断:

第一,对象数量是否足够大。如果总共才几百个对象,省也省不出明显效果,反而增加缓存和工厂的复杂度。通常要上万甚至更多才有意义。

第二,大量对象的属性是否可以拆成内部状态和外部状态。如果业务对象的大部分属性都随每个实例变化,拆不出来公共部分,那就没法共享,硬用享元只会徒增维护成本。

第三,外部状态是否能被安全维护。外部状态既然由调用方持有,就要保证每次使用前都能正确传入,否则会出现 A 场景的外部状态影响 B 场景的显示。这一点在并发场景尤其要注意。

另外,共享对象必须是线程安全的。最稳妥的做法是把内部状态设计成不可变对象,所有字段只读,没有 setter。这样即使多个线程同时调用同一个享元对象,也不会发生数据竞争。

3. 手把手实现一个字符渲染系统中的享元

3.1 场景设定

我拿一个文本渲染场景来做完整例子。假设你正在做一个文档预览引擎,要渲染一篇 10 万字符的长文章。文章内容虽然长,但真正用到的字符有限,大概率就是 26 个英文字母、数字、常见标点和中文字符。字体样式也很集中,可能就两三种字体、一两种字号。

最糟糕的写法,是给每一个字符都创建一个完整对象,对象里既包含字符本身和字体,又包含该字符的坐标和颜色。这样做的结果是,10 万个位置上创建了 10 万个对象,但其中很多字符本身是重复的。

3.2 优化前的反例实现

先看一个反面代码。每个字符对应一个类,所有信息都塞在里面:

public class TextCharV1 { private final char c; private final String fontFamily; private final int fontSize; private final boolean bold; private final int x; private final int y; private final int color; public TextCharV1(char c, String fontFamily, int fontSize, boolean bold, int x, int y, int color) { this.c = c; this.fontFamily = fontFamily; this.fontSize = fontSize; this.bold = bold; this.x = x; this.y = y; this.color = color; } }

然后渲染长文章时,直接循环创建:

List<TextCharV1> document = new ArrayList<>(); for (int i = 0; i < content.length(); i++) { document.add(new TextCharV1( content.charAt(i), "SimSun", 12, false, i * 8, 0, 0x333333 )); }

这段代码执行完,document里有 10 万个独立对象。但你把它们拆开看,字符类型可能只有几十种,字体和字号完全一样。真正不同的只有x坐标。这 10 万个对象里,大部分字段都是重复数据。

3.3 用享元重构后的实现

重构的第一步,是把公共部分抽成共享的 Glyph 对象。它只保存字符本身和字体相关的内部状态:

public interface Glyph { void draw(GlyphContext context); } public class CharGlyph implements Glyph { private final char c; private final String fontFamily; private final int fontSize; private final boolean bold; public CharGlyph(char c, String fontFamily, int fontSize, boolean bold) { this.c = c; this.fontFamily = fontFamily; this.fontSize = fontSize; this.bold = bold; } @Override public void draw(GlyphContext context) { // 共享对象不持有坐标和颜色,只从上下文读取 System.out.println("draw " + c + " at (" + context.getX() + "," + context.getY() + ") color=" + context.getColor() + ", font=" + fontFamily + ", size=" + fontSize + ", bold=" + bold); } }

第二步,把坐标和颜色这些外部状态放到独立的上下文对象里:

public class GlyphContext { private final Glyph glyph; private final int x; private final int y; private final int color; public GlyphContext(Glyph glyph, int x, int y, int color) { this.glyph = glyph; this.x = x; this.y = y; this.color = color; } public Glyph getGlyph() { return glyph; } public int getX() { return x; } public int getY() { return y; } public int getColor() { return color; } }

注意,GlyphContext里持有的是共享的Glyph,不是每个字符都 new 一个CharGlyph。这样上下文对象仍然有 10 万个,但它们的重量轻多了,而且真正占内存的字符对象可以复用。

第三步,加入工厂,统一获取共享对象。工厂内部维护一个池子,池子里放的是已经创建好的 Glyph:

import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public final class GlyphFactory { private static final Map<String, Glyph> POOL = new ConcurrentHashMap<>(); private GlyphFactory() { } public static Glyph get(char c, String fontFamily, int fontSize, boolean bold) { // 用分隔符拼接 key,避免字符边界模糊 String key = fontFamily + ":" + fontSize + ":" + bold + ":" + c; return POOL.computeIfAbsent( key, k -> new CharGlyph(c, fontFamily, fontSize, bold) ); } }

这里有个非常容易踩的细节:key 拼接时不能直接写fontFamily + fontSize + bold + c。比如"Arial" + 121 + false + 'A'和"Arial" + 12 + 1 + false + 'A'可能拼出类似字符串导致碰撞。所以要么像上面一样加明确分隔符,要么单独写一个 Key 类,把字段封装起来,重写equals和hashCode。生产项目里我更推荐后者,更安全也更容易扩展。

3.4 外部状态组装与性能说明

改造后的渲染流程变成两步:先从工厂拿到共享的字符字形对象,再创建一个轻量上下文,把坐标和颜色传进去。

List<GlyphContext> document = new ArrayList<>(content.length()); for (int i = 0; i < content.length(); i++) { Glyph glyph = GlyphFactory.get( content.charAt(i), "SimSun", 12, false ); document.add(new GlyphContext( glyph, i * 8, 0, 0x333333 )); } // 渲染时遍历上下文,调用共享对象的方法 for (GlyphContext ctx : document) { ctx.getGlyph().draw(ctx); }

重构后,10 万字符的文档里,CharGlyph的实例数量不再等于字符总数,而是等于“不同字符 x 不同字体风格”的组合数。如果正文只包含 26 个字母、10 个数字、若干标点,再加上固定一种字体样式,那共享对象只有几十个。内存占用主要剩在GlyphContext上,这个该省不了,因为它确实保存着每个字符的位置信息。

如果你的场景比这个更极端,连GlyphContext都不想满大街建对象,还可以进一步用并行数组替代对象数组,把 x、y、color 分别存在三个 int 数组里,逻辑上相同,但省去了对象头和引用指针。这个属于优化里的再进一步,普通业务不做也可以。

4. 工程化落地:工厂入口、线程安全与边界

4.1 让工厂成为唯一入口

享元模式能不能生效,有一个前提:所有客户端都走同一个工厂拿对象。如果代码里有人绕过工厂直接 newCharGlyph,共享池就形同虚设,又会冒出大量重复对象。

我在项目里一般把享元类的构造函数设成包级私有或者私有,然后静态工厂方法放在同一个包路径下。这样外面的人想直接 new 都 new 不了,只能调用工厂。代码风格上再配合强制代码评审,基本能防住这个问题。如果你用的是依赖注入容器,也可以把工厂注册成单例,让所有调用方都注入同一个工厂实例。

工厂内部也不一定非要懒加载。如果业务里使用的 key 非常确定,可以在系统启动时把所有常用对象预加载到池子里,这样运行时就没有computeIfAbsent的创建开销,连锁竞争都少很多。比如字体渲染系统,启动时就预加载常见字体的A-Z、0-9,完全可行。你甚至可以做一个启动检查,如果某些共享对象的 key 不在预加载名单里,直接抛异常,把问题提前暴露出来。

4.2 线程安全与缓存生命周期

享元对象一旦被多个线程共享,就必须考虑并发。最简单安全的方法是内部状态不可变:所有字段用 final,类本身不再留 setter。再配合构造完成后安全发布,多线程就能放心使用。

ConcurrentHashMap.computeIfAbsent是一个很趁手的工具,它可以保证同一个 key 在并发场景下只会执行一次构造逻辑。但要注意,构造函数里不要做耗时太长的操作,也不要依赖可变的外部变量。如果创建共享对象的成本很高,可以考虑预热,而不是把成本堆在首次请求里。这里说句实在话:computeIfAbsent虽然保证同一个 key 只创建一个,但映射函数内部执行时仍可能被其他线程看到部分中间状态,所以构造共享对象时一定要保证内部状态完全初始化后再发布。

缓存生命周期也要提前想清楚。享元对象的池子通常持有强引用,只要 key 不固定,池子就可能慢慢变大。以我们刚才的字符渲染为例,如果业务允许任意自定义字体、任意字号组合,那 key 数量就不是几十个,而是几万个甚至更多。这种场景下强引用缓存就变成了内存泄漏。常见做法是给缓存加容量上限,或者只对高频 key 做共享,低频的走普通对象。还有一种思路是把 value 用弱引用包一层,让 JVM 在内存紧张时可以回收。但弱引用会带来命中率波动,使用时要有心理准备。

4.3 和单例、对象池、缓存的区别

很多初学者会把享元、单例、对象池搞混,因为它们看起来都是“复用对象”。但它们的适用场景差别很大,我整理了一张表:

模式核心思想对象数量典型代表
单例模式整个系统一个实例固定一个配置管理器、线程池
享元模式按 key 共享只读对象多个不同 key 对应多个实例字符字形、图标对象
对象池模式复用可变对象,借了要还池子里少数几个数据库连接池
缓存模式存储计算结果或数据通常按需淘汰热点数据缓存

单例强调“全局唯一”,享元强调“按特征共享同一份对象”,它允许同时存在多个不同的共享对象。对象池和享元差别更大,池化对象用完之后要归还,可以被外部修改和重置;享元对象则是永久共享,不存在“借走、归还”的概念,它的状态必须稳定。缓存偏重结果存储,不一定保证同一个 key 只创建一份业务对象,它也允许失效淘汰。理解这些边界,能帮你在选型时少走弯路。

5. 踩坑实录与排查技巧:享元模式实战中容易翻车的地方

5.1 高频误区清单

实战中看到的错误,大多集中在状态划分和并发控制上。我把常见问题整理成一份速查表:

现象根本原因解决方式
共享率很低,内存没降把本属于外部状态的字段放在了共享对象里重新拆分状态,动态属性全部外移
并发下出现串数据共享对象存在 setter,被线程修改内部状态 final,去除所有写方法
同类对象创建了多份调用方绕过工厂直接 new私有构造函数,统一工厂入口
key 相同却拿到不同对象key 拼接冲突,或者 Map 的 equals/hashCode 没实现使用专用 Key 类,避免字符串拼接
池子越来越大,内存持续增长强引用缓存没有上限给缓存加容量上限或弱引用策略
首次调用很慢共享对象构造成本高启动预热,或缓存构造结果

举一个我印象很深的例子。有人在字符渲染里把颜色放进了共享对象,结果同一个A在红色段落和蓝色段落里必须创建两个对象。颜色越多,共享对象越多,内存优化效果越来越差,最后还不如不用享元。这就是状态划分没做对。只要问一句“这个值在对象整个生命周期里会变吗”,基本就能判断出它属于内部还是外部。

5.2 线上问题排查思路

如果你在线上怀疑享元没生效,或者想验证改完之后内存到底降没降,我的建议是直接看堆转储。压力测试环境里,用 JDK 自带的 jmap 把堆 dump 下来,然后用 MAT 分析对象实例数和引用链。

操作分成四步:先跑一段典型业务逻辑,让对象数量上去;再用 jmap dump 堆文件;接着在 MAT 里按类名查看CharGlyph的实例数量;最后看看是否和理论 key 数量一致。如果数量远大于预期,顺着引用链找来源,十有八九是有人绕过了工厂。

还有一种更隐蔽的情况:工厂统一了,但外部状态本身太重。如果你把外部状态做成一个包含 10 个字段的对象,内存压力还是很大。这时候要优化的不是享元,而是外部状态的表示方式。比如把坐标和颜色用基本类型数组替代对象,或者减少外部状态的字段数量。

5.3 适用边界与最后一点经验

享元模式不是银弹。对象数量少于几千时,我不建议上;内部状态拆不出来时,不建议上;团队里没人能保证共享对象永远不可变时,更不建议硬上。它带来的复杂度是真实存在的,如果收益不明显,那这个复杂度就是纯负债。

我个人的习惯是,在动手写享元代码前,先做一个最简单的空间估算:当前对象的每条实例数据有多少字节,一共有多少实例,其中可共享的数据占多大比例。如果可共享部分占比不到一半,或者对象总数不超过几千,那这个优化投入产出比就很低。如果估算结果是能节省 50% 以上内存,那就值得做,做完再实测验证。

最后分享一个实用的小技巧:给享元工厂加上一个日志接口,在创建新对象时打印一行统计信息。这样你不需要等到内存告警,就能从日志里看到池子大小和命中率。如果池子大小一直在涨,说明 key 的数量超出了预期,趁早调整设计。设计模式本身不难,难的是在真实业务里维持它的边界,能在早期发现问题的人,才是真正把模式用到位的开发者。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 16:51:41

项目资料缺失?补齐标题正文关键词与摘要

您好&#xff0c;我没收到可用的项目内容。这次输入里的【项目标题】等内容都是空白的&#xff0c;所以暂时无法生成对应的博文。麻烦您补充一下&#xff1a;项目标题项目正文&#xff08;可以是零散的原始描述&#xff09;关键词摘要描述内容主题不限&#xff0c;科技、生活、…

作者头像 李华
网站建设 2026/10/21 16:51:39

AM解调仿真:包络检波与相干解调原理、噪声响应及工程实现

简介&#xff1a;本资源是一份面向通信工程专业本科生的《通信原理》课程设计报告&#xff0c;聚焦AM调制信号的两种核心解调方式——包络检波与相干解调的MATLAB/Simulink建模仿真与性能对比。报告完整呈现从信源生成&#xff08;1kHz基带1MHz载波&#xff09;、加性高斯白噪声…

作者头像 李华
网站建设 2026/10/21 16:49:41

lingbot-map:轻量级机器人地图数据管理与查询实战指南

1. 从“lingbot-map”这个名字说起&#xff1a;它到底想解决什么问题 第一次看到“lingbot-map”这个项目名&#xff0c;我的直觉是&#xff1a;这大概率是一个把“机器人&#xff08;bot&#xff09;”和“地图&#xff08;map&#xff09;”绑在一起的工具&#xff0c;而且“…

作者头像 李华
网站建设 2026/10/21 16:49:04

51单片机智能加湿器毕设实战:从原理图到闭环控制

简介&#xff1a;本资源是一份面向计算机应用技术&#xff08;嵌入式方向&#xff09;专业高年级学生与单片机初学者的毕业设计论文文档&#xff0c;聚焦解决传统加湿器依赖人工操作、缺乏环境感知与安全保护的问题。论文以AT89C51单片机为核心&#xff0c;完整实现温湿度自动监…

作者头像 李华
网站建设 2026/10/21 16:47:45

把学习当项目来记录:从零散笔记到可复用的知识体系

记录一下学习说起来也挺有意思的&#xff0c;以前我总觉得自己学得挺努力——书架上堆着翻了一半的技术书&#xff0c;浏览器里躺着几十个收藏夹&#xff0c;网盘里存了几个T的视频课程。可每次到了要动手做个什么东西的时候&#xff0c;脑子里就只剩下一片空白&#xff0c;要么…

作者头像 李华
网站建设 2026/10/21 16:47:41

从模糊代号到可运行系统:响应式实时资源处理架构实战

1. 从“rea”这个标题说起&#xff1a;一个被低估的通用缩写第一次看到“rea”这个标题&#xff0c;很多人会愣一下——三个字母&#xff0c;既不像完整单词&#xff0c;也不像常见的技术栈缩写。但如果你在工程、设计、数据分析或者自动化领域待过一段时间&#xff0c;就会发现…

作者头像 李华