Unity 游戏的 String interning 优化

作者:吴小含

导语: 通常情况下,我们难以注意到运行着的 Unity 程序内 String 的实例化情况。这些字符串的创建,销毁的时机是否合理,是否存在有重复 (相同内容的字符串),冗余 (存有已不再有意义的垃圾字符),低效 (capacity 远大于 length),以及泄漏 (没有在期望的时机及时销毁) 的情况就更容易被忽视了。

在最近的开发中,遇到了一个关于String的问题,使用自制工具,可以发现 Unity 游戏运行时 mono(il2cpp) 内有大量重复的字符串,如下所示:

手动 Intern()

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

但是为什么上面的 Unity 程序内仍然有大量的重复字符串呢?

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

做个实验吧:

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 构造出来的,这样就解释得通了。

手动 Intern 一下试试吧。

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() 接口用于周期性地释放整个字符串池,可在地图切换等时机调用。这个类通过判断参数来确认,是将字符串放入全局的系统池,还是支持周期性清理的用户池。

using System.Collections;
using System.Collections.Generic;

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)  
    {
        if (str == null)
            return null;

        string ret = IsInterned(str);
        if (ret != null)
            return ret;

        if (removable)
        {
            // the app-level interning (which could be cleared regularly)
            m_strings.Add(str, str);
            return str;
        }
        else
        {
            return string.Intern(str);
        }
    }

    // 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)      
    {
        if (str == null)
            return null;

        string ret = string.IsInterned(str);
        if (ret != null)
            return ret;

        if (m_strings.TryGetValue(str, out ret))
            return ret;

        return null;
    }

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

    // Why use Dictionary? 
    //  http://stackoverflow.com/questions/7760364/how-to-retrieve-actual-item-from-hashsett
    private static Dictionary<string, string> m_strings = new Dictionary<string, string>();    
}

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

效果

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

小结

1.直接写在代码里的常量字符串 (即所谓的 literal string) 会在启动时被系统自动 Intern 到系统字符串池;而通过拼接,解析,转换等方式在运行时动态产生的字符串则不会。 2.避免在 C# 代码里写多行的巨型 literal string,避免无谓的内存浪费。常见的情况是很大的 Lua 代码块,很密集的生成路径,大块 xml/json 等等,见下面的例子。 3.已经被自动或手动 Intern 的字符串在之后的整个生命期中常驻内存无法移除,但可以使用上面提供的 UniqueString 类实现周期性的清理。

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

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"
};

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏从流域到海域

《笨办法学Python》 第45课手记

《笨办法学Python》 第45课手记 本节课将对象和类以及他们之间的从属关系。 本学期刚开始学数据结构,贴一些相关的概念: 从整个计算机科学的角度来说,对象是...

1909
来自专栏开发技术

spring-boot-2.0.3不一样系列之源码篇 - SpringApplication的run方法(一)之SpringApplicationRunListener,绝对有值得你看的地方

  Springboot启动源码系列还只写了一篇,已经过去一周,又到了每周一更的时间了(是不是很熟悉?),大家有没有很期待了?我会尽量保证启动源码系列每周一更,...

1042
来自专栏xingoo, 一个梦想做发明家的程序员

合并排序

分治算法: 用分治策略实现n个元素进行排序的方法。 基本思想: 将待排序元素分成大小大致相同的两个子集合,分别对两个子集合进行排序,最终排好序的子集合合并成所要...

1869
来自专栏光变

3.1 ASM-方法-结构

ASM-方法-结构 本章将会介绍如果使用ASM core API生成或者转换Java编译后的method。 本将开始会展示编译后的method,然后使用很多说...

882
来自专栏木宛城主

Unity应用架构设计(7)——IoC工厂理念先行

一谈到 『IoC』,有经验的程序员马上会联想到控制反转,将创建对象的责任反转给工厂。IoC是依赖注入 『DI』 的核心,大名鼎鼎的Spring框架就是一个非常...

2587
来自专栏专注研发

IDEA环境下SSM整合------注解开发

3.别的好像没啥介绍了,基本步骤就这些,数据流程在上面,注解介绍在下面。 基本注解介绍 @Controller   负责注册一个控制器,Singlet...

1102
来自专栏iOS技术杂谈

Python Garbage Collection 与 Objective-C ARCPython GC 与 Objective-C ARC

转载请注明出处 https://cloud.tencent.com/developer/user/1605429 Python GC 与 Objective-C...

2827
来自专栏程序员维他命

《Effective Objective-C》干货三部曲(一):概念篇

本书是iOS程序员入门的必读书籍,它讲述了在iOS开发中(Objective-C语言)可以遵循的规范和一些开发技巧。

1082
来自专栏Spark学习技巧

海量数据处理之bitmap

本文将讲述Bit-Map算法的相关原理,Bit-Map算法的一些利用场景,例如BitMap解决海量数据寻找重复、判断个别元素是否在海量数据当中等问题.最后说说B...

702
来自专栏Flutter知识集

Flutter 实践 MVVM

在做Android或iOS开发时,经常会了解到MVC,MVP和MVVM。MVVM在移动端一度被非常推崇,虽然也有不少反对的声音,不过MVVM确实是不错的设计架构...

1543

扫码关注云+社区