14. 享元模式:定义、结构、实现(内部状态、外部状态)、优缺点、应用场景

享元模式,这个名字听起来有点抽象。说白了,它就是「共享对象」

你想想看,我们写代码的时候,是不是经常创建大量相似的对象?每个对象都占内存,对象一多,内存就扛不住了。享元模式就是来解决这个问题的——把那些可以共享的部分抽出来,大家共用

我记得有一次做游戏开发,地图上要显示成千上万棵树。每棵树都有位置、颜色、纹理。如果每棵树都创建一个独立对象,内存直接爆炸。后来我用享元模式重构,只保留了几种树模型,位置信息单独存。嗯,效果立竿见影。

14.1 什么是享元模式

享元模式属于结构型设计模式。它的核心思想是:通过共享技术,有效支持大量细粒度对象的复用

它把对象的属性分成了两类:

  • 内部状态(Intrinsic State):可以共享的、不变的部分。比如树的纹理、颜色。
  • 外部状态(Extrinsic State):不能共享的、随环境变化的部分。比如树的位置坐标。

内部状态存在享元对象内部,外部状态由客户端维护,使用时传入。

核心要点:享元模式的关键在于「分离」——把不变的部分和变化的部分分开。不变的部分共享,变化的部分外部传入。

14.2 结构图

先看一张图,帮你快速建立整体认知。

FlyweightFactory 享元工厂 Flyweight 享元接口 ConcreteFlyweight 具体享元 内部状态(共享) UnsharedConcreteFlyweight 非共享具体享元 (可选,不强制共享) Client 客户端 创建/管理 实现 实现 请求享元 传入外部状态 工厂类 接口 具体享元 非共享 客户端

这张图展示了享元模式的经典结构。工厂负责创建和管理享元对象,客户端通过工厂获取享元,然后传入外部状态来使用。

14.3 内部状态 vs 外部状态

这是享元模式最核心的概念。我见过不少开发者在这上面栽跟头。

对比维度 内部状态 外部状态
存储位置 享元对象内部 客户端维护
是否可变 不可变(通常 final) 可变
是否共享 共享 不共享
典型例子 字体、颜色、纹理 坐标、大小、角度
内存影响 节省内存(共享) 占用内存(每个对象一份)

我的经验:判断一个属性该放内部还是外部,有个简单方法——问自己:「这个属性会不会随着使用场景变化?」如果会,那就是外部状态。比如树的纹理不会变,但树的位置会变。

14.4 代码实现

我们用一个文本编辑器的例子来说明。假设我们要显示大量字符,每个字符都有字体、大小、颜色和位置。

// 1. 享元接口
public interface CharacterFlyweight {
    void display(int x, int y);  // 外部状态:位置
}

// 2. 具体享元 - 内部状态共享
public class ConcreteCharacter implements CharacterFlyweight {
    private final char character;   // 内部状态
    private final String font;      // 内部状态
    private final int size;         // 内部状态
    private final String color;     // 内部状态

    public ConcreteCharacter(char character, String font, int size, String color) {
        this.character = character;
        this.font = font;
        this.size = size;
        this.color = color;
    }

    @Override
    public void display(int x, int y) {
        System.out.println("字符: " + character +
                           ", 字体: " + font +
                           ", 大小: " + size +
                           ", 颜色: " + color +
                           ", 位置: (" + x + ", " + y + ")");
    }
}

// 3. 享元工厂
public class CharacterFactory {
    private final Map<String, CharacterFlyweight> pool = new HashMap<>();

    public CharacterFlyweight getCharacter(char c, String font, int size, String color) {
        String key = c + "-" + font + "-" + size + "-" + color;
        CharacterFlyweight flyweight = pool.get(key);
        if (flyweight == null) {
            flyweight = new ConcreteCharacter(c, font, size, color);
            pool.put(key, flyweight);
            System.out.println("创建新享元: " + key);
        }
        return flyweight;
    }

    public int getPoolSize() {
        return pool.size();
    }
}

// 4. 客户端使用
public class Client {
    public static void main(String[] args) {
        CharacterFactory factory = new CharacterFactory();

        // 显示大量字符
        String text = "Hello World";
        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            // 内部状态相同(字体、大小、颜色)的字符共享同一个对象
            CharacterFlyweight flyweight = factory.getCharacter(c, "Arial", 12, "black");
            flyweight.display(i * 10, 100);  // 外部状态:位置
        }

        System.out.println("享元池大小: " + factory.getPoolSize());
        // 输出:享元池大小: 8(因为 'l' 重复了3次,'o' 重复了2次)
    }
}

关键点:享元工厂内部维护了一个对象池。当请求享元时,先检查池中是否存在,存在则复用,不存在则创建。这就是「共享」的实现机制。

14.5 优缺点

优点

  • 大幅减少内存占用:共享内部状态,避免重复创建。我在项目中遇到过,用享元模式后内存占用降低了70%以上。
  • 提升性能:对象少了,GC压力也小了,创建和销毁的开销都降低。
  • 逻辑清晰:把不变和变化分离,代码结构更干净。

缺点

  • 增加了复杂度:需要区分内部状态和外部状态,设计上要多花心思。
  • 外部状态管理麻烦:客户端需要维护外部状态,如果外部状态很多,反而增加了内存开销。
  • 线程安全问题:如果享元对象被多个线程共享,内部状态必须是不可变的,否则会有并发问题。

避坑指南:我曾经在一个项目中,把「位置」也放到了内部状态里,结果所有树都长在同一个地方。嗯,调试了半天才发现问题。记住:内部状态必须是不变的、可共享的。任何随环境变化的东西,都别放进去。

14.6 应用场景

享元模式不是万能的,它适合特定场景。我个人总结了几类典型应用:

  1. 大量相似对象:比如游戏中的子弹、粒子效果、地图上的树木。
  2. 字符串常量池:Java中的String常量池就是享元模式的经典实现。
  3. 数据库连接池:连接对象复用,避免频繁创建和销毁。
  4. 缓存系统:缓存热点数据,减少重复计算。
  5. 编辑器中的字符渲染:每个字符的字体、颜色可以共享,位置单独存储。

判断是否该用享元模式,有个简单标准:如果系统中存在大量细粒度对象,且它们大部分属性相同,只有少数属性不同,那就该考虑享元模式了。

我的建议:不要为了用模式而用模式。如果对象数量不多(比如几百个),享元模式带来的复杂度可能得不偿失。我一般会在对象数量超过一万时,才认真考虑享元模式。

好了,享元模式就讲到这里。记住三个关键词:共享、内部状态、外部状态。理解透了,你就能在合适的场景下用好它。


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