享元模式完全指南:从 Integer 缓存到游戏粒子系统的内存优化

享元模式(Flyweight)在 GoF 23 个模式里使用频率不高,但它解决的问题非常具体:当系统里需要大量"细粒度对象"时,如何避免内存爆炸。Java 的 Integer.valueOf、String 池、字体渲染、游戏里的粒子系统,都在悄悄使用它。这篇文章把享元从原理讲到实战,讲清楚"内部状态 vs 外部状态"这个核心区分,以及它在不可变对象大行其道的今天为什么仍然重要。

问题:海量相似对象的内存压力

想象一个文档编辑器,要渲染一本书 —— 假设有 50 万个字符,每个字符是一个 Character 对象,带 char value、font、size、color、style 等字段:

class Character {
    char ch;
    String font;
    int size;
    String color;
    boolean bold;
    boolean italic;
    int x, y;   // 位置
}

// 50 万个字符,每个对象至少几十字节,总共几十 MB —— 内存吃不消

仔细观察:50 万个对象里,绝大多数字符值是重复的(英文只有 128 种,中文常用 3000 种);字体/字号/颜色样式大多数页面只用 3-5 种。如果把这些"会重复的部分"提取出来共享,内存能省下 90%+。

享元的核心:内部状态 vs 外部状态

享元模式的精髓是把对象状态分成两部分:

  • 内部状态(Intrinsic):可以共享的、不依赖上下文的部分。例:字符的 ch、font、size、color。
  • 外部状态(Extrinsic):不能共享的、每次使用都不同的部分。例:字符在文档里的位置 x、y。

内部状态封装成不可变对象,做成一个共享池;外部状态由客户端在使用时传入。这样 50 万个字符可能只对应几百个不同的内部状态实例。

标准结构

// 1. 享元接口(可选,Flyweight 本身就是个值对象时可以不要)
interface CharacterFlyweight {
    void render(int x, int y);   // 外部状态作为参数传入
}

// 2. 具体享元:内部状态都是 final,实例不可变
class ConcreteCharacter implements CharacterFlyweight {
    private final char ch;
    private final String font;
    private final int size;
    private final String color;

    public ConcreteCharacter(char ch, String font, int size, String color) {
        this.ch = ch; this.font = font; this.size = size; this.color = color;
    }

    public void render(int x, int y) {
        System.out.printf("'%c' at (%d, %d), %s %dpt %s%n", ch, x, y, font, size, color);
    }

    @Override
    public boolean equals(Object o) { ... }    // 内部状态相同就是同一对象
    @Override
    public int hashCode() { ... }
}

// 3. 享元工厂:维护池,保证相同 key 的请求返回同一个享元实例
class CharacterFactory {
    private static final Map<String, ConcreteCharacter> pool = new HashMap<>();

    public static ConcreteCharacter get(char ch, String font, int size, String color) {
        String key = ch + "|" + font + "|" + size + "|" + color;
        return pool.computeIfAbsent(key,
            k -> new ConcreteCharacter(ch, font, size, color));
    }

    public static int poolSize() { return pool.size(); }
}

// 4. 客户端使用
class Document {
    private List<PlacedChar> chars = new ArrayList<>();   // 只存"享元 + 位置"

    public void add(char ch, String font, int size, String color, int x, int y) {
        chars.add(new PlacedChar(
            CharacterFactory.get(ch, font, size, color),   // 共享
            x, y                                            // 外部状态
        ));
    }

    public void render() {
        for (PlacedChar pc : chars) pc.flyweight.render(pc.x, pc.y);
    }
}

record PlacedChar(ConcreteCharacter flyweight, int x, int y) {}

结果:Document 里有 50 万个 PlacedChar(每个只占两个 int + 一个引用,非常小),但 CharacterFactory 池里可能只有几百个 ConcreteCharacter 实例。内存占用从 50 万 × 大对象 降到 50 万 × 小对象 + 几百 × 大对象。

实战 1:Java Integer 缓存

Integer a = Integer.valueOf(100);
Integer b = Integer.valueOf(100);
System.out.println(a == b);     // true —— 同一对象

Integer c = Integer.valueOf(200);
Integer d = Integer.valueOf(200);
System.out.println(c == d);     // false —— 不同对象

原因:Java 内置了一个 Integer 缓存,默认范围 -128 到 127。这个范围内的 valueOf 返回缓存对象;超出就 new 一个新的。这是享元模式在 JDK 标准库的内置实现

同样的还有 Boolean.TRUE / Boolean.FALSELong.valueOf(也有缓存)、String.intern()(字符串池)。这就是为什么"包装类型用 valueOf 而不是 new"是个良好习惯 —— 它让你的代码顺势享受 JDK 内置的享元优化。

实战 2:字符串池

所有字符串字面量都自动放进字符串池(intern pool):

String a = "hello";
String b = "hello";
System.out.println(a == b);     // true —— 同一对象,来自字符串池

String c = new String("hello"); // 强制 new,不走池
System.out.println(a == c);     // false

String d = c.intern();          // 显式加入/查找池
System.out.println(a == d);     // true

字符串池是跨整个 JVM 共享的享元 —— 任何代码里出现的 "hello" 字面量都指向同一个对象。这是为什么 Java 启动时类加载会把大量字符串放进池,内存中重复字符串大幅减少。

但要注意:过度 intern 会让 PermGen / Metaspace 撑爆。如果你 intern 大量用户输入的字符串,池会无限增长。规则:只 intern 那些"可控、有限、复用频繁"的字符串。

实战 3:游戏里的粒子系统

射击游戏里,玩家发射 1000 颗子弹,每颗都是一个 Bullet 对象:

class Bullet {
    String texture;          // 子弹贴图(几 KB)
    String sound;            // 命中音效
    double speed;
    int damage;
    // ... 内部状态

    // 外部状态:位置、方向、当前飞行时间
    Vec2 position;
    Vec2 direction;
    double age;
}

1000 颗"霰弹枪子弹"内部状态完全一样,只是位置不同。用享元拆分:

class BulletType {           // 享元
    final String texture;
    final String sound;
    final double speed;
    final int damage;
}

class BulletInstance {        // 外部状态 + 享元引用
    BulletType type;
    Vec2 position;
    Vec2 direction;
    double age;

    void update(double dt) {
        position.add(direction.mul(type.speed * dt));
        age += dt;
    }
}

// 类型池
Map<String, BulletType> types = Map.of(
    "shotgun", new BulletType("shotgun.png", "shotgun.wav", 800, 5),
    "rifle",   new BulletType("rifle.png",   "rifle.wav",   1200, 15),
    "rocket",  new BulletType("rocket.png",  "rocket.wav",  400,  100)
);

// 创建 1000 颗
List<BulletInstance> bullets = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    BulletInstance b = new BulletInstance();
    b.type = types.get("shotgun");      // 共享同一个 BulletType
    b.position = new Vec2(x, y);
    b.direction = randomDir();
    bullets.add(b);
}

1000 颗子弹只对应 3 个 BulletType 实例(几 KB 重的资源只加载 3 份),每颗 BulletInstance 只占几十字节。这种设计让游戏可以同屏渲染数万粒子。

实战 4:字体渲染

字体引擎里,每个字形(glyph)是一组矢量信息或位图,加载一次后被所有"在文档里出现的这个字"共享。这是享元的极致应用 —— 一份字形数据,被显示成千上万次。Skia / FreeType / DirectWrite 这些字体引擎内部都有 glyph cache。

享元 + 不可变 = 默认安全

享元对象必须是不可变的 —— 一旦被多个客户端共享,任何修改都会影响所有用它的地方。所以:

  1. 所有字段 final。
  2. 没有 setter。
  3. 构造时初始化完毕,之后只能读不能改。

这正好和现代 Java / Kotlin / Rust 推崇的"不可变设计"完美契合。事实上,所有 immutable 对象天然适合做享元—— 你只要加个池就能享元化。

享元工厂:线程安全的池

享元池如果被多个线程同时访问,要保证 get 的原子性。否则两个线程同时查 "key X 在池里吗",都发现不在,都各自 new 了一个,池里有了两份"享元",共享就被破坏。

// 错:非线程安全
Map<String, Flyweight> pool = new HashMap<>();
Flyweight get(String key) {
    Flyweight f = pool.get(key);
    if (f == null) { f = new Flyweight(...); pool.put(key, f); }
    return f;
}

// 对 1:ConcurrentHashMap.computeIfAbsent —— 原子且高效
Map<String, Flyweight> pool = new ConcurrentHashMap<>();
Flyweight get(String key) {
    return pool.computeIfAbsent(key, k -> new Flyweight(...));
}

// 对 2:synchronized —— 简单但每次访问都锁
synchronized Flyweight get(String key) { ... }

实战推荐 ConcurrentHashMap + computeIfAbsent,既线程安全又对读优化。

享元和缓存的区别

很多人觉得享元就是"加个缓存",但概念上有差别:

  • 缓存:为了加速,数据可以丢(缓存失效后重新计算)。
  • 享元:为了共享,数据是"权威的"(同样输入永远是同一对象)。

缓存可以有 TTL、有淘汰策略;享元池通常不淘汰(或只在内存极紧张时清理)。但实际工程里两者经常混合 —— "带 LRU 淘汰的享元池"既享元又控内存。

享元 vs 单例

都涉及"对象数量受限",但:

  • 单例:全局只有一个实例,无论参数。
  • 享元:每种"内部状态"只有一个实例,实例数等于不同内部状态的种类数。

单例是"享元 with key=空"的退化形态。

各语言里的享元

Python:函数式实现

from functools import lru_cache

# lru_cache 直接把函数变成享元工厂
@lru_cache(maxsize=None)
def get_character(ch, font, size, color):
    return Character(ch, font, size, color)

# 调用:同样参数返回同一对象
a = get_character('a', 'Arial', 14, 'black')
b = get_character('a', 'Arial', 14, 'black')
assert a is b

JavaScript:Map + WeakMap

const pool = new Map();
function getCharacter(ch, font, size, color) {
    const key = `${ch}|${font}|${size}|${color}`;
    if (!pool.has(key)) pool.set(key, Object.freeze({ ch, font, size, color }));
    return pool.get(key);
}

// 如果 key 是对象,且希望"key 没人引用时享元也能被 GC",用 WeakMap
const wpool = new WeakMap();

享元的代价

1. 代码变复杂

原本一个类,现在要拆成"内部状态类 + 外部状态类 + 工厂",且使用时要区分什么是内部什么是外部。除非真有大量重复对象,否则别上享元。100 个对象的场景不值得。

2. 不能修改

共享对象不能改,意味着字段被外部需要"个性化"修改的就不能放进内部状态。设计时要想清楚什么字段真的"普遍"。

3. 池的内存管理

享元池不释放会持续增长,尤其是用户输入参与 key 时(每个不同输入就增加一个享元)。需要:

  • 限制 key 的取值范围(枚举/有限集合)。
  • 给池加 LRU 或定期清理。
  • 用 SoftReference / WeakReference 让 GC 在内存紧张时回收享元。

识别享元场景

清单(满足越多越值得用享元):

  1. 系统中有大量相似对象(数千到数百万级)。
  2. 这些对象的大部分状态可以共享
  3. 剩余独有状态相对小
  4. 对象本身不需要被修改(或修改可以接受"换一个享元")。
  5. 对象身份不重要(== 比较意义不大)。

常见坑

坑 1:把可变字段放进内部状态。 享元实例被多处共享,任何一处修改影响全部。规则:内部状态必须 final + immutable。

坑 2:hashCode/equals 不一致。 享元池靠 key 找实例,如果 key 的 hashCode 不稳定(基于可变字段),查找会失败,池会有重复对象。key 用所有内部状态的不可变值。

坑 3:把"看起来重复的"硬享元化。 100 个不同的 User 对象虽然字段名相同,但每个 User 的字段值都不同 —— 不存在共享空间,享元没用。值得享元的是"种类有限、实例无限"的对象。

坑 4:池泄漏。 池里的享元永远不释放,如果 key 是用户输入(可能是任意字符串),池会无限增长直到 OOM。要么限制 key 集合,要么加淘汰。

写在最后

享元模式的核心是"识别重复,提取共享"。它在内存敏感的场景(游戏、文档处理、大数据缓存、嵌入式)是必备工具,在常规业务里反而少见。但它的思想 —— 把对象状态分成"可共享"和"个性化"两部分 —— 是一种通用的"识别冗余"能力,你在做任何系统设计时都会无意识地用到。

下次面对一个"我要建几万个小对象"的需求,先停一秒,问:这些对象之间有多少字段是重复的?重复部分能不能共享?能的话,享元会让你的内存占用骤降几个量级,而代码改动不大。这是 GoF 模式里 ROI 最隐蔽但最高的一个 —— 用对了,它默默地把你从内存崩溃边缘救回来。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

外观模式完全指南:从 jQuery 到 API 网关的分层简化

2026-5-15 11:57:55

技术教程

代理模式完全指南:从静态代理到 Spring AOP 的工程化巅峰

2026-5-15 11:57:56

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索