關(guān)于GC的原理和 Unity 中如何針對 GC 進行優(yōu)化的建議

例牌美女

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)存的上限決定

  • 堆的分配特點
    通常使用 newmalloc來動態(tài)分配。堆的分配效率比較低。系統(tǒng)通常使用一個鏈表記錄堆中所有空閑區(qū)域的首地址指針,當(dāng)進行內(nèi)存分配時,需要遍歷鏈表,來選取一個大小足夠容納的區(qū)域進行分配,然后修改鏈表中的指針值;如果沒有找到足夠的空間,會調(diào)用系統(tǒng)接口來增加,因此堆的分配效率比較低,且容易生成內(nèi)存碎片。另外,堆的分配是從低地址往高地址增長
  • 內(nèi)存釋放
    需要調(diào)用 deletefree主動釋放

2. 內(nèi)存管理方式

主流的內(nèi)存管理方式包括三種:手動管理、引用計數(shù)和垃圾回收(Garbege Collect,GC)

2.1 手動管理

  • 管理方式
    手動調(diào)用 mallocnew 進行分配,手動調(diào)用 freedelete 進行釋放回收
  • 優(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)存區(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.nameGameObject.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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容