Unity 再聊C#字符串

2020-09-25  本文已影响0人  雄关漫道从头越

上周研究了下IL,这周在看《你必须知道的.Net》,还有点意思,书中讲了些平时比较少关注的C#的底层特性,今天再来聊聊这个String。

一、字符串特殊性

String类型很特殊,为什么呢?
1.创建特殊性: String 对象不以 newobj 指令创建,而是 ldstr 指令创建。在实现机制上, CLR 给
了特殊照顾来优化其性能。

2.String 类型是.NET 中不变模式的经典应用,在 CLR 内部由特定的控制器来专门处理 String 对象。
3.应用上, String 类型表现为值类型语义;内存上,String 类型实现为引用类型,存储在托管堆中。
4.两次创建内容相同的 String 对象可以指向相同的内存地址。
5.String 类型被实现为密封类,不可在子类中继承。
6.String 类型是跨应用程序域的,可以在不同的应用程序域中访问同一 String 对象。
这是书中的归纳总结,初学者非常容易被String是值类型还是引用类型搞晕,因为String使用起来跟值类型几乎没有什么区别,对于引用类型的"=="与"Equals"方法比较的两个引用类型的地址是否相等,而我们这么写

string a = "unity";
string b = "unity";

Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(ReferenceEquals(a, b));

//执行结果:
//True
//True
//True

都返回真,为什么?通常的理解是String类型的"=="与"Equals"比较的是值是否相等,那么ReferenceEquals又如何解释呢?
如果我们把代码改一下

string a = "unity";
string c = "uni";
string b = c + "ty";
Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(ReferenceEquals(a, b));

//执行结果:
//True
//True
//False

为什么?

二、字符串恒定性

字符串恒定性(Immutability),是指字符串一经创建,就不可改变。这是 String 对象最为重要的特性之一,是 CLR 高度集成 String 以提高其性能的考虑。具体而言,字符串一旦创建,就会在托管堆上分配一块连续的内存空间,我们对其的任何改变都不会影响到原 String 对象,而是重新创建出新的 String 对象。

string str = "This is a test about immutablitity of string type.";
Console.WriteLine(str.Insert(0, "Hi, ").Substring(19).ToUpper());
Console.WriteLine(str);

//执行结果:
//ABOUT IMMUTABLITITY OF STRING TYPE.
//This is a test about immutablitity of string type.

这段代码会产生多少个临时字符对象?答案是3个,str的结果还是原来的结果,Insert、Substring 和ToUpper 方法都会创建出新的临时,而且因为没有被其他代码引用,会成为内存垃圾,等待下次垃圾回收器回收,造成性能损失,这也是为什么对于字符串大量连接操作建议使用StringBuilder的原因。

三、字符串驻留

先看个例子

class StringInterning
{
    public static void Main()
    {
        string strA = "abcdef";
        string strB = "abcdef";
        Console.WriteLine(ReferenceEquals(strA, strB));
        string strC = "abc";
        string strD = strC + "def";
        Console.WriteLine(ReferenceEquals(strA, strD));
        Console.WriteLine(strA == strD);
        Console.WriteLine(strA.Equals(strD));
        strD = String.Intern(strD);
        Console.WriteLine(ReferenceEquals(strA, strD));
    }
}

//执行结果:
//True
//False
//True
//True
//True

是不是有点意外,strA和strB是两个不同的String对象,按理说会在托管堆上分配两个不同的内存块,内存地址应该不同,但是结果却说明strA和strB指向的是同一内存地址;而strA与strD的值是相同的,所以"=="与"Equals"方法返回的结果为True,而两者指向的却是不同的对象;最后String.Intern(strD)后,strA和strD又指向了相同的对象,ReferenceEquals返回真。

因为上面说了字符串是具有恒定性的, 对字符串的任何操作,包括字符串比较,字符串链接,字符串格式化等会创建新的字符串,原来已经创建的不会修改,这样就会导致大量的性能浪费,为了解决性能的问题,CLR创建了一种叫“字符串驻留机制”,对于相同的字符串,CLR不会分别分配内存空间,而是共享同一内存,同时CLR内部维护了一个哈希表(Hash Table)来管理其创建的大部分 string 对象,Key 为 string 本身,而 Value 为分配给对应的 string 的内存地址。


内部字符串哈希表

CLR 初始化时,会创建一个空哈希表,当 JIT 编译方法时,会首先在哈希表中查找每一个字符串常量,显然第一次它不会找到任何“abcdef”常量,因此会在托管堆中创建一个新的 string 对象 strA,并在哈希表中创建一个 Key-Value 对,将“abcdef”串赋给 Key,而将 strA 对象的引用赋给 Value,也就是说 Value 内保持了指向“abcdef”字符串在托管堆中的引用地址。这样就完成了第一次字符串的创建过程。
程序接着运行

...
string strB = "abcdef";
...

JIT 根据“abcdef”在哈希表中逐个查找,结果找到了该字符串,所以 JIT 不会执行任何操作,只是把找到的 Key-Value 对的 Value 值赋给 strB 对象。由此可知, strA 和 strB 具有相同的内存引用,所以ReferenceEquals 方法当然返回 true。

...
string strC = "abc";
string strD = strC + "def";
...

接着, JIT 以类似的过程来向哈希表中添加了“abc”字符串,并将引用返回给 strC 对象;但是 strD 对象的创建过程又有所区别,因为 strD 是动态生成的字符串,这样的字符串是不会被添加到哈希表中维护的,因此以 ReferenceEquals 来比较 strA 和 strD 会返回 false。
对于动态生成的字符串,因为没有添加到 CLR 内部维护的哈希表而使字符串驻留机制失效。但是,当我们需要高效的比较两个字符串是否相等时,可以手工启用字符串驻留机制,这就是调用 String 类型的两个静态方法,它们是:

public static string Intern(string str);
public static string IsInterned(string str);

二者的处理机制都是在哈希表中查找是否存在 str 参数字符串,如果找到就返回已存在的 String 对象的引用,否则 Intern 方法将该 str 字符串添加到哈希表中,并返回引用;而 IsInterned 方法则不会向哈希表中添加字符串,而只是返回 null。

综上所述,当一个引用字符串的方法被编译时,所有的字符串常量都会被以这种方式添加到该哈希表中,但是动态生成的字符串并未执行字符串驻留机制。

注意:

string strA = "abcdef";
string strC = "abc";
string strD = strC + "def";//动态字符串
Console.WriteLine(ReferenceEquals(strA, strD));
string strE = "abc" + "def";//编译器自动连接为一个文本常量
Console.WriteLine(ReferenceEquals(strA, strE));

//执行结果
//False
//True

直接赋值 strE = “abc” + “def”的操作,编译器会自动将其连接为一个文本常量加载,因此会添加到内部哈希表中,所以 strA 和 strE 指向同一对象。

四、Unity验证

下面我们在Unity做几个小测试来验证一下。
首先我们来比较一下内存地址,先创建一个内存地址获取的Util类

///MemoryUtil.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;

public static class MemoryUtil
{
    /// <summary>
    /// 获取引用类型的内存地址方法
    /// </summary>
    /// <param name="o"></param>
    /// <returns></returns>
    public static string GetMemory(object o)
    {
        GCHandle h = GCHandle.Alloc(o, GCHandleType.Pinned);
        IntPtr addr = h.AddrOfPinnedObject();
        return "0x" + addr.ToString("X");
    }
}

接下来通过不同的生命周期来比较一下相同字符串内存地址是否变化

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Text;
/// <summary>
/// 字符串恒定性与驻留测试
/// </summary>
public class StringTester : MonoBehaviour
{
    public Text text;
    private const string PATH = "ui/main/main_view";
    private int count = 1;

    private void Awake()
    {
        string path = "main_view";
        Debug.Log("Awake:" + MemoryUtil.GetMemory(path));
        System.GC.Collect(0);//清理第0代
    }

    private void Start()
    {
        string path = "main_view";
        Debug.Log("Start:" + MemoryUtil.GetMemory(path));
        System.GC.Collect(0);
    }
  
    //使用ref的方式修改字符串
    public void ChangePath(ref string path)
    {
        path = path + ":open";
        //path = string.Intern(path);//string.Intern将动态生成的字符串添加到CLR内部维护的哈希表,下次相同字符串不会再申请新的地址,直接指向之前的地址,并返回地址
    }

    public void OnBtnClickHandler()
    {
        string path = "main_view";
        Debug.Log("Before-"+count+":" + MemoryUtil.GetMemory(path));
        ChangePath(ref path);
        Debug.Log("After-" + count + ":" + MemoryUtil.GetMemory(path));
        count++;
    }
}

运行结果

使用局部变量 string path = "main_view",分别在Awake、Start和按钮点击事件时输出path的内存地址,其中按钮点击事件时做一次ref 传参的方法修改字符串,并计数,结果显示Awake、Start和点击事件Before输出的内存地址均相同,path变量都是局部变量,只是字符串的值相同,证明了CLR内部字符串哈希表的存在,相同的字符串共享同一内存地址,而每次After修改后的内存地址都改变了,虽然每次返回的值都是相同的,这说明了动态字符串不会添加到CLR内部字符串哈希表中,如果修改一下ChangePath方法

//使用ref的方式修改字符串
    public void ChangePath(ref string path)
    {
        path = path + ":open";
        path = string.Intern(path);//string.Intern将动态生成的字符串添加到CLR内部维护的哈希表,下次相同字符串不会再申请新的地址,直接指向之前的地址,并返回地址
    }
使用string.Intern方法后指向同一内存地址

可以看到再调用了string.Intern方法后,3次返回的内存地址都是相同的,说明string.Intern方法将动态生成的字符串添加到CLR内部维护的哈希表中了,但是要注意

path = path + ":open";

还是会生成临时字符串,产生额外的GC。

这里要思考一个问题,ref关键字发生了什么?
根据String类型的恒定性,path="main_view"不可修改,所以当使用ref方式传参时,会将对字符串操作重新生成的字符串,并将新的内存地址返回给path,所以调用的参数path指向了新的地址,而如果不使用ref方式传参,ChangePath中对参数path的修改只会生成新的字符串,而不会返回新生成字符串的内存地址给调用的参数path。
这个跟值类型是一致的,也是与其他的引用类型不一样的地方。

细心的朋友可能留意到Awake和Start方法是调用了System.GC.Collect(0)方法的,手动强制回收托管堆内存,但是可以发现,无论在哪里声明的string path = "main_view",内存地址都没有改变,这说明什么?一旦字符串被添加到CLR内部字符串哈希表是无法被垃圾回收器回收的,尽管代码已经没有引用了,但是CLR内部字符串哈希表还在引用它们,这一点是要注意的。

不过这同时也给我们开发过程中使用字符串带来新的问题:
1.非动态字符串只会产生一次内存申请
2.无法被垃圾回收器回收,所以要注意常量字符串的使用

对于UI路径、语言包的key等常量型可以考虑使用字符串常量保存,同时也要控制字符串常量的数量,避免使用过多使用动态字符串等,大量字符串操作使用StringBuilder。

对于已经添加到内部字符串哈希表的字符串,无论使用变量还是直接写字符串都不会产生GC

void Update()
    {
        ProfilerUtil.BeginSample("StringTester");
        ///"ui/main/main_view"已经添加到CLR内部维护的哈希表,不会再产生GC
        OpenView("ui/main/main_view");
        OpenView(PATH);
        ProfilerUtil.EndSample();
    }

    public void OpenView(string path)
    {
      //判断path指向的字符串是否已经添加到CLR内部维护的哈希表,已经添加到返回字符串,否则返回null
        text.text = string.IsInterned(path);
    }
字符串已添加内部字符串哈希表,再次使用无GC

可以看到在Update连续两次调用,都不会产生GC。

...
OpenView("ui/main/main_view");
OpenView(PATH);
...

而且无论是直接写常量字符串,还是使用变量都是一样的,因为他们都指向同一个内存地址。
再说一下,代码被编译后就没有什么变量名了,变量名是给开发者看的,机器直接对内存操作,不存在什么变量名了,可能你还听说过符号表,符号表就是变量名、函数名、文件名、行号的映射表跟内存地址的映射了,反编译和看运行时堆栈信息,定位bug就需要它了。

最后回答最前面提出的问题:

string a = "unity";
string b = "unity";

a和b均为常量字符串且值相同,所以指向同一内存地址,所以"=="、"Equals"和ReferenceEquals均返回True。

string a = "unity";
string c = "uni";
string b = c + "ty";

a为常量字符串,b为动态字符串,a与b的值相等,所以"=="与"Equals"返回True,但是a与b的内存地址不相同,所以ReferenceEquals返回False。

Reference

《你必须知道的.Net》

上一篇下一篇

猜你喜欢

热点阅读