
设计模式这个系列写到第11篇终于轮到享元模式了。说实话享元模式是我在实际项目里见过最多、也最容易被误用的模式之一。很多人一听“享元”两个字就觉得抽象其实它解决的就是一个非常朴素的问题当系统里出现大量重复对象、内存吃紧时如何通过共享来减少对象数量。今天这篇我会用代码、场景、踩坑经历和面试常考点把享元模式一次性讲透。适合看这篇的人有三类正在准备面试或设计模式考试的同学在做大作业、写业务系统时发现内存占用高但不知道怎么优化的人以及想搞清楚连接池、字符串常量池、Integer缓存这些底层机制和享元模式到底是什么关系的人。不管你是刚接触设计模式还是已经用过但没想明白这篇都能给你一个清晰的脉络。1. 享元模式到底在解决什么问题1.1 从重复对象的浪费说起先设想一个很常见的业务场景你在做一个在线文档编辑器每一行文字里的字符都需要用一个对象表示。假设一篇文档有一万个字每个字符对象里存了字体、字号、颜色、字符内容、位置坐标等信息。如果每个字符都单独new一个完整对象那这一万字的文档就会产生一万个对象。粗看还行但如果文档放大到十万字、百万字再叠加多人同时在线编辑内存立刻就会报警。问题出在哪里你会发现大量字符对象的某些属性是完全一样的比如文档里所有宋体正文都是同一个字体、同一个字号、同一个颜色。这些信息其实没必要每个字符都存一份它们完全可以被所有相同样式的字符共享。真正每个字符独有的只有字符本身和它在页面上的位置。如果把对象拆成“共享部分”和“独特部分”那么一千个相同样式的字符只需要创建一个共享对象位置和内容由客户端各自持有内存占用就能大幅下降。这就是享元模式的核心思想运用共享技术有效地支持大量细粒度对象的复用。它不做别的就是把对象的属性拆成两类——一类可以共享一类必须独立然后通过工厂来管理那些共享对象。1.2 享元模式的核心定义与角色划分享元模式的正式定义很简洁减少创建对象的数量通过共享相同对象来降低内存使用。它属于结构型模式英文名是 Flyweight Pattern。一个标准享元模式通常包含四个角色抽象享元Flyweight定义对外接口通常包含一个操作外部状态的方法。具体享元ConcreteFlyweight实现接口保存内在状态。这个角色必须可以被共享。非共享享元UnsharedConcreteFlyweight某些情况下不需要共享但可以存在于享元体系中。享元工厂FlyweightFactory负责创建和管理享元对象通常用一个容器比如Map来保存已有实例客户端请求时先查容器有就返回没有才创建。这里最容易被忽略的就是享元工厂。没有工厂的享元模式是不完整的因为共享的关键在于“统一入口”。如果客户端直接new对象那就谈不上共享了。工厂保证了同一个内在状态永远只有一个实例这也是享元模式和普通缓存、静态变量最大的区别。1.3 内在状态与外在状态必须先分清想真正掌握享元模式必须先理解两个概念内在状态Intrinsic State和外在状态Extrinsic State。内在状态是存在享元对象内部、可以被共享的信息它不会随环境改变而改变。拿字符来说字体、字号、颜色就是内在状态因为只要样式相同这些值对任何字符都一样。外在状态是随场景变化的信息必须由客户端保存并在调用时传入。字符的位置坐标就是外在状态因为每个字符出现在不同位置。这两个概念决定了你能不能正确使用享元模式。如果拆错了要么共享对象之间互相污染要么又退回大量创建对象的老路。我在实际项目里见过有人把颜色当外在状态结果一个文档里一千个红色字符创建了一千个享元对象内存优化了个寂寞。判断一条属性该放哪边有个简单原则放对象内部会不会导致不同使用方互相干扰如果会它就不是内在状态。一个安全的内在状态必须是只读的、不随外部环境变化的。下面这张表可以帮你快速区分维度内在状态外在状态存储位置享元对象内部客户端外部保存是否可变创建后固定只读随场景变化是否共享共享不共享举例字体、颜色、连接信息坐标、Session、上下文2. 手写一个享元模式代码与流程拆解2.1 场景设计一个文本编辑器里的字符光讲理论很容易飘我们直接拿一个“文本编辑器中的字符”场景来手写实现。这个例子是GoF书里的经典案例也是理解享元模式最好的模型。假设我们现在要渲染一篇英文文章每个字母都有字体、字号、颜色、加粗、斜体等样式信息。如果一页有五千个字符最粗暴的做法是new五千个Letter对象。现在用享元模式重构把字体、字号、颜色、加粗、斜体作为内在状态把字符内容和坐标作为外在状态。相同样式的所有字符只保留一个共享对象。这样做的好处非常直观假设文档里有两百个字符都是“Arial 16号 黑色”那就只需要创建一个LetterFlyweight而不用创建两百个。外在状态字符内容和坐标则由Client维护。2.2 Java实现享元工厂、享元对象、客户端我用Java来写一套最小可用实现各位也可以顺手改成C或者C#逻辑完全一致。先定义抽象享元接口public interface CharacterFlyweight { void display(int x, int y, char content); }接口里只有一个display方法它接收外部状态坐标和字符内容。为什么要把content也当作外部状态传进来因为字符内容变化很频繁如果放在共享对象里这个对象就无法复用了。共享对象只存储稳定的样式信息。再来写具体享元类public class CharacterStyle implements CharacterFlyweight { private final String font; private final int size; private final String color; private final boolean bold; private final boolean italic; public CharacterStyle(String font, int size, String color, boolean bold, boolean italic) { this.font font; this.size size; this.color color; this.bold bold; this.italic italic; } Override public void display(int x, int y, char content) { System.out.printf(字符 %c 位于(%d,%d)样式[font%s, size%d, color%s, bold%s, italic%s]%n, content, x, y, font, size, color, bold, italic); } }注意构造函数里的所有字段都用了final这是关键。享元对象一旦创建内在状态就不能再变否则多个调用方共享同一个对象时会出现数据串味。接下来是享元工厂import java.util.HashMap; import java.util.Map; public class CharacterStyleFactory { private static final MapString, CharacterFlyweight STYLE_POOL new HashMap(); public static CharacterFlyweight getStyle(String font, int size, String color, boolean bold, boolean italic) { String key font - size - color - bold - italic; CharacterFlyweight flyweight STYLE_POOL.get(key); if (flyweight null) { flyweight new CharacterStyle(font, size, color, bold, italic); STYLE_POOL.put(key, flyweight); } return flyweight; } public static int getPoolSize() { return STYLE_POOL.size(); } }工厂里维护了一个Map以“样式组合”作为key。客户端拿到同一个样式key时永远只会得到同一个对象。这里的key生成方式我故意写得很简单实际项目中如果字段很多建议用专门的值对象做key或者用StringJoiner/Objects.hash的组合避免字符串拼接带来的性能开销。最后写客户端使用public class EditorClient { public static void main(String[] args) { // 模拟渲染一句话 hello render(h, 0, 0); render(e, 1, 0); render(l, 2, 0); render(l, 3, 0); render(o, 4, 0); System.out.println(享元对象池中共享的样式数量 CharacterStyleFactory.getPoolSize()); } private static void render(char c, int x, int y) { CharacterFlyweight style CharacterStyleFactory.getStyle(Arial, 16, black, false, false); style.display(x, y, c); } }运行结果会显示池里只有一个样式对象但五个字符都在正常渲染。这就是享元模式的威力对象共享可以按数量级减少内存占用。2.3 关键代码逐行解读很多人抄完代码就完事但面试官最爱问的是“你为什么要这么写”。我挑几个最关键的细节说一下。第一个是享元对象内部字段用final修饰。前面说了内在状态一旦被共享就不允许修改final从语言层面保证了这个约束。如果不用final万一有人在某个调用方里通过setter改了颜色其他所有同样式字符都会跟着变色这种bug特别难排查。第二个是工厂里用了Map做缓存池。这里要注意线程安全性。单线程环境下HashMap完全没问题但如果是多线程环境多个线程同时get到null然后同时put就可能创建多个相同享元对象。解决方式可以用ConcurrentHashMap或者给getStyle方法加synchronized更严谨的做法是putIfAbsent。第三个是display方法接收外部状态。这是享元模式和普通缓存最不一样的地方。普通缓存缓存的是完整对象取出来直接用享元对象本身是不完整的必须配合外部状态才能完成一次完整的业务行为。这一点想清楚你就不会把享元模式和简单的Map缓存混淆了。3. 实际项目中的享元模式应用与坑3.1 典型应用连接池、线程池、String常量池、Integer缓存享元模式离我们其实非常近只不过很多时候我们没意识到它就是享元。最典型的就是String常量池。JVM里的字符串常量池保证了相同的字符串字面量引用同一个String对象这也是为什么两个内容相同的字符串用比较时可能返回true。这里的String对象就是享元内容就是内在状态。Integer也有类似机制。Java里Integer有缓存区间默认是-128到127在这个范围内的整数调用valueOf时不会创建新对象而是返回缓存中的同一个对象。这就是享元模式在JDK底层最直观的体现。但超出范围的Integer就会new新对象这也是经典面试题“Integer 128为什么用判断为false”的原因。连接池和线程池从广义上来说也是享元思想。一个数据库连接池里维护了一批连接对象多个线程复用这些连接而不是每次新建核心目的同样是避免重复创建。但严格来讲连接池和线程池不一定符合享元模式的标准结构因为连接对象通常不是以“内部状态是否相同”为共享依据而是以“是否空闲”为分配依据。我更喜欢把它们理解为“对象池”而不是纯享元。面试的时候如果被问到可以先说明两者的异同显得你有深度。3.2 结合工厂模式与单例模式组合使用享元模式几乎不会单独出现它通常和工厂模式、单例模式搭配使用。享元工厂本身就是一个单例的容器全局只有一份对象池。同时享元对象的获取方式又完全符合工厂方法的思想客户端不关心对象是怎么创建的只负责传参获取。在实际项目里我见过一种比较稳妥的组合方式享元工厂内部用一个双重检查锁的单例Map来保存池子同时提供注册方法和获取方法。这样做的好处是既保证了全局池只有一份又避免了多线程下的重复创建。下面给一个适合多线程环境的生产级写法import java.util.concurrent.ConcurrentHashMap; public class FlyweightFactory { private static final ConcurrentHashMapString, CharacterFlyweight POOL new ConcurrentHashMap(); private FlyweightFactory() { } public static CharacterFlyweight getFlyweight(String key, FlyweightCreator creator) { return POOL.computeIfAbsent(key, k - creator.create()); } } FunctionalInterface interface FlyweightCreator { CharacterFlyweight create(); }这里用ConcurrentHashMap的computeIfAbsent代替繁琐的加锁逻辑不仅线程安全代码还清爽很多。creator是一个创建函数实际使用时可以传lambda或者方法引用。这种写法也是我在项目中比较推荐的因为扩展性很强。如果以后需要增加其他类型的享元对象只要复用同一个工厂传入不同creator就行不用再写第二个工厂类。3.3 四个容易踩的坑第一个坑把可变对象当内在状态。比如在享元类里放一个ArrayList然后某个调用方往列表里加数据其他调用方全被影响。解决方案是内在状态必须是不可变对象或者至少在外传时做防御性拷贝。第二个坑共享对象持有用户上下文。我曾经在一个报表系统里踩过这个坑把用户ID放进了享元对象导致A用户生成的报表样式串到了B用户身上。排查了很久才发现是享元对象被错误共享了。正确的做法是用户ID必须作为外在状态由客户端单独持有。第三个坑key设计不合理导致对象池无限膨胀。有些同学把所有字段拼成字符串当key字段一多字符串又长又难读。更糟糕的是如果key里包含了本应属于外在状态的字段那每个调用方拿到的key都不一样享元池退化成普通缓存内存反而更浪费。设计key时一定要严格区分内在和外在状态。第四个坑过度设计。享元模式本身会引入工厂和对象池代码复杂度是增加的。如果项目里只有几百个对象内存也没有压力完全没必要用享元。我见过有人为了“用设计模式”而用享元模式最后代码变得又绕又难维护。设计模式的目的是解决问题不是为了让代码看起来高端。4. 面试与考试高频考点如何说得专业4.1 常见面试题与答题思路享元模式在面试里出现频率极高尤其是Java方向基本围绕String和Integer展开。我整理几个最常被问的问题和答题思路。第一题“说说你对享元模式的理解。” 这种开放式问题最容易暴露理解深度。建议按三层递进回答先是定义即通过共享技术减少重复对象的内存开销再说核心角色重点是内在状态和外在状态的分离最后举一个你熟悉的实战例子比如文档编辑器的字符渲染或JDK里的Integer缓存。三层说完基本就能拿高分。第二题“Java的Integer为什么小于等于127时能用判断为true128就不行” 这道题本质就在考享元。你需要说出IntegerCache缓存区间是-128到127valueOf方法会优先返回缓存对象超出范围会new新对象。同时要提醒面试官实际开发中对象比较应该用equals而不是。如果你能补充一句“这就是享元模式在JDK中的应用”瞬间提升回答的层次。第三题“享元模式和单例模式的区别是什么” 单例模式保证某个类只有一个实例而享元模式是某个相同状态的类可能有多个实例但它们会被池管理不同状态对应不同实例。单例的重点是“全局唯一”享元的重点是“相同共享”。所以一个类可以同时有单例工厂和多个享元实例。4.2 与相关模式的对比很多人会把享元模式和对象池、缓存、代理模式搞混。我列个对比方便复习。对比项享元模式对象池模式缓存模式共享依据内在状态相同对象可复用、有空闲请求结果复用对象创建时机首次访问对应状态时池预热或首次请求首次计算后典型场景文本字符、IntegerCache数据库连接池、线程池Redis缓存、方法缓存核心目的减少内存对象数量减少创建和销毁开销减少重复计算享元模式和对象池的目标确实相似都在减少对象创建但共享的出发点不一样。享元的重点是“状态相同可以共用一个对象”对象池的重点是“对象太重用完放回去重复利用”。举一个例子一个数据库连接对象不会因为配置相同就共享因为同一时刻只能被一个调用方使用而一个样式对象可以同时被多个字符使用因为它没有互斥资源。4.3 一句话总结模板如果面试只能用一句话概括我会这么说“享元模式是一种通过共享相同内在状态的对象来减少内存中对象数量的结构型模式它要求把对象属性拆成不可变的内在状态和可变化的外在状态并由工厂管理共享实例。”这句话包含了三个得分点模式类型、核心机制、关键约束。面试官听到这几个词基本就能确认你是真懂还是背稿子。5. 我的实操体会与扩展建议5.1 什么时候该用、什么时候别用我的判断标准很简单内存里同时存在大量相似对象并且这些对象的创建成本不低时优先考虑享元模式。比如游戏里的子弹特效、地图里的树木模型、报表里的单元格样式、聊天系统里的富文本样式这些都是典型的享元应用场景。反过来如果对象数量本来就少或者对象之间几乎没有可共享状态强行用享元就是自找麻烦。我当年做一个小工具时试图用享元模式管理表单输入框结果发现每个输入框都有独立的id、值、校验状态几乎没有可共享属性最后只能回退到普通写法。这个教训告诉我享元模式不是万能的内存优化方案它是一个需要根据数据特征做取舍的工程决策。另外要注意一点享元模式和垃圾回收的关系。在某些场景下创建新对象并不昂贵反而GC清理无用的短期对象更高效。享元对象因为长期存活在池子里反而可能影响GC的年轻代回收。所以当你发现应用内存并不紧张时别为了优化而优化。5.2 结合JDK源码看享元学任何设计模式我建议都去JDK源码里找到对应实现这样记忆特别牢固。String的常量池机制本身就是一个全局享元池但它是由JVM层面的intern机制管理的普通业务代码无法干预。IntegerCache则是非常清晰的Java代码实现你可以直接看java.lang.Integer类的源码里面有一个私有的静态内部类IntegerCache缓存了-128到127的值。这个范围的高位可以通过JVM参数调整但平时我们基本不会动它。除了String和IntegerJDK里还有不少享元思想的影子。比如Character.UnicodeScript和各种枚举值它们都是不可变且可复用的对象。在我印象里Java的枚举本质上也利用了享元的思想每个枚举实例在JVM中只有一个天然适合做单例和共享对象。如果你把这些都串起来看就会发现设计模式不是书本上的死概念而是语言底层设计者每天都在用的工具。理解了这一点你写业务代码时看待对象的方式都会不同。5.3 从享元到自我重构最后分享一个我自己的重构经验。前两年我维护过一个文档导出的老系统导出大文件时频繁出现内存溢出。最初的代码里每个单元格都new了一个Style对象一份几千行的报表就会创建上万个重复样式对象。后来我把样式提取成享元用样式字符串做key在工厂里缓存内存占用直接降了一个数量级导出速度也跟着快了不少。这个重构让我对享元模式产生了更深的信任。但我也记得当时踩的坑一开始我把单元格坐标也放进了享元对象里导致同一列不同行的样式串了整个报表错乱。后来把坐标移到外部状态问题才解决。所以说理论学得再熟实操中该踩的坑一个都不会少只有亲自调过一遍bug你才会真正记住内在状态和外在状态的边界到底在哪里。如果你也想把享元模式用好我建议你找一个正在写的项目试着找出里面可以被共享的对象按照规范的角色拆一遍然后把重构前后的内存占用对比记录下来。这个练习做完你就不只是“看过”享元模式而是真正“用过”了。