结构型模式的最后一站,是享元模式(Flyweight)。它和前面几个模式关注点不太一样——前面的代理、装饰、适配、组合、外观,主要解决的是"结构、耦合、灵活性"问题;而享元,解决的是一个非常具体、非常"物理"的问题:内存。当系统里存在海量的、重复的相似对象,把内存吃紧了,享元教你怎么用少量对象扛住大量场景。
“Flyweight"这个词,原意是拳击里的"最轻量级”。这个命名很传神——享元模式的核心,就是想尽办法让对象变得尽可能"轻",轻到可以被大量共享。它的思路一句话:很多对象看起来各不相同,但它们的大部分数据其实是重复的、可以共享的;把这些共享的部分提取出来,做成一个共享对象,让所有需要它的地方都指向同一个实例,而不是各自复制一份。
举个最能体现威力的例子:电商大促,系统里瞬间涌入一百万个订单,每个订单都关联着商品信息(SKU 名称、图片地址、品牌、规格描述……)。如果每个订单都完整地存一份 SKU 数据,而这一百万个订单其实来来回回就买那几千种热门商品——那内存里就躺着大量内容完全一样的 SKU 副本,纯属浪费。享元模式让这几千种 SKU 数据只在内存里存一份,一百万个订单共享它们。这就是享元要干的事。
这篇文章按这条线索展开:先看"海量重复对象"如何撑爆内存;再引出享元的核心思想——区分"内部状态"和"外部状态";然后给出实现,并讲清那个关键的"享元工厂 + 缓存池";接着揭示一个你天天在用的享元——Integer缓存和String常量池;最后给出适用边界,并为整个结构型模式做收官。贯穿例是订单里的 SKU 共享。
目录
- 海量重复对象:内存的隐形杀手
- 享元的核心:内部状态 vs 外部状态
- 实现:享元工厂与共享池
- 你天天在用的享元:Integer 缓存与字符串常量池
- 什么时候用享元
- 结构型模式总收官
一、海量重复对象:内存的隐形杀手
先看问题。订单关联商品,一个直觉的写法是每个订单项都持有完整的 SKU 信息:
publicclassOrderItem{privatelongorderId;privateintcount;// 买了几件(每个订单项不同)// 下面这些是 SKU 信息,每个 OrderItem 都存了一整份privateStringskuName;// "iPhone 15 Pro 256G 黑色"privateStringskuImage;// 图片地址(一长串 URL)privateStringbrand;// "Apple"privateStringspec;// 规格描述(可能很长)// ...}问题出在哪?大促时一百万个OrderItem,但热门商品其实就那几千种。这意味着——同一个 SKU 的名称、图片、品牌、规格,在内存里被重复存储了成千上万次。"iPhone 15 Pro 256G 黑色"这个字符串、那一长串图片 URL,在一百万个订单里可能重复了几十万遍。这些重复副本白白占着内存,是压垮堆内存的隐形杀手。
关键的观察是:这些 SKU 数据,对于同一种商品来说是完全相同、且不会变的。一百万个订单项里,买 iPhone 的那几十万个,它们的skuName、skuImage、brand、spec一模一样。既然一样,为什么要存几十万份?存一份,让它们都指向这一份,不就行了?
这就是享元的出发点。但要做到"共享",我们得先把OrderItem里的数据分成两类:哪些是"每个订单项都相同、可以共享"的,哪些是"每个订单项各不相同、必须独立"的。这个区分,就是享元模式的灵魂。
二、享元的核心:内部状态 vs 外部状态
享元模式把一个对象的状态,划分成两部分:
- 内部状态(Intrinsic State):不随环境变化、可以共享的部分。它是对象的"固有属性",和使用它的具体场景无关。在我们的例子里,SKU 的名称、图片、品牌、规格,就是内部状态——不管哪个订单用它,这些都一样。
- 外部状态(Extrinsic State):随环境变化、不能共享的部分。它依赖于具体的使用场景,每次使用都可能不同。在例子里,"买了几件"
count、属于哪个订单orderId,就是外部状态——每个订单项都不一样。
享元模式的做法就是:把内部状态提取出来,做成一个可共享的"享元对象",在内存里只存一份;而外部状态则不放进享元里,由调用方在使用时从外部传入。
用一张图看这个"拆分 + 共享"的核心思想最清楚:
图里的关键就是那次"拆分":
- 内部状态(SKU 数据)→ 提取成共享的
SkuFlyweight,几千种商品只存几千个对象; - 外部状态(数量、订单号)→ 留在
OrderItem里,一百万个订单项各存各的,但它们的 SKU 字段只是一个指向共享享元的引用。
这样一来,内存里 SKU 数据从"一百万份"骤降到"几千份",而每个订单项只多存一个引用(几个字节)。用极小的引用开销,换掉了海量的重复数据——这就是享元省内存的原理。
三、实现:享元工厂与共享池
理解了内外状态的划分,实现就水到渠成了。分三步。
第一步,定义享元类(只包含可共享的内部状态):
// 享元:只存不变的、可共享的 SKU 内部状态publicclassSkuFlyweight{privatefinallongskuId;privatefinalStringskuName;privatefinalStringskuImage;privatefinalStringbrand;publicSkuFlyweight(longskuId,StringskuName,StringskuImage,Stringbrand){this.skuId=skuId;this.skuName=skuName;this.skuImage=skuImage;this.brand=brand;}// 只有 getter,没有 setter —— 享元必须是不可变的(见下方提醒)}第二步,享元工厂(维护一个缓存池,保证同一个 SKU 只创建一次):
publicclassSkuFactory{// 缓存池:key 是 skuId,value 是共享的享元对象privatestaticfinalMap<Long,SkuFlyweight>pool=newConcurrentHashMap<>();// 获取享元:池里有就直接返回,没有才创建并放入池publicstaticSkuFlyweightgetSku(longskuId,Stringname,Stringimage,Stringbrand){returnpool.computeIfAbsent(skuId,id->newSkuFlyweight(id,name,image,brand));}}这个工厂是享元模式的关键——它保证了"同一个 SKU 全局只有一个实例"。第一次请求某个 SKU 时创建并缓存,以后所有请求都返回同一个对象。你会发现,这其实和第 6 篇原型模式的"原型注册表"、乃至广义的"缓存"思想是相通的。
第三步,订单项持有享元引用 + 自己的外部状态:
publicclassOrderItem{privatelongorderId;// 外部状态privateintcount;// 外部状态privatefinalSkuFlyweightsku;// 指向共享享元(不是复制!)publicOrderItem(longorderId,intcount,longskuId,...){this.orderId=orderId;this.count=count;this.sku=SkuFactory.getSku(skuId,...);// 从工厂拿共享的那一份}}现在,一百万个买 iPhone 的订单项,它们的sku字段全都指向内存里同一个SkuFlyweight对象。SKU 数据只存了一份。
一个必须遵守的铁律:享元对象(内部状态)必须是不可变的。想想看,既然一个
SkuFlyweight被一百万个订单共享,如果它是可变的、某个订单不小心改了它的skuName,那所有共享它的订单的商品名就全被改了——这是灾难性的串改(和第 6 篇浅拷贝"串味"是同一类问题)。所以享元类必须字段全final、无 setter、做成不可变对象。这里,第 1 篇讲的"不可变对象天生线程安全、可放心共享"再一次成了享元能成立的基石。
四、你天天在用的享元:Integer 缓存与字符串常量池
享元听起来像个"高级优化",但其实你每天都在用 JDK 内置的享元,只是没意识到。这里有两个最经典的:
其一,Integer的缓存(IntegerCache)。看这段经常让人困惑的代码:
Integera=127,b=127;System.out.println(a==b);// trueIntegerc=128,d=128;System.out.println(c==d);// false !为什么 127 相等、128 就不等了?因为Integer用了享元模式:Integer.valueOf()内部维护了一个缓存池,默认缓存了−128 到 127之间的所有Integer对象。当你用一个在这个范围内的值时(自动装箱会调valueOf),拿到的是缓存里同一个共享对象,所以a == b为true;而 128 超出了缓存范围,每次都new一个新对象,所以c == d为false。这里 −128~127 这个区间的整数值就是"内部状态",被提前创建好、共享复用——这是享元模式在 JDK 里最著名的应用,也是"为什么比较 Integer 要用equals而不是=="这个经典坑的根源。
其二,字符串常量池(String Pool)。你写的所有字符串字面量,都被享元化了:
Strings1="hello";Strings2="hello";System.out.println(s1==s2);// true,指向常量池里同一个 "hello"JVM 维护了一个字符串常量池,相同内容的字符串字面量在池里只存一份,所有引用都指向它。这就是为什么两个"hello"字面量==为true——它们共享了常量池里的同一个String对象。字符串内容(不可变!)就是内部状态。String 的不可变性,正是它能被安全地放进常量池共享的前提,又一次印证了"享元必须不可变"这条铁律。
其他还有Boolean.valueOf()、Long/Short/Byte/Character的缓存等,思路都一样。认出这些,你就明白享元不是什么屠龙之技,而是就藏在你天天写的代码底下。
五、什么时候用享元
老规矩,泼冷水。享元是所有模式里最该"谨慎使用"的一个,因为它是一种用复杂度换内存的优化,而优化必须有的放矢。
适合用的信号(几个条件最好同时满足):
- 系统里存在海量的对象(成千上万、乃至百万级),多到内存吃紧;
- 这些对象里,大部分状态是重复的、可共享的(内部状态占比高);
- 对象的状态能被清晰地拆分成内部状态和外部状态;
- 剥离外部状态后,共享对象的种类数远小于对象总数(几千种 SKU 撑起一百万订单)。
不该用的信号:
- 对象数量不大——那享元带来的内存节省微乎其微,却凭空增加了工厂、内外状态拆分的复杂度,得不偿失;
- 对象的状态大部分是"外部的"、可共享的部分很少——那没什么可共享的,享元无从发挥;
- 状态无法清晰拆分——强行拆会让代码难以理解。
这里要特别强调,享元是全系列里最典型的"不要过早优化"的例子。它不是用来提升"代码优雅度"的模式,而是用来解决实实在在的内存压力的。先用性能分析工具确认"内存确实被海量重复对象吃掉了"这个事实,再上享元。在没有真实内存瓶颈时就套享元,是拿代码复杂度去换一个不存在的收益——比过度设计还冤,因为你连"以防万一"的借口都没有。记住:享元是优化手段,优化的前提是先有可度量的问题。
六、结构型模式总收官
第 12 篇结束,七个结构型模式全部讲完,做个总收官。结构型模式关心的都是"类和对象怎么组合、连接",但各自解决的问题不同,可以分成两组来记:
第一组:“包装一个对象”(通过包装改变对象的表现)
| 模式 | 一句话 | 核心意图 |
|---|---|---|
| 代理 Proxy | 给对象一个替身 | 控制访问(日志/权限/事务) |
| 装饰器 Decorator | 像洋葱层层包裹 | 动态增强功能 |
| 适配器 Adapter | 接口转接头 | 转换接口,让不兼容的能协作 |
第二组:“组织一群对象”(处理多个对象/子系统的关系)
| 模式 | 一句话 | 核心意图 |
|---|---|---|
| 组合 Composite | 单个和一组长得一样 | 统一处理树形结构 |
| 外观 Facade | 给子系统盖门面 | 简化复杂子系统的使用 |
| 享元 Flyweight | 提取共享,省内存 | 用少量对象扛住海量场景 |
| 桥接 Bridge | 抽象与实现分离 | 让两个维度独立变化(下一篇讲) |
(注:桥接模式是结构型第七个,我们放在下一篇讲,它解决的是"多维度变化"的问题。)
回望这七个模式,你会发现它们共享着几条贯穿全系列的主线:"组合优于继承"在代理、装饰、适配、桥接里反复做选择;迪米特法则是外观的灵魂;不可变对象是享元的基石;而面向接口 + 隔离变化,则是它们共同的底色。这再次说明:模式不是二十三个孤立的技巧,而是那几条设计原则在不同场景下结出的果实。
小结。享元模式专治"海量重复对象吃内存"的问题:把对象状态拆成可共享的内部状态和因场景而异的外部状态,把内部状态提取成一个不可变的共享享元、由工厂缓存池保证全局唯一,让海量对象共享少数享元,用极小的引用开销换掉海量的重复数据。Integer的 −128~127 缓存、字符串常量池,就是你天天在用的享元。它是一种"用复杂度换内存"的优化,必须在确认真实内存瓶颈后才使用,是"不要过早优化"最好的注脚。至此,结构型七模式(含下一篇的桥接)成型。下一篇我们讲桥接模式——当一个东西同时在两个维度上变化时(比如"订单类型"和"通知渠道"),继承会导致维度相乘的类爆炸,桥接教你把两个维度拆开、让它们各自独立变化。