12 享元(结构型)

系统里有海量对象,大部分状态还都重复,内存扛不住。把“可共享的内在状态”抽出来复用,只给每个实例留“随它变化的外在状态”。讲清内在/外在状态的划分,以及 Java Integer 缓存这个经典享元池。

作者 David更新于

12 享元 · Flyweight(结构型)

结构型收官。这一篇是唯一一个为“性能/内存”而生的模式——当对象多到以百万计,它教你怎么把它们塞进有限的内存。

"Flyweight“是拳击里的”蝇量级“(最轻量级)。这个名字本身就在说:让每个对象尽可能轻。

一、这是什么:游戏地图里的一片森林

你在做一个游戏,地图上要渲染一百万棵树。每棵树对象都存:名称、颜色、纹理贴图(可能几 MB)、加上自己的坐标。一百万棵 × 每棵几 MB = 内存直接爆炸。

但你冷静看一眼:这一百万棵树,其实翻来覆去就那么几种——松树、枫树、橡树。同一种松树,它们的名称、颜色、纹理完全一样,唯一不同的只是长在哪(坐标)。

那还存一百万份纹理干嘛?把“松树的名称/颜色/纹理”只存一份,让所有松树共享;每棵树自己只留一个坐标 + 一个指向“松树数据”的引用。 一百万棵树,纹理数据却只有 3 份。这就是享元。

配套代码正是这个森林:TreeType(名称/颜色/纹理)被共享,种 5 棵树只创建了 2 个 TreeType。

二、解决什么痛:海量对象,状态大量重复

痛点很硬核,就是内存:

  • 对象数量巨大(百万级的树、编辑器里每个字符、地图上每个图标)。
  • 这些对象的状态,很大一部分是重复的(同种树的纹理、同个字母的字形数据)。
  • 如果每个对象都完整存一份,内存被大量重复数据吃光。

享元的破解思路,是把对象的状态劈成两半:

状态叫法特点例子存哪
可共享的内在状态(intrinsic)不随实例变、可复用树的名称/颜色/纹理存一份,共享
随实例变的外在状态(extrinsic)每个实例都不同树的坐标 (x,y)每个实例自己存/外部传入

享元隔离的“会变的东西”,恰恰是通过分离“不变的东西”实现的。 它把“到处重复的、不变的内在状态”抽出来集中共享一份,把“每个实例独有的外在状态”剥离出去。核心动作就一个:分清哪些状态能共享、哪些不能——这是用享元最关键、也最烧脑的一步。

三、怎么实现:享元工厂管缓存,复用内在状态

实现有两个要件:一个不可变的享元对象(存内在状态),一个享元工厂(缓存已创建的享元,相同的直接复用)。

go/flyweight/main.go(核心)
// 享元:内在状态(可共享),相同名称/颜色/纹理的树共用一个实例
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 个):对象都造好、也拼好了,现在研究它们之间怎么分工、怎么通信、怎么协作。第一个出场的是行为型里最实用、也最能体现“面向接口编程”的——策略模式。

本页目录