你是否曾經遇到過系統因創建大量重複對象而導致內存佔用激增的情況?在處理成千上萬個文本字符、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 緩存、字體/顏色資源共享 |
| 實現關鍵 | 內部狀態不可變、外部狀態由客户端管理、線程安全的工廠實現 |
| 常見組合 | 工廠模式、單例模式、裝飾器模式 |
| 常見問題 | 內外狀態混淆、使用非線程安全集合、忽略內部狀態不可變性 |