ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

设计模式 12 · 享元模式

设计模式 12 · 享元模式 结构型模式的最后一站,是享元模式(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;// AppleprivateStringspec;// 规格描述(可能很长)// ...}问题出在哪?大促时一百万个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.skuIdskuId;this.skuNameskuName;this.skuImageskuImage;this.brandbrand;}// 只有 getter,没有 setter —— 享元必须是不可变的(见下方提醒)}第二步,享元工厂(维护一个缓存池,保证同一个 SKU 只创建一次):publicclassSkuFactory{// 缓存池:key 是 skuId,value 是共享的享元对象privatestaticfinalMapLong,SkuFlyweightpoolnewConcurrentHashMap();// 获取享元:池里有就直接返回,没有才创建并放入池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.orderIdorderId;this.countcount;this.skuSkuFactory.getSku(skuId,...);// 从工厂拿共享的那一份}}现在,一百万个买 iPhone 的订单项,它们的sku字段全都指向内存里同一个SkuFlyweight对象。SKU 数据只存了一份。一个必须遵守的铁律:享元对象(内部状态)必须是不可变的。想想看,既然一个SkuFlyweight被一百万个订单共享,如果它是可变的、某个订单不小心改了它的skuName,那所有共享它的订单的商品名就全被改了——这是灾难性的串改(和第 6 篇浅拷贝串味是同一类问题)。所以享元类必须字段全final、无 setter、做成不可变对象。这里,第 1 篇讲的不可变对象天生线程安全、可放心共享再一次成了享元能成立的基石。四、你天天在用的享元:Integer 缓存与字符串常量池享元听起来像个高级优化,但其实你每天都在用 JDK 内置的享元,只是没意识到。这里有两个最经典的:其一,Integer的缓存(IntegerCache)。看这段经常让人困惑的代码:Integera127,b127;System.out.println(ab);// trueIntegerc128,d128;System.out.println(cd);// 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)。你写的所有字符串字面量,都被享元化了:Strings1hello;Strings2hello;System.out.println(s1s2);// true,指向常量池里同一个 helloJVM 维护了一个字符串常量池,相同内容的字符串字面量在池里只存一份,所有引用都指向它。这就是为什么两个hello字面量为true——它们共享了常量池里的同一个String对象。字符串内容(不可变!)就是内部状态。String 的不可变性,正是它能被安全地放进常量池共享的前提,又一次印证了享元必须不可变这条铁律。其他还有Boolean.valueOf()、Long/Short/Byte/Character的缓存等,思路都一样。认出这些,你就明白享元不是什么屠龙之技,而是就藏在你天天写的代码底下。五、什么时候用享元老规矩,泼冷水。享元是所有模式里最该谨慎使用的一个,因为它是一种用复杂度换内存的优化,而优化必须有的放矢。适合用的信号(几个条件最好同时满足):系统里存在海量的对象(成千上万、乃至百万级),多到内存吃紧;这些对象里,大部分状态是重复的、可共享的(内部状态占比高);对象的状态能被清晰地拆分成内部状态和外部状态;剥离外部状态后,共享对象的种类数远小于对象总数(几千种 SKU 撑起一百万订单)。不该用的信号:对象数量不大——那享元带来的内存节省微乎其微,却凭空增加了工厂、内外状态拆分的复杂度,得不偿失;对象的状态大部分是外部的、可共享的部分很少——那没什么可共享的,享元无从发挥;状态无法清晰拆分——强行拆会让代码难以理解。这里要特别强调,享元是全系列里最典型的不要过早优化的例子。它不是用来提升代码优雅度的模式,而是用来解决实实在在的内存压力的。先用性能分析工具确认内存确实被海量重复对象吃掉了这个事实,再上享元。在没有真实内存瓶颈时就套享元,是拿代码复杂度去换一个不存在的收益——比过度设计还冤,因为你连以防万一的借口都没有。记住:享元是优化手段,优化的前提是先有可度量的问题。六、结构型模式总收官第 12 篇结束,七个结构型模式全部讲完,做个总收官。结构型模式关心的都是类和对象怎么组合、连接,但各自解决的问题不同,可以分成两组来记:第一组:“包装一个对象”(通过包装改变对象的表现)模式一句话核心意图代理 Proxy给对象一个替身控制访问(日志/权限/事务)装饰器 Decorator像洋葱层层包裹动态增强功能适配器 Adapter接口转接头转换接口,让不兼容的能协作第二组:“组织一群对象”(处理多个对象/子系统的关系)模式一句话核心意图组合 Composite单个和一组长得一样统一处理树形结构外观 Facade给子系统盖门面简化复杂子系统的使用享元 Flyweight提取共享,省内存用少量对象扛住海量场景桥接 Bridge抽象与实现分离让两个维度独立变化(下一篇讲)注:桥接模式是结构型第七个,我们放在下一篇讲它解决的是多维度变化的问题。)回望这七个模式,你会发现它们共享着几条贯穿全系列的主线:组合优于继承在代理、装饰、适配、桥接里反复做选择;迪米特法则是外观的灵魂;不可变对象是享元的基石;而面向接口 隔离变化,则是它们共同的底色。这再次说明:模式不是二十三个孤立的技巧,而是那几条设计原则在不同场景下结出的果实。小结。享元模式专治海量重复对象吃内存的问题:把对象状态拆成可共享的内部状态和因场景而异的外部状态,把内部状态提取成一个不可变的共享享元、由工厂缓存池保证全局唯一,让海量对象共享少数享元,用极小的引用开销换掉海量的重复数据。Integer的 −128~127 缓存、字符串常量池,就是你天天在用的享元。它是一种用复杂度换内存的优化,必须在确认真实内存瓶颈后才使用,是不要过早优化最好的注脚。至此,结构型七模式(含下一篇的桥接)成型。下一篇我们讲桥接模式——当一个东西同时在两个维度上变化时(比如订单类型和通知渠道),继承会导致维度相乘的类爆炸,桥接教你把两个维度拆开、让它们各自独立变化。
返回列表