news 2026/8/7 3:48:04

设计模式 12 · 享元模式

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
设计模式 12 · 享元模式

结构型模式的最后一站,是享元模式(Flyweight)。它和前面几个模式关注点不太一样——前面的代理、装饰、适配、组合、外观,主要解决的是"结构、耦合、灵活性"问题;而享元,解决的是一个非常具体、非常"物理"的问题:内存。当系统里存在海量的、重复的相似对象,把内存吃紧了,享元教你怎么用少量对象扛住大量场景。

“Flyweight"这个词,原意是拳击里的"最轻量级”。这个命名很传神——享元模式的核心,就是想尽办法让对象变得尽可能"轻",轻到可以被大量共享。它的思路一句话:很多对象看起来各不相同,但它们的大部分数据其实是重复的、可以共享的;把这些共享的部分提取出来,做成一个共享对象,让所有需要它的地方都指向同一个实例,而不是各自复制一份。

举个最能体现威力的例子:电商大促,系统里瞬间涌入一百万个订单,每个订单都关联着商品信息(SKU 名称、图片地址、品牌、规格描述……)。如果每个订单都完整地存一份 SKU 数据,而这一百万个订单其实来来回回就买那几千种热门商品——那内存里就躺着大量内容完全一样的 SKU 副本,纯属浪费。享元模式让这几千种 SKU 数据只在内存里存一份,一百万个订单共享它们。这就是享元要干的事。

这篇文章按这条线索展开:先看"海量重复对象"如何撑爆内存;再引出享元的核心思想——区分"内部状态"和"外部状态";然后给出实现,并讲清那个关键的"享元工厂 + 缓存池";接着揭示一个你天天在用的享元——Integer缓存和String常量池;最后给出适用边界,并为整个结构型模式做收官。贯穿例是订单里的 SKU 共享。

目录

  1. 海量重复对象:内存的隐形杀手
  2. 享元的核心:内部状态 vs 外部状态
  3. 实现:享元工厂与共享池
  4. 你天天在用的享元:Integer 缓存与字符串常量池
  5. 什么时候用享元
  6. 结构型模式总收官

一、海量重复对象:内存的隐形杀手

先看问题。订单关联商品,一个直觉的写法是每个订单项都持有完整的 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 的那几十万个,它们的skuNameskuImagebrandspec一模一样。既然一样,为什么要存几十万份?存一份,让它们都指向这一份,不就行了?

这就是享元的出发点。但要做到"共享",我们得先把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 == btrue;而 128 超出了缓存范围,每次都new一个新对象,所以c == dfalse。这里 −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 缓存、字符串常量池,就是你天天在用的享元。它是一种"用复杂度换内存"的优化,必须在确认真实内存瓶颈后才使用,是"不要过早优化"最好的注脚。至此,结构型七模式(含下一篇的桥接)成型。下一篇我们讲桥接模式——当一个东西同时在两个维度上变化时(比如"订单类型"和"通知渠道"),继承会导致维度相乘的类爆炸,桥接教你把两个维度拆开、让它们各自独立变化。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/7 3:47:47

动态数组原理与实现:从固定长度到自动扩容的工程实践

1. 从“固定”到“可变”&#xff1a;为什么我们需要变长数组&#xff1f;在编程世界里&#xff0c;数组&#xff08;Array&#xff09;通常是很多人接触到的第一种数据结构。教科书上会告诉你&#xff0c;数组是一片连续的内存空间&#xff0c;用来存储一系列相同类型的元素&a…

作者头像 李华
网站建设 2026/8/7 3:46:12

如何快速掌握MTKClient刷机工具:新手必看的完整指南

如何快速掌握MTKClient刷机工具&#xff1a;新手必看的完整指南 【免费下载链接】mtkclient MTK reverse engineering and flash tool 项目地址: https://gitcode.com/gh_mirrors/mt/mtkclient MTKClient刷机工具是一款专业的联发科芯片设备调试工具&#xff0c;无论你是…

作者头像 李华
网站建设 2026/8/7 3:45:58

科技论文写作方法论:从核心认知到实战技巧的系统指南

1. 项目概述&#xff1a;从一份“答案”到一套“方法”最近在几个学术交流群里&#xff0c;看到不少同学在讨论“如何写好科技论文”这门课的期末复习&#xff0c;甚至有人直接求“雨课堂期末答案”。这个现象很有意思&#xff0c;也让我这个在科研一线摸爬滚打了十来年的“老油…

作者头像 李华
网站建设 2026/8/7 3:45:39

VSCode C++开发环境配置:精准设置语言标准与智能提示优化指南

1. 项目概述&#xff1a;为什么我们需要在VSCode中修改C版本如果你用VSCode写C&#xff0c;大概率遇到过这样的场景&#xff1a;项目里用到了C17的新特性&#xff0c;比如结构化绑定&#xff08;auto [a, b] pair&#xff09;&#xff0c;但编译时编译器却报错说“不认识这个语…

作者头像 李华
网站建设 2026/8/7 3:43:45

HT32F52352舵机控制全解析:从PWM配置到多路协同与调试实战

1. 项目概述&#xff1a;从芯片选型到舵机驱动的全链路思考 最近在做一个需要精确角度控制的小型机械臂项目&#xff0c;核心需求是驱动多个舵机实现平滑、稳定的运动。在众多微控制器选项中&#xff0c;我最终选用了合泰半导体的HT32F52352这款ARM Cortex-M0内核的MCU。选择它…

作者头像 李华
网站建设 2026/8/7 3:43:16

OpenClaw自定义Skill开发实战:从AI提示词到Webhook部署全流程解析

1. 项目概述&#xff1a;从“调用者”到“创造者”的转变如果你和我一样&#xff0c;已经用了一段时间的OpenClaw&#xff0c;或者类似的AI智能体平台&#xff0c;那你一定体验过在Skill商店里“淘宝”的乐趣。看到别人开发的、能帮你自动整理文档、分析数据、甚至写周报的Skil…

作者头像 李华