什么是享元模式?应用场景有哪些?

#中间件#面试八股

什么是享元模式?应用场景有哪些?

结论先行:享元(Flyweight)把对象里的可共享部分抽出来复用,叫内部状态(intrinsic state);把每个实例独有的部分在调用时传进去,叫外部状态(extrinsic state)。它换的不是"对象变少",而是"重复数据变少"——一万个坐标点共用一份图标样式,而不是各存一份。

1. 内部状态与外部状态

维度内部状态(Intrinsic)外部状态(Extrinsic)
归属对象自身,可被多方共享每个使用场景独有
是否可变必须不可变每次调用可不同
存放位置享元池(工厂缓存)调用方/上下文
例子字体名、字号、颜色、图标 URL坐标、时间、用户 ID
数量级少(有限组合)多(随业务线性增长)

判据很直接:能按有限组合枚举的放内部,随每次使用变化的放外部。

2. 结构:池 + 引用

                    ┌─────────────────────────────┐
   Client ─────────►│  FlyweightFactory           │
     │              │  pool: Map<key, Flyweight>  │
     │              │  get(key): 有则复用,无则建  │
     │              └──────────────┬──────────────┘
     │                             │ 返回共享引用
     │              ┌──────────────▼──────────────┐
     │              │  Flyweight(不可变)          │
     │              │  color / size / icon         │
     │              └─────────────────────────────┘
     │
     └─ 调用时自带外部状态:draw(x, y)  ← 坐标不进享元

关键:外部状态绝不出现在享元对象的字段里,否则共享就失效了。

3. 代码:地图标注点

// 内部状态:样式,同类标注共用一份
final class PinStyle {
    final String color;
    final int size;
    final String icon;

    PinStyle(String color, int size, String icon) {
        this.color = color; this.size = size; this.icon = icon;
    }
    // 只读、无 setter,保证共享安全
}

// 享元工厂:按 key 复用,避免重复创建
final class PinStyleFactory {
    private static final Map<String, PinStyle> POOL = new ConcurrentHashMap<>();

    static PinStyle of(String color, int size, String icon) {
        String key = color + "|" + size + "|" + icon;
        return POOL.computeIfAbsent(key, k -> new PinStyle(color, size, icon));
    }
}

// 外部状态:每个标注自己的坐标
final class Pin {
    private final PinStyle style;   // 共享
    private final int x, y;         // 独有

    Pin(String color, int size, String icon, int x, int y) {
        this.style = PinStyleFactory.of(color, size, icon);
        this.x = x; this.y = y;
    }

    void render() {
        System.out.println("draw " + style.icon + " at (" + x + "," + y + ")");
    }
}

// 10 万个标注,样式只有 3 种 → 样式对象只有 3 个

4. Java 里现成的享元

场景共享什么说明
String 常量池字面量字符串相同字面量指向同一对象,== 为 true
Integer.valueOf(-128~127)小整数对象超出范围每次 new,所以 == 比较会翻车
Boolean.TRUE/FALSE布尔常量天然享元
枚举每个常量JVM 保证唯一实例
线程池 / 连接池可复用资源池化思想,与享元同源不同目的
字体、图元、图标渲染资源GUI/游戏里最常见的用法
游戏子弹、粒子纹理与模型上千粒子共用一个纹理

5. 与单例、对象池的区别

对比项享元单例对象池
实例数量多份(按 key 分组)一份固定若干份
目的减少重复数据全局唯一访问点复用昂贵资源(连接、线程)
是否独占共享,可并发读共享独占,用完归还
生命周期与池同长与进程同长借出/归还循环

6. 代价与坑

  1. 共享对象必须不可变:一旦有人改了共享的 PinStyle.size,所有标注一起变形,且并发下没有任何保护。
  2. 池要有边界:ConcurrentHashMap 无上限时,key 空间随参数组合爆炸,本质是另一种内存泄漏。做法是限定枚举组合、加 LRU 上限,或改用弱引用(WeakHashMap)。
  3. 不是所有对象都值得共享:只有"对象数量巨大 + 内部状态重复率高"时收益才明显;十个对象共享没意义,反而多一层查表。

7. 常见追问

Q:new String("abc") 创建几个对象? 两个:常量池里的 "abc"(若首次出现)和堆上一个新的 String。后者不共享,所以 new String("abc") == "abc" 为 false,equals 为 true。

Q:Integer a = 127, b = 127; a == b 为什么 true,128 就 false? valueOf 对 -128~127 走缓存(享元),超出范围直接 new。所以包装类型比较一律用 equals。

Q:享元能减少 GC 压力吗? 能减少存活对象数量,但共享对象一旦进入老年代就长期存活,属于"用常驻内存换分配频率",要结合池大小评估。

小结

  • 享元 = 拆内部状态(共享、不可变)+ 外部状态(调用时传入)。
  • 核心收益是内存与创建开销,前提是"对象多、重复率高"。
  • 三大坑:共享对象被改、池无上限、参数组合爆炸。
  • Java 里 String 常量池、Integer 缓存、枚举都是现成例子,面试可以直接引用。
笔记加载中…