毫無疑問,字串是我們使用頻率最高的型別。但是如果我問大家一個問題:「一個字串物件在記憶體中如何表示的?」,我相信絕大部分人回答不上來。我們今天就來討論這個問題。
一、字串物件的記憶體佈局
二、以二進位制的方式建立一個String物件
三、字串的「可變性」
從「值型別」和「參照型別」來劃分,字串自然屬於參照型別的範疇,所以一個字串物件自然採用參照型別的記憶體佈局。我在很多文章中都介紹過參照型別範例的記憶體佈局(《以純二進位制的形式在記憶體中繪製一個物件》 和《如何將一個範例的記憶體二進位制內容讀出來?》,總的來說整個記憶體佈局分三塊:ObjHeader + TypeHandle + Payload。對於一般的參照型別範例來說,最後一部分存放的就是該範例所有欄位的值,但是字串有點特別,它有哪些欄位呢?
說到這裡,可能有人想去反編譯一下String型別,看看它定義了那些欄位。其實沒有必要,字串這個型別有點特別,它的Payload部分由兩部分組成:字串長度(不是位元組長度)+編碼的文字,下圖揭示了字串物件的記憶體佈局。那麼具體採用怎樣的編碼方式呢?可能很多人會認為是UTF-8,實在不然,它採用的是UTF-16,大部分字元通過兩個位元組來表示,少數的則需要使用四個位元組。至於位元組序,自然是使用小端位元組序。我們知道Go的字串採用UTF-8編碼,這也是Go在網路程式設計具有較好效能的原因之一。
在《以純二進位制的形式在記憶體中繪製一個物件》中,我們通過構建一個位元組陣列來表示建立的物件,現在我們依然可以採用類似的方式來建立一個真正的String物件。如下所示的AsString方法用來將用於承載字串範例的位元組陣列轉換成一個String物件,至於這個位元組陣列的構建,則有CreateString方法完成。CreateString方法根據指定的字串內容建立一個String物件,並利用輸出引數返回該物件對映在記憶體中的位元組陣列。
static unsafe string CreateString(string value, out byte[] bytes) { var byteCount = Encoding.Unicode.GetByteCount(value); // ObjHeader + TypeHandle + Length + Encoded string var size = sizeof(nint) + sizeof(nint) + sizeof(int) + byteCount; bytes = new byte[size]; // TypeHandle BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(sizeof(nint)), typeof(string).TypeHandle.Value.ToInt64()); // Length BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(sizeof(nint) * 2), value.Length); // Encoded string Encoding.Unicode.GetBytes(value).CopyTo(bytes, 20); return AsString(bytes); } static unsafe string AsString(byte[] bytes) { string s = null!; Unsafe.Write(Unsafe.AsPointer(ref s), new IntPtr(Unsafe.AsPointer(ref bytes[8]))); return s; }
由於我們需要建立一個位元組陣列來表示String物件,所以必須先計算出這個位元組陣列的長度。我們在上面說過,String型別採用UTF-16/Unicode編碼方式,所以我們呼叫Encoding.Unicode的GetByteCont方法可以計算出指定的字串編碼後的位元組數。在此基礎上我們還需要加上通過一個整數(sizeof(int))表示字串長度和TypeHandle(sizeof(nint))和ObjHeader(sizeof(nint),含padding),就是整個String範例在記憶體中佔用的位元組數。
接下來我們填充String型別的TypeHandle的值(String型別方法表地址)、字串長度和編碼後的位元組,最終將填充好的位元組陣列作為引數呼叫AsString方法,返回的就是我們建立的String物件。CreateString方法針字串物件的建立可以通過如下的程式碼來驗證。
var literal = "foobar"; string s = CreateString(literal, out var bytes); Debug.Assert(literal == s);
對於上面定義的AsString方法來說,作為輸入引數的位元組陣列字串範例的記憶體片段,所以該方法針對同一個陣列返回的都是同一個範例,如下的演示程式碼證明了這一點。
var literal = "foobar"; CreateString(literal, out var bytes); var s1 = AsString(bytes); var s2 = AsString(bytes); Debug.Assert(ReferenceEquals(s1,s2));
我們都知道字串一經建立就不會改變,但是對於上面建立的字串來說,由於我們都將承載字串範例的記憶體位元組都拿捏住了,那還不是想怎麼改就怎麼改。比如在如下所示的程式碼片段中,我們將同一個字串的文字從「foo」改成了「bar」。
var literal = "foo"; var s = CreateString(literal, out var bytes); Debug.Assert(s == "foo"); Encoding.Unicode.GetBytes("bar").CopyTo(bytes, 20); Debug.Assert(s == "bar");