享元模式(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 种类型,共享后内存节省非常明显。
享元模式的结构图
下面这张图展示了享元模式的核心结构,我习惯用这种方式来理解它:
享元模式的优缺点
| 优点 | 缺点 |
|---|---|
| 大幅减少内存占用,尤其适合大量细粒度对象 | 增加了系统复杂度,需要区分内部/外部状态 |
| 提升性能,减少对象创建和 GC 压力 | 外部状态如果管理不当,容易造成线程安全问题 |
| 逻辑清晰,把不变和变化分离 | 需要额外维护享元工厂和缓存 |
避坑指南:我曾经在一个多线程游戏服务器里用享元模式,结果忘了处理外部状态的线程安全问题。多个线程同时修改同一个享元的外部状态,导致渲染错乱。后来加了个 ThreadLocal 才搞定。记住:享元对象本身必须是线程安全的,外部状态要独立管理。
什么时候用享元模式?
- 对象数量巨大:比如文本编辑器里的字符、游戏里的粒子
- 对象种类有限:比如围棋只有黑白两色,子弹只有几种类型
- 对象的大部分状态可以外部化:位置、角度、颜色这些可以传进来
- 内存是瓶颈:服务器内存快撑不住了,享元能救你一命
我个人习惯在项目初期就考虑享元模式。虽然有点「过度设计」的嫌疑,但一旦对象数量上来,再重构就麻烦了。你想想看,一个文本编辑器如果上线后发现内存爆了,那时候再改享元,工作量可不是一般的大。
小技巧:享元工厂可以用 WeakHashMap 来实现,这样当享元对象不再被引用时,可以自动被 GC 回收,避免内存泄漏。
好了,享元模式就聊到这儿。记住一句话:能共享的绝不重复创建。这是节省内存的终极奥义。