你知道.NET的字串在記憶體中是如何儲存的嗎?

2023-07-17 09:00:37

毫無疑問,字串是我們使用頻率最高的型別。但是如果我問大家一個問題:「一個字串物件在記憶體中如何表示的?」,我相信絕大部分人回答不上來。我們今天就來討論這個問題。

一、字串物件的記憶體佈局
二、以二進位制的方式建立一個String物件
三、字串的「可變性」

一、字串物件的記憶體佈局

從「值型別」和「參照型別」來劃分,字串自然屬於參照型別的範疇,所以一個字串物件自然採用參照型別的記憶體佈局。我在很多文章中都介紹過參照型別範例的記憶體佈局(《以純二進位制的形式在記憶體中繪製一個物件》 和《如何將一個範例的記憶體二進位制內容讀出來?》,總的來說整個記憶體佈局分三塊:ObjHeader + TypeHandle + Payload。對於一般的參照型別範例來說,最後一部分存放的就是該範例所有欄位的值,但是字串有點特別,它有哪些欄位呢?

說到這裡,可能有人想去反編譯一下String型別,看看它定義了那些欄位。其實沒有必要,字串這個型別有點特別,它的Payload部分由兩部分組成:字串長度(不是位元組長度)+編碼的文字,下圖揭示了字串物件的記憶體佈局。那麼具體採用怎樣的編碼方式呢?可能很多人會認為是UTF-8,實在不然,它採用的是UTF-16,大部分字元通過兩個位元組來表示,少數的則需要使用四個位元組。至於位元組序,自然是使用小端位元組序。我們知道Go的字串採用UTF-8編碼,這也是Go在網路程式設計具有較好效能的原因之一。

image

二、以二進位制的方式建立一個String物件

在《以純二進位制的形式在記憶體中繪製一個物件》中,我們通過構建一個位元組陣列來表示建立的物件,現在我們依然可以採用類似的方式來建立一個真正的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");