libstdc++ string:从 COW 到 SSO 的 ABI 迁移
COW 时代(GCC 4.x,ABI v1)
libstdc++ 在 C++11 之前使用写时复制。string 对象本身只有 1 个指针(32 位系统下 4 字节!):
string a string b string c
┌────────────┐ ┌────────────┐ ┌────────────┐
│ _M_p ●───┼──┐ │ _M_p ●───┼──┐ │ _M_p ●───┼──┐
└────────────┘ │ └────────────┘ │ └────────────┘ │
│ │ │
▼ ▼ │
堆内存: ┌───────────────────────────┐ │
│ _M_length = 5 │ │
│ _M_capacity = 15 │ 共享块 │
│ _M_refcount = 3 ◄───────┼─────────────────┘
├───────────────────────────┤
│ H e l l o \0 │ ← _M_p 指向此处
└───────────────────────────┘COW 的致命问题(C++11 后不合规)
string a = "hello";
char& c = a[0]; // 获取引用
string b = a; // COW:b 和 a 共享数据,refcount=2
c = 'H'; // 写入触发 lazy copy → b 的数据被影响
// 但 c 引用的是 a[0],COW 实现可能违反标准全局空字符串实例
┌────────────────────────┐
│ _M_length = 0 │
│ _M_capacity = 0 │
│ _M_refcount = -1 │ ← 永不释放
├────────────────────────┤
│ \0 │
└────────────────────────┘
所有空 string 的 _M_p → 此全局实例SSO 迁移(GCC 5.0,ABI v2)
basic_string 对象 (32 字节)
┌───────────────────────────────────────────────────────────────────────┐
│ 偏移 0 ── 7 │ _M_dataplus._M_p (指向实际字符数据) │
│ 偏移 8 ── 15 │ _M_dataplus._M_alloc (空 allocator, EBO 压缩) │
│ 偏移 16 ── 23 │ union: _M_string_length │ _M_local_buf[16] 前 8 字节│
│ 偏移 24 ── 31 │ _M_allocated_capacity │ _M_local_buf[16] 后 8 字节│
└───────────────────────────────────────────────────────────────────────┘
Short(≤15):_M_p 指向自身的 _M_local_buf
Long(>15):_M_p 指向堆分配
SSO 容量 = 16 - 1 = 15 字节 (比 libc++ 的 22 少 7 字节)
sizeof = 32 字节 (比 libc++ 的 24 多 8 字节)双 ABI 共存
-D_GLIBCXX_USE_CXX11_ABI=0 默认 (=1)
┌──────────────────────────────┐ ┌────────────────────────────┐
│ namespace std { │ │ namespace std { │
│ inline namespace __cxx11 { │ │ // (无内联命名空间) │
│ class basic_string; │ │ class basic_string; │
│ } │ │ } │
│ } │ │ │
└──────────────────────────────┘ └────────────────────────────┘
ABI v1: _ZNSt12basic_stringIcSt11char_traitsIcESaIcEE...
ABI v2: _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE...
^^^^^^^ 命名空间注入 + [abi:cxx11] tagabi_tag 属性将 ABI 标签编码到符号名中,使同一 .so 中可以同时存在两种 ABI 的 string 类型。
ABI 稳定的代价:libstdc++ 不得不保留次优实现。例如 std::list::size() 必须是 O(1)(ABI 锁定),即使这使得 splice 需要 O(n) 的计数更新。
用户 API
用户侧看到的是 std::string 的构造、拼接、修改与 data()/c_str() 访问;本文现有正文主要解释这些 API 背后的 COW→SSO ABI 迁移。
标准语义
C++11 对 std::basic_string 施加了多项语义约束,直接终结了 COW 实现的合规性:
| 标准要求 | 条款 | COW 实现 (ABI v1) 的冲突 |
|---|---|---|
| 连续存储 (contiguous storage) | [string.require] | COW 共享块本身是连续的,但写入时 _M_mutate() 可能重新分配,导致旧指针悬空 |
operator[] 返回 reference / const_reference | [string.access] | COW 版 operator[] 非 const 重载需要触发 _M_unshare() 分离副本;若调用者持有之前的 const_reference,该引用指向的内存可能已变 |
data() 返回 const char* (C++11);C++17 增加非 const data() | [string.accessors] | COW 版 data() 可能返回共享缓冲区地址;非 const data() 要求可写连续内存,与共享语义矛盾 |
| 非变异操作不使引用/指针/迭代器失效 | [string.iterators] | COW 的 operator[] const、begin() const 等可能触发 _M_unshare()(如果 rep 是共享的),间接导致内存重新分配,使已获引用失效 |
c_str() 与 data() 返回相同指针 | [string.accessors] | COW 实现中两者语义等价,但若 _M_unshare() 在两者之间触发,c_str() 返回的指针可能失效 |
// C++11 标准要求此代码在无 UB 的情况下始终正确
std::string a = "hello";
const char& r = a[0]; // 获取 const 引用
std::string b = a; // 拷贝
assert(&r == &a[0]); // 引用仍指向 a 的数据——COW 下若 r 触发了 unshare 则可能不成立
// C++17 增加的非 const data() 要求可写连续内存
std::string s = "test";
char* p = s.data(); // C++17 合法,p 指向可写内存
*p = 'T'; // 必须不触发任何 copy-on-writeCOW 实现在 C++11 标准草案阶段曾有专门的缺陷报告(LWG 2268 等),最终 C++11 明确禁止了引用语义与 COW 的共存。GCC 5.0(2015)发布时切换到 SSO+深拷贝模型,彻底满足这些要求。
对象布局
上文已经覆盖 COW 单指针布局、SSO 时代的 32 字节对象布局与双 ABI 符号;后续补一张 v1/v2 并排偏移图。
核心源码路径
libstdc++ string 实现散布在以下头文件中:
| 文件 | 职责 |
|---|---|
include/bits/basic_string.h | 主模板 basic_string<CharT,Traits,Alloc> 定义、SSO 布局、所有内联成员函数 |
include/bits/basic_string.tcc | out-of-line 成员函数模板实现(_M_create、_M_mutate、replace、find 系列等) |
include/bits/allocator.h | std::allocator 模板;basic_string 通过 EBO 压缩空 allocator 以减小对象尺寸 |
include/bits/cow-string.h(已废弃) | GCC 5 前的 COW 实现,包含 _Rep、_M_refcount、_M_is_shared() 等;ABI v1 路径仍引用此文件的部分类型 |
include/bits/c++config.h | ABI 分叉点:定义 _GLIBCXX_USE_CXX11_ABI 默认值(GCC 5+ 默认为 1),以及 namespace __cxx11 的 inline namespace 注入 |
ABI v1/v2 分叉逻辑:basic_string.h 根据 _GLIBCXX_USE_CXX11_ABI 宏展开为两条路径——
=1(默认):使用 SSO 布局,basic_string定义在inline namespace __cxx11中,符号带[abi:cxx11]tag=0:使用旧 COW 布局,basic_string定义在std直接命名空间中,符号为_ZNSs...格式
可通过 _GLIBCXX_USE_CXX11_ABI 宏检查当前 ABI 版本:
#include <bits/c++config.h>
static_assert(_GLIBCXX_USE_CXX11_ABI == 1, "需要 ABI v2");核心类 / 函数
ABI v2(SSO 时代)核心成员
// basic_string 的实际数据成员(简化,去除 allocator traits 细节)
struct _Alloc_hider : allocator_type { // EBO:空 allocator 不占空间
pointer _M_p; // 指向实际字符数据
};
_Alloc_hider _M_dataplus; // 偏移 0: 指针 + allocator (EBO)
size_type _M_string_length; // 偏移 8: 当前长度
union {
char _M_local_buf[16]; // SSO 缓冲区(容量 15 + 1 字节 '\0')
size_type _M_allocated_capacity; // 堆模式下记录分配容量
};| 成员 | 偏移 | 大小 | 说明 |
|---|---|---|---|
_M_dataplus._M_p | 0 | 8 字节 | SSO 时指向 _M_local_buf;堆时指向 new char[] |
_M_dataplus._M_alloc (EBO) | — | 0 字节 | 默认 allocator<char> 是空类,EBO 压缩后不占空间 |
_M_string_length | 8 | 8 字节 | SSO 和堆模式共用,始终存储当前字符串长度 |
_M_local_buf[16] | 16 | 16 字节 | SSO 缓冲区;与 _M_allocated_capacity 共享 union |
_M_allocated_capacity | 16 | 8 字节 | 堆模式下存储分配容量(union 的另一视图) |
关键成员函数
| 函数 | 作用 |
|---|---|
_M_is_local() | 判断 _M_dataplus._M_p == _M_local_buf,即是否处于 SSO 模式 |
_M_data() | 返回 _M_dataplus._M_p(实际字符数据指针) |
_M_set_length(n) | 设置 _M_string_length = n 并在 data()[n] 写入 '\0' |
_M_capacity() | SSO 返回 15;堆返回 _M_allocated_capacity |
_M_create(capacity, old_cap) | 分配 capacity+1 字节的堆内存,返回新指针 |
_M_dispose() | SSO 时为无操作;堆时 _M_destroy() 释放堆内存 |
_M_mutate(pos, len1, s, len2) | 核心变异原语:若 SSO 或容量不足则创建新缓冲区,移动/拷贝字符 |
_M_leak_hard() | ABI v2 中的遗留函数(COW 时代调用 _M_rep()->_M_set_leaked(),SSO 时代为 no-op) |
ABI v1(COW 时代)历史成员
// COW 布局:string 对象只有 1 个指针
struct _Rep { // 堆上的元数据块
size_type _M_length;
size_type _M_capacity;
_Atomic_word _M_refcount; // 原子引用计数
// 字符数据紧跟 _Rep 之后
};
char* _M_p; // 指向 _Rep 之后的字符数据| 成员/函数 | 说明 |
|---|---|
_Rep::_M_refcount | 原子引用计数;-1 表示永不释放(空字符串全局实例) |
_Rep::_M_is_shared() | _M_refcount > 0 时表示数据被多个 string 共享 |
_M_rep() | 从 _M_p 反推 _Rep* 地址(_M_p - sizeof(_Rep) 处) |
_M_grab(alloc, alloc2) | COW 核心:若 rep 未共享则直接复用,否则 _M_clone() 深拷贝 |
_M_leak() | 标记 rep 为 leaked(_M_refcount = -1),断开共享 |
关键算法
SSO / Long 模式切换
_M_is_local() 判定:
_M_dataplus._M_p == (pointer)_M_local_buf ?
├─ true → Short 模式:数据在栈上,容量 = 15
└─ false → Long 模式:数据在堆上,容量 = _M_allocated_capacity构造/赋值时的决策路径:
size <= 15→ 直接写入_M_local_buf,_M_p指向自身size > 15→ 调用_M_create(size+1, 0)堆分配,数据拷贝到堆缓冲区
容量增长策略
// _M_check_len(n) 的计算逻辑(简化)
size_type _M_check_len(size_type n) const {
if (max_size() - size() < n)
__throw_length_error("basic_string::_M_check_len");
size_type new_size = size() + std::max(size(), n); // max(2*current, current+needed)
return std::min(new_size, max_size()); // 不超过 max_size()
}增长因子为 2x(与 libc++ 一致),但上限受 max_size() 约束。实际分配字节数为 new_size + 1(含终止符 '\0')。
历史 COW 写时复制路径(ABI v1)
// 任何可能修改数据的操作前,检查共享状态
void _M_check_mutate() {
if (_M_rep()->_M_is_shared()) // refcount > 0
_M_mutate(0, 0, 0); // 分离副本:分配新缓冲区,拷贝数据,refcount--
}写时复制的调用链: operator[] (非 const) → _M_check_mutate() → _M_is_shared() → 若共享则 _M_mutate() → _M_clone() → 新 _Rep + 拷贝字符
空字符串优化:所有空 string 共享全局 _Rep(_M_refcount = -1,永不释放),避免零长度字符串的任何堆分配。
ABI 符号选择
编译时 _GLIBCXX_USE_CXX11_ABI 决定符号名:
| ABI 版本 | std::string 的 mangled 名 | 内联命名空间 |
|---|---|---|
v1 (=0) | _ZNSsC1Ev (构造函数) | 无,直接 std::basic_string |
v2 (=1) | _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEC1Ev | std::__cxx11::basic_string |
v2 符号中 __cxx11 段是 GCC abi_tag 属性自动注入的,无需手动编码。
ABI 约束
本篇主题本身就是 ABI;后续这里补齐 __cxx11 命名空间、abi_tag、_GLIBCXX_USE_CXX11_ABI 与混用二进制边界的约束清单。
异常安全
SSO 时代(ABI v2)的异常安全
| 操作 | 保证 | 机制 |
|---|---|---|
reserve() | 强保证 | 先 _M_create() 分配新缓冲区,拷贝数据到新缓冲区,再释放旧缓冲区;若分配抛异常,原数据不变 |
append(const char* s, size_type n) | 强保证 | 通过 _M_check_len() 计算所需容量;若超出当前容量,先分配新缓冲区再写入;分配失败时原 string 不变 |
replace(pos, len, s, n) | 强保证(长度不变时为 no-throw) | 若替换后长度不变且不触发扩容,直接原地修改(no-throw);否则通过 _M_mutate() 分离新缓冲区 |
operator=(const basic_string&) | 基本保证 | 赋值前先 _M_dispose() 旧缓冲区,再拷贝新内容;若拷贝抛异常,string 处于合法但未指定状态 |
operator=(basic_string&&) | no-throw | 移动语义:交换指针和长度,_M_p 置为 _M_local_buf(空 SSO),源 string 为空 |
clear() | no-throw | 仅 _M_set_length(0),不释放内存 |
COW 时代(ABI v1)的异常安全差异
COW 的 _M_mutate()(分离副本)存在微妙的异常安全问题:
// COW 的 append 路径(简化)
void append(const char* s, size_type n) {
_M_mutate(size(), n, s, n); // 步骤 1: 若 rep 共享则 clone,然后扩容
// 步骤 2: 写入新字符
// 问题:_M_mutate 内部的 clone 失败时,原 rep 的 refcount 已递减
// 若 clone 抛异常,原 string 可能指向已释放的 rep
}具体来说,COW 的 _M_mutate() 执行以下步骤:
- 检查
_M_is_shared();若共享,调用_M_clone()创建新_Rep(可能抛std::bad_alloc) _M_clone()成功后,旧 rep 的_M_refcount递减- 若
_M_refcount降为 0,旧 rep 被释放 _M_p指向新 rep 的字符区域
如果步骤 1 的 _M_clone() 抛异常,此时旧 rep 的 refcount 已经递减(从 2 变为 1),但 _M_p 仍指向旧 rep。这意味着 string 对象仍有效,但共享关系已被破坏——其他持有旧 rep 的 string 将看到 refcount 少 1。这是 基本保证(而非强保证),因为 string 的内部状态已发生不可逆改变。
iterator / reference invalidation
SSO 时代(ABI v2)失效规则
| 操作 | 迭代器/指针/引用是否失效 | 原因 |
|---|---|---|
SSO → 堆转换(如 append 导致长度 > 15) | 全部失效 | _M_p 从 _M_local_buf 切换到堆分配的新地址,所有指针不再有效 |
堆内扩容(如 append 导致超出当前容量) | 全部失效 | _M_create() 分配新缓冲区,释放旧缓冲区 |
堆内原地修改(长度不变,如 operator[] 写入) | 不失效 | 数据仍在同一堆缓冲区 |
shrink_to_fit() | 可能全部失效 | 堆模式下可能重新分配更小缓冲区;SSO 模式下为 no-op(不失效) |
clear() | 不失效 | 仅 _M_set_length(0),容量和缓冲区不变 |
reserve(n) 若 n > capacity() | 全部失效 | 分配新缓冲区,释放旧缓冲区 |
swap() | 全部失效 | 交换指针和长度,原引用指向另一 string 的数据 |
erase() | 不失效(但指向被删元素的引用无效) | 字符原地移动,长度缩短,缓冲区不变 |
COW 时代(ABI v1)的额外陷阱
COW 实现中,非变异操作也可能导致失效:
std::string a = "hello";
std::string b = a; // COW:a 和 b 共享同一 rep,refcount=2
const char* p = a.c_str(); // 获取 a 的数据指针
char c = b[0]; // b 的 operator[] const 触发 _M_check_mutate()
// 若 _M_is_shared() 返回 true,b 会 clone 一个新 rep
// a 的 refcount 降为 1,但 p 仍然有效——这一轮没问题
// 真正的陷阱:
std::string c = a;
char& r = a[0]; // 获取非 const 引用 → 触发 _M_mutate(),a 可能 clone
// 若 clone 发生,r 引用的是旧 rep 的数据,可能已悬空关键区别:
- SSO 时代:只有触发容量变化的操作才失效——失效边界清晰可预测
- COW 时代:任何非 const 访问都可能触发 clone——即使
begin()非 const 版本也可能失效迭代器 - COW 的
c_str()在 GCC 4.x 中有一次著名的缓存失效 bug:operator[] const会破坏c_str()返回的缓冲区
标准规范(§[string.iterators])
标准要求:
- 非变异操作(
begin()const、end()const、data()const、operator[]const、c_str())不得使迭代器、引用、指针失效 - 变异操作仅在导致重新分配时使迭代器失效
COW 实现无法满足第一条——这是 C++11 迫使 ABI 变更的核心原因之一。
性能模型
SSO 性能特征
| 参数 | libstdc++ (GCC) | libc++ (Clang) | 影响 |
|---|---|---|---|
sizeof(string) | 32 字节 | 24 字节 | libstdc++ 的 union { _M_local_buf[16]; _M_allocated_capacity; } 比 libc++ 的 __short_ 联合体多占 8 字节 |
| SSO 容量 | 15 字节 | 22 字节 | libc++ 将 size_type(8 字节)与指针(8 字节)重叠,腾出更多内联空间 |
| SSO 触发阈值 | len <= 15 | len <= 22 | 约 85% 的现实字符串 ≤ 15 字节(路径、邮件地址、标识符等),两者均覆盖大部分场景 |
| 缓存行占用 | 32 字节 = 半个 cache line(64B) | 24 字节 < 半个 cache line | 32 字节的 string 数组每两个对象恰好占满一个 cache line;24 字节则有 16 字节碎片 |
复制性能权衡
COW 时代(ABI v1):
复制 string a → b:
1. 拷贝 _M_p(1 个指针) ← 极快
2. _M_refcount.fetch_add(1) ← 原子操作,~10-50ns
3. 写入时才 _M_clone()(深拷贝) ← 延迟到第一次修改
问题:每次写入都有 _M_is_shared() 检查分支;多线程下原子操作有 cache-line bouncing
SSO 时代(ABI v2):
复制 string a → b:
若 SSO:memcpy 16 字节(_M_local_buf) ← 等价于 2 次 8 字节 load+store
若堆:malloc + memcpy(size) ← 每次复制都是深拷贝
收益:无原子操作、无共享检查分支、无 cache-line bouncing对于短字符串(≤ 15 字节),SSO 的深拷贝实际上比 COW 更快——memcpy 16 字节比 fetch_add 原子操作更快。
ABI 兼容成本
双 ABI 共存的代价:
- 二进制体积:每个
basic_string成员函数都有两份符号(v1 + v2),增加约 10-15% 的 string 相关代码体积 - 链接时间:符号表更大,链接器需要处理更多符号
- 模板实例化:若翻译单元使用不同 ABI 宏,同一
basic_string<T>实例化会产生两套不同的代码 - 不允许混用:v1 的
std::string和 v2 的std::__cxx11::basic_string是不同类型——传递到对方的函数会链接失败(而非静默 UB)
与 MSVC STL 对比
MSVC 的 std::string(VS 2015+):
- SSO 容量:15 字节(
_BUF_SIZE = 16) - 对象大小:32 字节(与 libstdc++ 相同)
- 无 COW 历史包袱(MSVC 从未实现 COW)
- 无 ABI 双版本问题(MSVC 的 ABI 由编译器版本号锁定,如
_MSC_VER)
libstdc++ vs libc++ vs MSVC
正文已给出 SSO 容量和对象大小的部分差异;后续在这里补齐三家 string 布局、增长策略、调试模式与 ABI 策略对照。
最小复现代码
#include <string>
int main() {
std::string s = "hello";
s += " world";
return static_cast<int>(s.size());
}编译 / 反汇编 / benchmark 证据
验证 SSO / Long 分界
// sso_boundary.cpp — 验证 15 字节 SSO 阈值
#include <cstdio>
#include <string>
#include <cstring>
int main() {
// 15 字节:SSO
std::string short_str("0123456789abcde"); // len = 15
printf("short: data=%p local=%p is_local=%d cap=%zu\n",
(void*)short_str.data(), (void*)&short_str,
short_str.data() == (const char*)&short_str,
short_str.capacity());
// 16 字节:堆分配
std::string long_str("0123456789abcdef"); // len = 16
printf("long: data=%p local=%p is_local=%d cap=%zu\n",
(void*)long_str.data(), (void*)&long_str,
long_str.data() == (const char*)&long_str,
long_str.capacity());
}编译运行:
g++ -std=c++20 -O2 sso_boundary.cpp -o sso_boundary && ./sso_boundary
# 预期输出:
# short: data=0x7fff... local=0x7fff... is_local=1 cap=15
# long: data=0x555... local=0x7fff... is_local=0 cap=31双 ABI 符号名验证
# 编译同一个测试程序,分别用 v1 和 v2 ABI
g++ -std=c++20 -O2 -D_GLIBCXX_USE_CXX11_ABI=1 string_test.cpp -o v2
g++ -std=c++20 -O2 -D_GLIBCXX_USE_CXX11_ABI=0 string_test.cpp -o v1
# 查看 v2 符号:包含 __cxx11 命名空间
objdump -t v2 | grep basic_string | head -5
# 预期:_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE...
# ^^^^^^^ ABI tag
# 查看 v1 符号:直接在 std 命名空间
objdump -t v1 | grep basic_string | head -5
# 预期:_ZNSs... (简写) 或 _ZNSt12basic_stringIcSt11char_traitsIcESaIcEE...
# 无 __cxx11对象大小与布局验证
# 编译包含两种 ABI 的程序,比较对象大小
cat > string_size.cpp << 'EOF'
#include <cstdio>
#include <string>
struct WithString { std::string s; int x; };
int main() {
printf("sizeof(std::string) = %zu\n", sizeof(std::string));
printf("sizeof(WithString) = %zu\n", sizeof(WithString));
printf("SSO capacity = %zu\n", std::string().capacity());
}
EOF
g++ -std=c++20 -O2 string_size.cpp -o string_size && ./string_size
# 预期(libstdc++ ABI v2, x86-64):
# sizeof(std::string) = 32
# sizeof(WithString) = 40 (32 + 4 + 4 padding)
# SSO capacity = 15append 热路径反汇编
# 查看 append 的内联路径(SSO 模式下的快速路径)
cat > append_bench.cpp << 'EOF'
#include <string>
#include <benchmark/benchmark.h>
static void BM_AppendSSO(benchmark::State& state) {
for (auto _ : state) {
std::string s = "hello";
s += " world"; // 5+6=11, 仍在 SSO 内
benchmark::DoNotOptimize(s);
}
}
static void BM_AppendHeap(benchmark::State& state) {
for (auto _ : state) {
std::string s = "hello world! this is a long string";
s += " and more data"; // 触发堆分配
benchmark::DoNotOptimize(s);
}
}
BENCHMARK(BM_AppendSSO);
BENCHMARK(BM_AppendHeap);
BENCHMARK_MAIN();
EOF
# 编译并运行 benchmark
g++ -std=c++20 -O2 -lbenchmark append_bench.cpp -o append_bench && ./append_bench反汇编查看 SSO 快速路径:
g++ -std=c++20 -O2 -S -masm=intel append_bench.cpp -o append_bench.s
# 搜索 SSO append 的关键指令:应看到 memcpy/mov 操作而非 _M_create 调用
grep -A 20 'append' append_bench.s | head -30典型 benchmark 结果(参考值,x86-64 GCC 13 -O2)
| 场景 | 耗时(ns/op) | 说明 |
|---|---|---|
| SSO 构造 + 拷贝(11 字节) | ~5-8 | 纯 memcpy 32 字节 |
| 堆构造 + 拷贝(64 字节) | ~20-30 | 含 malloc + memcpy |
s += "abc" SSO 内 | ~8-12 | 原地追加,无分配 |
s += "abc" 触发扩容 | ~40-60 | 含 realloc/新分配 + 拷贝 |
| COW 构造 + 拷贝(ABI v1,64 字节) | ~15-25 | 仅拷贝指针 + 原子操作 |
COW s += "abc"(共享状态) | ~30-50 | 含 clone + 追加 |
SSO 在短字符串场景下比 COW 快 2-3 倍;长字符串 COW 的复制更便宜,但写入时的 clone 开销在热路径中可能抵消收益。