天天看點

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

作者:opendotnet

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

一、字元串對象的記憶體布局

二、以二進制的方式建立一個String對象

三、字元串的“可變性”

一、字元串對象的記憶體布局

從“值類型”和“引用類型”來劃分,字元串自然屬于引用類型的範疇,是以一個字元串對象自然采用引用類型的記憶體布局。我在很多文章中都介紹過引用類型執行個體的記憶體布局(《以純二進制的形式在記憶體中繪制一個對象》 和《如何将一個執行個體的記憶體二進制内容讀出來?》,總的來說整個記憶體布局分三塊:ObjHeader + TypeHandle + Payload。對于一般的引用類型執行個體來說,最後一部分存放的就是該執行個體所有字段的值,但是字元串有點特别,它有哪些字段呢?

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

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

二、以二進制的方式建立一個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 = !;
 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");           

繼續閱讀