享元模式(Flyweight):围棋游戏中的棋子

说实话,享元模式是我在实际项目中用得最「爽」的一个模式。为什么?因为它能实实在在地帮你省内存。我记得有一次接手一个老项目,服务器内存动不动就飙到 90% 以上,查了半天,发现罪魁祸首就是大量重复的对象被反复创建。嗯,这时候享元模式就派上用场了。

什么是享元模式?

说白了,享元模式就是「共享」两个字。它的核心思想是:把对象的内部状态和外部状态分开。内部状态是那些可以共享的、不变的东西;外部状态是那些会变化的、需要从外部传入的东西。

你想想看,围棋盘上有 361 个交叉点,每个点都可以落子。如果每个棋子都创建一个独立对象,那 361 个棋子就是 361 个对象。但如果黑白两色棋子本质上只有两种——黑色和白色,那为什么不让所有黑子共享同一个对象呢?

核心定义:运用共享技术有效地支持大量细粒度的对象。

业务场景:文本编辑器中的字符

我在做文本编辑器的时候遇到过这个问题。一个文档可能有几十万个字符,如果每个字符都创建一个对象,内存直接爆炸。但仔细想想,字符「A」和字符「A」有什么区别?字体、字号、颜色这些是外部属性,但字符本身的字形数据是共享的。

来看个例子:

// 享元接口
public interface Glyph {
    void draw(Context context);
}

// 具体享元:字符
public class Character implements Glyph {
    private final char symbol;  // 内部状态,共享

    public Character(char symbol) {
        this.symbol = symbol;
    }

    @Override
    public void draw(Context context) {
        // context 包含字体、颜色、位置等外部状态
        System.out.println("绘制字符 " + symbol +
            " 字体: " + context.getFont() +
            " 颜色: " + context.getColor());
    }
}

// 享元工厂
public class GlyphFactory {
    private Map<Character, Glyph> pool = new HashMap<>();

    public Glyph getGlyph(char symbol) {
        Glyph glyph = pool.get(symbol);
        if (glyph == null) {
            glyph = new Character(symbol);
            pool.put(symbol, glyph);
        }
        return glyph;
    }
}

// 外部状态
public class Context {
    private String font;
    private String color;
    private int x, y;
    // getter/setter 省略
}

你看,所有字符 'A' 都共享同一个对象,只是外部传入的字体、颜色不同。这样内存占用从 O(n) 降到了 O(1)。

业务场景:游戏中的子弹和粒子效果

游戏开发是享元模式的另一个重灾区。我曾经参与过一个射击游戏,屏幕上同时有上千颗子弹在飞。如果每颗子弹都 new 一个对象,帧率直接掉到个位数。

解决方案是什么?把子弹的「类型」和「状态」分开:

// 享元:子弹类型
public class BulletType {
    private final String texture;  // 纹理,共享
    private final int damage;      // 伤害值,共享
    private final float speed;     // 速度,共享

    public BulletType(String texture, int damage, float speed) {
        this.texture = texture;
        this.damage = damage;
        this.speed = speed;
    }

    public void render(float x, float y, float angle) {
        // 使用外部状态 x, y, angle 渲染
    }
}

// 子弹实例(轻量级)
public class Bullet {
    private BulletType type;  // 引用享元
    private float x, y;       // 外部状态
    private float angle;      // 外部状态

    public void update() {
        // 更新位置
    }
}

// 享元工厂
public class BulletFactory {
    private static Map<String, BulletType> cache = new HashMap<>();

    public static BulletType getBulletType(String texture, int damage, float speed) {
        String key = texture + "_" + damage + "_" + speed;
        BulletType type = cache.get(key);
        if (type == null) {
            type = new BulletType(texture, damage, speed);
            cache.put(key, type);
        }
        return type;
    }
}
我的经验:享元模式特别适合用在「对象数量巨大但种类有限」的场景。比如粒子系统,几千个粒子可能只有 5-6 种类型,共享后内存节省非常明显。

享元模式的结构图

下面这张图展示了享元模式的核心结构,我习惯用这种方式来理解它:

客户端 享元工厂 getFlyweight(key) 享元接口 operation(extState) 具体享元 intrinsicState 非共享享元 allState 请求 返回 传入外部状态 内部状态(共享) 外部状态(不共享)

享元模式的优缺点

优点 缺点
大幅减少内存占用,尤其适合大量细粒度对象 增加了系统复杂度,需要区分内部/外部状态
提升性能,减少对象创建和 GC 压力 外部状态如果管理不当,容易造成线程安全问题
逻辑清晰,把不变和变化分离 需要额外维护享元工厂和缓存
避坑指南:我曾经在一个多线程游戏服务器里用享元模式,结果忘了处理外部状态的线程安全问题。多个线程同时修改同一个享元的外部状态,导致渲染错乱。后来加了个 ThreadLocal 才搞定。记住:享元对象本身必须是线程安全的,外部状态要独立管理

什么时候用享元模式?

  • 对象数量巨大:比如文本编辑器里的字符、游戏里的粒子
  • 对象种类有限:比如围棋只有黑白两色,子弹只有几种类型
  • 对象的大部分状态可以外部化:位置、角度、颜色这些可以传进来
  • 内存是瓶颈:服务器内存快撑不住了,享元能救你一命

我个人习惯在项目初期就考虑享元模式。虽然有点「过度设计」的嫌疑,但一旦对象数量上来,再重构就麻烦了。你想想看,一个文本编辑器如果上线后发现内存爆了,那时候再改享元,工作量可不是一般的大。

小技巧:享元工厂可以用 WeakHashMap 来实现,这样当享元对象不再被引用时,可以自动被 GC 回收,避免内存泄漏。

好了,享元模式就聊到这儿。记住一句话:能共享的绝不重复创建。这是节省内存的终极奥义。


公众号:蓝海资料掘金营,微信deep3321