你是否曾经遇到过系统因创建大量重复对象而导致内存占用激增的情况?在处理成千上万个文本字符、UI 控件或游戏中的粒子效果时,如果为每个实例分配独立内存,很快就会耗尽系统资源。这时,享元模式就像是 Java 开发中的"内存省钱法",它能让你在不牺牲功能的前提下大幅降低内存消耗。
什么是享元模式?
享元模式(Flyweight Pattern)是一种结构型设计模式,核心思想是共享细粒度对象,减少内存使用,提高性能。它通过共享技术有效支持大量细粒度对象的复用。
这里的"细粒度对象"指的是单个字符、像素、图标等轻量且大量重复出现的小型对象,这些对象虽然体积小,但数量庞大,如不共享会占用大量内存。
简单来说,享元模式将对象分为两部分:
- 内部状态(Intrinsic State):可以共享的、不会随环境变化的状态
- 外部状态(Extrinsic State):不可共享的、会随环境变化的状态
享元模式的应用场景
享元模式在以下场景特别适用:
- 大量相似对象:系统中存在大量相似对象,造成内存开销大
- 对象状态可分离:对象的状态能分为内部和外部状态
- 外部状态可通过上下文获取:外部状态可通过运行时环境或上下文动态生成,无需由享元对象自身维护
- 对象的内存地址不影响业务逻辑:对象的内存地址(唯一性)不影响业务逻辑,客户端只需关注其状态(内部+外部)是否符合需求
常见的应用例子:
- 文本编辑器中的字符渲染
- 图形应用中的图元(点、线、矩形等)
- 游戏中的粒子系统
- 缓存系统(如字符串常量池)
享元模式实现案例
让我们通过一个实际例子来理解享元模式。假设我们在开发一个在线文档系统,需要渲染大量文本,每个字符可能有不同的样式(颜色、位置等)。
步骤 1:定义享元接口
/**
* 字符享元接口
*/
public interface Character {
/**
* 显示字符
* @param fontSize 字体大小(外部状态)
* @param x X坐标位置(外部状态)
* @param y Y坐标位置(外部状态)
*/
void display(int fontSize, int x, int y);
}
步骤 2:实现具体享元类
/**
* 具体字符享元实现
*/
public class CharacterImpl implements Character {
// 内部状态 - 应当设为不可变(使用final修饰)
private final char symbol;
public CharacterImpl(char symbol) {
this.symbol = symbol;
System.out.println("创建字符: " + symbol);
}
@Override
public void display(int fontSize, int x, int y) {
// 方法参数作为外部状态传入,不存储在对象中
System.out.println("字符: " + symbol + " | 字号: " + fontSize +
" | 位置: (" + x + "," + y + ")");
}
}
错误示范:错误的享元实现
/**
* 错误的字符享元实现 - 不应在享元对象中存储外部状态
*/
public class BadCharacterImpl implements Character {
private final char symbol; // 内部状态 - 正确
private int fontSize; // 外部状态 - 错误:不应存储在享元对象中
private int x; // 外部状态 - 错误:不应存储在享元对象中
private int y; // 外部状态 - 错误:不应存储在享元对象中
public BadCharacterImpl(char symbol) {
this.symbol = symbol;
}
// 错误:设置外部状态会导致共享对象的状态污染
public void setPosition(int x, int y) {
this.x = x;
this.y = y;
}
public void setFontSize(int fontSize) {
this.fontSize = fontSize;
}
@Override
public void display(int fontSize, int x, int y) {
// 使用内部存储的外部状态,会导致状态冲突
System.out.println("字符: " + symbol + " | 字号: " + this.fontSize +
" | 位置: (" + this.x + "," + this.y + ")");
}
}
步骤 3:创建享元工厂(线程安全版本)
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
/**
* 字符享元工厂 - 管理享元对象池
*/
public class CharacterFactory {
// 使用ConcurrentHashMap保证线程安全
private static final Map<Character, Character> characterPool = new ConcurrentHashMap<>();
// 工厂方法无需synchronized,ConcurrentHashMap已保证线程安全
public static Character getCharacter(char symbol) {
// 检查缓存池中是否已有该字符
return characterPool.computeIfAbsent(symbol, s -> {
// 没有则创建新的享元对象
return new CharacterImpl(s);
});
}
public static int getPoolSize() {
return characterPool.size();
}
}
步骤 4:客户端使用享元模式
/**
* 文档编辑器客户端 - 使用享元模式渲染文本
*/
public class DocumentEditor {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
// 模拟文档中渲染文本
String text = "Hello, Java设计模式之享元模式!";
for (int i = 0; i < 3; i++) {
for (int j = 0; j < text.length(); j++) {
// 获取享元对象(内部状态)
Character character = CharacterFactory.getCharacter(text.charAt(j));
// 外部状态:由客户端维护并传入
int fontSize = 12 + i;
int xPosition = j * 10;
int yPosition = i * 20;
// 调用享元对象的操作,传入外部状态
character.display(fontSize, xPosition, yPosition);
}
}
long endTime = System.currentTimeMillis();
// 计算内存和时间节省
int uniqueChars = CharacterFactory.getPoolSize();
int totalRenders = text.length() * 3;
int bytesSaved = (totalRenders - uniqueChars) * 100; // 假设每个对象100字节
System.out.println("总共创建字符数: " + uniqueChars);
System.out.println("总共渲染字符数: " + totalRenders);
System.out.println("估计节省内存: " + bytesSaved + " 字节");
System.out.println("执行时间: " + (endTime - startTime) + "ms");
}
}
运行结果展示了享元模式的效果:相同的字符只会被创建一次,但可以在不同位置以不同样式渲染多次。
享元模式解析
享元模式的组成部分
- 享元接口:定义享元对象的操作方法
- 具体享元类:实现享元接口,包含不可变的内部状态
- 享元工厂:管理享元对象池,负责创建和提供享元对象
- 客户端:维护外部状态,并调用享元对象
内部状态与外部状态的区分
正确区分内部状态和外部状态是实现享元模式的关键:
- 内部状态:必须是不可变的(immutable),如字符的 Unicode 值、图形的形状等
- 外部状态:可变且由客户端维护,如字符的位置、字体大小等
享元模式的实战应用
Java 标准库中的享元模式
Java 中最典型的享元模式应用就是 String 常量池:
// 字面量形式,直接使用常量池中的对象
String str1 = "Hello";
String str2 = "Hello";
System.out.println(str1 == str2); // 输出true,因为是同一个对象
// 非字面量形式,不使用常量池
String str3 = new String("Hello");
System.out.println(str1 == str3); // 输出false,不同对象
// 使用intern()方法显式入池
String str4 = new String("Hello").intern();
System.out.println(str1 == str4); // 输出true,强制使用常量池
此外,Integer 缓存也是享元模式的应用:
// IntegerCache默认缓存范围是[-128, 127]
Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
System.out.println(a == b); // 输出true,因为在缓存范围内
Integer c = Integer.valueOf(200);
Integer d = Integer.valueOf(200);
System.out.println(c == d); // 输出false,超出缓存范围
Integer.valueOf()源码实现了享元模式:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
对象池与享元模式的区别
很多人容易将对象池(如连接池)与享元模式混淆,它们有重要区别:
- 享元模式:强调共享不可变的内部状态,减少对象数量
- 对象池模式:强调重用可变对象实例,避免频繁创建和销毁
虽然享元模式与缓存策略都涉及复用,但二者有明显区别:享元模式专注于对象状态共享和分离,目的是减少对象数量;而通用缓存(如 Guava Cache)更关注结果复用,目的是减少计算成本。享元对象通常是不可变的,而缓存对象可以是任何类型。
以字体工厂为例,体现享元思想的标准实现:
public class FontFactory {
private static final Map<String, Font> fontCache = new ConcurrentHashMap<>();
public static Font getFont(String name, int style) {
// 内部状态:字体名称和样式(不可变,可共享)
String key = name + "_" + style;
// computeIfAbsent保证线程安全的获取或创建
return fontCache.computeIfAbsent(key, k -> {
// 创建字体是昂贵操作,仅包含内部状态
return new Font(name, style, 12); // 12是默认字号
});
}
// 使用示例,外部状态(大小)通过客户端传入
public static void renderText(String text, String fontName, int style, int size, int x, int y) {
// 获取共享的字体对象(仅包含内部状态)
Font font = getFont(fontName, style);
// 通过派生新字体对象设置外部状态(大小),而不修改原共享对象
Font sizedFont = font.deriveFont((float) size);
// 使用含有内部状态+外部状态的完整对象进行渲染
// 渲染逻辑...
}
}
享元模式与其他模式的组合
享元模式常与其他设计模式结合使用,形成更强大的解决方案:
- 享元+工厂模式:最常见的组合,工厂管理享元对象池
// 单例工厂管理享元对象池
public class SingletonFlyweightFactory {
private static final SingletonFlyweightFactory INSTANCE = new SingletonFlyweightFactory();
private final Map<String, Flyweight> pool = new ConcurrentHashMap<>();
private SingletonFlyweightFactory() {}
public static SingletonFlyweightFactory getInstance() {
return INSTANCE;
}
public Flyweight getFlyweight(String key) {
return pool.computeIfAbsent(key, k -> new ConcreteFlyweight(k));
}
}
- 享元+装饰器模式:装饰器包装享元对象,添加额外功能
// 装饰器为享元对象添加行为,而不改变内部状态
public class FlyweightDecorator implements Flyweight {
private final Flyweight flyweight;
public FlyweightDecorator(Flyweight flyweight) {
this.flyweight = flyweight;
}
@Override
public void operation(String extrinsicState) {
// 增强行为
flyweight.operation(extrinsicState);
// 额外行为
}
}
分布式环境中的享元模式
在分布式系统中,享元模式需要特别考虑:
- 对象序列化:享元对象通常需要在网络间传输
public class SerializableFlyweight implements Serializable {
private static final long serialVersionUID = 1L;
// 确保内部状态不可变
private final String intrinsicState;
public SerializableFlyweight(String intrinsicState) {
this.intrinsicState = intrinsicState;
}
// 不可变对象天然线程安全
public String getIntrinsicState() {
return intrinsicState;
}
}
- 分布式缓存:可使用 Redis 等分布式缓存共享享元对象
public class DistributedFlyweightFactory {
private final RedisTemplate<String, Flyweight> redisTemplate;
public Flyweight getFlyweight(String key) {
// 先从Redis缓存获取
Flyweight flyweight = redisTemplate.opsForValue().get(key);
if (flyweight == null) {
// 创建并存入Redis
flyweight = new ConcreteFlyweight(key);
redisTemplate.opsForValue().set(key, flyweight);
}
return flyweight;
}
}
享元模式的注意事项
- 线程安全问题:享元对象通常被多个线程共享,需要确保:
- 工厂使用线程安全的集合(如 ConcurrentHashMap)
- 内部状态设计为不可变(final)
- 避免在享元对象中存储任何可变状态
- 内存与时间权衡:
- 享元模式适用于对象数量庞大(通常至少数百个)的场景
- 对象数量少时,工厂管理的开销可能超过内存节省收益
- 当对象池持续增长时,可结合 LRU(最近最少使用)等淘汰算法,定期清理不再活跃的享元对象,防止内存泄漏
- 状态区分边界:
- 必须严格划分内部状态和外部状态
- 内部状态应尽可能精简,仅包含共享必需的信息
- 不变性是内部状态的强制要求
- 设计复杂度增加:
- 享元模式增加了系统复杂度
- 需衡量内存优化收益与代码复杂度的平衡
总结
方面 | 内容 |
---|---|
核心思想 | 共享不可变的细粒度对象,减少内存占用 |
适用场景 | 大量相似对象、可分离状态、对象内存地址不影响业务逻辑 |
主要组件 | 享元接口、具体享元类、享元工厂、客户端 |
优点 | 显著减少内存使用、提高系统性能 |
缺点 | 系统复杂度增加、需注意线程安全、状态分离设计要求高 |
典型应用 | String 常量池、Integer 缓存、字体/颜色资源共享 |
实现关键 | 内部状态不可变、外部状态由客户端管理、线程安全的工厂实现 |
常见组合 | 工厂模式、单例模式、装饰器模式 |
常见问题 | 内外状态混淆、使用非线程安全集合、忽略内部状态不可变性 |
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。