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。
代价:永不释放与查表加锁
这套机制的坑也写在代码注释里:
_Data永不回收。静态表里的条目一旦创建就活到进程退出。长期运行的服务器如果动态生成海量唯一字符串(比如把玩家 ID 拼成 StringName),这张表会单调膨胀。引擎自己的对策是:内部热路径才用 StringName,用户数据别往里塞。- 查表要加锁。4.x 的静态表配了互斥锁(
Mutex),多线程同时构造新 StringName 会串行化。命中已有条目不写表,但早期版本读也走锁;现在的实现里查和插共用一把锁,极端多线程场景下"每帧构造几千个新 StringName"会成为可见开销。 - 相等不等于同字符串的语义陷阱。
StringName == StringName是内容相等(靠 intern 保证指针相等),但StringName("a") == String("a")这种跨类型比较要走operator==的字符路径,比纯指针比较贵。热循环里混用两种类型是比较隐蔽的退化。
哈希冲突时的行为
静态表内部是哈希桶加链,冲突不影响正确性(_Data 里存了完整 String,命中前会比对内容),只影响查找常数。djb2 对标识符的分布足够好,实际工程里桶的负载远低于退化阈值——这张表的性能问题从来不在哈希质量,而在前文说的锁竞争和单调膨胀。
结构示意
实战推论
- 比较键用 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,均有删减。