12 享元(结构型)
系统里有海量对象,大部分状态还都重复,内存扛不住。把“可共享的内在状态”抽出来复用,只给每个实例留“随它变化的外在状态”。讲清内在/外在状态的划分,以及 Java Integer 缓存这个经典享元池。
12 享元 · Flyweight(结构型)
结构型收官。这一篇是唯一一个为“性能/内存”而生的模式——当对象多到以百万计,它教你怎么把它们塞进有限的内存。
"Flyweight“是拳击里的”蝇量级“(最轻量级)。这个名字本身就在说:让每个对象尽可能轻。
一、这是什么:游戏地图里的一片森林
你在做一个游戏,地图上要渲染一百万棵树。每棵树对象都存:名称、颜色、纹理贴图(可能几 MB)、加上自己的坐标。一百万棵 × 每棵几 MB = 内存直接爆炸。
但你冷静看一眼:这一百万棵树,其实翻来覆去就那么几种——松树、枫树、橡树。同一种松树,它们的名称、颜色、纹理完全一样,唯一不同的只是长在哪(坐标)。
那还存一百万份纹理干嘛?把“松树的名称/颜色/纹理”只存一份,让所有松树共享;每棵树自己只留一个坐标 + 一个指向“松树数据”的引用。 一百万棵树,纹理数据却只有 3 份。这就是享元。
配套代码正是这个森林:TreeType(名称/颜色/纹理)被共享,种 5 棵树只创建了 2 个 TreeType。
二、解决什么痛:海量对象,状态大量重复
痛点很硬核,就是内存:
- 对象数量巨大(百万级的树、编辑器里每个字符、地图上每个图标)。
- 这些对象的状态,很大一部分是重复的(同种树的纹理、同个字母的字形数据)。
- 如果每个对象都完整存一份,内存被大量重复数据吃光。
享元的破解思路,是把对象的状态劈成两半:
| 状态 | 叫法 | 特点 | 例子 | 存哪 |
|---|---|---|---|---|
| 可共享的 | 内在状态(intrinsic) | 不随实例变、可复用 | 树的名称/颜色/纹理 | 存一份,共享 |
| 随实例变的 | 外在状态(extrinsic) | 每个实例都不同 | 树的坐标 (x,y) | 每个实例自己存/外部传入 |
享元隔离的“会变的东西”,恰恰是通过分离“不变的东西”实现的。 它把“到处重复的、不变的内在状态”抽出来集中共享一份,把“每个实例独有的外在状态”剥离出去。核心动作就一个:分清哪些状态能共享、哪些不能——这是用享元最关键、也最烧脑的一步。
三、怎么实现:享元工厂管缓存,复用内在状态
实现有两个要件:一个不可变的享元对象(存内在状态),一个享元工厂(缓存已创建的享元,相同的直接复用)。
// 享元:内在状态(可共享),相同名称/颜色/纹理的树共用一个实例
type TreeType struct {
Name, Color, Texture string // ← 内在状态,不可变
}
// 享元工厂:缓存并复用,相同内在状态绝不重复创建
type TreeFactory struct {
types map[string]*TreeType
}
func (f *TreeFactory) GetTreeType(name, color, texture string) *TreeType {
key := name + "|" + color + "|" + texture
if t, ok := f.types[key]; ok {
return t // ← 已有,直接复用同一个对象
}
t := &TreeType{name, color, texture}
f.types[key] = t // ← 没有,创建并缓存
return t
}
// 树:外在状态(坐标) + 指向共享内在状态的引用
type Tree struct {
X, Y int // ← 外在状态,每棵不同
Type *TreeType // ← 引用共享的内在状态
}结果:种了 5 棵树,TreeType 只造了 2 个(松树、枫树各一),其余全是共享引用。树的数量再翻万倍,TreeType 还是那 2 个。内存占用从“棵数 × 完整数据”降到“棵数 × 一个指针 + 极少数几份完整数据”。
四、有哪些坑
- 划分内在/外在状态是全部难点。 划错了,享元就失效甚至出 bug。判据:在实例间会变的,一定是外在状态,不能塞进共享对象;实例间恒定的,才是内在状态。上面若手滑把坐标放进
TreeType,那所有树就会共享同一个坐标,全叠在一个点上——灾难。 - 享元对象必须不可变。 既然被无数实例共享,谁都不能改它——改一个等于改所有。共享对象一旦可变,就是并发和逻辑双重噩梦。内在状态创建后只读,是铁律。
- 只有对象数量巨大时才划算。 享元引入了工厂、缓存、内外状态分离的复杂度。对象就几十个?这套开销纯亏,直接每个存一份更清晰。它是“以复杂度换内存”的交易,只有内存真的紧张、对象真的海量时才成立。
- 共享带来的线程安全。 多线程访问同一个享元工厂的缓存
map,要加锁(Go 里map并发写会 panic)。共享的东西越多,并发考量越重。
五、开源里怎么用
- Java
Integer.valueOf()的缓存池 —— 面试常客,享元实锤。 Java 对-128 ~ 127的Integer做了缓存:Integer.valueOf(100)多次调用返回同一个对象。所以Integer a = 100, b = 100; a == b是true,而200 == 200(超出缓存范围)却是false——这个著名的“坑”,本质就是享元池。Boolean、Character、短Long都有类似缓存。 - 字符串驻留(String Interning):Java 的字符串常量池、Go/Python 对字符串字面量的驻留,让内容相同的字符串共享一份存储——享元思想在语言运行时层面的落地。
- 文字排版/浏览器渲染:一个字体里字母
a的字形(glyph)轮廓数据只存一份,页面上出现一万个a,共享这一份字形,各自只带位置和大小——GoF 原书举的正是文档编辑器这个例子。 - 游戏引擎:粒子系统、植被系统、地图瓦片(tile),海量重复元素共享网格/纹理/材质(内在状态),各自只带变换矩阵(外在状态)。没有享元,开放世界游戏的内存根本扛不住。
结构型七连,收工。 回看这一组主线:它们全在管“怎么把对象拼成更大的结构还不别扭”——适配器/装饰器/代理/外观管“包装”,桥接管“多维度”,组合管“树”,享元管“海量共享”。
下一章进入最大的一族行为型(11 个):对象都造好、也拼好了,现在研究它们之间怎么分工、怎么通信、怎么协作。第一个出场的是行为型里最实用、也最能体现“面向接口编程”的——策略模式。