1. 程式人生 > >理解自動記憶體管理(Automatic Memory Management)

理解自動記憶體管理(Automatic Memory Management)

記憶體管理

當一個物件,字串或者陣列被建立時,和它所需大小相符的儲存空間會從一箇中央記憶體池中被分配出來,這個記憶體池我們一般稱之為堆(heap)。

原理可以類比於C中的malloc()函式,只不過我們無需手動指定所需的記憶體大小。

當上述物體建立後不再被使用時,它所佔用的記憶體會被回收以便重複利用。在早些時候,這個分配和釋放堆中記憶體的功能都是由程式猿控制的,祕訣就是使用適宜的函式呼叫。而到了如今,很多執行時系統(例如Unity的Mono引擎)已經可以自動地幫你管理記憶體了。自動記憶體管理(即Automatic Memory Management)相對而言減少了程式碼量,還可以極大地避免記憶體洩露。

執行時系統(Runtime System), 指由程式語言自身實現的核心程式碼,它們包含了一些底層的處理策略(例如自動記憶體管理),雖然我們沒有直觀地看到,但是我們寫的每個程式都包含了執行時系統。
記憶體洩露(Memory Leakage), 指在記憶體被分配後從未被銷燬,在你編寫的專案體量越來越大時記憶體洩露的弊端會更容易出現,自動記憶體管理簡化了記憶體管理的工作,所以某種程度上避開了這個問題。

值型別和引用型別

當函式被呼叫時,它的所有引數值都會被複制到一塊為該函式專門開闢的保留記憶體區(該過程被稱為引數傳遞, parameter passing)。僅佔用少量位元組的資料型別可以被快速簡單地完成複製。然而,對於那些物件,字串和陣列來說,它們佔用的記憶體空間通常非常大,採用常規的方法進行拷貝往往效率低下。幸運的是,這種複製操作並不是必要的,大塊資料的儲存空間往往直接從堆中分配,它們出現的位置都會被一個佔用位元組數極少的指標所替代。指標儲存的值用於記錄大塊資料的實際存放位置。採用這種方式,在引數傳遞時大塊資料僅僅需要複製一個指向它們實際存放位置的指標即可。只要執行時系統可以成功定位到指標記錄地址所在的資料,一個單獨的資料副本就足矣應付所有必要的函式呼叫了。

在引數傳遞過程中可以直接儲存和複製的型別被稱為值型別(value types)。這種型別資料的代表有整數,浮點數,布林值以及Unity中定義的結構體型別(比如Color和Vector3)。在堆中分配,並通過指標存取的資料型別則被統稱為引用型別(Reference types)。這種叫法是源於存放在變數中的值僅僅用於“引用”真實的資料。引用型別的例子包括物件,字串以及陣列。

值型別和引用型別分別有一種型別時常被錯誤地歸類。一個是Vector3,它是貨真價實的結構體型別,所以嘗試修改函式的Vector3引數的xyz值不會影響輸入的資料。另一個就是字串型別,它是“飽受誤解”的引用型別,直接在堆中分配記憶體。

記憶體分配和垃圾回收

記憶體管理器可以實時追蹤堆中未被使用的記憶體區域,你也可以認為管理器儲存有一張未使用區域的列表。當有新的記憶體塊被請求時(換句話說就是有物件被初始化),管理器會選擇堆中未使用區域的記憶體進行分配並將該部分記憶體移出未使用區域的列表。隨後的請求都會按照相同的套路處理,直到沒有足夠大的記憶體塊可以被分配位置。實際上此時堆中被分配出去的記憶體並不是都處於使用狀態。堆中一個引用型別的資料只有在仍有引用型別變數指向它時才可能被訪問和修改。所以當一個記憶體塊的所有引用都消失時(例如引用變數被賦予新的記憶體地址或者作為區域性變數超出作用範圍),這塊被佔用的記憶體空間就可以被安全地回收了。

為了確保堆中某塊記憶體不再被使用,記憶體管理器會搜尋當前所有活躍的引用變數並且將他們引用的資料塊標記為“存活”狀態。在搜尋結束後,所有位於“存活”區塊之間的記憶體區域都會被當做未使用區域處理並且可以在後續的記憶體分配過程中使用。事實上,這種定位和釋放未使用記憶體區域的過程就是大名鼎鼎的垃圾回收(Garbage Collection, 簡寫為GC)。

效能優化

垃圾回收對於程式猿來說是自動化且不可見的,但這並不表明我們不需要關注它。事實上,垃圾回收的過程需要在後臺佔用大量的CPU時間。如果使用得當,自動內部分配通常在總體效能上可以媲美甚至打敗手動記憶體分配。然而對於程式猿來說,如何避免由於GC頻繁觸發而導致的遊戲卡頓是一個很重要的課題。

有一些聲名狼藉的程式碼寫法被稱為”GC的噩夢”,儘管第一眼看上去它們看起來顯得很清白。重複的字串拼接就是一個典型的例子:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void ConcatExample(int[] intArray) {
        string line = intArray[0].ToString();

        for (i = 1; i < intArray.Length; i++) {
            line += ", " + intArray[i].ToString();
        }

        return line;
    }
}

這段程式碼的關鍵細節就在於字串組不能直接採用累加的方式直接拼在一起。在每次迴圈中實際進行的操作其實是:之前的引用變數指向的字串“當場去世”,一個嶄新的字串會被建立幷包含舊字串以及新的字串。由於字串會隨著for迴圈變得越來越長,被消耗的堆記憶體空間也會越來越多。所以每次該函式被呼叫時輕輕鬆鬆就會消耗掉數以百計位元組的空閒記憶體空間。如果你需要把很多字串連線在一起,更好的選擇是使用Mono庫中的System.Text.StringBuilder類。

然而,即使是重複的字串拼接,在非頻繁呼叫的情況下也不會帶來太大的麻煩。一個錯誤的示範就是在Update函式中使用:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public int score;

    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

由於Update()每一幀都會被呼叫,所以上述寫法會在每一幀產生固定大小的垃圾。大多數類似情況都可以通過條件語句判斷score是否改變,並僅在改變時修改text來節約記憶體:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;

    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}

另一個潛在的問題會在函式返回一個數組型別的值時產生:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];

        for (int i = 0; i < numElements; i++) {
            result[i] = Random.value;
        }

        return result;
    }
}

這種型別的函式在建立帶有指定數值的陣列時非常高效優雅。然而如果它被頻繁地呼叫,每次都會分配一個嶄新的記憶體區域。由於陣列可以變得很大,空閒的堆記憶體空間就會因此被迅速消耗,從而導致頻繁的GC。一種避免該問題的方案是利用陣列型別也屬於引用型別的事實。由於引用型別可以被作為引數傳遞,在函式中被修改後即使離開作用範圍仍不會立刻回收,所以上述程式碼經常可以被替換為:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}

上述方法只是簡單地使用全新的內容替換陣列中已有的內容。雖然在使用之前需要進行額外的初始化操作(看起來一點也不清真),但是這個函式被呼叫時不再會產生新的垃圾了。

請求垃圾回收

像之前提到過的,最好儘可能地避免垃圾回收。但是鑑於GC並不能被完全地消除,一般都有兩種主要的策略可以用於最小化GC對遊戲過程產生的衝擊。

小型堆記憶體,快速頻繁GC

該策略常常適用於擁有較長遊戲週期的遊戲。在該類遊戲中平穩的幀率往往是主要的考量。在這類遊戲中小塊的記憶體會被頻繁地分配,但僅僅只會使用很短的時間。在iOS上使用該策略時,典型的堆大小大約為200KB。在iPone 3G上GC大概會花費5ms。如果堆大小增加到1MB,GC花費的時間就會增長到7ms。這時以固定的間隔請求GC有時會變得比較有用。儘管這麼做通常會讓垃圾回收比常規情況更加頻繁,但是每次GC都可以處理的很快,對遊戲的影響可以降到很低:

if (Time.frameCount % 30 == 0)
{
   System.GC.Collect();
}

大型堆記憶體,緩慢低頻GC

該策略適用於記憶體分配相對頻率較低並且可以在暫停時(例如載入頁面)進行記憶體處理的遊戲。該情況下堆記憶體儘可以大會比較好,但要保證堆記憶體不會過大而導致應用被作業系統幹掉。不幸的是,Mono的執行時環境會盡可能避免堆記憶體擴張。但是你仍可以通過在遊戲啟動時預分配一些佔位空間來強制擴張堆記憶體:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];

        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];

        // release reference
        tmp = null;
    }
}

一個足夠大的堆記憶體在兩次遊戲暫停的間隙不太可能被填滿。而在遊戲暫停的過程中你完全可以通過顯式呼叫GC進行記憶體回收。

System.GC.Collect();

再次提醒,為了達到理想的效果,你應該分析效能統計資料來調整堆記憶體的大小,而不是想當然的認為可以滿足預期。

可重用的物件池(Object Pools)

很多情況下你可以簡單地通過減少建立和銷燬的物件數量來避免產生垃圾。在遊戲裡有很多種類的物件會被頻繁地使用但僅有一小部分會同時顯示。在這種情況下,通常選擇複用物件要比直接銷燬+建立高效的多。

結語