天天看点

你知道.NET的字符串在内存中是如何存储的吗?

毫无疑问,字符串是我们使用频率最高的类型。但是如果我问大家一个问题:“一个字符串对象在内存中如何表示的?”,我相信绝大部分人回答不上来。我们今天就来讨论这个问题。

一、字符串对象的内存布局

二、以二进制的方式创建一个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");