少女祈祷中...
残云的学习笔记

享元模式:内部状态共享、外部状态外置,以及它和对象池到底差在哪

享元回答的问题:对象太多、内存扛不住时怎么办。它的核心只有一句话——把状态拆成「可以共享的内部状态」和「不能共享的外部状态」,共享前者、外置后者。本文讲清内部/外部状态这条分界线、工厂池与线程安全、JDK 里 Integer 缓存和 String 常量池这两个现成的享元,最后把享元与对象池这对最容易混的概念彻底分开。

一、问题的起点:一百万个字符,一百万个对象

文本编辑器是最典型的例子。如果每个字符都建一个对象,并且把字体、字号、位置、颜色全都塞进对象里:

/** 没有享元:每个字符一个对象,各自带着完全相同的字体信息 */
class CharObject {
    char ch;
    String font;      // 一百万个字符 → 一百万个一模一样的 "Noto Sans SC"
    int size;         // 同样重复了一百万遍
    int x, y;         // 位置:每个字符都不同
    String color;     // 颜色:同一段文字里其实也大量重复
}

问题很清楚:一百万字符里,真正不同的「字形 + 字体 + 字号」组合可能只有几十种,但每个字符对象都重复存了一遍。 每个对象至少几十字节,一百万字符就是几十 MB 的额外开销——而这些字段里,绝大多数值是完全相同的。

flyweight-1-problem

二、结构:四个角色与那个「池」

享元的结构里最关键的不是那个享元类,而是工厂里的那个池:

flyweight-2-structure

  • Flyweight(抽象享元):Glyph,对外暴露的操作接口。注意它的方法签名里带着外部状态(x, y, color)——这是享元最重要的设计信号。
  • ConcreteFlyweight(具体享元):CharGlyph,只存内部状态(字符、字体、字号),且这些字段必须是 final。
  • FlyweightFactory(享元工厂):GlyphFactory,持有一个 Map 池,按「内部状态的组合」做 key,有就返回旧的,没有才创建。
  • Client(客户端):持有外部状态(每个字符的坐标、颜色),在调用时传进去。

两个容易漏掉的点:

  1. 享元类里不能有外部状态字段。一旦存了坐标,这个对象就只能给一个字符用,"共享"立刻失效。
  2. 工厂必须保证"同一个 key 只创建一个对象"。否则池里出现多个相同实例,共享是假的(第五节讲并发下怎么做)。

三、手写一个字符享元

下面是可直接运行的单文件版本(Java 8+):

import java.util.HashMap;
import java.util.Map;

/** Flyweight:只保存内部状态(字符、字体、字号),字段全部 final */
final class Glyph {
    private final char ch;
    private final String font;
    private final int size;

    Glyph(char ch, String font, int size) {
        this.ch = ch;
        this.font = font;
        this.size = size;
    }

    /** 外部状态(坐标、颜色)由客户端传入,享元自己不保存 */
    void draw(int x, int y, String color) {
        System.out.println("绘制 '" + ch + "' " + font + "/" + size + "px "
                + "于 (" + x + "," + y + ") 颜色 " + color);
    }
}

/** FlyweightFactory:池 + 复用,key 由内部状态的组合构成 */
class GlyphFactory {
    private final Map<String, Glyph> pool = new HashMap<>();

    Glyph get(char ch, String font, int size) {
        String key = ch + "|" + font + "|" + size;
        Glyph glyph = pool.get(key);
        if (glyph == null) {                 // 池里没有才创建
            glyph = new Glyph(ch, font, size);
            pool.put(key, glyph);
        }
        return glyph;                        // 池里已有就直接复用
    }

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

public class FlyweightDemo {
    public static void main(String[] args) {
        GlyphFactory factory = new GlyphFactory();
        String text = "享元模式享元模式享元模式享元模式";      // 16 个字符,只有 4 种不同的字

        for (int i = 0; i < text.length(); i++) {
            // 内部状态交给工厂复用;外部状态(位置、颜色)每次传入
            Glyph glyph = factory.get(text.charAt(i), "Noto Sans SC", 16);
            glyph.draw(i * 16, 100, "#333333");
        }

        System.out.println("字符数:" + text.length());
        System.out.println("实际创建的 Glyph 对象:" + factory.poolSize());
    }
}

输出(节选):

绘制 '享' Noto Sans SC/16px 于 (0,100) 颜色 #333333
绘制 '元' Noto Sans SC/16px 于 (16,100) 颜色 #333333
绘制 '模' Noto Sans SC/16px 于 (32,100) 颜色 #333333
绘制 '式' Noto Sans SC/16px 于 (48,100) 颜色 #333333
绘制 '享' Noto Sans SC/16px 于 (64,100) 颜色 #333333
...
字符数:16
实际创建的 Glyph 对象:4

一次 get() 在工厂内部是这样走的:

flyweight-3-sequence

三点值得注意:

  1. 16 次绘制只创建了 4 个对象——同样一个字,在不同位置画,用的是同一个实例。
  2. draw 的参数就是外部状态:坐标和颜色每次不同,所以它们不能进对象,只能当参数。
  3. 如果后面再画 100 万字,只要字符和字体不新增,池的大小仍然是 4——这就是"支持大量细粒度对象"的含义。

四、核心:内部状态与外部状态

享元能不能成立,全看这条分界线画得对不对。

flyweight-4-intrinsic-extrinsic

内部状态外部状态
是否随场景变化不变每次可能不同
存在哪享元对象里(final 字段)客户端 / 调用参数里
能否共享能,且必须共享不能共享
典型例子字符、字体、字号、图的网格模型、棋子的颜色坐标、颜色、缩放、棋子的位置

判断方法很朴素:问一句"这个值在同一个享元的不同使用场景里会变吗"——不变的是内部状态,会变的是外部状态。

还有一个容易被忽略、但决定成败的细节:外部状态放在哪。正确做法是把它集中存成一张紧凑的「位置表」(比如 int[] x、int[] y,或一个 Position 数组),而不是"一个字符一个对象、对象里挂着享元引用"——后者虽然共享了字形,但每个字符仍然是一个对象,内存收益会大打折扣。享元真正省下来的内存,来自「少量享元 + 一张扁平的外部状态表」这个组合。

两个坑:

  • 把会变的状态放进享元 → 共享失效,还会出现"A 改一处、B 跟着变"的串数据问题;
  • 把不变的状态也外置 → 每次调用都要传一堆参数,享元退化成"无状态的工具类",白折腾。

五、工厂与线程安全

工厂的 get 必须是"同一个 key 只返回同一个实例"。单线程下 HashMap 够了,但在 Web 应用里,这个池会被多个请求线程同时访问,必须换成并发容器:

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

class GlyphFactory {
    // 并发场景:computeIfAbsent 保证 key 不存在时才创建,且整个过程是原子的
    private final ConcurrentMap<String, Glyph> pool = new ConcurrentHashMap<>();

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

另外两点工程实践:

  1. 池要有上限:如果 key 的组合会无限增长(比如颜色、样式也进了 key),池会变成内存泄漏点,需要设上限或改成弱引用(WeakHashMap)。
  2. 享元对象不可变:所有字段 final、不提供 setter、不暴露可变集合——这是共享的前提,不是风格偏好。

六、JDK 与框架里的享元

① Integer 缓存 —— 最贴近日常的享元:Integer.valueOf() 对 -128 ~ 127 复用同一批对象,所以这个范围内 == 会返回 true:

Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
System.out.println(a == b);        // true:命中缓存,同一个对象

Integer c = Integer.valueOf(128);
Integer d = Integer.valueOf(128);
System.out.println(c == d);        // false:超出缓存范围,各自新建

Integer e = new Integer(127);      // 显式 new 一定绕开缓存(Java 9 起已废弃)
System.out.println(a == e);        // false

flyweight-5-integer-cache

② String 常量池 —— 字面量字符串自动入池、intern() 手动入池;两个内容相同的字面量指向同一个对象。判断字符串相等永远用 equals,== 比较的是"是不是同一个享元"。

③ 其他 —— Boolean.TRUE / FALSE、Character 缓存(0~127)、枚举实例(天然的单例享元)、游戏引擎里共享的网格/材质。

判断标准:对象里是否只有"不可变的、重复率极高的"状态,并且被大量复用。

七、享元 vs 对象池 vs 单例

这是两个最容易被混的概念。享元共享的是"数据",对象池复用(独占)的是"实例":

享元 Flyweight对象池 Object Pool单例 Singleton
共享/复用的对象不可变的内部状态可变、有状态的对象实例唯一的全局实例
同一时刻的使用者多个(可同时读)只有一个(借出期间独占)多个(但要注意线程安全)
解决的问题内存占用(对象太多)创建/销毁成本(连接、线程昂贵)全局唯一性 / 受控访问
典型例子Integer 缓存、字符字形、瓦片地图数据库连接池、线程池配置中心、日志器
归还不需要归还必须归还不存在归还

一句话记忆:享元是"很多人同时看同一张地图",对象池是"一支笔借给一个人用完再还"。

顺带澄清一个常见的误认:享元也不是缓存。缓存关心的是「结果算过一次就存起来」,key 通常是一次请求或一次计算的输入,并且会过期、会淘汰;享元关心的是「同一个不可变对象被大量地方同时引用」,在对象的生命周期内通常一直有效。

八、什么时候不要用享元

  1. 对象数量本来就不多:为了几十个对象搞一套池 + 内外状态拆分,是纯粹的过度设计。
  2. 状态无法清晰分离内外:如果每个对象的状态都随着场景变化,那没有可共享的部分。
  3. 需要频繁修改共享数据:享元的前提是不可变;一旦要改,共享就会变成一致性地狱。
  4. key 的组合会无限增长:池会变成内存泄漏点,除非有上限或弱引用策略。

九、结构型七个:一张表理清

模式一句话意图结构特征典型场景
代理 Proxy控制对单个对象的访问一对一,接口不变权限、远程调用、延迟加载
适配器 Adapter让接口对不上的双方协作一对一,接口转换接第三方 SDK、老系统兼容
装饰者 Decorator不改源码叠加职责一层套一个,接口不变IO 流、加缓存/日志/加密
桥接 Bridge分离两个独立变化的维度两棵树 + 一座桥JDBC、跨平台 GUI
外观 Facade简化一组子系统的调用多对一,统一入口下单编排、日志门面
组合 Composite表示部分-整体的层次结构一对多自聚合,多叉树组织架构、菜单、文件系统
享元 Flyweight共享大量细粒度对象池 + 内外状态分离Integer 缓存、字形、瓦片

十、参考资料

  • GoF《设计模式:可复用面向对象软件的基础》——享元模式章节
  • 《Head First 设计模式》——享元与「共享大量细粒度对象」的讨论
  • JDK 源码:java.lang.Integer.IntegerCache、java.lang.String#intern、java.lang.Character.CharacterCache
  • 本系列相关篇目:组合模式(一对多树结构)、外观模式(多对一简化)、单例模式(单例与享元的区别)
分享到

评论