🎯 什麼情境該想到我

當你「記憶體裡有幾十萬、幾百萬個小物件,而且它們大部分內容是重複的」的時候——
遊戲的地圖磚塊、文字編輯器的字元樣式、解析器的 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——★ 但它不是享元

享元 Flyweightsync.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 分清楚(見上)。

🔗 相關工具