Appearance
享元模式 (Flyweight Pattern)
一、定义
一句话概括:运用共享技术有效地支持大量细粒度对象的复用,通过共享减少内存占用。
官方定义(GoF):Use sharing to support large numbers of fine-grained objects efficiently.
享元模式的核心思想是:将对象的内部状态(Intrinsic State,可共享的、不随环境变化的部分)和外部状态(Extrinsic State,不可共享的、随环境变化的部分)分离。内部状态存储在享元对象内部,可以共享;外部状态由客户端在调用时传入。
二、解决的问题
2.1 什么场景下需要享元模式?
- 大量相似对象:系统中存在大量细粒度对象,且这些对象的大部分状态可以共享。
- 内存瓶颈:对象数量过多导致内存溢出或性能下降。
- 对象创建开销大:对象的创建和销毁代价高昂,需要复用。
- 缓存复用:需要缓存和复用已经创建的对象。
2.2 不用享元模式会有什么问题?
假设一个文本编辑器,每个字符都是一个对象(包含字体、大小、颜色、位置等属性)。一篇文章有 10 万个字符:
问题1:内存爆炸 —— 100,000 个字符对象,每个 100 字节,总内存 ≈ 10MB
问题2:GC 压力 —— 创建和销毁大量对象,频繁触发 GC
问题3:创建开销 —— 每个字符对象都需要初始化,耗时
问题4:缓存失效 —— 无法复用相同字体、大小的字符对象如果用享元模式,26 个字母 + 标点 ≈ 100 个享元对象,每个对象被共享,内存从 10MB 降到 10KB。
三、结构
3.1 文字描述
享元模式包含以下角色:
- Flyweight(抽象享元):定义享元接口,接受外部状态。
- ConcreteFlyweight(具体享元):实现享元接口,存储内部状态(可共享)。
- UnsharedConcreteFlyweight(非共享具体享元):不需要共享的享元实现。
- FlyweightFactory(享元工厂):创建和管理享元对象,维护享元池。
- Client(客户端):维护外部状态,通过工厂获取享元对象。
3.2 ASCII 类图
┌──────────────────────┐ ┌────────────────────────┐
│ FlyweightFactory │ │ <<interface>> │
├──────────────────────┤ │ Flyweight │
│ - pool: Map<K, FW> │ ├────────────────────────┤
│ + getFlyweight(key) │────>│ + operation(extState) │
│ 检查池中是否存在 │ └───────────┬────────────┘
│ 存在则返回,否则创建 │ │
└──────────────────────┘ ┌────────────┴────────────┐
│ │
┌────────┴──────────┐ ┌──────────┴──────────┐
│ ConcreteFlyweight │ │UnsharedFlyweight │
├───────────────────┤ ├─────────────────────┤
│ - intrinsicState │ │ - allState │
│ + operation(ext) │ │ + operation(ext) │
└───────────────────┘ └─────────────────────┘
内部状态(intrinsicState):存储在享元内部,可共享,不随环境变化
外部状态(extrinsicState):由客户端传入,随环境变化,不可共享四、代码实现
4.1 基础实现
场景:棋盘游戏中的棋子
java
// ============ Flyweight:抽象享元 ============
interface ChessPiece {
void display(int x, int y); // x, y 是外部状态
}
// ============ ConcreteFlyweight:具体享元 ============
class ConcreteChessPiece implements ChessPiece {
private String color; // 内部状态:颜色(可共享)
private String shape; // 内部状态:形状(可共享)
public ConcreteChessPiece(String color, String shape) {
this.color = color;
this.shape = shape;
System.out.println("创建棋子: " + color + " " + shape); // 只打印一次
}
@Override
public void display(int x, int y) {
// x, y 是外部状态:位置(不可共享,每次变化)
System.out.println(color + " " + shape + " 显示在 (" + x + ", " + y + ")");
}
}
// ============ FlyweightFactory:享元工厂 ============
class ChessPieceFactory {
private static final Map<String, ChessPiece> pool = new HashMap<>();
public static ChessPiece getChessPiece(String color, String shape) {
String key = color + "_" + shape;
if (!pool.containsKey(key)) {
pool.put(key, new ConcreteChessPiece(color, shape));
}
return pool.get(key);
}
public static int getPoolSize() {
return pool.size();
}
}
// ============ 客户端测试 ============
public class FlyweightDemo {
public static void main(String[] args) {
// 下棋:10 步操作,但只需要 2 个棋子对象
ChessPiece blackKnight = ChessPieceFactory.getChessPiece("黑", "马");
ChessPiece whiteKnight = ChessPieceFactory.getChessPiece("白", "马");
// 复用同一个棋子对象,只是位置不同
blackKnight.display(1, 2);
blackKnight.display(3, 4);
blackKnight.display(5, 6);
whiteKnight.display(2, 3);
whiteKnight.display(4, 5);
System.out.println("享元池大小: " + ChessPieceFactory.getPoolSize());
// 输出:创建棋子: 黑 马
// 创建棋子: 白 马
// 黑 马 显示在 (1, 2)
// 黑 马 显示在 (3, 4)
// 黑 马 显示在 (5, 6)
// 白 马 显示在 (2, 3)
// 白 马 显示在 (4, 5)
// 享元池大小: 2
}
}4.2 进阶实现
4.2.1 内部状态 vs 外部状态
java
// ============ 文本编辑器字符享元 ============
// 字符享元(内部状态:字符值、字体、大小、颜色)
class CharacterFlyweight {
private final char value; // 内部状态
private final String font; // 内部状态
private final int fontSize; // 内部状态
private final String color; // 内部状态
public CharacterFlyweight(char value, String font, int fontSize, String color) {
this.value = value;
this.font = font;
this.fontSize = fontSize;
this.color = color;
}
// 外部状态:行号、列号(由客户端传入)
public void render(int row, int column) {
System.out.printf("渲染字符 '%c' [%s, %dpt, %s] 在 (%d, %d)%n",
value, font, fontSize, color, row, column);
}
}
// 享元工厂
class CharacterFactory {
private static final Map<String, CharacterFlyweight> cache = new HashMap<>();
public static CharacterFlyweight getCharacter(char c, String font, int size, String color) {
String key = c + "_" + font + "_" + size + "_" + color;
return cache.computeIfAbsent(key,
k -> new CharacterFlyweight(c, font, size, color));
}
public static int getCacheSize() {
return cache.size();
}
}
// 客户端:渲染一篇文章
class TextEditor {
// 每个字符位置保存享元引用 + 外部状态
static class CharPosition {
CharacterFlyweight flyweight;
int row, column;
}
private List<CharPosition> document = new ArrayList<>();
public void addCharacter(char c, String font, int size, String color, int row, int col) {
CharPosition pos = new CharPosition();
pos.flyweight = CharacterFactory.getCharacter(c, font, size, color);
pos.row = row;
pos.column = col;
document.add(pos);
}
public void render() {
for (CharPosition pos : document) {
pos.flyweight.render(pos.row, pos.column);
}
}
}
// 测试:10000 个字符,但只需要 26 个享元对象
// TextEditor editor = new TextEditor();
// for (int i = 0; i < 10000; i++) {
// editor.addCharacter((char)('A' + i % 26), "宋体", 12, "黑色", i / 80, i % 80);
// }
// System.out.println("享元对象数: " + CharacterFactory.getCacheSize()); // 264.2.2 连接池(享元模式变体)
java
// ============ 数据库连接池 ============
class ConnectionPool {
private final List<Connection> pool;
private final int maxSize;
private int currentSize = 0;
public ConnectionPool(int maxSize) {
this.maxSize = maxSize;
this.pool = new ArrayList<>(maxSize);
}
// 获取连接
public synchronized Connection getConnection() {
if (!pool.isEmpty()) {
System.out.println("从池中复用连接");
return pool.remove(pool.size() - 1);
}
if (currentSize < maxSize) {
System.out.println("创建新连接");
currentSize++;
return new Connection("Conn-" + currentSize);
}
throw new RuntimeException("连接池已满,等待释放");
}
// 归还连接
public synchronized void releaseConnection(Connection conn) {
if (conn != null) {
pool.add(conn);
System.out.println("归还连接: " + conn.getName());
}
}
}
class Connection {
private String name;
public Connection(String name) { this.name = name; }
public String getName() { return name; }
public void execute(String sql) { System.out.println(name + " 执行: " + sql); }
}4.2.3 Integer 缓存(JDK 享元模式)
java
// JDK 源码中 Integer.valueOf() 使用了享元模式
// IntegerCache 缓存了 -128 到 127 的 Integer 对象
public class IntegerCacheDemo {
public static void main(String[] args) {
Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
System.out.println(a == b); // true —— 从缓存中获取同一个对象
Integer c = Integer.valueOf(128);
Integer d = Integer.valueOf(128);
System.out.println(c == d); // false —— 超出缓存范围,创建新对象
// 这就是享元模式:-128~127 之间的 Integer 对象被共享
// 注意:Integer 的内部状态 value 是 final 的,不可变
}
}4.3 生产级实现
Spring Boot 缓存 + 享元模式
java
// ============ 商品 SKU 享元 ============
// 场景:电商系统中,同一个 SKU 的商品信息被大量订单引用
@Component
class SkuFlyweightFactory {
// 一级缓存:本地内存
private final Map<Long, SkuFlyweight> localCache = new ConcurrentHashMap<>();
@Autowired
private SkuRepository skuRepository;
// 获取 SKU 享元(只读、共享)
public SkuFlyweight getSku(Long skuId) {
return localCache.computeIfAbsent(skuId, id -> {
SkuEntity entity = skuRepository.findById(id)
.orElseThrow(() -> new BusinessException("SKU 不存在: " + id));
return new SkuFlyweight(entity);
});
}
// 刷新缓存(商品信息变更时调用)
public void refresh(Long skuId) {
localCache.remove(skuId);
}
public int getCacheSize() {
return localCache.size();
}
}
// 享元对象:只包含内部状态(可共享的商品属性)
class SkuFlyweight {
private final Long id; // 内部状态
private final String name; // 内部状态
private final BigDecimal price; // 内部状态
private final String imageUrl; // 内部状态
private final String category; // 内部状态
public SkuFlyweight(SkuEntity entity) {
this.id = entity.getId();
this.name = entity.getName();
this.price = entity.getPrice();
this.imageUrl = entity.getImageUrl();
this.category = entity.getCategory();
}
// 外部状态(数量)由客户端传入
public OrderItemInfo createOrderItem(int quantity) {
BigDecimal totalPrice = price.multiply(BigDecimal.valueOf(quantity));
return new OrderItemInfo(id, name, price, quantity, totalPrice, imageUrl);
}
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
}
// 订单项信息(包含外部状态)
class OrderItemInfo {
private Long skuId;
private String skuName;
private BigDecimal unitPrice;
private int quantity;
private BigDecimal totalPrice;
private String imageUrl;
public OrderItemInfo(Long skuId, String skuName, BigDecimal unitPrice,
int quantity, BigDecimal totalPrice, String imageUrl) {
this.skuId = skuId;
this.skuName = skuName;
this.unitPrice = unitPrice;
this.quantity = quantity;
this.totalPrice = totalPrice;
this.imageUrl = imageUrl;
}
}
// 在订单服务中使用
@Service
class OrderService {
@Autowired
private SkuFlyweightFactory skuFactory;
public OrderResult createOrder(OrderRequest request) {
List<OrderItemInfo> items = new ArrayList<>();
for (OrderItem item : request.getItems()) {
// 获取享元(共享的商品信息)
SkuFlyweight sku = skuFactory.getSku(item.getSkuId());
// 传入外部状态(数量)
items.add(sku.createOrderItem(item.getQuantity()));
}
// ... 创建订单逻辑
return new OrderResult(items);
}
}五、优缺点
优点
| 优点 | 说明 |
|---|---|
| 大幅节省内存 | 共享相同状态的对象,减少内存占用 |
| 减少 GC 压力 | 对象数量减少,GC 频率降低 |
| 提高性能 | 复用已有对象,避免重复创建的开销 |
缺点
| 缺点 | 说明 |
|---|---|
| 复杂度增加 | 需要分离内部状态和外部状态,设计复杂 |
| 线程安全 | 享元对象被多线程共享,需要保证线程安全(通常设计为不可变对象) |
| 外部状态管理 | 外部状态由客户端维护,增加了客户端的负担 |
| 不适合所有场景 | 如果对象状态差异很大,无法共享,享元模式没有收益 |
六、适用场景
- 大量相似对象:文本编辑器中的字符、游戏中的粒子、地图中的瓦片。
- 缓存系统:字符串常量池、Integer 缓存、数据库连接池、线程池。
- 数据字典:系统中的枚举值、状态码、配置项等。
- 图形处理:图形编辑器中的形状、颜色、笔刷等。
- 电商系统:商品 SKU、优惠券模板等被大量引用的只读对象。
七、JDK / Spring 框架中的实际应用
7.1 JDK 中的享元模式
java
// 1. String 常量池
String s1 = "hello"; // 从常量池获取
String s2 = "hello"; // 复用常量池中的对象
System.out.println(s1 == s2); // true
// 2. Integer 缓存 (-128 ~ 127)
Integer i1 = Integer.valueOf(100);
Integer i2 = Integer.valueOf(100);
System.out.println(i1 == i2); // true
// 3. Boolean 常量
Boolean b1 = Boolean.valueOf(true);
Boolean b2 = Boolean.valueOf(true);
System.out.println(b1 == b2); // true —— Boolean.TRUE 和 Boolean.FALSE 是静态常量
// 4. Byte, Short, Long 缓存 (-128 ~ 127)
// 5. Character 缓存 (0 ~ 127)7.2 Spring 中的享元模式
java
// 1. Spring Bean 单例作用域
// 默认 Singleton 的 Bean 可以被视为享元对象
// 所有注入该 Bean 的地方共享同一个实例
// 2. Spring 的 ConversionService 中的 Converter 缓存
// 3. Spring 的 PropertyEditor 注册表
// 4. Hibernate 的一级缓存 / 二级缓存八、与其他模式的关系
| 模式 | 关系 |
|---|---|
| 组合模式 | 组合模式中的叶子节点可以使用享元模式共享,减少内存占用。 |
| 单例模式 | 享元工厂通常是单例的,享元对象是多例但可共享的。享元模式可以看作单例模式的扩展(多例共享)。 |
| 工厂模式 | 享元工厂通常使用工厂方法创建和管理享元对象。 |
| 状态模式 | 享元模式中的内部状态类似状态模式中的共享状态,但意图不同。 |
| 原型模式 | 两者都可以减少对象创建开销,但享元通过共享,原型通过克隆。 |
九、面试常见问题
Q1:什么是内部状态和外部状态?如何区分?
答:
- 内部状态(Intrinsic State):存储在享元对象内部,不随环境变化,可以共享。例如:字符的字体、大小、颜色。
- 外部状态(Extrinsic State):随环境变化,不可共享,由客户端在调用时传入。例如:字符在文档中的行号、列号。
- 区分原则:问自己"这个状态是否可以被多个客户端共享?" 能共享的就是内部状态,不能共享的就是外部状态。
Q2:享元模式和单例模式有什么区别?
答:
- 单例模式:一个类只有一个实例,全局唯一。
- 享元模式:一个类可以有多个实例,但每个实例被多个客户端共享。享元模式可以看作"多例模式"——每个唯一键对应一个实例。
- 享元模式关注的是"大量相似对象的复用",单例模式关注的是"全局唯一实例"。
Q3:享元模式如何保证线程安全?
答:最推荐的方式是将享元对象设计为不可变对象(Immutable)。所有字段声明为 final,不提供 setter 方法,这样天然线程安全。例如 JDK 中的 Integer、String 都是不可变的。如果享元对象必须可变,则需要使用并发控制(如 synchronized、ReentrantLock、ConcurrentHashMap)。
Q4:JDK 中 Integer 的缓存范围是多少?为什么是 -128 到 127?
答:Integer 默认缓存范围是 -128 到 127(可以通过 -XX:AutoBoxCacheMax 调整上限)。选择这个范围是因为 -128 到 127 涵盖了一个字节的表示范围,是最常用的整数范围。这是典型的享元模式——用空间换时间,用少量内存缓存常用对象,避免重复创建。
Q5:享元模式在什么情况下反而会降低性能?
答:
- 对象状态差异很大时,缓存命中率低,享元工厂的查找开销反而成为负担。
- 享元对象很少被复用时,维护缓存池的代价大于创建新对象的代价。
- 外部状态过多时,客户端管理外部状态的复杂度增加。
- 享元对象是可变的时,需要额外的同步开销保证线程安全。
