Fork me on GitHub

Unity/C#基础复习(3) 之 String与StringBuilder的关系

参考资料

[1] @毛星云【《Effective C#》提炼总结】 https://zhuanlan.zhihu.com/p/24553860
[2] 《C# 捷径教程》
[3] @flashyiyi【C# NoGCString】 https://zhuanlan.zhihu.com/p/35525601
[4] 如何理解 String 类型值的不可变? @胖君和@程序媛小双的回答 https://www.zhihu.com/question/20618891

基础知识

  1. String类型在C#中用于保存字符,为引用类型,一旦创建,就不能再进行修改,其底层是根据字符数组(char[])实现的。
  2. StringBuilder表示可变字符字符串类型,其中的字符可以被改变、增加、删除,当向一个已满的StringBuilder添加字符时,其会自动申请内存进行扩容。
  3. Unity中Profiler窗口的GC Alloc那一列的信息表示的是当前帧产生了多少垃圾(指一块存储不再使用的数据的内存)。Unity官方文档对此标签是这样的解释的:

The GC Alloc column shows how much memory has been allocated in the current frame, which is later collected by the garbage collector.

大致意思是,GC Alloc这一列表示当前帧有多少内存被分配,这些内存将会在之后被垃圾回收器进行清理。

疑难解答

  1. 如何理解String类型值的不可变?
  2. 为什么String类型的连接(加法和Concat)性能低下?与之相比,为什么StringBuilder更快?
  3. String类型与GC(垃圾回收器)的关系?
  4. 如何正确的使用String与StringBuilder?

如何理解String类型值的不可变?

在C#中string类型的底层由char[],即字符数组进行实现,但我们并不能像修改字符数组的方式来对字符串进行修改。事实上,我们以为的修改(字符串的连接,字符串的赋值)对于字符串来说都不是真正的修改,每当我们对字符串进行赋值时,底层会进行两个操作。

  1. 首先会去查找字符串池,如果字符串池有这个字符串,那么直接将当前变量指向字符串池内的字符串。
  2. 如果字符串池内没有这个字符串,那么在堆上创建一块内存用于放置这个字符串,并将当前变量指向这个新建的字符串。

一个新建字符串的简单例子如下:

public static void Main(string[] args) {
    string s = "abc";
    Console.WriteLine(s);
    s = "123";
    Console.WriteLine(s);
}

其中第4行s的赋值语句并不是将原本"abc"的字符串修改成"123",而是另外在堆上创建了一个新的内存"123",并将s变量指向这个新字符串,而旧的字符串"abc"就被丢弃了,但它仍然在堆上占据着内存,等待GC将其回收。

对于字符串的连接(加法或Concat函数),其原理同上,事实上原来的字符串并没有真正在后面增加了字符,而是创建了一个新的字符串,其值是两个字符串连接后的结果。

字符串的这种特性,使得它的赋值和连接操作很容易造成内存浪费,因为每一次都将在堆上创建一个新的字符串对象。所以一个比较明确的思路是,不要频繁的调用字符串的连接操作(比如放在Unity的Update函数中)。

既然不可变特性使得我们不得不小心的使用字符串,那么字符串为什么还会被设计成不可变的形式呢?很显然,不可变的形式对于字符串可变的形式是利大于弊的,下面根据参考资料[4][3],尝试列举、阐述一下为什么字符串一定要是不可变的。

  1. 线程安全。在多线程环境下,只有对资源的修改是有风险的,而不可变对象只能对其进行读取而非修改,所以是线程安全。如果字符串是可修改的,那么在多线程环境下,需要对字符串进行频繁加锁,这是比较影响性能的。
  2. 为了安全(防止程序员意外修改了字符串)。想象下面这样一种情况,一个静态方法用于给字符串(或StringBuilder)后面增加一个字符串。
public class StringTest{

    public static string AppendString(string s) {
        s += "abc";
        return s;
    }

    public static StringBuilder AppendString(StringBuilder s) {
        s = s.Append("abc");
        return s;
    }

    public static void Main(string[] args) {
        string s = "123";
        string s2 = AppendString(s);
        Console.WriteLine("原字符串:"+s+" 经过添加后的字符串:"+s2);

        StringBuilder sb = new StringBuilder("123");
        StringBuilder sb2 = AppendString(sb);
        Console.WriteLine("原字符串:" + sb.ToString() + " 经过添加后的字符串:" + sb2.ToString());
    }
}

运行结果如下:

原字符串:123 经过添加后的字符串:123abc
原字符串:123abc 经过添加后的字符串:123abc

可以看到StringBuilder因为是可变的,所以原字符串直接在静态方法中被修改成了"123abc",而string类型因为其不可变的特性,所以它的原字符串和修改后的新字符串是不同的,这种不可变特性也就避免了程序员直接在方法里面直接对字符串进行连接操作,导致字符串在不知情的情况下被修改了(就像StringBuilder一样)。

  1. 因为字符串的不可变特性,所以其可以放心地作为Dictionary和Set的键(在Java中则是Map和Set)。在Dictionary和Set中使用可变类型作为键是极其危险的事,因为可修改键可能会导致Set和Dictionary中键值的唯一性被破坏。

为什么String类型的连接(加法和Concat)性能低下?与之相比,为什么StringBuilder更快?

先解决第一个问题,为什么String类型的连接(加法和Concat)性能低下?

前面提到了,因为字符串是不可变的,所以所有看似对其进行了修改的操作,都是在堆上另外创建了一个新的字符串,而这创建过程是耗费性能(申请内存,检查内存是否足够,不够的情况还要让GC对垃圾内存进行回收),所以可想而知字符串连接性能是比较低的。

当然,性能高低是需要有一个参照物的,与StringBuilder的连接操作相比,string类型就是相当慢了,除了慢以外,字符串的连接操作还会产生大量GC,因为每一次连接,都创建了新的字符串,而旧的字符串理所当然就被丢弃了,在没有任何变量引用这些旧字符串的情况下,GC要对这些旧字符串占据的内存进行回收,而GC的触发是十分耗费性能的(简单来说就是费时,因为GC是要遍历堆上所有无引用的对象),表现在Unity中,就是在某一帧相比其他帧额外消耗了几十ms来处理GC。

那么,StringBuilder的连接操作为什么快呢?

这要从StringBuilder的底层开始说起,StringBuilder的底层与string一样都是字符数组(即char[]),与string被设计为不可变不同的是,StringBuilder是可变的。

当StringBuilder进行连接操作时,它会经历以下步骤:

  1. 检查当前字符数量是否大于长度,如果大于,那么对StringBuilder进行扩容。
  2. 向char[]数组后面添加字符

很显然,只有在StringBuilder长度小于添加的字符时,才会额外申请内存对char[]数组进行扩容,其他情况下,就是对数组内的元素进行变换而已,与string类型每次连接都会废弃掉一个对象相比,StringBuilder就显得更快一些了。

当然,除了连接操作,StringBuilder还支持删除、修改字符串,这当然也是根据其中的char []数组进行操作的(而字符串因为其不可变性,是不支持这些操作的)。

考虑到StringBuilder扩容也是会产生GC的,所以一般比较好的做法是,在StringBuilder创建时就根据之后的使用情况为其指定一个容量。

String类型与GC(垃圾回收器)的关系?

这里主要研究在Unity3D引擎下,string类型和StringBuilder进行连接操作产生的GC Alloc情况。

之前一直说string类型的连接操作浪费内存,那么具体是什么情况呢?这里可以使用Unity3D引擎进行试验,下面尝试在每帧进行1000次字符串加法,然后使用Profiler查看GC Alloc。

public class StringAppendGC : MonoBehaviour {

    string s = "";

    // Use this for initialization
    void Start () {
        
    }

    // 测试字符串加法在每一帧带来的GC
    void Update () {
        
        s = "";

        // 每一帧进行1000次字符串加法
        for (int i=0;i<=1000;i++) {
            s += i;            
        }
    }
}

GC产生情况如下:

可以看到上面的函数每一帧都产生了2.7M的垃圾,而Unity官方对于GC Alloc这一列的描述是这样的:

Keep this value at zero to prevent the garbage collector from causing hiccups in your framerate

大致意思是,保持该值为0以防止垃圾回收器使得某一帧(与其他帧相比)耗费的时间过长,造成“大帧”现象。

那么如果将上面的String改用StringBuilder会怎么样呢?将上面的代码改为如下所示:

public class StringAppendGC : MonoBehaviour {


    // Use this for initialization
    void Start () {
        
    }

    // 测试字符串加法在每一帧带来的GC
    void Update () {
        StringBuilder stringBuilder = new StringBuilder(1000);

        // 每一帧进行1000次字符串加法
        for (int i=0;i<=1000;i++) {
            stringBuilder.Append(i);
        }
    }
}

GC Alloc情况如下:

可以看到每帧分配内存的情况从2.7M下降到了44KB,相比于String类型有了明显的改善。

如何正确的使用String与StringBuilder?

既然知道了String类型在某些操作上会造成浪费,那么我们使用它的时候就要万分小心,根据参考资料[1]浅墨大佬所说,正确使用String与StringBuilder的姿势如下:

创建不可变类型的最终值。比如string类的+=操作符会创建一个新的字符串对象并返回,多次使用会产生大量垃圾,不推荐使用。对于简单的字符串操作,推荐使用string.Format。对于复杂的字符串操作,推荐使用StringBuilder类

posted @ 2019-03-18 09:05 sword_magic 阅读(...) 评论(...) 编辑 收藏