享元模式案例

wen java案例 2

从游戏开发到文本编辑器的内存优化秘笈

目录导读

  1. 什么是享元模式?—— 从“共享”说起
  2. 享元模式的核心结构(Flyweight/ConcreteFlyweight/FlyweightFactory)
  3. 经典案例一:围棋游戏中的黑白棋子
  4. 经典案例二:文本编辑器中的字符对象
  5. 经典案例三:网页前端中的DOM节点复用
  6. 享元模式 vs 单例模式 vs 对象池(易混淆点深度对比)
  7. 常见陷阱与最佳实践(如何避免线程安全问题)
  8. 高频面试问答(含代码示例)
  9. 什么时候该用享元模式?

什么是享元模式?—— 从“共享”说起

想象一下,你正在开发一个在线五子棋游戏,棋盘上有300个格子,每次落子都要创建一个棋子对象,如果一盘棋下完,程序中可能同时存在几百个棋子实例——每个都包含颜色、坐标、大小等属性,这会导致内存占用急剧上升,尤其在移动端设备上。

享元模式案例

享元模式(Flyweight Pattern) 正是为了解决这类“大量细粒度对象”带来的内存浪费问题,它的核心思想是:将对象的公共状态(内部状态)提取出来共享,而将变化状态(外部状态)从对象中剥离,这样,相同内在状态的对象只需创建一份,供所有客户端复用。

类比:图书馆里的书(内部状态:书名、作者、内容)可以被无数人借阅;而“谁借了这本书”(外部状态:借阅人、借阅日期)只存在于借阅记录中,不会复制到书本上。


享元模式的核心结构

角色 职责 示例
Flyweight(抽象享元) 声明接口,接受外部状态作为参数 接口 Piece { void move(int x, int y); }
ConcreteFlyweight(具体享元) 实现内部状态存储与外部状态处理 棋子类(只含颜色属性)
FlyweightFactory(享元工厂) 管理池化对象,缓存已创建的享元 棋子工厂(用HashMap存储)

关键代码示意(Java)

// 内部状态:颜色(共享);外部状态:坐标(传入)
class ChessPiece implements Piece {
    private final String color; // 内部状态
    public ChessPiece(String color) { this.color = color; }
    public void move(int x, int y) { System.out.println(color + "移->" + x + "," + y); }
}
class PieceFactory {
    private static final Map<String, Piece> pool = new HashMap<>();
    public static Piece getPiece(String color) {
        return pool.computeIfAbsent(color, ChessPiece::new);
    }
}

经典案例一:围棋游戏中的黑白棋子

场景:假设棋盘上有361个落子点,一局棋可能产生200+个棋子对象,若每个棋子都存储坐标+颜色,内存开销为 200 * (int+String),而享元模式后:

  • 共享部分:仅2个棋子对象(黑、白各一个)
  • 外部状态:坐标作为方法参数传入
  • 内存节省:约99%的实例被消除

代码实践

class GoPiece:
    def __init__(self, color): self.color = color  # 内部状态
def draw(self, x, y): print(f"在({x},{y})落{self.color}")
class GoFactory:
    _pieces = {}
    @classmethod
    def get(cls, color):
        if color not in cls._pieces:
            cls._pieces[color] = GoPiece(color)
        return cls._pieces[color]
# 客户端使用
for x in range(100):
    p1 = GoFactory.get('black').draw(x, x%20)
# 全程只创建2个对象,而非100个!

经典案例二:文本编辑器中的字符对象

场景:Word文档包含10万个字符,每个字符都有字体、字号、颜色、样式,若为每个字符创建一个对象,内存直接爆炸。

享元设计

  • 内部状态:字符本身(如'A'、'B')及其字体属性(如Times New Roman 12pt)
  • 外部状态:字符在文档中的位置(行号、列号)、是否加粗等临时排版信息

实际效果:一篇10万字的文档,只需创建100多个独特字符对象(如26个小写+26个大写+标点符号+常用汉字),其余全部复用。

性能对比

传统方式:100,000个对象 × 50字节 = 5MB
享元模式:200个对象 × 50字节 + 外部位置数组(仅游标) ≈ 10KB

经典案例三:网页前端中的DOM节点复用

场景:一个无限滚动列表,每滚动一行就要创建新的li元素,如果列表有1万行,同时只显示10行,但DOM节点却累积到1万+,浏览器渲染卡顿。

前端享元实现

  • 只创建10个li节点(内部状态:节点模板)
  • 滚动时更新位置(外部状态:数据内容)
  • 类似Vue/React的虚拟列表原理(react-window / vue-virtual-scroller
// 享元工厂
const liPool = [];
function getLi() {
    return liPool.pop() || document.createElement('li');
}
// 滚动事件中
function onScroll(index) {
    const li = getLi();
    li.textContent = data[index];
    container.appendChild(li);
    // 当li滑出视口,移回池中
}

享元模式 vs 单例模式 vs 对象池

维度 享元模式 单例模式 对象池
目的 共享多个不同实例的相同部分 保证全局唯一实例 复用创建昂贵对象(数据库连接)
实例数量 多个(按内部状态分组) 一个 多个(有限数量)
外部状态处理 由客户端传入 对象内部维护
典型场景 字符、图标、棋子 配置管理器 万能连接池

易混淆点:享元模式强调“用同一个对象表示多种状态”,而对象池是“临时借出对象再归还”,两者可以组合使用(如线程池中的享元)。


常见陷阱与最佳实践

⚠️ 陷阱1:误用外部状态导致线程不安全

  • 问题:多个线程同时修改传入的外部状态,导致共享对象数据错乱。
  • 解决:外部状态尽量使用final变量或每次调用时新建不可变对象。

⚠️ 陷阱2:过度设计

  • 判断标准:如果对象数量<10,000且每个对象<100字节,享元带来的复杂度可能得不偿失。
  • 建议:先用Profiler检测内存,再决定是否优化。

✅ 最佳实践清单

  1. 内部状态必须完全不变(final字段)
  2. 工厂内做并发控制ConcurrentHashMap保证线程安全的池)
  3. 外部状态优先用基本类型(int/long)减少包装对象开销
  4. 与工厂模式结合:工厂是享元的唯一入口,禁止直接new

高频面试问答(含代码示例)

❓ Q1:享元模式和原型模式的区别?

:原型模式通过克隆创建新对象(副本),享元模式通过共享不创建新对象,原型复制可能产生大量独立实例,享元则保持少量共享实例。

❓ Q2:如何设计一个可变外部状态的享元?

:外部状态必须放在客户端,每次调用时传入。

class Flyweight {
    void operate(int x, int y) { // x,y为外部状态
        // 使用内部状态 + 外部参数
    }
}
// 客户端
fw.operate(10, 20); fw.operate(30, 40); // 同一fw处理不同位置

❓ Q3:享元模式适合缓存所有对象吗?

:不,只适合相同内部状态大量重复的情况,如果每个对象的内部状态都不同,享元退化为普通对象池,建议改用其他模式。


什么时候该用享元模式?

✅ 应用场景 ❌ 不适合场景
系统存在大量相似对象(>100k) 对象状态高度个性化,几乎无共享部分
对象区分主要靠外部状态 对象生命周期很短且频繁创建/销毁
内存敏感(如嵌入式、移动端) 代码可读性要求远高于性能优化需求
内部状态可被多个场景复用 项目已在稳定运行且瓶颈不在此

最终一句话:享元模式是“用空间换时间”的逆向思维——用共享换内存,用池化换性能,在高性能游戏引擎、文本渲染、虚拟滚动列表中,它是隐藏的英雄。

如果你正在处理的系统内存告急,且对象字段中有大量重复值,不妨先看看能不能用享元模式“瘦身”——往往一个简单的工厂改动,就能带来5~10倍的内存优化。

抱歉,评论功能暂时关闭!