C++ 性能优化必知:std::string 的 15 字节临界点

128 阅读4分钟

一、前言

在 C++ 开发中,std::string 是最常用的数据结构之一。表面上它只是一个“字符串类”,但其底层实现却暗藏玄机。理解这些原理,不仅有助于写出更高效的代码,还能避免一些隐蔽的性能陷阱。本文我们聚焦一个核心机制:小字符串优化(Small String Optimization, SSO)。

二、小字符串优化(SSO)的核心思想

C++ 标准并没有强制 std::string 的具体实现,但主流库(如 GCC 的 libstdc++ 和 LLVM 的 libc++)都实现了 小字符串优化(SSO)。
SSO 的基本思路是:

  • 当字符串较短时,直接存在 std::string 对象本身内部的 buffer 中,而不去堆上分配内存。
  • 只有超过一定阈值(常见是 15 个字节)时,才会使用堆内存。

以 GCC 11.5(libstdc++,x86-64) 为例,std::string 的i源码为:

 84   template<typename _CharT, typename _Traits, typename _Alloc>                                                                                                      
 85     class basic_string                                                                                                                                              
 86     {
            // ......省略
 158       struct _Alloc_hider : allocator_type // TODO check __is_final                                                                                                 
 159       {                                                                                                                                                             
 160 #if __cplusplus < 201103L                                                                                                                                           
 161     _Alloc_hider(pointer __dat, const _Alloc& __a = _Alloc())                                                                                                       
 162     : allocator_type(__a), _M_p(__dat) { }                                                                                                                          
 163 #else                                                                                                                                                               
 164     _Alloc_hider(pointer __dat, const _Alloc& __a)                                                                                                                  
 165     : allocator_type(__a), _M_p(__dat) { }                                                                                                                          
 166                                                                                                                                                                     
 167     _Alloc_hider(pointer __dat, _Alloc&& __a = _Alloc())                                                                                                            
 168     : allocator_type(std::move(__a)), _M_p(__dat) { }                                                                                                               
 169 #endif                                                                                                                                                              
 170                                                                                                                                                                     
 171     pointer _M_p; // The actual data.                                                                                                                               
 172       };                                                                                                                                                            
 173                                                                                                                                                                     
 174       _Alloc_hider  _M_dataplus;                                                                                                                                    
 175       size_type     _M_string_length;                                                                                                                               
 176                                                                                                                                                                     
 177       enum { _S_local_capacity = 15 / sizeof(_CharT) };                                                                                                             
 178                                                                                                                                                                     
 179       union                                                                                                                                                         
 180       {                                                                                                                                                             
 181     _CharT           _M_local_buf[_S_local_capacity + 1];                                                                                                           
 182     size_type        _M_allocated_capacity;                                                                                                                         
 183       };
             // ......省略
 3106    };

这解释了为什么在 GCC 11.5 下 std::string 的大小是 32 字节,以及为什么 SSO 容量正好是 15。

也就是说:

  • 当字符串长度 ≤ 15 时,内容直接存放在 _M_local_buf 中;
  • 当长度 ≥ 16 时,才会分配堆内存并把 _M_p 指向它。

三、验证 SSO 的小实验

下面这段代码可以验证 std::string 的大小,以及 15 和 16 字节时的分配差异:

// test.cpp
// g++ test.cpp -o test
#include <string>
#include <iostream>

const char* str_15 = "abcdefghijklmno";   // length is 15
const char* str_16 = "abcdefghijklmnoj";  // length is 16

void print_usage()
{
    std::cerr << "Usage: ./test [15|16]" << std::endl;
}

int main(int argc, char** argv) {
    if (argc < 2) {
        print_usage();
        return -1;
    }
    const char* str = nullptr;
    int arg = std::stoi(argv[1]);
    if (arg == 15) {
        str = str_15;
    } else if (arg == 16) {
        str = str_16;
    } else {
        print_usage();
        return -1;
    }
    std::cout << "sizeof(std::string) = " << sizeof(std::string) << std::endl;

    // 无限循环创建临时std::string对象
    while (true) {        
        std::string s(str);
    }
    return 0;
}

接下来,创建perf监控事件用于监控进程调用malloc的情况:

[root@instance-bguv65e0 string_sso]# perf probe /lib64/libc.so.6 malloc
Added new event:
  probe_libc:malloc    (on malloc in /lib64/libc.so.6)

You can now use it in all perf tools, such as:

        perf record -e probe_libc:malloc -aR sleep 1

分别运行string为15字节和16字节两种场景,并使用perf监控此进程调用malloc的情况,输出示例(GCC 11.5, x86-64):

[root@instance-bguv65e0 string_sso]# ./test 15 &
[1] 4162216
[root@instance-bguv65e0 string_sso]# sizeof(std::string) = 32

[root@instance-bguv65e0 string_sso]# perf stat -e probe_libc:malloc -t 4162216 -- sleep 5

 Performance counter stats for thread id '4162216':

                 0      probe_libc:malloc                                                     

       5.001849405 seconds time elapsed

[root@instance-bguv65e0 string_sso]# 
[root@instance-bguv65e0 string_sso]# ./test 16 &
[1] 4162321
[root@instance-bguv65e0 string_sso]# sizeof(std::string) = 32

[root@instance-bguv65e0 string_sso]# perf stat -e probe_libc:malloc -t 4162321 -- sleep 5

 Performance counter stats for thread id '4162321':

         1,604,918      probe_libc:malloc                                                     

       5.001863361 seconds time elapsed

[root@instance-bguv65e0 string_sso]#

分析运行结果可知,15字节std::string 没有触发堆分配,而16字节std::string触发了堆分配,从而直观印证了 SSO 的阈值就是15。

四、性能上的巨大意义

为什么这对性能优化如此重要?

  1. 避免动态内存分配
    堆分配 (malloc/free) 是昂贵的操作,可能导致锁竞争、缓存失效。
    短字符串(如日志标签、JSON 字段名、临时拼接 key 等)非常常见,避免堆分配能显著提升性能。
  2. 提升缓存命中率
    小字符串直接存在对象内部,连续存放在栈或数组中,局部性更好。
    CPU cache 命中率提升,内存访问速度加快。
  3. 减少碎片化
    大量短字符串如果频繁分配/释放,会造成堆碎片化。SSO 避免了这种问题。

五、结语

std::string 的小字符串优化(SSO)是一个看似微不足道却极其重要的性能特性。
它让我们在日常编程中无须担心小字符串的性能问题,大多数短小字符串操作几乎是零开销的。
写高性能 C++ 时,我们可以放心大胆地使用 std::string,而不必像早期那样到处用 const char* 或手写缓冲区来规避堆分配。
📬 欢迎关注VX公众号“Hankin-Liu的技术研究室”,持续分享信创、软件性能测试、调优、编程技巧、软件调试技巧相关内容,输出有价值、有沉淀的技术干货。