
1. 應(yīng)用程序內(nèi)存結(jié)構(gòu)
應(yīng)用程序內(nèi)存空間通常劃分為五個部分
1.1 靜態(tài)/全局存儲區(qū)
存放全局和靜態(tài)變量,靜態(tài)分配的,在程序執(zhí)行的最開是分配,后面不會再增長
1.2 常量存儲區(qū)
存儲程序中的常量
1.3 代碼段
存放程序執(zhí)行代碼的內(nèi)存區(qū)域,在程序運行之前就已經(jīng)確定了,通常是只讀的,當(dāng)多個進程運行同樣的程序時,可以使用同一個代碼段
1.4 棧
棧是一塊連續(xù)的內(nèi)存區(qū)域,棧的容量由系統(tǒng)規(guī)定,棧底地址在程序初始化時就確定了。棧通常用來存儲局部變量、函數(shù)的參數(shù)和返回值
- 快速存取
棧最大的特點是快速存取,這是因為操作系統(tǒng)本身就支持棧這種數(shù)據(jù)結(jié)構(gòu),有專門的寄存器指向棧底,同時有專門的匯編指令進行入棧和出棧操作 - 內(nèi)存分配特點
定義局部變量時進行分配,或函數(shù)參數(shù)/返回值自動分配和入棧。
在棧中分配內(nèi)存是連續(xù)的,不會產(chǎn)生碎片,且棧的分配是從高地址往低地址方向增長 - 內(nèi)存釋放
當(dāng)變量超出作用域時,系統(tǒng)自動釋放
1.5 堆
堆主要用來存放動態(tài)分配的對象,堆的大小由系統(tǒng)內(nèi)存/虛擬內(nèi)存的上限決定
- 堆的分配特點
通常使用new和malloc來動態(tài)分配。堆的分配效率比較低。系統(tǒng)通常使用一個鏈表記錄堆中所有空閑區(qū)域的首地址指針,當(dāng)進行內(nèi)存分配時,需要遍歷鏈表,來選取一個大小足夠容納的區(qū)域進行分配,然后修改鏈表中的指針值;如果沒有找到足夠的空間,會調(diào)用系統(tǒng)接口來增加,因此堆的分配效率比較低,且容易生成內(nèi)存碎片。另外,堆的分配是從低地址往高地址增長 - 內(nèi)存釋放
需要調(diào)用delete或free來主動釋放
2. 內(nèi)存管理方式
主流的內(nèi)存管理方式包括三種:手動管理、引用計數(shù)和垃圾回收(Garbege Collect,GC)
2.1 手動管理
- 管理方式
手動調(diào)用malloc或new進行分配,手動調(diào)用free或delete進行釋放回收 - 優(yōu)點
速度快,無額外開銷 - 缺點
較難管理,必須明確跟蹤對象使用情況,容易產(chǎn)生分配后未回收導(dǎo)致內(nèi)存泄漏、錯誤回收導(dǎo)致野指針、空指針等問題
2.2 引用計數(shù)
- 管理方式
對象使用時計數(shù)器 +1,使用完畢計數(shù)器 -1,當(dāng)計數(shù)器為 0 時進行銷毀 - 優(yōu)點
半自動管理,切速度較快 - 缺點
存在循環(huán)引用的情況,會導(dǎo)致內(nèi)存泄漏
2.3 垃圾回收(GC)
- 管理方式
自動進行垃圾回收 - 優(yōu)點
整個過程是全自動的,用戶幾乎不需要參與內(nèi)存的管理,并且不存在循環(huán)引用的問題 - 缺點
在進行垃圾回收時需要停止所有線程 Stop the World
3. .Net/Java 中 GC 的原理
3.1 堆內(nèi)存劃分區(qū)域

將堆內(nèi)存劃分為年輕代、老年代和永久代
- 年輕代
年輕代主要存放新創(chuàng)建的對象,內(nèi)存大小相對會比較小,垃圾回收會比較頻繁。年輕代又被劃分為 Eden 區(qū)、Survivor 區(qū),Survivor 區(qū)分為 S1 和 S2 區(qū),Eden 區(qū)和 S1、S2 區(qū)的大小比通常為 8:1:1,年輕代內(nèi)存的代銷和 Eden 區(qū)和 Survivor 區(qū)的大小比例都可以通過 JVM 參數(shù)來進行設(shè)置 - 老年代
老年代主要存放系統(tǒng)認(rèn)為生命周期比較長的對象,區(qū)域大小相對會比較大,垃圾回收也相對沒有那么頻繁 - 永久代
持久代主要存放類定義、字節(jié)碼和常量等很少會變更的信息
3.2 內(nèi)存分配過程
當(dāng)需要為一個對象分配內(nèi)存時,過程大概是
- 一般對象往 Eden 區(qū)分配,查看 Eden 區(qū)中的空間是否足夠容納對象,如果足夠則在 Eden 區(qū)進行分配
- Eden 區(qū)容納不下該對象時,觸發(fā) MinorGC,結(jié)束后重新分配
- 大對象直接往老年代分配,若容納不下將觸發(fā) FullGC,結(jié)束后重新分配
3.3 GC 觸發(fā)時機
以下幾種情況會觸發(fā) GC:
- 在 Eden 區(qū)分配時空間不足,觸發(fā) MinorGC
- 大對象分配,老年代空間不足,觸發(fā) FullGC
- 主動調(diào)用 GC.Collect 時,觸發(fā) FullGC
3.4 GC 過程和算法
3.4.1 年輕代基于復(fù)制的 GC 算法
執(zhí)行在年輕代的 GC 也稱之為 MinorGC,過程如下
- 遍歷 Eden 區(qū)和 S0 區(qū),計算每個對象是否存活,存活對象全部復(fù)制到 S1 區(qū),然后清空 Eden 和 S0 區(qū)
- 此過程中若 S1 區(qū)空間不夠存放對象,對象直接進入老年區(qū)
- Eden 區(qū)和 S0 區(qū)的對象,每復(fù)制一次年齡 + 1,年齡超過某個閾值(默認(rèn)為15,可以通過 JVM 參數(shù)設(shè)置),進入老年代
- 清空 Eden 和 S0 區(qū)后,S0 區(qū)和 S1 區(qū)互換,下一次 Minor GC 觸發(fā)時 S0 區(qū)用來接收 Eden 區(qū)和 S1 區(qū)的存活對象
3.4.2 老年代標(biāo)記-清除的 GC 算法
執(zhí)行于老年代的 GC,也成為 Major GC,其中一種算法是標(biāo)記-清除算法,過程如下
- 第一趟遍歷對象列表,標(biāo)記所有未存活對象
- 第二趟遍歷對象列表,清除所有未存活對象
標(biāo)記-清除算法存在問題:
清除的對象很可能不在連續(xù)空間,容易產(chǎn)生內(nèi)存碎片,隨著時間推移,連續(xù)的內(nèi)存區(qū)域越來越少,一次稍微大一點的分配就可能觸發(fā) GC,導(dǎo)致 GC 會越來越頻繁
3.4.3 老年代標(biāo)記-整理的 GC 算法
另外一種算法改進了標(biāo)記-清除算法的問題,稱為標(biāo)記-整理算法,過程如下
- 第一趟遍歷對象,標(biāo)記所有未存活對象
- 第二趟遍歷對象,進行整理,將所有存活對象復(fù)制到連續(xù)區(qū)域:使用 memmove 移動內(nèi)存空間,同時修改引用該對象的指針值
標(biāo)記-整理的問題是效率稍微低一些
3.4.3 GC時如何判斷對象是否存活
- 可達(dá)性算法:如果對象 A 到對象 B 存在引用鏈路,說明 A 為 B 的可達(dá)對象
- 判斷對象 A 是否存活:遍歷程序的 根對象列表,若 A 為任何根對象的可達(dá)對象,則A為存活對象
3.4.4 哪些對象是根對象
可以作為根對象來進行可達(dá)性判定的對象包括:
- 棧中的局部變量
- 類靜態(tài)變量
- 全局變量和常量
3.4.5 Minor GC,Major GC 和 FullGC
Minor GC 是發(fā)生在年輕代中的 GC,觸發(fā)較為頻繁,Major GC 是發(fā)生在老年代中的 GC,相對不頻繁,觸發(fā) FullGC 時會先執(zhí)行 Minor GC,然后執(zhí)行 Major GC
4. Unity 中的 GC
4.1 Unity 中GC的特性
- Stop the World
Unity 不支持多線程 GC,要停止所有線程,GC才能繼續(xù)執(zhí)行。即便 Unity 2019 引入了增量式GC,將 GC 操作分散到不同幀當(dāng)中,仍然是需要停止所有線程的 - 不分代
Unity 中的托管堆內(nèi)存未分代,只要觸發(fā) GC,就是 FullGC - 不整理
Unity 中 GC 算法是基于標(biāo)記-清除算法,不會和并對象空間,容易造成內(nèi)存碎片,且 GC 頻率會越來越高
4.2 Unity 中關(guān)于 GC 優(yōu)化的建議
4.2.1 減少對象的大小
合理安排類或結(jié)構(gòu)體的字段聲明順序,以優(yōu)化其對象的內(nèi)存布局進而減少對象大小,結(jié)構(gòu)體可以使用 StructLayout 屬性
關(guān)于結(jié)構(gòu)體,結(jié)構(gòu)體本身是值類型。若結(jié)構(gòu)體中不包含引用類型時,針對結(jié)構(gòu)體的 new 操作不會造成 GCAlloc,但若結(jié)構(gòu)體中包含引用類型字段,如 string 或數(shù)組等,那么在對結(jié)構(gòu)體執(zhí)行 new 操作時會產(chǎn)生 GC Alloc
4.2.2 降低內(nèi)存分配的頻次
也就是盡量減少 GCAlloc
- 減少引用類臨時對象的分配,傳遞結(jié)構(gòu)類型的參數(shù)時,如果對象尺寸超過 IntPtr.Size 時,采用引用傳遞方式,參數(shù)加關(guān)鍵字
ref,類類型的對象本身已經(jīng)是引用傳值了,不會生成臨時對象 - 使用泛型優(yōu)化裝箱,例如
void Func(object o)方法在傳值類型參數(shù)時會進行裝箱,使用 void Func<T>(T o) 則不會產(chǎn)生裝箱,但泛型在 IL2cpp 時會生成多中類型對應(yīng)的代碼 - 可變參數(shù)的方法,先定義常用參數(shù)個數(shù)的方法,再定義可變參數(shù)方法,確保絕大多數(shù)調(diào)用是固定個數(shù)的方法。如
string.Format方法是將1個、2個和3個參數(shù)的方法單獨提出,另外再實現(xiàn)一個可變參數(shù)的方法 - 緩存某些 Get 類方法或?qū)傩缘慕Y(jié)果,例如不要使用
GameObject.name和GameObject.tag,但GameObject.CompareTag()方法不會產(chǎn)生 GCAlloc - 盡量減少裝箱和拆箱
- 不要在 Update 等頻次較高的方法中分配堆內(nèi)存
- 使用對象池
- 事先申請足量的容器尺寸,避免申請尺寸不足時添加元素造成的復(fù)制和重新申請操作
- 字符串本身是引用類型,字符串連接時會申請新的空間,所以盡量避免字符串拼接
- 協(xié)程
yield return 0應(yīng)該使用yield return null來替代,避免裝箱 - 協(xié)程
yield return new WaitForSeconds應(yīng)該先將new WaitForSeconds緩存下來
5. 查看 Android 應(yīng)用的內(nèi)存情況
4.2.3 在適當(dāng)?shù)臅r機主動調(diào)用 GC.Collect
例如在場景切換顯示加載界面時調(diào)用,用戶無感知
4.2.4 關(guān)于調(diào)試日志的字符串參數(shù)
正式版本中使用 unityLogger.logEnabled = false 僅僅只是不打日志,但字符串已經(jīng)被分配內(nèi)存,GC Alloc 還是產(chǎn)生了。解決方法是使用 Conditional 特性來處理日志輸出,在正式版本中不要生成打印相關(guān)的代碼,也不會有字符串的生成,減少了 GCAlloc