享元模式(Flyweight):结构、状态与String常量池

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

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

为什么需要享元模式?

我记得几年前做过一个文档编辑器。每个字符都是一个对象,一篇文档几十万字,内存直接爆了。当时我就在想,有没有办法让相同的字符只存一份?

嗯,这就是享元模式的典型场景。它通过共享技术,有效支持大量细粒度的对象复用。

核心思想: 将对象的状态分为内部状态(可共享)和外部状态(不可共享),通过共享内部状态来减少内存开销。

内部状态 vs 外部状态

这是享元模式最关键的概念。我刚开始学的时候也绕了一阵子,这里给你讲清楚。

状态类型 特点 示例
内部状态 不随环境改变,可共享 字符的字体、大小、颜色
外部状态 随环境改变,不可共享 字符在文档中的位置(x, y)

内部状态存储在享元对象内部,外部状态由客户端维护,在使用时传入。这样,同一个享元对象可以配合不同的外部状态,表现出不同的行为。

我的经验: 设计享元模式时,先问自己「哪些属性是对象固有的?哪些是使用场景决定的?」固有属性就是内部状态,场景属性就是外部状态。

享元模式的结构

结构其实不复杂,就四个角色:

  • Flyweight(抽象享元): 声明接口,接受外部状态作为参数
  • ConcreteFlyweight(具体享元): 实现内部状态,可共享
  • UnsharedConcreteFlyweight(非共享具体享元): 不共享,但通常很少用
  • FlyweightFactory(享元工厂): 创建和管理享元对象,确保共享

下面这张图帮你理清关系:

享元模式结构图 Client(客户端) FlyweightFactory Flyweight(接口) ConcreteFlyweight 外部状态(Extrinsic) 工厂维护享元池,客户端通过工厂获取享元对象

代码示例:文本编辑器中的字符共享

来看一个实际例子。假设我们要渲染一段文字,每个字符都是一个对象。

// 抽象享元
interface CharacterFlyweight {
    void display(int x, int y);  // 外部状态:位置
}

// 具体享元 - 内部状态:字符本身、字体、大小
class ConcreteCharacter implements CharacterFlyweight {
    private final char character;      // 内部状态
    private final String font;         // 内部状态
    private final int size;            // 内部状态
    
    public ConcreteCharacter(char c, String font, int size) {
        this.character = c;
        this.font = font;
        this.size = size;
    }
    
    @Override
    public void display(int x, int y) {
        System.out.println("字符 '" + character + "' 字体=" + font 
                         + " 大小=" + size + " 位置=(" + x + "," + y + ")");
    }
}

// 享元工厂
class CharacterFactory {
    private final Map<String, CharacterFlyweight> pool = new HashMap<>();
    
    public CharacterFlyweight getCharacter(char c, String font, int size) {
        String key = c + ":" + font + ":" + size;
        CharacterFlyweight flyweight = pool.get(key);
        if (flyweight == null) {
            flyweight = new ConcreteCharacter(c, font, size);
            pool.put(key, flyweight);
            System.out.println("创建新享元: " + key);
        }
        return flyweight;
    }
    
    public int getPoolSize() {
        return pool.size();
    }
}

// 客户端使用
public class Editor {
    public static void main(String[] args) {
        CharacterFactory factory = new CharacterFactory();
        
        // 渲染 "hello" 每个字符位置不同
        factory.getCharacter('h', "Arial", 12).display(0, 0);
        factory.getCharacter('e', "Arial", 12).display(10, 0);
        factory.getCharacter('l', "Arial", 12).display(20, 0);
        factory.getCharacter('l', "Arial", 12).display(30, 0);  // 复用
        factory.getCharacter('o', "Arial", 12).display(40, 0);
        
        System.out.println("享元池大小: " + factory.getPoolSize());  // 输出 4('l'被复用)
    }
}
输出结果: 虽然渲染了5个字符,但享元池中只创建了4个对象。因为两个'l'字符的内部状态完全相同,被共享了。

String常量池:最经典的享元模式应用

说到享元模式,Java开发者最熟悉的例子就是String常量池。我个人觉得这是理解享元模式最好的切入点。

String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");

System.out.println(s1 == s2);      // true - 指向常量池中同一个对象
System.out.println(s1 == s3);      // false - s3在堆中新建对象
System.out.println(s1.equals(s3)); // true - 内容相同

为什么会这样?

JVM在加载类时,会把字符串字面量放入常量池。当再次遇到相同的字面量时,直接返回池中的引用。这就是享元模式——内部状态是字符串内容,外部状态?嗯,String本身没有外部状态,但你可以把字符串的上下文(比如它在哪个类中被使用)看作外部信息。

避坑指南: 我曾经在项目中用大量new String()创建字符串,结果内存飙升。后来改成直接使用字面量或调用intern()方法,内存占用直接降了40%。记住:能用字面量就别new,能复用就别创建。

享元模式的优缺点

任何模式都有两面性,享元模式也不例外。

  • 优点: 大幅减少内存占用,尤其适合大量相似对象的场景
  • 优点: 外部状态独立,享元对象可以在不同上下文中复用
  • 缺点: 需要分离内部状态和外部状态,增加了系统复杂度
  • 缺点: 读取外部状态需要额外的时间开销
我的建议: 享元模式不是银弹。如果你的对象数量不多(几百个以内),或者对象之间的共享部分很少,那就不值得用。我一般会在对象数量超过万级别时考虑使用享元模式。

实际应用场景

除了String常量池,享元模式在很多地方都有应用:

  • Integer缓存: Java中-128到127的Integer对象被缓存复用
  • 数据库连接池: 连接对象被复用,避免频繁创建销毁
  • 游戏开发: 粒子系统、地图中的重复元素(树木、石头)
  • 文本处理: 字符、字体、格式的共享

嗯,享元模式就讲到这里。它不像单例或工厂那样常用,但在特定场景下,它能帮你省下大量内存。下次遇到大量相似对象时,不妨想想——能不能用享元模式来优化?


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