参考:

  1. C# 中的 String 究竟是个怎样的类型
  2. 浅谈C#字符串构建利器StringBuilder

string

.Net框架程序设计(修订版)中有这样一段描述:

String类型直接继承自 Object,这使得它成为一个引用类型,也就是说线程上的栈上不会驻留有任何字符串。

String类型对象直接派生自Object,所以String是引用类型,因此,String对象总是存在于堆上,永远不会跑到线程栈。

但String类型却又有值类型的特性,所以问到String究竟是值类型还是引用类型时,我们一般称为特殊的引用类型

具体请看以下例子:

1
2
3
4
5
6
string str1 = "str1";
string str2 = str1;
str1 = "str3";

Console.WriteLine(str1); // str3
Console.WriteLine(str2); // str1

按普通引用类型的特性来理解以上代码,str2 应该指向 str1 的同一个内存地址,若此时修改 str1 的值,str2 应该也会发生变化,但修改 str1 的值为 “str3” 后,str2 的值仍然为 “str1”,这就是上文所说的 String 类型具有值类型的特征。

出现上述情况其实是因为字符串是“不变的”这一特性。出于性能考虑,String 类型与 CLR 紧密集成。具体地说,CLR 知道 String 类型中定义的字段如何布局,会直接访问这些字段。但为了获得这种性能和直接访问的好处,String只能为密封类。即不可把 String 作为自定义类型的基类,以防破坏了 CLR 对 String 类型的预设。而String对象的“不可变”特性就是CLR对String对象的预设之一,是 String 对象最重要的一个特性,也是引起上文所说的 “String类型具有值类型特征” 的原因。下文来具体说一下String类型的几个重要特性。

字符串的不变性

String 对象一旦创建,就不能再更改,包括不能变长、变短或修改其中任何字符。

上文代码中str1="str3"看似修改了字符串,但实际上是创建了一个新的String对象,并把原引用指向这个新对象,对于旧的“str1”是没有被改动的,所以最后str2仍然是指向这个旧的“str1”对象。

字符串的不可变简单地说有以下优点:

  • 可以连续使用 ToUpperSubstring 等修改字符串的方法,但又不影响原本字符串
  • 访问字符串不会发生线程同步问题
  • CLR 可优化多个值相同的 String 变量指向同一个 String 对象,从而减少内存中 String 数量(字符串留用)

但需要注意的是,因为字符串的不变性,在进行字符串拼接、修改等操作时,实际上会产生大量的临时字符串对象,造成更频繁的垃圾回收,从而影响应用程序性能。若要高效执行大量字符串拼接操作,建议使用StringBuilder类。

字符串留用

编译源代码时,编译器必须处理每个字面值字符串,并在托管模块的元数据中嵌入。同一个字符串在源码中多次出现,把它们都嵌入元数据会使生成的文件无谓地增大。

为解决这个问题,编译器会只在模块的元数据中只将字面值字符串写入一次。引用该字符串的所有代码都被修改成引用元数据的同一个字符串。编译器将单个字符串的多个实例合并成一个实例,能显著减少模块的大小。这并不是一项新技术,但仍然是提升字符串性能的有效方式之一,开发者应该注意到这个优化方式的存在。

因为字符串的“不可变”性,在内存中对同一个字符串复制多个实例是纯属浪费,在内存中只保留字符串的一个实例可以显著降低内存消耗,需要引用该字符串的所有变量都统一指向该字符串对象即可。

CLR 初始化时,会在内部创建一个哈希表,这个表中,key 是字符串,value 则是托管堆中 String 对象的引用(堆上 String 对象的地址)。String类型提供了静态方法InternIsInterned以便访问这个内部哈希表。

1
2
public static String Intern(String str);
public static String IsInterned(String str);
  • Intern 方法,它首先会获取参数 String 对象的哈希码,并在内部哈希表中检查是否有相匹配的,若存在,则返回对该 String 对象的引用,若不存在则创建该字符串副本,把副本添加到哈希表中,并返回该副本的引用。

  • IsInterned 方法也是获取参数 String 对象的哈希码,并在内部哈希表中查找他,若存在,则返回该 String 对象的引用,若不存在则返回 null,不会添加到哈希表中。

1
2
3
4
5
6
7
//示例1
string str1 = "Hello World";
string str2 = "Hello World";

bool equal = string.ReferenceEquals(str1, str2); // ReferenceEquals方法比较两者是否同一个引用

Console.WriteLine(equal); // True (CLR 4.5)

CLR 会默认留用程序集的元数据中描述的所有字面值字符串,所以可能出现上面示例1代码情况,因为str1和str2是引用了堆中同一个字符串对象。但这不是必然的,考虑到性能问题,C#编译器会指定某个特性标记让CLR不对元数据中的字符串进行留用,但CLR可能会忽视这个标记。所以除非是显示调用Intern方法,否则永远不要以“字符串已留用”为前提来写代码

另外要注意,垃圾回收器不能释放内部哈希表引用的字符串,因为哈希表正在容纳对它们的引用。除非卸载AppDomain或进程终止,否则内部哈希表引用的String 对象不能被释放。

注意,由于字符串池需要维护所有字符串的引用,保持字符串不被 GC,因此字符串池的哈希表本身也不能被 GC,由此结合 C# 补完计划(二):GC 可以得出,字符串池的哈希表本身是一个 GC Root,因此应该处于常量区。

🌰

1
2
3
4
5
6
7
// 示例2
string str1 = "Hello World";
string str2 = "Hello" + " " + "World";

bool equal = string.ReferenceEquals(str1, str2);

Console.WriteLine(equal); // True (CLR 4.5)

示例2结果与示例1相同,在特定CLR版本(4.5)都为True,对于字符串字面值的拼接,实际上都是编译时可确定的,编译时str2会自动拼接成"Hello World"这样的一个完整字符串,并适用了字符串留用,所以str1和str2都是引用同一个String对象。

1
2
3
4
5
6
7
//示例3
string str1 = "Hello World";
string str2 = string.Format("{0} {1}", "Hello", "World");

bool equal = string.ReferenceEquals(str1, str2);

Console.WriteLine(equal);//False (CLR 4.5)

示例3结果与上面两个示例不一样,因为str2并不是字符串字面值的直接拼接,是要在运行时才能确定的,不会作为字面值自动启用字符串留用,与str1引用的是不同且独立的String对象,所以结果为False。

字符串的构造

C# 把 String 视为基元类型,也就是说,编译器允许在源代码中直接使用字面值字符串。编译器会将这些字符串放到模块的元数据中,并在运行时加载和引用它们。

1
2
string str1 = new string("Hello World");  // error
string str2 = "Hello World"; // right

C# 不允许直接使用 new 操作符从字面值来构造 String 对象,相反必须用简化的语法直接赋值字面值。

如果用反编译软件查看直接用字面值构造的String对象的的IL代码,会发现IL代码中并没有出现newobj指令,而是使用了特殊的ldstr(load string)指令,它使用从元数据获得的字面值字符串去构造String对象。 这证明CLR实际上是用一中特殊的方式去构造字面值String对象。

猜测若在留用了字面值字符串情况下,构建过程中会在字符串留用的内部哈希表中查找是否已存在相同的字符串,若有则返回该引用,若没有则创建,并返回地址。

StringBuilder

StringBuilder 类是一个可变的字符串,它允许在字符串中插入、删除、追加字符,而不会创建新的 String 对象。这样可以避免创建大量的临时字符串对象,提高性能。

1
2
3
4
5
6
StringBuilder sb = new StringBuilder();
sb.Append("Hello");
sb.Append(" ");
sb.Append("World");

string str = sb.ToString(); // Hello World

StringBuilder 的原理详见 浅谈C#字符串构建利器StringBuilder,此处仅简要说明结论。

  • c# StringBuilder 的本质是单向链表操作,StringBuilder 本身包含了 m_ChunkPrevious 指向的是上一个扩容时保存的数据,扩容的本质就是给这个链表新增一个节点。

  • c# StringBuilder 每次扩容的长度是不固定的,实际的扩容长度是 max(当前追加字符的剩余长度,min(当前StringBuilder长度,8000)),每次扩容新增的节点存储块的容量都会增加。大部分使用时遇到的情况是首次为16、二次为16、三次为32、四次为64以此类推。

  • c# StringBuilder类的 ToString 本质就是倒序遍历单向链表,每一次遍历都获取当前 StringBuilderm_ChunkPrevious 字符数组获取数据拼接完成之后,然后获取 m_ChunkPrevious 指向的上一个 StringBuilder 实例,最终把结果组装成一个字符串返回。

后日谈:C++ 中的字符串

类似于 C#,在 C++ 中也有两种字符串类型:char*std::string

char* 是 C 语言中的字符串类型,是一个指向字符数组的指针,用于表示字符串。char* 类型的字符串是一个字符数组,以空字符 '\0' 结尾。char* 类型的字符串是一个指针,指向字符串的首字符。

std::string 是 C++ 标准库中的字符串类型,是一个类,用于表示字符串。std::string 类型的字符串是一个类对象,包含了字符串的长度和内容,可以调用类的成员函数来操作字符串。

由上文我们了解到,CLR 会优化多个值相同的 String 变量指向同一个 String 对象,从而减少内存中 String 数量。而在 C++ 中,std::string 类型的字符串是一个类对象,所以 std::string 类型的字符串是不可共享的,即使两个 std::string 对象的内容相同,它们也是不同的对象。

但 C++ 编译器本身会对 const char* 的字面值常量进行优化,如下面的例子:

1
2
3
4
5
const char* str1 = "Hello World";
const char* str2 = "Hello World";

// 打印 a 与 b 指向的地址
cout << (void*)a << " " << (void*)b << endl; // 相同

内存管理杂谈 中我们总结过,C++ 中的字面值字符串被储存在常量区(即 .rodata)段,编译器会在编译的过程中对字符串进行优化,使得内存中仅有一份相同的字符串,在 MSVC 中,对应的编译器指令即为 /GF

image.png

详见 /GF(消除重复的字符串)

/GF 使编译器在执行过程中能够在程序映像和内存中创建相同字符串的单个副本。 这是一种称为字符串池的优化方法,可以创建较小的程序。

如果使用 /GF,则操作系统不会交换内存的字符串部分,并且可以从映像文件读取字符串。

/GF 池字符串为只读。 如果尝试修改 /GF 下的字符串,则会发生应用程序错误。

字符串池允许原本为指向多个缓冲区的多个指针变成指向单个缓冲区的多个指针。