Unity3D高级编程:主程手记
上QQ阅读APP看书,第一时间看更新

2.8.4 字符串导致的性能问题

本质上,字符串性能问题在大部分语言中都是比较难解决的,C#中尤其如此。在C#中,string是引用类型,每次动态创建一个string,C#都会在堆内存中分配一个内存用于存放字符串。我们来看看它到底有多么“恐怖”,其源码如下:


string strA = "test";
for(int i = 0 ; i<100 ; i++)
{
    string strB = strA + i.ToString();

    string[] strC = strB.Split('e');

    strB = strB + strC[0];

    string strD = string.Format("Hello {0}, this is {1} and {2}.",strB, strC[0], 
        strC[1]);
}

这是一段“恐怖”的程序,循环中每次都会将strA字符串和i整数字符串连接,strB所得到的值是从内存中新分配的字符串,然后将strB切割成两半,使其成为strC,这两半又重新分配两段新的内存,再将strB与strC[0]连接起来,这又申请了一段内存,这段内存装上strB和strC[0]连接的内容,并赋值给strB,strB原来的内容因为没有变量指向就找不到了,最后用string.Format的形式将4个字符串串联起来,新分配的内存中装有4者的连接内容。

这里要注意一点,字符串常量是不会被丢弃的,比如这段程序中的"test"和"Hello {0}, this is {1} and {2}."这两个常量,它们常驻于内存,即使下次没有变量指向它们,它们也不会被回收,下次使用时也不需要重新分配内存。关于原因,我们放到计算机执行原理中介绍。

每次循环都向内存申请了5次内存,并且抛弃了一次strA+i.ToString()的字符串内容,这是因为没有变量指向这个字符串。这还不是最“恐怖”的,最“恐怖”的是,每次循环结束都会将前面所有分配的内存内容抛弃,再重新分配一次,就这样不断地抛弃和申请,总共向内存申请了500次内存段,并全部抛弃,内存被浪费得很厉害。

为什么会这样呢?究其原因是,C#语言对字符串并没有任何缓存机制,每次使用都需要重新分配string内存,据我所知,很多语言都没有字符串的缓存机制,因此字符串连接、切割、组合等操作都会向内存申请新的内存,并且抛弃没有变量指向的字符串,等待GC单元回收。我们知道,GC单元执行一次会消耗很多CPU空间,如果不注意字符串的问题,不断浪费内存,则将导致程序不定时卡顿,并且,随着程序运行时间的加长,各程序模块不良代码的运行积累,程序卡顿次数会逐步增加,运行效率也将越来越低。

解决字符串问题有两种方法。

第一种方法是自建缓存机制,可以用一些标志性的Key值来一一对应字符串,比如游戏项目中常用ID来构造某个字符串,伪代码如下:


int ID = 101;

ResData resData = GetDataById(ID);

string strName = "This is " + resData.Name;

return strName;

一个ID变量对应一个字符串,这种形式下可以建立一个字典容器将它缓存起来,下次用的时候就不需要重新申请内存了,伪代码如下:


Dictionary<int,string>strCache;

string strName = null;
if(!strCache.TryGetValue(id, out strName))
{
    ResData resData = GetDataById(ID);
    string strName = "This is " + resData.Name;
    strCache.Add(id, strName);
}

return strName;

我们用Dictionary字典容器将字符串缓存起来,每次先查询字典中的内容是否存在,若有,则直接使用,若没有,则创建一个并将其植入字典容器中,以便下次使用。

第二种方法需要用到C#中一些“不安全”的native方法,也就是类似C++的指针方式来处理string类。

由于string类本身一定会申请新的内存,因此需要突破这个瓶颈,直接使用指针来改变string中字符串的值,这样就能重复利用string,而不需要重新分配内存。

C#虽然委托了大部分内存内容,但它也允许我们使用非委托的方式来访问和改变内存内容,这对C#来说是不安全的(C#中有unsafe关键字)。下面通过非委托的方式来改变string中的内容,使它能够被我们再利用,代码如下:


string strA = "aaa";

string strB = "bbb" + "b";

fixed(char* strA_ptr = strA)
{
    fixed(char* strB_ptr = strB)
    {
        memcopy((byte*)strB_ptr, (byte*)strA_ptr, 3*sizeof(char));
    }
}

print(strB); // 此时strB的内容为“aaab”

注意,这里用“bbb”+“b”的方式生成新字符串,是因为我们不打算改变常量字符串内存块,所以新分配了内存来做实验。

我们把strB的前3个字符的内容变成了strA中的内容,但并没有增加其他内存,因为我们使用了不安全的非托管方法来控制内存。通过这样的方式再利用已经申请的字符串内存,可将已有的字符串缓存起来再利用。我们看看再利用的例子,其源码如下:


Dictionary<int,string>cacheStr;

public unsafe string Concat(string strA, string strB)
{
    int a_length = a.Length;

    int b_length = b.Length;

    int sum_length = a_Length + b_Length;

    string strResult = null;

    if(!cacheStr.TryGetValue(sum_length, out strResult))
    {
        // 如果不存在sum_length长度的缓存字符串,那么直接连接后存入缓存
        strResult = strA + strB;

        cacheStr.Add(sum_length, strResult);

        return strResult;
    }

    // 将缓存字符串再利用,用指针方式直接改变它的内容
    fixed(char* strA_ptr = strA)
    {
        fixed(char* strB_ptr = strB)
        {
            fixed(char* strResult_ptr = strResult)
            {
                // 将strA中的内容复制到strResult中
                memcopy((byte*)strResult_ptr, (byte*)strA_ptr,
                    a_length*sizeof(char));

                // 将strB中的内容复制到strResult的a_Length长度后的内存中
                memcopy((byte*)strResult_ptr+a_Length,
                    (byte*)strB_ptr, b_length*sizeof(char));
            }
        }
    }

    return strResult;
}

当需要将多个字符串连接起来时,先看看缓存中是否有可用长度的字符串,如果没有,就直接连接并缓存,如果有,则取出来,使用指针的方式改变缓存字符串的值。其中memcopy并不是系统函数,因此需要自己编写,写法很简单,拿到两个指针根据长度遍历并赋值即可。源码如下:


public unsafe void memcopy(byte* dest, byte* src, int len)
{
    while((--len)>=0)
    {
        dest[len] = src[len];
    }
}