11-A-享元模式详解
关键词:享元、Flyweight、对象池、内部状态、外部状态、Integer 缓存、规格字典
享元模式解决一个问题:大量相似对象占内存太多,把"可共享的部分"抽出来复用。一亿个商品 SKU 都要引用"颜色:曜石黑"这个规格对象,没必要一亿份——共享一份,把"每个 SKU 不同的部分"留在外面传参。
📖 术语速查表
| 术语 | 人话解释 |
|---|---|
| 享元(Flyweight) | 被共享的细粒度对象 |
| 内部状态 | 可以共享、不随场景变化的状态(如规格名"曜石黑")——存在享元里 |
| 外部状态 | 随场景变化、不能共享的状态(如某个 SKU 的价格)——调用时传参 |
| 享元工厂/池 | 管理共享实例的工厂,通常ConcurrentHashMap.computeIfAbsent |
| 对象池 | 享元的工程化近亲:复用昂贵对象(连接、线程) |
一、不用享元的问题
电商商品的规格体系:每个 SKU 由多个"规格项"组成(颜色、尺寸、版本)。系统里有 500 万 SKU:
// ❌ 每个 SKU 都 new 一套规格对象publicclassSku{privateSpecItemcolor;// new SpecItem("颜色", "曜石黑", "#000000", ...)privateSpecItemsize;// new SpecItem("尺寸", "256G", ...)privateSpecItemversion;// new SpecItem("版本", "国行", ...)privateBigDecimalprice;}全站其实只有 200 种颜色、50 种尺寸、10 种版本——但内存里躺着 1500 万个SpecItem对象。每个对象 64 字节的话就是近 1GB,而实际只需要 260 个。
二、模式结构:内部状态进享元,外部状态传参
拆分原则:
- 内部状态(规格名、色值、排序权重):所有 SKU 一样 → 存进享元,共享;
- 外部状态(这个 SKU 的价格、库存):每个 SKU 不同 → 留在 SKU 里或调用时传参。
三、业务场景:商品规格字典共享
享元对象(不可变是共享的前提)
/** 规格项享元:全字段 final——不可变对象才能安全共享 */publicfinalclassSpecItem{privatefinalStringspecName;// 内部状态:规格名,如"颜色"privatefinalStringspecValue;// 内部状态:规格值,如"曜石黑"privatefinalStringcolorCode;// 内部状态:色值privatefinalintsortOrder;// 内部状态:展示排序SpecItem(StringspecName,StringspecValue,StringcolorCode,intsortOrder){this.specName=specName;this.specValue=specValue;this.colorCode=colorCode;this.sortOrder=sortOrder;}/** 外部状态通过参数传入,不存在享元里 */publicStringrenderDisplay(BigDecimalskuPrice){returnspecName+": "+specValue+"(¥"+skuPrice+")";}// 只有 getter,没有 setterpublicStringgetSpecValue(){returnspecValue;}}享元工厂
@ComponentpublicclassSpecItemFactory{/** 享元池:key = 规格名 + 规格值 */privatefinalConcurrentHashMap<String,SpecItem>pool=newConcurrentHashMap<>();publicSpecItemget(StringspecName,StringspecValue){Stringkey=specName+":"+specValue;// computeIfAbsent 保证并发下同一 key 只创建一次returnpool.computeIfAbsent(key,k->loadFromDb(specName,specValue));}privateSpecItemloadFromDb(StringspecName,StringspecValue){// 从规格字典表加载(颜色/色值/排序)SpecDictdict=specDictDao.find(specName,specValue);returnnewSpecItem(specName,specValue,dict.getColorCode(),dict.getSortOrder());}/** 池大小监控:暴露给 Prometheus,防止 key 设计错误导致池无限膨胀 */publicintpoolSize(){returnpool.size();}}使用方
publicclassSkuAssembler{privatefinalSpecItemFactoryspecItemFactory;publicSkuassemble(SkuRawDataraw){Skusku=newSku();// 500 万个 SKU 共享 260 个 SpecItem 实例sku.setColor(specItemFactory.get("颜色",raw.getColor()));sku.setSize(specItemFactory.get("尺寸",raw.getSize()));sku.setPrice(raw.getPrice());// 外部状态留在 SKU 自己身上returnsku;}}效果:1500 万个SpecItem→ 260 个,内存从近 1GB 降到 KB 级。
四、JDK 里最熟悉的享元:Integer 缓存
Integera=127,b=127;System.out.println(a==b);// true —— 共享了缓存池里的同一个对象Integerc=128,d=128;System.out.println(c==d);// false —— 超出缓存范围,各自 newInteger.valueOf()内部就是享元工厂:IntegerCache预创建 -128~127(上界可用-XX:AutoBoxCacheHigh调)。这也是"锁Long skuId可能锁错对象"的根源(见 02-01-B-JMM与volatile-synchronized面试与生产事故实战)——两个 127 是同一个对象,两个 128 不是。
同类实现:Long/Short/Byte/Character缓存、String常量池、Boolean.valueOf()。
五、享元 vs 对象池 vs 单例
| 概念 | 共享什么 | 生命周期 | 典型例子 |
|---|---|---|---|
| 享元 | 不可变的细粒度对象,用完不还 | 长期驻留池里 | Integer 缓存、规格字典 |
| 对象池 | 昂贵的可变资源,借了要还 | 借出→使用→归还复用 | 数据库连接池、线程池 |
| 单例 | 全局唯一的服务对象 | 全局一份 | 配置客户端 |
关键区别:享元的对象是"大家同时用"(因为不可变所以安全),连接池的连接是"同一时刻只能一个人用"(可变所以要独占借还)。
六、框架中的同款实现
| 框架 | 应用 | 说明 |
|---|---|---|
| JDK | Integer.valueOf()、String 常量池 | 最经典的享元 |
| Netty | PooledByteBufAllocator | 内存块复用,享元+对象池的混合体 |
| 数据库连接池 | HikariCP | 对象池形态(借还模型) |
| 游戏引擎 | 子弹/粒子对象池 | 高频创建销毁的对象复用 |
| Caffeine/Guava Cache | 本地缓存本身 | “缓存即享元池”——很多享元场景直接用 Caffeine 实现(带容量上限和过期) |
七、常见误用与生产事故
误用一:共享了可变对象
// ❌ SpecItem 有 setter,还被共享——A 业务改了 colorCode,B 业务看到的也变了publicclassSpecItem{privateStringcolorCode;publicvoidsetColorCode(Stringc){this.colorCode=c;}}享元的铁律:必须不可变(全 final + 无 setter + 集合字段防御性拷贝)。做不到不可变,就不要共享。
误用二:池的 key 里混入了外部状态
// ❌ key 带了价格——每个价格一个实例,池退化成"每个 SKU 一份",享元失效pool.computeIfAbsent(specName+":"+specValue+":"+price,...);key 只能由内部状态组成。判断方法:如果 key 的某个组成部分"每个调用方都不一样",它就是外部状态,不该进 key。
事故:享元池无上限导致内存泄漏
某系统用享元缓存"用户收货地址解析结果",key 是完整地址字符串——地址是无限基数(每个用户都不一样),池只增不减,运行两周后 Old 区被 2000 万个"享元"占满,频繁 Full GC 后 OOM。
修复:
- 享元只适合有限基数的字典类数据(规格/地区码/配置模板),无限基数的数据不配进享元池;
- 必须带上限:用 Caffeine 替代裸
ConcurrentHashMap(maximumSize(10_000)+expireAfterAccess); - 池大小打点监控,超过预期告警——任何"只进不出"的 Map 都是内存泄漏嫌疑人(同款问题见 01-06-A-JVM排查实战详解)。
八、总结
- 享元的核心:内部状态(可共享)存享元,外部状态(因人而异)传参数——用共享换内存。
- 两个前提:对象数量巨大且大部分状态可共享;享元必须不可变。
- 实现三件套:不可变享元类 +
computeIfAbsent享元工厂 + 池大小监控。 - 适用边界:有限基数的字典类数据(规格/地区/模板);无限基数数据进池 = 内存泄漏,宁可用带淘汰的 Caffeine。
- 辨析:享元是"大家同时用不可变对象",对象池是"排队借还可变资源",别混为一谈。
📌上一篇:10-A-外观模式详解
📌下一篇:12-A-代理模式详解