C# NoGCString

C# NoGCString

修改字元串內容

EditableStringExtender

C#的字元串是不允許修改其內容的,這樣做的理由主要是為了線程安全,因為字元串是非常常用的類型,頻繁對其加鎖會非常不便。

此外,由於字元串內容不會變化,只要其引用地址不變,就可以認為內容不變,處理起來會比較簡單。

但這就導致在修改字元串對象的時候不得不進行大量複製,並且重複分配堆內存給新的字元串,成為了GC Allow的重要來源。

我們可以換用char[]來實現字元串並代替原字元串工作,但如果想進行顯示,卻依然要轉換成系統字元串,因此並不能解決問題。

限制使用頻率是最好的解決辦法,但是,當我想在界面上隨時顯示當前的滑鼠坐標,就不得不每幀生成新的字元串,這是這個需求所無法迴避的。在這種時候,只有修改原始字元串這種做法可以滿足需要。

C#的字元串可以修改其內容嗎?當然是可以的。因為它畢竟本質上還是一個數組,只是其修改的手段被系統屏蔽了。

所以我們可以在unsafe塊內將string轉換成char[],並通過和C語言完全相同的指針操作來為其賦值。

public static unsafe void TestFunction(){ string str = "aaa"; fixed (char* ptr = str) { ptr[0] = b; ptr[1] = b; ptr[2] = b; } Debug.Log(str);//"bbb"}

但是要注意,由於C#的常量字元串都會使用同一份內存,修改了"aaa"這個字元串,其他所有以常量字元串形式存在的"aaa"都會變成"bbb"。

Debug.Log("aaa");//"bbb"

這會導致意想不到的錯誤。所以在字元串修改前必須先進行一次複製:str = string.Copy("aaa");

Mono的字元串,是由16B的Mono管理信息,4B長度,再加一個預定長度的C語言數組構成的,和System.Array的結構完全一致。C數組的內容是每2B存儲一個char,最初申請的時候會申請字元串長度+1的內存量,末尾加0。

在隱式轉換成char[]時候,Mono自動位移了20B,讓指針停在了內部數組的開頭。所以我們只需要再向前位移4B,就能停在表示字元串長度的int的位置,然後就能修改它。

public static unsafe void TestFunction(){ string str = string.Copy("aaa"); fixed (int* ptr = str) { *(iptr - 1) = 2; } Debug.Log(str);//"aa"}

減少長度是「安全」的。但如果增加長度,由於C數組並沒有邊界檢查,就會寫到原有的數據塊外面去。這很可能會破壞某個地方的數據,或者寫出去的字元串數據又被相鄰的數據破壞。總之,會導致非常嚴重,難以復現的隨機錯誤(如果你想把項目坑死也可以用這個辦法)

擴大數組在目前的API下難以做到,所以只能在一開始就創建足夠長的字元串,再修改其內容和長度以符合需要。

string str = new string((char)0, 50);fixed (int* ptr = str){ *(iptr - 1) = 0;}//創建一個可安全容納50字元的空字元串

一個封裝了一系列方法的字元串擴展(可以用Int來給String設值,這樣就能顯示坐標了)

String/EditableStringExtender.cs?

github.com圖標

不過,在將字元串賦值到UGUI的Text屬性上時,由於字元串引用未發生變化,組件並不會自動更新(畢竟這是預期之外的「不安全」行為),所以我們必須手動要求組件更新。

text.cachedTextGenerator.Invalidate();text.SetVerticesDirty();

類似的問題可能會有很多。

此外,還要注意這種修改很明顯是「線程不安全」的。而原始的string本來是線程安全的,相關庫在讀取時都不會對它加鎖,所以你必須非常明確這個字元串相關的庫都不存在可能衝突的多線程操作。

UGUI只會在主線程上使用這個字元串,但是其他庫就很難講了,這裡必須注意。

字元串池

PoolStringExtender

C#的相同內容的字元串常量都會使用同一份實例,但是動態生成的字元串全都是不同的實例,即使它們的內容完全相同。

這常見於數據表,字典鍵對象,以及將字元串用於代替枚舉使用的情況。在這種情況下,會產生大量臃余的字元串。

但我必須得強調,在合理的習慣下,是不應該在數據表內儲存大量相同字元串的,因為這會導致數據表體積變大。完全可以把相同字元串單獨建表,並在原處使用數值。字典用字元串作鍵,字元串枚舉判斷相等,其實也是動態語言遺留下的病灶,這樣做雖然簡便,但並不高效。

Lua這種動態語言,由於不可能迴避字元串鍵的存在,所以在生成一個新的字元串前會預先計算hash,如果相同hash的字元串已經存在,就會將那個字元串直接復用。這樣固然是解決了重複字元串的問題,但每次生成新字元串都需要生成一次hash,相同字元串存在,且比較指令較多時是優化,但如果這些字元串操作並不常見,這個hash計算就是沒有意義的。

而如果你有正確的編程習慣,重複的動態字元串其實是少見的,大部分的字元串應該用來顯示,而不是幹些完全能用別的值類型代替的事情。

但是輸入數據不歸你控制的時候,那就沒辦法了。比如像處理JSON的時候,還有通信時從外部傳來的協議數據。

外面如果一直給你發重複字元串讓你接受,為了不產生大量GC Allow,就只能向LUA學習,搞一個預先生成Hash的東西出來。

String/PoolStringExtender.cs?

github.com圖標

不過,如果是這種「內容相同,引用就一定相同」的字元串,就沒有必要用==判等,而應該用(object)str1 == (object)str2來判斷相等。默認的判等,因為字元串地址不同內容依然可能相同,所以在字元串引用不同的時候,會先判斷一次長度,再遍歷所有內容數據來判斷是否相等,在兩個字元串不同的時候會浪費很多性能(然而這時為了避免計算hash不得已的犧牲)

既然已經做了合併處理,就可以直接用object類型的==重載,判斷一次地址是否相同就夠了。

(object.ReferenceEquals也可以)

雖然string.Intern(string)也可以將一個字元串加入「系統字元串池」共用同一份內存,但它只接受字元串作為參數,生成這個參數字元串的GC Allow還是是無法避免的。

而如果希望新生成的字元串可以和常量字元串通過ReferenceEquals進行比較,則需要執行一次string.Intern(string),把字元串的引用換成「常量池」內的(只需要在生成不重複字元串時執行)。


推薦閱讀:

作大死項目筆記 - 從掃描自己開始
[Unity]面試題整理(一)
Rendering in UE4(Epic Game TA-Homam Bahnassi講座個人筆記)
遊戲性能優化(1)-why & benchmark
BGE:在3D軟體里做遊戲 (1)

TAG:Unity遊戲引擎 | 遊戲開發 | C |