Unity 游戏的 string interning 优化

思考并回答以下问题:

http://gulu-dev.com/

问题描述

在开始之前,先说一下这个问题为什么很容易被忽视吧。

通常情况下,我们难以注意到运行着的Unity程序内string的实例化情况。这些字符串的创建,销毁的时机是否合理,是否存在有重复(相同内容的字符串),冗余(存有已不再有意义的垃圾字符),低效(capacity远大于length),以及泄漏(没有在期望的时机及时销毁)的情况就更容易被忽视了。由于string没法随时像普通的Unity对象那样通过调用 Object.GetInstanceID() 来查看实例id,我们不太容易感知字符串对象的实际内存开销。其实要不是偶然在工具里发现了大量的此类情况,俺也没想到看起来颇单纯的immutable string里居然隐藏着这么多秘密。

一次只说一件事,这次我们只讨论重复字符串的问题。

使用自制工具ResourceTracker,可以发现Unity游戏运行时mono(il2cpp) 内有大量重复的字符串,如下所示:

before_intern

手动Intern()

对.Net特性有了解的同学,应该知道C#同Java一样,提供了一套内建的string interning 机制,能够在后台维护一个字符串池,从而保证让同样内容的字符串始终复用同一个对象。这么做有两个好处,一个是节省了内存(重复字符串越多,内存节省量越大),另一个好处是降低了字符串比较的开销(如果两个字符串引用一致,就不用逐字符比较内容了)但是为什么上面的Unity程序内仍然有大量的重复字符串呢?

查看他们的地址,发现彼此各不相同,说明的确没有引用到同一块内存区域。由于C#语言实现以静态的特性为主,俺推测,也许只有编译期可以捕捉到的字符串(也就是通常用字面字符串literal string来构建时)才会interning。

做个实验吧:

1
2
3
4
5
string foobar = "foobar";
string foobar2 = new StringBuilder().Append("foo").Append("bar").ToString();

Debug.Log(foobar == foobar2);
Debug.Log(System.Object.ReferenceEquals(foobar, foobar2));

运行上面的代码,输出结果分别是True和False。嗯,也就是说,即使运行时内容一样(== 返回 True),手动在运行时拼出来的字符串也不会自动复用已有的对象。查看游戏代码,发现很多重复字符串是通过解析 binary stream 或 text stream 构造出来的,这样就解释得通了。(String literals get interned automatically)

手动Intern一下试试吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
string foobar0 = "foobar";
string foobar1 = new StringBuilder().Append("foo").Append("bar").ToString();
string foobar2 = string.Intern(foobar1);
string foobar3 = new StringBuilder().Append("f").Append("oo").Append("b").Append("ar").ToString();
string foobar4 = string.Intern(foobar3);

Debug.Log(foobar0 == foobar1); // True
Debug.Log(foobar0 == foobar2); // True
Debug.Log(foobar0 == foobar3); // True
Debug.Log(foobar0 == foobar4); // True
Debug.Log(System.Object.ReferenceEquals(foobar0, foobar1)); // False
Debug.Log(System.Object.ReferenceEquals(foobar0, foobar2)); // True
Debug.Log(System.Object.ReferenceEquals(foobar0, foobar3)); // False
Debug.Log(System.Object.ReferenceEquals(foobar0, foobar4)); // True

注意,C#并没有提供“清除已经Intern的字符串”的接口。也就是说,如果不由分说地把产生的字符串都扔进去,会造成大量短生命期字符串 (如某个地图上特有的特效名) 在全局池内的堆积。

解决这个问题并不难,手写一个可清除的版本就可以了。

可清除的 Interning - UniqueString

下面的UniqueString类除了提供两个与string.Intern()和string.IsInterned()一致的接口外,还提供了Clear()接口用于周期性地释放整个字符串池,可在地图切换等时机调用。这个类通过判断参数来确认,是将字符串放入全局的系统池,还是支持周期性清理的用户池。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UniqueString
{
// 'removable = false' means the string would be added to the global string pool
// which would stay in memory in the rest of the whole execution period.
public static string Intern(string str, bool removable = true)

// Why return a ref rather than a bool?
// return-val is the ref to the unique interned one, which should be tested against `null`
public static string IsInterned(string str)

// should be called on a regular basis
public static void Clear();
}

通过参数removable我们可以指定使用默认intern还是removable-intern。显式地指定后者的字符串将可被随后的UniqueString.Clear()清理。UniqueString 的实现 (及更新) 在这里。

效果

使用上面的机制在关键点加了几行代码简单地优化后,内存中的字符串从88000条降低到 4000条左右(仍有很多重复存在)。

after_intern

小结

直接写在代码里的常量字符串(即所谓的 literal string)会在启动时被系统自动Intern到系统字符串池;而通过拼接,解析,转换等方式在运行时动态产生的字符串则不会。

避免在C#代码里写多行的巨型 iteral string,避免无谓的内存浪费。常见的情况是很大的Lua代码块,很密集的生成路径,大块xml/json等等,见下面的例子。

已经被自动或手动Intern的字符串在之后的整个生命期中常驻内存无法移除,但可以使用上面提供的UniqueString类实现周期性的清理。

下面是一些不合理的常见的代码内的常量字符串的情况(都是常驻内存无法释放的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
string query = @"SELECT foo, bar
FROM table
WHERE id = 42";

string lua_code_block = @"
local ns = foo.bar(self.nID)
for i,v in ipairs(self.imgs) do
if (i - 1) < ns then
Obj.SetActive(self.imgs[i], true)
else
Obj.SetActive(self.imgs[i], false)
end
end
";

string[] resFiles = new string[]
{
"Assets/Scenes/scene_01.unity",
"Assets/Scenes/scene_02.unity",
"Assets/Scenes/scene_03.unity",
"Assets/Scenes/scene_04.unity",
"Assets/Scenes/scene_05.unity"
};

0%