🎯 什麼情境該想到我
當你「記憶體裡有幾十萬、幾百萬個小物件,而且它們大部分內容是重複的」的時候——
遊戲的地圖磚塊、文字編輯器的字元樣式、解析器的 token、大量連線的共用設定。
★ 現代記憶體便宜,所以這個模式的適用範圍比 1994 年窄得多——但在高頻場景仍然關鍵。
⚙️ 怎麼用
★ 核心:把狀態拆成「內在」與「外在」
運用共享來有效支援大量細粒度的物件。
| 狀態 | 意思 | 放哪 |
|---|---|---|
| 內在狀態(intrinsic) | 與情境無關、可共享、不可變 | ★ 存在享元物件裡,所有使用者共用一份 |
| 外在狀態(extrinsic) | 與情境相關、每個實例不同 | ★ 由呼叫端在使用時傳入,不存在享元裡 |
❌ 沒有享元:100 萬棵樹,每棵都存自己的 mesh、材質、貼圖
→ 100 萬 × 2 MB = 2 TB
✅ 享元:5 種樹種的 mesh/材質/貼圖各存一份(內在)
每棵樹只存座標與縮放(外在)
→ 5 × 2 MB + 100 萬 × 16 bytes ≈ 26 MB
★ Go 的長相
// 內在狀態:不可變、可共享
type TreeType struct {
Name string
Mesh *Mesh
Texture *Texture
}
// ★ 享元工廠:確保同樣的內在狀態只有一份
var (
mu sync.Mutex
types = map[string]*TreeType{}
)
func GetTreeType(name string) *TreeType {
mu.Lock()
defer mu.Unlock()
if t, ok := types[name]; ok {
return t // ★ 共用既有的
}
t := &TreeType{Name: name, Mesh: loadMesh(name), Texture: loadTexture(name)}
types[name] = t
return t
}
// 外在狀態:每個實例自己的
type Tree struct {
X, Y, Z float32
Scale float32
Type *TreeType // ★ 只是一個指標,8 bytes
}
func (t Tree) Draw(canvas *Canvas) {
t.Type.Mesh.Render(canvas, t.X, t.Y, t.Z, t.Scale) // ★ 外在狀態當參數傳入
}★ Go 裡真正常見的形態
① 字串駐留(interning)
var interned sync.Map // map[string]string
func Intern(s string) string {
if v, ok := interned.Load(s); ok { return v.(string) }
v, _ := interned.LoadOrStore(s, s)
return v.(string)
}用在解析大量 JSON/CSV 時的重複欄位名——百萬列資料裡的 "user_id" 只存一份。
(Go 1.23 起標準函式庫新增了 unique 套件,是正式的穩定套件、不是實驗功能:unique.Make[T] 回傳 Handle[T],比較兩個 handle 只是指標比較,比上面手寫的 sync.Map 更適合做 interning。查證 2026-10-15)
② sync.Pool——★ 但它不是享元
| 享元 Flyweight | sync.Pool | |
|---|---|---|
| 目的 | 共享不可變的資料 | 重用可變的緩衝區,減少 GC 壓力 |
| 同時 | 多個使用者同時共用同一份 | ★ 一次只有一個使用者持有 |
| 內容 | 不可變 | 可變(用完要 Reset) |
★ 兩者常被混為一談,但意圖完全相反:享元是「大家共用同一份」,池是「輪流使用、用完歸還」。
面試時能區分這兩個是加分點。
③ 不可變的共享設定
// ★ 最常見也最無感的享元:一份 *tls.Config 被幾千條連線共用
var tlsConf = &tls.Config{...} // 不可變 → 安全共享TypeScript / JS 的形態
// ★ JS 引擎本身就對字串做 interning(字面量會被共用)
// 手動的享元通常是一個 Map 快取
const styleCache = new Map<string, Style>();
function getStyle(key: string): Style {
let s = styleCache.get(key);
if (!s) { s = createStyle(key); styleCache.set(key, s); }
return s;
}Symbol.for(key) 是語言層的享元(全域 symbol registry)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- ★ 先量再做。這是效能優化,不是架構模式。
沒有 profiling 數據(pprof的 heap profile)就引入享元,只是把程式弄複雜。
它的適用門檻是「物件數量以十萬、百萬計」。 - ★ 內在狀態必須真的不可變。共享的物件被其中一個使用者改掉 → 所有人都受影響,
而且這種 bug 極難重現。→ 只暴露值或唯讀方法,不要回傳可變的 slice/map(見 工具-Go切片的共享與陷阱)。 - ★ 享元快取本身會洩漏記憶體。
map[string]*T只增不減 → 長期執行會無限成長。
key 來自使用者輸入時尤其危險(可被攻擊)。 → 要有容量上限或 LRU 淘汰。 - 並行存取要鎖。
sync.Map適合「寫少讀多」,一般情況用sync.RWMutex+map可能更快。 - 快取查找本身有成本。物件很小時,「查 map 拿指標」可能比「直接配置」還慢。
- ★ 現代 GC 讓它常常不必要。1994 年配置物件很貴;現在 Go 的小物件配置極快。
先確認瓶頸真的是「物件數量」而不是別的。 - 和
sync.Pool分清楚(見上)。
🔗 相關工具
- 單例模式 —— 極端的享元(只有一個實例)
- 原型模式 —— 相反的取捨:複製 vs 共享
- 代理模式 —— 智慧參考常與物件池一起出現
- 工具-快取與資源優化 —— 快取淘汰策略
- 工具-Go切片的共享與陷阱 —— 共享可變狀態的危險
- 工具-程式碼調校 —— 先量再優化
- 設計模式