写代码写久了,尤其是做后端或者图形渲染这类重对象业务,会经常遇到同一个问题:内存不受控。我印象很深的一次排查,是一个批量导出模块在高峰期内存飙升,堆占用肉眼可见地往上涨,最后只能靠重启扛过去。用转储文件分析的时候,发现里面挤满了重复对象,光一个样式字段就散落在几万个对象里。当时我脑子里立刻冒出来一个设计模式:享元模式(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 的数量超出了预期,趁早调整设计。设计模式本身不难,难的是在真实业务里维持它的边界,能在早期发现问题的人,才是真正把模式用到位的开发者。