技术博客

String/StringName 的内部表示与哈希缓存

第 13 / 149 篇 · 2026-08-25 · godot · 引擎架构源码分析字符串

GDScript 里写 get_node("Player/HealthBar"),每次调用都触发一次字符串解析。如果底层是普通字符串比较,路径上每一段都要逐字符对比,一帧几千次调用会把 CPU 烧在 memcmp 上。Godot 的答案是 StringName——一个"intern 过的字符串":全局只存一份,比较就是比指针。这篇拆 core/string/string_name.h 和 core/string/ustring.h,看这套机制怎么工作、代价是什么。源码引自 4.2.2-stable。

String:COW 的 UTF-32 缓冲

先说 String。它内部是 CowData<char32_t>(core/templates/cowdata.h),即:

  • 字符编码固定 UTF-32,一个字符 4 字节,不管中文还是 ASCII;
  • 写时复制:两个 String 赋值共享同一份字符缓冲,谁要写入谁先复制。

选 UTF-32 而不是 UTF-8 是明确的空间换时间决策。UTF-8 变长编码让"第 n 个字符"变成 O(n) 扫描,而 String::operator[] 在引擎里是高频操作(解析、文本渲染、正则都要随机访问)。UTF-32 保证下标访问是 O(1):

$$ \text{offset}(i) = 4i \ \text{字节} $$

代价很直接:ASCII 文本内存占用是 UTF-8 的 4 倍。引擎对此的态度是"字符串不是大数据"——真正的批量数据应该走 PackedByteArray,String 服务的是标识符、路径、UI 文本这类小体量场景。

CowData 的引用计数存在缓冲头部(指针负偏移 8 字节处),String::operator=(const String&) 只加计数不复制字符。这就是为什么上一篇里说"String 赋值比想象便宜"——它退化成一次指针拷贝加一次原子自增。

StringName:全局哈希表 + 指针相等

StringName 的结构(string_name.h,简化):

class StringName {
    struct _Data {
        String name;            // 实际字符
        uint32_t hash;          // 缓存的哈希值
        // ...
    };
    _Data *_data = nullptr;     // 全部家当就是一个指针
};

整个对象 8 字节。构造时,如果 _data 为空而你有字符串内容,会走 StringName::StringName(const char *p_name) 这类构造函数,内部调用 setup() 查静态表:

// string_name.cpp
static HashTable<StringName::Table, StringName::_Data *> table;

流程是:算哈希 → 在静态表里查 hash → _Data* → 命中就直接指向既有条目,未命中就 memnew 一个 _Data 塞进去。此后这个字符串在进程内永远只有一份 _Data,两个内容相同的 StringName 必然 _data 指针相同。

于是比较操作变成:

bool StringName::operator==(const StringName &p_name) const {
    return _data == p_name._data;   // 指针比较,一条 cmp 指令
}

和 String 的逐字符比较相比,复杂度从 $O(\text{len})$ 降到 $O(1)$,而且常数极小。哈希值也缓存在 _Data 里,hash() 不需要重算——这对 HashMap<StringName, T> 的查找路径是关键优化。

哈希函数与缓存的时机

StringName 查表用的哈希是 djb2 的变体(String::hash(),乘 33 累加):

$$ h_0 = 5381,\quad h_{i+1} = 33 \cdot h_i + c_i $$

djb2 的特点是快(一次乘加)且对短字符串分布均匀,正好匹配标识符场景。但它不是加密哈希,构造碰撞不难——所以这张表只做身份标识,不承担任何安全语义。

String 本身的 hash() 是每次调用重算的($O(\text{len})$),而 StringName 的哈希在 _Data 创建时算一次缓存起来。这个差异在 HashMap 键选择上直接体现:百万次 HashMap<String, V> 查找的哈希计算开销可能超过查找本身,换 HashMap<StringName, V> 后这部分归零。引擎内部所有高频表(ClassDB 方法表、信号表、属性表)的键都是 StringName,不是巧合。

String 与 StringName 的互转成本

String(sn) 便宜——_Data 里就存着 String,返回一次 COW 引用。StringName(s) 贵——要算哈希查表,未命中还要建条目。所以方向性纪律是:持有用 StringName,需要读内容时临时转 String;反过来"手里只有 String、要拿去做键"的场景,应该在边界处一次转好缓存起来,别在循环里转。

还有个隐蔽成本:StringName(const char *) 的构造函数会把 char* 先按 UTF-8 解码成 UTF-32 再查表。C++ 侧写 StringName("fire") 每行都是一次解码加查表,热路径上的正确写法是静态缓存:

static const StringName fire_sn("fire");  // 只初始化一次

GDScript 侧对应物就是 &"fire" 字面量,编译期进常量区。

为什么 NodePath 内部用 StringName 数组

看 NodePath 的定义(core/string/node_path.h):

class NodePath {
    Vector<StringName> path;        // 路径每段一个 StringName
    Vector<StringName> subpath;     // :property 部分
    // ...
};

路径 "Player/HealthBar" 被拆成 ["Player", "HealthBar"] 两个 StringName。Node::get_node() 解析时逐级比较节点名(节点名本身是 StringName 类型的 data.name),段间比较是指针比较。如果 NodePath 存的是 String,每次 get_node 都要做多段逐字符比较;换成 StringName 后,路径解析的开销基本等于"遍历几层子节点指针数组"。

这也是信号系统的基础:Object::connect 和 emit_signal 的签名都用 StringName,信号名查找是哈希查表而不是字符串扫描。GDScript 编译器会把源码里的字符串字面量在合适的语境下直接编译成 StringName 常量——所以脚本里写 "Player" 传给 get_node 时,String→StringName 的转换(含查表)只发生一次。动画轨道的属性路径、tween 的目标属性名走的也是同一条路——凡"按名字定位"的引擎设施,键都是 StringName。

代价:永不释放与查表加锁

这套机制的坑也写在代码注释里:

  1. _Data 永不回收。静态表里的条目一旦创建就活到进程退出。长期运行的服务器如果动态生成海量唯一字符串(比如把玩家 ID 拼成 StringName),这张表会单调膨胀。引擎自己的对策是:内部热路径才用 StringName,用户数据别往里塞。
  2. 查表要加锁。4.x 的静态表配了互斥锁(Mutex),多线程同时构造新 StringName 会串行化。命中已有条目不写表,但早期版本读也走锁;现在的实现里查和插共用一把锁,极端多线程场景下"每帧构造几千个新 StringName"会成为可见开销。
  3. 相等不等于同字符串的语义陷阱。StringName == StringName 是内容相等(靠 intern 保证指针相等),但 StringName("a") == String("a") 这种跨类型比较要走 operator== 的字符路径,比纯指针比较贵。热循环里混用两种类型是比较隐蔽的退化。

哈希冲突时的行为

静态表内部是哈希桶加链,冲突不影响正确性(_Data 里存了完整 String,命中前会比对内容),只影响查找常数。djb2 对标识符的分布足够好,实际工程里桶的负载远低于退化阈值——这张表的性能问题从来不在哈希质量,而在前文说的锁竞争和单调膨胀。

结构示意

StringName 的 intern 机制 StringName 对象(8 字节) _data 指针 另一个内容相同的 StringName _data 指针(同一个) 全局静态表中的 _Data name: "HealthBar"(String, COW) hash: 0x8f3a…(缓存) 比较语义 StringName == StringName → 比 _data 指针,O(1) String == String → 逐字符比较 char32_t 缓冲,O(len)

实战推论

  • 比较键用 StringName,显示文本用 String。凡是"拿来做相等判断、哈希键"的字符串(节点名、组名、信号名、配置键)都应该尽早转成 StringName;凡是要拼接、格式化、输出的,用 String。
  • NodePath 别在热路径现构造。get_node("../A/B") 每次都拆字符串;把 NodePath 存成成员变量(或 @onready var n = %A),构造和 intern 只做一次。
  • StringName 字面量缓存:GDScript 里 &"name" 是 StringName 字面量语法(4.x 新增),和 &"../A/B" 之于 NodePath 的 ^"..." 一样,编译期确定、零运行时装拆。写性能敏感的 GDScript 时这是最便宜的优化。
  • 格式化输出别攒 StringName。StringName("score: " + str(score)) 这类动态字符串转 StringName 是把一次性文本塞进永不回收的全局表——拼接结果用 String 就好,StringName 只属于稳定的标识符。
  • 排查内存增长时先怀疑 StringName 表。StringName::get_string_count 这类调试接口不存在于发布版,但如果发现进程内存随运行时间单调涨且项目里有人 StringName(str(randi())),基本就是它。

本文代码引自 Godot 4.2.2-stable 的 core/string/string_name.h、core/string/string_name.cpp、core/string/ustring.h、core/string/node_path.h、core/templates/cowdata.h,均有删减。