在日常 C++ 开发中,std::string 几乎无处不在。但很多人对它的理解,仍停留在“一个比 char* 更安全的字符串封装”这一层。
一旦进入性能优化、内存分析、线上问题排查阶段,一个绕不开的问题就会出现:
std::string 的内存到底分配在哪里?什么时候在栈上?什么时候在堆上?又是通过哪一层内存体系拿到的?
本文从 std::string 的实现出发,把它在实际运行时的内存使用路径捋一捋。
一、先澄清一个常见的误解
很多资料中都会看到类似表述:string 小于 15 字节时在栈上,大于 15 字节时在堆上。
这句话本身并不严谨,甚至可以说是错误的。
更准确的说法是:std::string 对象本身,和普通对象一样,遵循 C++ 的对象生命周期和存储规则。局部变量在栈上,成员变量或 new 出来的对象在堆上。
真正有区别的,不是 string 在哪里,而是「string 内部用于存储字符的那块缓冲区在哪里」。
二、SSO:小字符串优化到底优化了什么
SSO,全称 Small String Optimization,小字符串优化。它解决的是一个非常实用的问题:如果每一个字符串都走一次堆分配,那么 malloc 和 free 的成本会迅速成为性能瓶颈。
于是,标准库的实现者做了一个经典取舍:当字符串足够短时,直接把字符数组放进 std::string 对象本身。
这样一来,如果 string 对象本身在栈上,那么字符串内容自然也就在栈上,整个过程不涉及任何堆分配,效率大大地提升。
在主流实现中,std::string 的对象大小通常是 32 字节左右,其中会预留一段内部缓冲区。当字符串长度不超过这个缓冲区容量时,就会启用 SSO。
需要特别强调的是,SSO 的阈值并不是标准规定的常量。SSO 属于实现层面的优化,实际编码时不妨看看自己环境里的 string 源码,确认是否真的存在这一行为,以及阈值究竟是多少,做到心中如有数,敲键盘就有神。
常见实现中,有 15 字节、22 字节、23 字节等差异。
三、sizeof(std::string) 为什么是 32 个字节
下面这段 string 源代码,来自我的 64 位 Linux 开发环境,文件路径是 /usr/local/gcc-7.5.0/include/c++/7.5.0/bits/basic_string.h。
剥离掉 typedef、模板参数、成员函数等之后,std::string 的核心数据结构可以简化成这样:
在我的环境上,直接打印 sizeof(std::string),结果是 32。
那么问题来了:这 32 个字节,到底是怎么算出来的?
我们一项一项来拆。
1、首先看 _M_dataplus
_M_dataplus 继承自 allocator_type,并额外包含一个指针 _M_p。在默认 allocator 的实现下,allocator 本身是一个空类,可以被编译器通过空基类优化消除。
因此,这一部分真正占用空间的,只有一个指针 _M_p。
在 64 位系统上,一个指针是 8 个字节。
到目前为止,占用 8 字节。
2、接下来的 enum 声明
它只是一个编译期常量定义,不是成员变量,因此完全不占用任何对象内存空间。enum 在这里的作用,只是给后面的数组维度提供一个常量值,而不是往对象里塞数据。
3、接下来是 _M_string_length
它的类型是 size_type,本质上就是 size_t。在 64 位系统上,size_t 也是 8 个字节。
累计占用变成 16 字节。
4、然后是最关键的 union。
先看 _S_local_capacity 的值。
在 char 类型的 string 中,sizeof(char) 是 1,因此:
_S_local_capacity = 15
而 _M_local_buf 的大小是:
15 + 1 = 16 个字节
union 的另一个成员 _M_allocated_capacity 是 size_type,也就是 8 个字节。
union 的规则是:大小等于其最大成员的大小。
因此,这个 union 的大小是 16 个字节,而不是 16 + 8。
5、现在把三部分加在一起
- _M_dataplus 8 字节
- _M_string_length 8 字节
- union 16 字节
总计:32 字节。
「这 32 个字节,不多不少,正好覆盖了 SSO 所需的本地缓冲区、字符串长度信息,以及堆模式下的容量信息。」
这也是为什么在大多数 64 位 Linux 系统上,sizeof(std::string) 会稳定地等于 32。
更重要的是,这 32 个字节并不是“随便凑出来的”,而是一个非常克制的工程结果:
既要能容纳小字符串,又要存下指针和长度,还要保证 ABI 稳定和缓存友好。
从这个角度再回头看 union 的设计,就会发现它并不是“语法技巧”,而是为了把 string 的对象体积,牢牢控制在一个 cache line 友好的范围内。
四、哪些操作会明确触发堆分配
理解 SSO 之后,接下来更重要的问题是:在什么情况下,它一定会失效。
第一类情况是,字符串长度本身超过了 SSO 的容量。这是最直观的情形,只要内容装不下,就必须走堆分配。
第二类情况,反而是最容易被忽略的,那就是主动调用 reserve。
一旦调用 reserve,并且传入的容量大于 SSO 阈值,std::string 会立即在堆上申请一块满足该容量的内存。即使后续真正写入的内容很短,也不会再回退到 SSO 模式。
所以,reserve 不只是性能优化,它会直接影响内存分配方式。
第三类情况,是从外部缓冲区构造较大的字符串,只要长度超过阈值,同样会直接走堆分配。
五、string 到底是从哪申请的“堆”内存
在理解到这一层时,一个常见的误解是:以为 std::string 会直接向操作系统申请内存。
现实情况并非如此。
从 C++ 标准的角度看,std::string 并不知道什么是操作系统,它只知道通过 allocator 申请内存。默认情况下,使用的是 std::allocator。
在 Linux 环境中,这条内存申请路径大致如下:
-
std::string 发起容量扩展,请求 allocator 分配内存。
-
allocator 内部调用 operator new。
-
operator new 再调用 C 运行时的 malloc。
-
malloc 并不会每次都向内核申请内存,而是由 glibc 中的 ptmalloc 在用户态维护内存池。
-
只有在内存池无法满足需求,或者请求的内存块足够大时,ptmalloc 才会通过 brk 或 mmap 向内核申请新的虚拟内存。
绝大多数 std::string 的堆内存,来自 glibc 的用户态内存池,而不是直接来自操作系统。
六、把整个内存路径串起来看
如果把 std::string 的内存使用抽象成一条链路,它并不是一跳直达内核,而是分层完成的。
std::string 位于最上层,只负责语义和容量管理。下面是 allocator,再下面是 operator new,然后是 glibc 的 malloc 和 ptmalloc,最底层才是操作系统内核。
理解这一层级关系之后,很多问题会自然变得清晰:为什么频繁构造 string 会产生额外开销,为什么 reserve 用不好反而更浪费内存,以及为什么替换 allocator 能改变整体内存行为。
七、几个小TPS
第一,不要迷信所谓的 15 字节规则,它只是某些实现下的结果。
第二,reserve 是一把双刃剑,它减少扩容次数的同时,也会强制放弃 SSO。
第三,在热点路径中,频繁创建和销毁较大的 string,往往是隐形的性能杀手。
第四,只有在对内存行为有极致控制需求时,才应该考虑自定义 allocator。
八、最后
std::string 看起来只是一个简单的字符串类,但它背后串起的是 C++ 对象模型、库实现策略、运行时内存管理以及操作系统虚拟内存机制。
真正理解了这条完整链路之后,再回头看一行 std::string payload 的时候,你看到的就不只是一个字符串了,而是理解了它为何被设计成现在这个样子。
到这里,其实还可以再往下追问一个问题: 在 C++ 的多线程环境中,如果多个线程同时使用 std::string,而这些 string 又频繁向 glibc 申请堆内存,那么 glibc 在分配内存时是否必然要加锁呢?这种锁的存在,会不会在高并发场景下逐渐演变成性能瓶颈?