
写代码写久了尤其是做后端或者图形渲染这类重对象业务会经常遇到同一个问题内存不受控。我印象很深的一次排查是一个批量导出模块在高峰期内存飙升堆占用肉眼可见地往上涨最后只能靠重启扛过去。用转储文件分析的时候发现里面挤满了重复对象光一个样式字段就散落在几万个对象里。当时我脑子里立刻冒出来一个设计模式享元模式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; } }然后渲染长文章时直接循环创建ListTextCharV1 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 万个但它们的重量轻多了而且真正占内存的字符对象可以复用。第三步加入工厂统一获取共享对象。工厂内部维护一个池子池子里放的是已经创建好的 Glyphimport java.util.Map; import java.util.concurrent.ConcurrentHashMap; public final class GlyphFactory { private static final MapString, 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 外部状态组装与性能说明改造后的渲染流程变成两步先从工厂拿到共享的字符字形对象再创建一个轻量上下文把坐标和颜色传进去。ListGlyphContext 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 的数量超出了预期趁早调整设计。设计模式本身不难难的是在真实业务里维持它的边界能在早期发现问题的人才是真正把模式用到位的开发者。