享元模式(Flyweight):结构、状态与String常量池
享元模式,名字听起来有点抽象。说白了,就是「共享对象」。
你想想看,我们写代码时,是不是经常创建大量相似的对象?每个对象都占内存,如果数量一多,内存就扛不住了。享元模式就是来解决这个问题的——把那些可以共享的部分提取出来,让多个对象共用。
为什么需要享元模式?
我记得几年前做过一个文档编辑器。每个字符都是一个对象,一篇文档几十万字,内存直接爆了。当时我就在想,有没有办法让相同的字符只存一份?
嗯,这就是享元模式的典型场景。它通过共享技术,有效支持大量细粒度的对象复用。
核心思想: 将对象的状态分为内部状态(可共享)和外部状态(不可共享),通过共享内部状态来减少内存开销。
内部状态 vs 外部状态
这是享元模式最关键的概念。我刚开始学的时候也绕了一阵子,这里给你讲清楚。
| 状态类型 | 特点 | 示例 |
|---|---|---|
| 内部状态 | 不随环境改变,可共享 | 字符的字体、大小、颜色 |
| 外部状态 | 随环境改变,不可共享 | 字符在文档中的位置(x, y) |
内部状态存储在享元对象内部,外部状态由客户端维护,在使用时传入。这样,同一个享元对象可以配合不同的外部状态,表现出不同的行为。
我的经验: 设计享元模式时,先问自己「哪些属性是对象固有的?哪些是使用场景决定的?」固有属性就是内部状态,场景属性就是外部状态。
享元模式的结构
结构其实不复杂,就四个角色:
- Flyweight(抽象享元): 声明接口,接受外部状态作为参数
- ConcreteFlyweight(具体享元): 实现内部状态,可共享
- UnsharedConcreteFlyweight(非共享具体享元): 不共享,但通常很少用
- FlyweightFactory(享元工厂): 创建和管理享元对象,确保共享
下面这张图帮你理清关系:
代码示例:文本编辑器中的字符共享
来看一个实际例子。假设我们要渲染一段文字,每个字符都是一个对象。
// 抽象享元
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