Unity中字符串拼接0GC方案

2024-03-01 1382阅读

温馨提示:这篇文章已超过411天没有更新,请注意相关的内容是否还可用!

本文主要分析C#字符串拼接产生GC的原因,以及介绍名为ZString的库,它可以将字符串生成的内存分配为零。

在C#中,字符串拼接通常有三种方式:

  1. 直接使用+号连接;
  2. string.format;
  3. 使用StringBuilder;

下面分别细述。

故事的开始

首先,简单介绍下String类型。C# String 类型内部是“UTF-16”字节字符串。

Unity中字符串拼接0GC方案

与普通对象一样,它有一个对象头,并在堆内存中分配。同样,字符串基本上只能由“新字符串”生成。'StringBuilder.ToString','Encoding.GetString'等,最后也调用'new string'来分配一个新字符串。

即使是相同的字符串值,“new string”生成的字符串也会分配在不同的内存空间中。只有常量字符串从称为实习生池的应用程序共享空间获取固定引用。

var x = new string(new[] { 'f', 'o', 'o' });
var y = new string(new[] { 'f', 'o', 'o' });
var z = "foo";
var u = "foo";
var v = String.Intern(x);
 
// different reference: x != y != z
Console.WriteLine(Object.ReferenceEquals(x, y)); // false
Console.WriteLine(Object.ReferenceEquals(x, z)); // false
 
// same reference: z == u == v
Console.WriteLine(Object.ReferenceEquals(z, u)); // true
Console.WriteLine(Object.ReferenceEquals(z, v)); // true
 
// same value
Console.WriteLine(x == y && x == z && x == u && x == v); // true

如果你想从intern池中获取,可以使用'String.Intern'方法。Intern方法是从Intern池中获取的。如果不存在,则注册并返回其引用。由于Intern池中注册的内存无法删除,因此可能很难很好地使用它。

+拼接(String.Concat)

使用+号连接时,C# 编译器会进行专门处理,将其转换为 String.Concat。

string.Concat(object arg0, object arg1)
string.Concat(object arg0, object arg1, object arg2)
string.Concat(params object[] values)
string.Concat(string str0, string str1)
string.Concat(string str0, string str1, string str2)
string.Concat(string str0, string str1, string str2, string str3)
string.Concat(params string[] values)

不同的编译器版本处理稍有不同。例如,Visual Studio 2019 的 C# 编译器 (int x) + (string y) + (int z) 的结果将为“String.Concat(x.ToString(), y, z.ToString())”。但是,Visual Studio 2017 的 C# 编译器将是“String.Concat((object)x, y, (object)z)”,如果连接非字符串参数,将使用对象重载。因此,发生了结构装箱。

如果我们连接的字符不匹配上方的重载,比如,连接了5个字符,那么就会产生一个“params array”的分配,同样会造成额外的GC。

针对上述情况,ZString提供了最多15个参数的泛型重载,且在内部使用了“Utf16ValueStringBuilder”(在StringBuilder小节中有解释),因此几乎可以完全避免数字类型的字符串转换分配。

StringBuilder

“StringBuilder”是一个以“char[]”作为临时缓冲区的类。StringBuilder.Append()方法用于写入缓冲区,StringBuilder.ToString() 生成最终字符串。

public class SimpleStringBuilder
{
    char[] buffer;
    int offset;
 
    public void Append(string value)
    {
        value.CopyTo(0, buffer, offset, value.Length);
        offset += value.Length;
    }
 
    public override string ToString()
    {
        return new string(buffer, 0, offset);
    }
}

如果要连接多个字符串,应避免使用“+=”,因为每个“+=”都会生成一个新字符串。StringBuilder 避免生成这个临时的新字符串,而是将其复制到“char[]”。

当追加数字以及某些类型时,.NET Standard 2.0(Unity 等)和 .NET Standard 2.1(.NET Core 3.0 等)之间的行为会有所不同。

// .NET Standard 2.0
public StringBuilder Append(int value)
{
    return Append(value.ToString(CultureInfo.CurrentCulture));
}
 
// .NET Standard 2.1
public StringBuilder Append(int value)
{
    return AppendSpanFormattable(value);
}
 
private StringBuilder AppendSpanFormattable(T value)
    where T : ISpanFormattable
{
    if (value.TryFormat(RemainingCurrentChunk,
        out int charsWritten, format: default, provider: null))
    {
        m_ChunkLength += charsWritten;
        return this;
    }
    return Append(value.ToString());
}

对于 .NET Standard 2.0,它Append时调用了ToString方法。但在 .NET Standard 2.1 中,“ISpanFormattable.TryFormat”将其直接写入缓冲区,而不通过字符串。ISpanFormattable这个接口是internal的 。但是,通过检查 [ ISpanFormattable.references ],您可以看到哪种类型实现了此接口。

通过ZString可以避免添加数字类型时的字符串分配。在 .NET Standard 2.1 中,ZString 使用它们的TryFormat。在.NET Standard 2.0中,ZString使用移植的TryFormat方法。

API 本身与 StringBuilder 几乎相同。但是,它必须用“using”括起来。

// using ZString.CreateStringBuilder instead of new StringBuilder
using (var sb = ZString.CreateStringBuilder())
{
    sb.Append(enemy.Name);
    sb.Append(" Current HP:");
    sb.Append(enemy.Hp);
    sb.Append(" Current MP:");
    sb.Append(enemy.Mp);
    if (addStatus)
    {
        sb.Append(" Status:");
        sb.Append(enemy.Status);
    }
    return sb.ToString();
}

ZString.CreateStringBuilder ()方法的返回值“Utf16ValueStringBuilder”是一个结构体,所以避免了分配到StringBuilder的堆内存。此外,由于用于内部写入的“char[]”缓冲区是从ArrayPool获取的,因此避免了缓冲区分配。(这也是为什么需要通过“using”返回缓冲区。)

String.Format

由于 String.Format 的参数只能接受对象,因此会发生装箱。

// conversion of String interpolation is rewrited to following by C# compiler
$"{enemy.Name} Current Hp:{enemy.Hp} Current Mp:{enemy.Mp}";
 
// string.Object(string, object, object, object)
String.Format("{0} Current Hp:{1} Current Mp:{2}", enemy.Name, enemy.Hp, enemy.Mp);
 
// String.Format can avoid params array until 3 arguments
string string.Format(string format, object arg0)
string string.Format(string format, object arg0, object arg1)
string string.Format(string format, object arg0, object arg1, object arg2)
string string.Format(string format, params object[] args)

此外,与 StringBuilder.Append 一样,在 .NET Standard 2.0 中,也会发生字符串转换分配。

与“ZString.Concat”一样,“ZString.Format”具有最多 15 个参数的通用重载。即使在.NET Standard 2.0环境下,通过TryFormat直接转换,也能实现零分配。

终极秘诀

ZString 的内部实现是零分配。但当最后总要输出一个字符串,还是会产生GC。但是,如果适用的库具有接受字符串以外的内容的 API,则也可以避免最终的字符串生成,并且可以实现完全零分配。例如,TextMeshPro有一个名为“SetCharArray(char[] sourceText, int start, int length)”的API,可以直接给出它,并且可以避免字符串生成。

TMP_Text tmp;
 
// create StringBuilder
using(var sb = ZString.CreateStringBuilder())
{
    sb.Append("foo");
    sb.AppendLine(42);
    sb.AppendFormat("{0} {1:.###}", "bar", 123.456789);
 
    // direct write(avoid string alloc) to TextMeshPro
    tmp.SetText(sb);
 
    // SetText(Utf16ValueStringBuilder) is the same as following
    var buffer= sb.AsArraySegment();
    tmp.SetCharArray(buffer.Array, buffer.Offset, buffer.Count);
}
 
// convinient helper to use ZString.Format
tmp.SetTextFormat("Position: {0}, {1}, {2}", x, y, z);
 
// other ZString direct write utilities
.AsSpan()
.AsMemory()
.TryCopyTo(Span, out int writtenChars);

参考文献:

ZString — Zero Allocation StringBuilder for .NET Core and Unity.

VPS购买请点击我

免责声明:我们致力于保护作者版权,注重分享,被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理! 图片声明:本站部分配图来自人工智能系统AI生成,觅知网授权图片,PxHere摄影无版权图库和百度,360,搜狗等多加搜索引擎自动关键词搜索配图,如有侵权的图片,请第一时间联系我们,邮箱:ciyunidc@ciyunshuju.com。本站只作为美观性配图使用,无任何非法侵犯第三方意图,一切解释权归图片著作权方,本站不承担任何责任。如有恶意碰瓷者,必当奉陪到底严惩不贷!

目录[+]