C++ 中 std::string 底层是如何使用内存的

28 阅读7分钟

在日常 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 在分配内存时是否必然要加锁呢?这种锁的存在,会不会在高并发场景下逐渐演变成性能瓶颈?