戰(zhàn):共享對(duì)象,告別游戲內(nèi)存爆炸)
做游戲最怕的就是內(nèi)存告警。功能都正常結(jié)果一壓測(cè)內(nèi)存曲線直接起飛幀率跟著往下掉。你把一個(gè)個(gè)對(duì)象new出來用完也不釋放或者釋放了又瘋狂創(chuàng)建后果就是GC壓力爆表甚至OOM。這種時(shí)候最容易被想起來的就是享元模式Flyweight。它的思路特別直白一樣的對(duì)象不重復(fù)建共用一份。別小看這層思路。真正理解享元能讓你對(duì)整個(gè)系統(tǒng)的對(duì)象劃分方式產(chǎn)生新的感覺——什么樣子的數(shù)據(jù)應(yīng)該拆出去什么樣子的數(shù)據(jù)必須留在對(duì)象內(nèi)部以及“共享”到底共享到什么程度。這篇文章我結(jié)合自己實(shí)際項(xiàng)目中的用法把享元模式掰開揉碎講清楚包括核心設(shè)計(jì)、代碼實(shí)驗(yàn)、實(shí)戰(zhàn)場(chǎng)景還有我在真實(shí)開發(fā)里踩過的一些坑。1. 內(nèi)存爆炸的本質(zhì)不必要的重復(fù)對(duì)象網(wǎng)上講設(shè)計(jì)模式的開頭常常是一套理論定義但你既然搜到這個(gè)標(biāo)題大概率是被內(nèi)存問題逼來的。我先用最實(shí)際的角度切入。1.1 一個(gè)真實(shí)的場(chǎng)景最普通的“像素樹”問題想象你在做一個(gè)開放世界游戲。地圖上要畫一萬棵樹每棵樹有位置、高度、樹的顏色、樹葉密度、樹干紋理、狀態(tài)動(dòng)畫幀。很多初學(xué)游戲開發(fā)的人會(huì)直接寫一個(gè)Tree類所有屬性塞到一起public class Tree { private double x; private double y; private double height; private Color leafColor; private Texture trunkTexture; private Texture leafTexture; private ListAnimationFrame growAnimation; }然后每生成一棵樹就new Tree(...)一次。一萬棵樹就是一萬個(gè)獨(dú)立對(duì)象每個(gè)對(duì)象里都存著同樣的樹干貼圖、同樣的樹葉紋理、同樣的動(dòng)畫幀序列。等場(chǎng)景里還有五十萬顆草、三萬個(gè)粒子、幾百個(gè)NPC的時(shí)候內(nèi)存就徹底失控了。這正是內(nèi)存爆炸最常見的來源不是數(shù)據(jù)量真的好大而是同一份數(shù)據(jù)反復(fù)占內(nèi)存。1.2 爆炸的本質(zhì)對(duì)象身份和數(shù)據(jù)被捆綁在了一起上面Tree類的例子說明一個(gè)關(guān)鍵問題我們把每個(gè)對(duì)象都當(dāng)成了“獨(dú)一無二”的個(gè)體但個(gè)體本身并不需要把全部數(shù)據(jù)都帶在身上。一萬棵樹位置坐標(biāo)各自不同這個(gè)是可變的部分。但樹干貼圖是三張、樹葉紋理是五張、動(dòng)畫幀序列就那么幾套這些是不變的公共數(shù)據(jù)。你在同一片森林里看到的所有樹共享同一套美術(shù)資源。如果每個(gè)樹對(duì)象都存一份完整的貼圖引用內(nèi)存當(dāng)然會(huì)崩。用生活里的例子類比一下你去大學(xué)圖書館如果每個(gè)學(xué)生進(jìn)圖書館都要自己帶一套《百科全書》副本圖書館早就塌了?,F(xiàn)實(shí)里不會(huì)這樣因?yàn)楣操Y源被共享人人按需借用。享元模式做的就是這件事——如果一類對(duì)象里有一部分?jǐn)?shù)據(jù)是公共不變的那就把它們抽出來作為共享資源讓各種“個(gè)體”按需引用。因此享元模式解決的不是“對(duì)象太多”而是可共享的重復(fù)數(shù)據(jù)太多。它的核心做法是把一個(gè)對(duì)象的屬性拆成兩類內(nèi)部狀態(tài)Intrinsic State不隨外部環(huán)境而變化屬于對(duì)象固有的、可以被共享的數(shù)據(jù)。外部狀態(tài)Extrinsic State依賴具體使用場(chǎng)景必須由調(diào)用方傳入不能被共享。想判斷某塊數(shù)據(jù)該放內(nèi)部還是外部主要看它是否是“所有同類對(duì)象本質(zhì)共有的、不會(huì)變的”。這屬于典型的從需求分析出發(fā)的建模思路但實(shí)際拆的時(shí)候很多人還是會(huì)卡。下面詳細(xì)說。2. 核心概念拆解內(nèi)部狀態(tài)、外部狀態(tài)、享元工廠2.1 內(nèi)部狀態(tài)與外部狀態(tài)拆分對(duì)象的分界線我們把樹再拆一下。內(nèi)部狀態(tài)共享部分樹干紋理、樹葉紋理、樹的高度范圍、顏色組合、動(dòng)畫幀。同一品種的樹完全用同一套。外部狀態(tài)非共享部分樹的坐標(biāo)X/Y/Z、當(dāng)前生長階段、是否被砍倒、是否正在播放動(dòng)畫。這些數(shù)據(jù)每棵樹都不同如果也塞進(jìn)共享對(duì)象里那共享就毫無意義了。正確做法是共享對(duì)象里只保存紋理、動(dòng)畫幀這類公共資源。外部狀態(tài)在使用時(shí)作為參數(shù)傳入或者由調(diào)用方另外維護(hù)一份輕量數(shù)據(jù)。一個(gè)容易被忽視的關(guān)鍵點(diǎn)是外部狀態(tài)不能存進(jìn)享元對(duì)象內(nèi)部。否則第一個(gè)使用它的人把這個(gè)狀態(tài)改了第二個(gè)使用的人就拿到被污染的數(shù)據(jù)了。很多享元模式用不好就是栽在這一步。2.2 享元工廠讓共享機(jī)制真正生效的“管理器”有了拆好的內(nèi)部狀態(tài)對(duì)象還得有地方讓它“只創(chuàng)建一次”。這個(gè)職責(zé)通常交給享元工廠Flyweight Factory。工廠內(nèi)部維護(hù)了一個(gè)池子通常是MapString, Flyweight或者ConcurrentHashMap。調(diào)用方每次要一個(gè)“某品種的樹”工廠先看池子里有沒有有就直接返回已有的沒有就創(chuàng)建一個(gè)放進(jìn)池子再返回。這種基于緩存的思路和實(shí)際工作中常見的緩存、池化思想完全一致。你在業(yè)務(wù)代碼里可能早就寫過類似的東西只是沒有意識(shí)到它正是享元模式的經(jīng)典形態(tài)。2.3 判斷標(biāo)準(zhǔn)什么時(shí)候該拆什么時(shí)候不該拆我自己通常按三條標(biāo)準(zhǔn)來判斷數(shù)據(jù)是否具有高度重復(fù)性一萬棵樹只有 5 套紋理高度重復(fù)適合共享。如果每棵樹的紋理都完全不同那就沒有任何共享價(jià)值硬套模式反而多此一舉。創(chuàng)建成本是否夠高紋理、模型、動(dòng)畫這些資源創(chuàng)建成本高適合共享。一個(gè)簡單的整數(shù)對(duì)象創(chuàng)建成本幾乎為零根本沒共享的必要。對(duì)象數(shù)量是否巨大成千上萬個(gè)對(duì)象內(nèi)存壓力明顯值得用享元。如果全場(chǎng)只有 10 個(gè)對(duì)象省不了多少內(nèi)存卻增加了外部狀態(tài)管理的復(fù)雜度。這三條同時(shí)滿足就果斷用享元。否則你可以繼續(xù)往下讀但建議不要讓這種模式成為項(xiàng)目里無處不在的“架構(gòu)炫技”。3. 代碼實(shí)戰(zhàn)Java 和 C 兩種寫法的完整演示理論講太多容易飄直接上代碼。我先用 Java 寫一個(gè)游戲里的樹渲染場(chǎng)景這是享元模式最貼合的形態(tài)再用 C 寫一個(gè)不同場(chǎng)景的簡單版本方便對(duì)比內(nèi)存布局差異。3.1 Java 實(shí)現(xiàn)把游戲資源劃分成共享單元先定義享元類也就是TreeType里面只放內(nèi)部狀態(tài)// TreeType 是享元對(duì)象內(nèi)部只保存公共的紋理/顏色等數(shù)據(jù) public class TreeType { private final String name; // 品種名 private final String trunkTexture; // 樹干紋理路徑 private final String leafTexture; // 樹葉紋理路徑 private final Color leafColor; // 樹葉顏色 private final ListString animationFrames; // 動(dòng)畫幀路徑 public TreeType(String name, String trunkTexture, String leafTexture, Color leafColor, ListString animationFrames) { this.name name; this.trunkTexture trunkTexture; this.leafTexture leafTexture; this.leafColor leafColor; this.animationFrames animationFrames; } public void draw(double x, double y, double height) { // 簡化渲染邏輯實(shí)際會(huì)依賴紋理句柄 System.out.println(繪制[ name ]于( x , y ) 高度 height); } }再寫享元工廠import java.util.HashMap; import java.util.List; import java.util.Map; public class TreeFactory { private static final MapString, TreeType pool new HashMap(); public static TreeType getTreeType(String name, String trunkTexture, String leafTexture, Color leafColor, ListString animationFrames) { TreeType result pool.get(name); if (result null) { result new TreeType(name, trunkTexture, leafTexture, leafColor, animationFrames); pool.put(name, result); System.out.println(創(chuàng)建新的樹類型: name); } return result; } }此時(shí)森林里每棵樹只是一個(gè)輕量的坐標(biāo)信息加共享類型的引用。我們不需要再創(chuàng)建一個(gè)完整的Tree類可以直接在渲染循環(huán)里這樣用// 場(chǎng)景中生成一萬棵樹 Random random new Random(); for (int i 0; i 10000; i) { TreeType type TreeFactory.getTreeType( 落葉松, trunk_01.png, leaf_03.png, Color.GREEN, List.of(anim_01.png, anim_02.png) ); type.draw(random.nextDouble() * 1000, random.nextDouble() * 1000, random.nextDouble() * 10); }無論循環(huán)多少萬次池子里只有一個(gè)TreeType實(shí)例。你創(chuàng)建了十萬棵樹共享類型對(duì)象仍是同一個(gè)內(nèi)存占用里的“樹資源”部分基本恒定。3.2 Java 內(nèi)置享元案例String 與 Integer理解上面的代碼后你會(huì)發(fā)現(xiàn)Java 語言本身到處是享元的影子。String 常量池編譯期確定的字符串被緩存相同字面量的String變量指向同一內(nèi)存地址。Integer 緩存Integer.valueOf(-128~127)返回的是緩存對(duì)象不會(huì)重新創(chuàng)建。ThreadPool線程池復(fù)用線程。數(shù)據(jù)庫連接池連接對(duì)象被復(fù)用而不是每次新建連接。這些不用刻意記它們的存在恰恰說明享元思想確實(shí)解決了真實(shí)問題。3.3 C 實(shí)現(xiàn)共享與內(nèi)存布局的直觀對(duì)比C 沒有 JVM 幫我們管理對(duì)象生命周期享元模式更適合用shared_ptr配合工廠管理同時(shí)更接近底層內(nèi)存視角。#include iostream #include map #include memory #include string // 棋子類型享元內(nèi)部狀態(tài) class ChessType { public: ChessType(std::string name, std::string texture) : name_(std::move(name)), texture_(std::move(texture)) {} void draw(int x, int y) const { std::cout 繪制 name_ at ( x , y ) std::endl; } private: std::string name_; std::string texture_; }; // 享元工廠 class ChessTypeFactory { public: std::shared_ptrconst ChessType getChessType(const std::string name, const std::string texture) { auto it pool_.find(name); if (it ! pool_.end()) { return it-second; } auto type std::make_sharedconst ChessType(name, texture); pool_[name] type; std::cout 創(chuàng)建棋子類型: name std::endl; return type; } private: std::mapstd::string, std::shared_ptrconst ChessType pool_; }; int main() { ChessTypeFactory factory; // 棋子位置是外部狀態(tài)通過參數(shù)傳入 auto black factory.getChessType(黑棋, black.png); auto white factory.getChessType(白棋, white.png); black-draw(1, 1); black-draw(2, 2); white-draw(3, 3); return 0; }這里的ChessType是“內(nèi)部狀態(tài)唯一的對(duì)象”而棋盤上每個(gè)棋子實(shí)例的坐標(biāo)則作為外部參數(shù)傳遞。其內(nèi)存意義是無論棋盤上有上千個(gè)黑子它們的類型對(duì)象只有一份。與 Java 版本相比C 需要注意的是生命周期管理和緩存失效問題。用shared_ptr持有時(shí)只要工廠存在類型對(duì)象就不會(huì)被釋放。這種共享更容易失控一定要在工廠層明確清理策略。4. 實(shí)戰(zhàn)應(yīng)用場(chǎng)景游戲、文本渲染與業(yè)務(wù)系統(tǒng)4.1 游戲開發(fā)粒子、草叢、樹木全都中招游戲是享元模式的重度用戶因?yàn)橛螒蚶飳?duì)象數(shù)量上限遠(yuǎn)高于業(yè)務(wù)系統(tǒng)。粒子系統(tǒng)是最經(jīng)典的案例。一場(chǎng)爆炸特效會(huì)產(chǎn)生幾百上千個(gè)粒子每個(gè)粒子都有坐標(biāo)、速度、生命周期、顏色、紋理。如果每個(gè)粒子都持有貼圖路徑或者材質(zhì)對(duì)象瞬間就是幾十MB的額外內(nèi)存。正確做法是粒子數(shù)據(jù)數(shù)組單獨(dú)管理外部狀態(tài)。紋理材質(zhì)作為共享資源內(nèi)部狀態(tài)。更新粒子時(shí)傳入共享資源引用。對(duì)比一下兩種方式的差異運(yùn)用模式對(duì)象數(shù)量每個(gè)對(duì)象持有數(shù)據(jù)內(nèi)存估算1000個(gè)粒子不使用享元1000個(gè)粒子對(duì)象紋理路徑材質(zhì)引用坐標(biāo)速度可能 80-120MB使用享元1個(gè)材質(zhì)1000個(gè)粒子數(shù)據(jù)坐標(biāo)速度共享材質(zhì)指針約 10-15MB數(shù)據(jù)會(huì)根據(jù)引擎不同有差異但數(shù)量級(jí)差距是真實(shí)存在的。建筑、地形塊、武器裝備、NPC 外觀也是同樣的邏輯。很多游戲引擎把“靜態(tài)網(wǎng)格資源”和“實(shí)例化繪制”分開本質(zhì)就是享元模式在底層發(fā)揮作用的實(shí)例。4.2 文本渲染與文檔系統(tǒng)字符級(jí)別的享元另一個(gè)經(jīng)典場(chǎng)景是文本編輯器語種、字體、字號(hào)、顏色完全相同的大量字符可以共享同一個(gè)字形對(duì)象。一個(gè)文本編輯器可能有幾十萬個(gè)字符每個(gè)字符如果都保存字體、字號(hào)、顏色、字形數(shù)據(jù)內(nèi)存開銷是可怕的。但實(shí)際做法是字形數(shù)據(jù)Glyph共享對(duì)象內(nèi)部狀態(tài)字符位置信息char x y外部狀態(tài)這讓我想起當(dāng)年做一個(gè)簡單的富文本預(yù)覽器時(shí)圖省事把所有樣式都存到每個(gè)字符對(duì)象里。一個(gè)文檔幾萬個(gè)字直接就卡到爆炸。后來改成字體樣式共享、位置信息單獨(dú)存的方案內(nèi)存占用直接降了一個(gè)數(shù)量級(jí)。4.3 業(yè)務(wù)系統(tǒng)中的“字典數(shù)據(jù)”與“用戶畫像標(biāo)簽”不僅在游戲和文檔里業(yè)務(wù)系統(tǒng)同樣大量用享元的思路。比如權(quán)限系統(tǒng)中的角色權(quán)限表如果幾千個(gè)用戶都擁有“經(jīng)理”角色你不會(huì)給每個(gè)用戶都復(fù)制一份經(jīng)理的全部權(quán)限列表而是讓所有用戶引用同一個(gè)角色權(quán)限對(duì)象。這就是典型的共享對(duì)象實(shí)戰(zhàn)運(yùn)用。數(shù)據(jù)庫表里的字典項(xiàng)也一樣性別、訂單狀態(tài)、貨幣類型這些枚舉值統(tǒng)一加載一份到內(nèi)存其它地方全部引用同一份實(shí)例。5. 常見問題與排查技巧實(shí)錄5.1 外部狀態(tài)被錯(cuò)誤寫進(jìn)共享對(duì)象這是最常見的寫法誤區(qū)。新手特別容易寫出的代碼如下// 錯(cuò)誤示例外部狀態(tài)被塞進(jìn)共享對(duì)象 public class TreeType { private double x; private double y; private double height; }如果共享對(duì)象包含了外部狀態(tài)那么張三改了坐標(biāo)李四的樹也被改了。排查方法很簡單檢查共享對(duì)象是否有非靜態(tài)的可變字段它是否真的“跨對(duì)象共享”。觀察是否出現(xiàn) A 操作影響 B 結(jié)果的現(xiàn)象。檢查調(diào)用方是否把當(dāng)前對(duì)象特有的值傳入了共享對(duì)象的 setter 里。正確做法是外部狀態(tài)要么作為方法參數(shù)傳入要么保存在一個(gè)輕量的外部包裝對(duì)象中。5.2 線程安全全局池子的并發(fā)訪問享元工廠的Map在多線程環(huán)境下必須考慮并發(fā)問題。不用HashMap而用ConcurrentHashMap或者在 get 方法里加鎖否則高并發(fā)下可能出現(xiàn)同一個(gè)類型被創(chuàng)建兩次雖然結(jié)果不一定會(huì)崩但邏輯上就沒達(dá)到“共享唯一實(shí)例”的目的。代碼層面要留意初始化時(shí)的競(jìng)態(tài)條件。最穩(wěn)妥的寫法是private static final ConcurrentMapString, TreeType pool new ConcurrentHashMap(); public static TreeType getTreeType(String key, SupplierTreeType creator) { return pool.computeIfAbsent(key, k - creator.get()); }ConcurrentHashMap.computeIfAbsent能保證同一個(gè) key 只會(huì)執(zhí)行一次創(chuàng)建邏輯是 Java 側(cè)最簡潔的方案。5.3 什么時(shí)候不能盲目追求共享可變對(duì)象的陷阱享元的前提是內(nèi)部狀態(tài)不變。如果你的“共享對(duì)象”是可變對(duì)象同時(shí)又會(huì)被多個(gè)調(diào)用方復(fù)用就會(huì)引發(fā)數(shù)據(jù)污染。排查這類問題時(shí)我是這樣做的檢查共享對(duì)象是否內(nèi)部有setter比如setColor、updateText。檢查共享對(duì)象是否暴露了可修改的List或Map調(diào)用方可能會(huì)直接 add 數(shù)據(jù)。凡是創(chuàng)建后需要改狀態(tài)的類不要輕易放進(jìn)享元池。規(guī)避方式創(chuàng)建后保持絕對(duì)不可變字段加上final集合返回時(shí)做Collections.unmodifiableList或者直接返回副本。5.4 一個(gè)更隱蔽的坑緩存永不釋放導(dǎo)致“偽內(nèi)存泄漏”工廠池里對(duì)象不斷增加如果類型種類特別多池子本身就成了另一個(gè)內(nèi)存消耗點(diǎn)。我這邊的處理方式是為享元池設(shè)定最大容量。使用 LRU 或定時(shí)清理無引用緩存。代碼里增加狀態(tài)監(jiān)控比如周期性打印池的大小發(fā)現(xiàn)異常增長時(shí)定位到創(chuàng)建方。這種問題一般只在 long-running 的服務(wù)端或特種場(chǎng)景中出現(xiàn)但一旦出現(xiàn)排查的成本遠(yuǎn)高于預(yù)防成本。6. 和對(duì)象池的區(qū)別以及樂與憂的邊界很多人從形式上把享元模式和對(duì)象池混淆。兩者在某些層面像但動(dòng)機(jī)和結(jié)構(gòu)有本質(zhì)差異維度享元模式對(duì)象池核心目的減少對(duì)象數(shù)量減少對(duì)象創(chuàng)建/銷毀的開銷數(shù)據(jù)維度共享不可變內(nèi)部狀態(tài)復(fù)用可變對(duì)象實(shí)例典型場(chǎng)景大樹、字符、貼圖線程池、數(shù)據(jù)庫連接池請(qǐng)求流程多次調(diào)用返回同一實(shí)例獲取時(shí)可能重置狀態(tài)后復(fù)用狀態(tài)特點(diǎn)內(nèi)部狀態(tài)不可變可重置可重用最典型的例子是線程池同一個(gè)線程會(huì)被復(fù)用每次執(zhí)行不同任務(wù)這是對(duì)象池而非享元。而字符串常量池里同一個(gè)“Hello”字符串被多處引用才是享元的典型形態(tài)。實(shí)際業(yè)務(wù)里兩者經(jīng)常結(jié)合使用。比如游戲引擎里的粒子系統(tǒng)一般先用一個(gè)粒子對(duì)象池減少創(chuàng)建銷毀開銷粒子們?cè)儆霉蚕砑y理減少重復(fù)數(shù)據(jù)這樣效果最優(yōu)也是一種合理的混合策略。7. 總結(jié)經(jīng)驗(yàn)的最后幾句話在我接觸過的項(xiàng)目中享元模式最省心也最容易見效的使用場(chǎng)景永遠(yuǎn)是那些“資源型數(shù)據(jù)”的共享。貼圖、模型、動(dòng)畫、文字字形、權(quán)限模板、配置條目這些都是天然共享的好苗子。只要內(nèi)部對(duì)象做到不可變外部狀態(tài)做好隔離再多對(duì)象也穩(wěn)得住。有一類項(xiàng)目并不適合硬套享元比如對(duì)象狀態(tài)本身就是千變?nèi)f化的幾乎沒有重復(fù)數(shù)據(jù)或者對(duì)象數(shù)量本來就少節(jié)省的存儲(chǔ)器遠(yuǎn)遠(yuǎn)填不平開發(fā)復(fù)雜度這個(gè)坑。模式是解決問題的工具不是必須完成的作業(yè)。想清楚這個(gè)問題往往比記住某段代碼重要得多。如果你手邊正好有一個(gè)內(nèi)存爆炸的對(duì)象集合先試著觀察一下它內(nèi)部有什么字段是“反復(fù)出現(xiàn)”的然后把那部分抽出來放入工廠池。就這么做往往比直接重構(gòu)整個(gè)對(duì)象體系要快得多。我自己的經(jīng)驗(yàn)是分享數(shù)據(jù)不只是一項(xiàng)內(nèi)存優(yōu)化它還會(huì)逼著你把“什么才是一個(gè)對(duì)象最本質(zhì)的屬性”想明白。想清楚了代碼結(jié)構(gòu)自然就清爽了。