mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2333 字
6 分钟
cpp(十二)——常见数据结构内存布局
2026-11-05

CPP(十二)——常见容器内存布局#

这篇也很硬,stl容器+lambda+迭代器。废话不多说,虽然很疲惫。

直接开始吧,顺便找个时间休息一下🤡。

先记住这张表:

容器底层结构逆向重点
std::vector连续动态数组三个指针、扩容、下标访问
std::string字符串存储SSO、长度、容量
std::array固定大小数组内嵌连续内存
std::list双向链表节点、前后指针
std::map通常是红黑树树节点、左右子树
std::unordered_map哈希表bucket、哈希、链表或节点
std::set通常是红黑树有序集合、节点结构
std::unordered_set哈希表哈希桶和节点

注意:这些是常见标准库实现的典型结构,不是 C++ 标准强制规定的内存布局。

一、vector#

基本的api在之前介绍过,这里不再过多赘述。

很多 C++ 标准库实现中,vector 的核心状态可以抽象为三个指针:

vector 对象
┌─────────────────────────────┐
│ start │ ───────┐
│ finish │ ────┐ │
│ end_of_storage │ ─┐ │ │
└─────────────────────────────┘ │ │ │
│ │ │
▼ ▼ ▼
堆内存:
┌────────┬────────┬────────┬────────┬────────┐
│ 10 │ 20 │ 30 │ 未使用 │ 未使用 │
└────────┴────────┴────────┴────────┴────────┘
▲ ▲ ▲
start finish end_of_storage

三个指针分别表示:

指针含义
start数组起始地址
finish已构造元素的末尾后一位置
end_of_storage已分配存储空间的末尾后一位置

所以不能推出容量大小与已经存储的容量公式:

size=finish−startsizeof(T)size = \frac{finish-start}{sizeof(T)}capacity=end_of_storage−startsizeof(T)capacity = \frac{end\_of\_storage-start}{sizeof(T)}

这里的T是模板,还记得吗?我们之前讲过,另外为啥除以sizeof(T),这个就不用说了吧,c里面算申请的容积时经常用到。

假设:

start = 0x5000
finish = 0x500C
end_of_storage = 0x5010

如果元素是 int,每个 4 字节:

size = (0x500C - 0x5000) / 4 = 3
capacity = (0x5010 - 0x5000) / 4 = 4

这意味着 vector 有 3 个元素,但分配了 4 个元素的空间。

push_back时的扩容#

push_back(value)
│
▼
判断 size < capacity?
│
┌────┴────┐
│ │
是 否
│ │
▼ ▼
直接写入 分配更大的内存
│
▼
搬迁旧元素
│
▼
写入新元素
│
▼
释放旧内存
│
▼
更新三个指针

ida实战#

先看源码,这里操作覆盖了:访问目标索引+查看容量+扩容+初始化。我们看看ida里是个什么样子。

#include<iostream>
#include<vector>
void veclab(std::vector<int>& v){
int m = v.size();
int n = v.capacity();
int a = v[0];
int b = v[2];
a=b;
v.push_back(22);
v.push_back(33);
v.push_back(67);
}
int main(){
std::vector<int> v;
v.reserve(5);
for(int i=0;i<5;i++){
v.push_back(i);
}
std::cout<<v.size();
std::cout<<v.capacity();
for(auto& x:v){
x*=2;
}
}

先来看看初始化

v3 = (int *)operator new(a1: 20u);
v17.__end_cap_.__value_ = v3 + 5; // 申请了20字节的内存,int一个占4字节,所以这里能存5个,end_cap指针指向5*4,注意这里是对指针运算
v17.__begin_ = v3;
v17.__end_ = v3;

接着是核心的循环插入,这里还叠加了要不要扩容的判断

for ( i = 0; i != 5; ++i )
{
end = v17.__end_;
if ( v17.__end_ == v17.__end_cap_.__value_ )//如果当前容量达到预设容量,就走扩容路径。
{
begin = v17.__begin_;
v7 = v17.__end_ - v17.__begin_;//是两个 int* 相减,所以得到的是元素数量,而不是字节数。
v8 = v7 + 1;//保留旧数组的起始地址,计算旧数组容量,v8 计算插入新元素后所需的最小容量。
if ( (unsigned __int64)(v7 + 1) >> 62 != 0 )
std::vector<int>::__throw_length_error[abi:v170000](this: &v17);
v9 = (char *)v17.__end_cap_.__value_ - (char *)v17.__begin_;//转成char*,一字节,这里得到的是旧数组的字节数。
if ( ((char *)v17.__end_cap_.__value_ - (char *)v17.__begin_) >> 1 > v8 )
//v9 >> 1相当于取了原始数组的一半字节数也就是10个字节。
v8 = v9 >> 1;
if ( (unsigned __int64)v9 >= 0x7FFFFFFFFFFFFFFCLL )
v10 = 0x3FFFFFFFFFFFFFFFLL;
else
v10 = v8;
if ( v10 != 0 )
{
if ( v10 >> 62 != 0 )
std::__throw_bad_array_new_length[abi:v170000]();
v11 = operator new(a1: 4 * v10);//这里是申请新内存的策略,申请了新的容量v10也就是v8也就是10,扩容了10个字节!
}
else
{
v11 = 0;
}
// 按照旧数组大小,找到新数组里新开辟的地址。
v12 = (int *)(v11 + 4 * v7); // 把新的数据填充进来。为什么先写新元素?这是 libc++ 这段扩容实现选择的操作顺序。
*v12 = i;
for ( j = v12 + 1; end != begin; --v12 )
{
v14 = *--end;// 搬迁循环,喜欢从后面来。它从旧数组末尾向前读取元素,再从新数组中对应的末尾位置向前写入
*(v12 - 1) = v14;// 这时候v12填充完就是起始地址,拿到他的地址。
}
v17.__begin_ = v12;
v17.__end_ = j;
v17.__end_cap_.__value_ = (int *)(v11 + 4 * v10);// 新数组的末尾指针。
if ( begin != nullptr )
operator delete(a1: begin);//释放旧数组。这就是一次完整的 vector 扩容。
}
else//没达到容量就直接赋值。
{
*v17.__end_ = i;
v17.__end_ = end + 1;
}
}

vector的动态扩容真牛叉,每次扩容原始的一半字节数,不够就再扩。

二、map红黑树#

map 和 vector 的最大区别:

  • vector:元素连续存储。
  • map:元素通常存储在树节点中。
#include <map>
int test_map() {
std::map<int, int> mp;
mp[10] = 100;
mp[20] = 200;
mp[5] = 50;
return mp[10];
}

map 会根据 key 排序。

例如:

10
/ \
5 20

这是为了便于查找、插入、删除,同时维持有序性。

红黑树典型结构#

可以先把一个节点理解成:

struct TreeNode {
TreeNode* parent;
TreeNode* left;
TreeNode* right;
bool color;
int key;
int value;
};

示意内存:

TreeNode
┌──────────────────┐
│ parent │
│ left │
│ right │
│ color │
│ key │
│ value │
└──────────────────┘

真实 STL 实现还可能有头节点、哨兵节点、颜色编码、对齐填充等。

ida实战#

源代码#

#include <map>
#include <iostream>
#include <algorithm>
int main()
{
#include <map>
std::map<std::string, int> scores;
scores["alice"] = 95; // 插入/更新
scores["bob"] = 88;
scores["mj"] = 67;
scores["miejianguo"] = 55;
std::cout << scores["alice"]; // 读取:95
auto it = scores.find("bob"); // 查找
if (it != scores.end())
{ /* 找到了 */
for (const auto &[name, score] : scores)
{
std::cout << name << ": " << score << "\n";
}
}
}

反编译的代码很长,主要是红黑树的实现,我这里只贴一部分慢慢讲解,要不然太难阅读了。

初始化#

看这几行:

v51.__pair1_.__value_.__left_ = nullptr;
v51.__pair3_.__value_ = 0;
v51.__begin_node_ = &v51.__pair1_.__value_;

它们是在初始化 std::map 内部的红黑树结构。

可以先把 v51 理解成一棵树的管理对象:

伪代码字段可以先理解为
__pair1_.__value_.__left_根节点左指针
__pair3_.__value_当前节点数量
__begin_node_指向最小节点的迭代器入口 / begin 节点

这里有一个容易混淆的地方:__begin_node_ 不一定直接指向根节点。它是为了支持 begin() 快速定位最小节点而维护的入口。

std::map 底层通常是红黑树。每个节点除了保存键值对,还会保存父节点、左孩子、右孩子等链接。

插入#

来看插入元素

先看源码:

scores["alice"] = 95;
scores["bob"] = 88;
scores["mj"] = 67;
scores["miejianguo"] = 55;

它们在伪代码里分别对应四段非常相似的逻辑。

以第一次插入 "alice" 为例。

准备 key

strcpy((char *)&v50.__r_.__value_.__l.0, "\nalice");

这里是在构造临时 std::string,内容是 "alice"。

这里多了个换行符,有点迷。

找到是否存在这个键#

我们继续往下看

v3 = std::__tree<...>::__find_equal<std::string>(
this: &v51, // 这个 map
__parent: &v52, // 输出参数:找到/应插入位置的父节点
__v: &v50); // 要找的 key = "\nalice"
v4 = *v3; // v4 = 该位置上的节点指针(若 key 已存在则非空)
ps:完整的类型v3 = std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string>,true>,std::allocator<std::__value_type<std::string,int>>>::__find_equal<std::string>(
很长,不过我们只需要把他当作map<string,int>即可。

创建新节点逻辑#

接着往下走

if ( *v3 == nullptr )//在map里没找到对应的键,就开始创建一个节点。
{
v5 = operator new(a1: 0x40u);//operator new(0x40) 表示申请 0x40 = 64 字节内存,作为一个红黑树节点。
data = v50.__r_.__value_.__l.__data_; //拿到键值
*(_DWORD *)(v5 + 56) = 0;
v7 = *(_OWORD *)v50.__r_.__value_.__l.gap0;
memset(&v50, 0, sizeof(v50));
v4 = (std::__tree_node_base<void *> *)v5;
*(_QWORD *)(v5 + 48) = data;
v8 = v52;
*(_OWORD *)(v5 + 32) = v7;
*(_QWORD *)v5 = 0;
*(_QWORD *)(v5 + 8) = 0;
*(_QWORD *)(v5 + 16) = v8;
//把新创建的节点塞到树上
*v3 = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > >::__node_base_pointer)v5;
//更新 __begin_node_:必要时更新最小节点入口。
if ( v51.__begin_node_->__left_ != nullptr )
v51.__begin_node_ = v51.__begin_node_->__left_;
std::__tree_balance_after_insert[abi:v170000]<std::__tree_node_base<void *> *>(
__root: v51.__pair1_.__value_.__left_,
__x: *v3);//__tree_balance_after_insert:插入后进行红黑树平衡。
++v51.__pair3_.__value_;//节点数量加一。
}
//__tree_balance_after_insert 是识别红黑树插入的重要特征。 它内部通常会处理节点颜色、父节点、叔叔节点,以及左旋、右旋等操作。
注意,不是每次插入都一定发生旋转。新节点插入后,可能只需要变色,也可能需要旋转和变色。

推断内存布局#

根据节点的偏移,我们可以推断一下红黑树节点的布局

节点偏移大小当前伪代码中的操作初步含义
+0x008写 0左孩子指针
+0x088写 0右孩子指针
+0x108写 v52父节点指针
+0x2016写入 v7std::string 内部数据
+0x308写入 datastd::string 相关指针/存储字段
+0x384写 0int value 的初始值

我们从ida里看看

00000000 struct __cppobj std::__tree_node<std::__value_type<std::string,int>,void *> : std::__tree_node_base<void *> // sizeof=0x40
00000000 {
00000020 std::__tree_node<std::__value_type<std::string,int>,void *>::__node_value_type __value_;
00000040 };

treenode继承了__tree_node_base

节点总大小 0x40 = 64,值区 __value_ 在 +0x20 = 32,所以基类 __tree_node_base<void\*> 大小是 32 字节。

00000000 struct std::__tree_end_node<...> // sizeof=0x8
00000000 pointer __left_; // +0x00
00000000 struct __tree_node_base<void*> : __end_node_type // sizeof=0x20
00000000 <继承自 __tree_end_node> // +0x00 ~ +0x07,8 字节
00000008 pointer __right_; // +0x08
00000010 __parent_pointer __parent_; // +0x10
00000018 bool __is_black_; // +0x18
00000019-1F padding

这样就能看出基类的数据了

偏移字段来源
+0x00__left_继承自 __tree_end_node(左孩子指针)
+0x08__right_本类
+0x10__parent_本类
+0x18__is_black_(bool,1 字节)本类
+0x19~0x1Fpadding

整个节点的内存布局就水落石出

偏移内容
+0x00__left_(左孩子)
+0x08__right_(右孩子)
+0x10__parent_(父指针)
+0x18__is_black_(bool,颜色)
+0x20pair<const string,int> __value_
+0x20└ const string first(0x18 字节,到 +0x38)
+0x38└ int second ← value,56 = 0x38
+0x3C~0x3Fpadding

写入数据#

v9 = v50.__r_.__value_.__s.0;
*(_DWORD *)&v4[1].__is_black_ = 95;
v4[1] // ① 数组下标(等价 *(v4 + 1))
v4[1].__is_black_ // ② 取结构体成员(等价 (*(v4+1)).__is_black_)
& v4[1].__is_black_// ③ 取该成员的地址 → 得到指针
(_DWORD *) ... // ④ 把指针强制转成“指向 4 字节整数”的指针
* (_DWORD *) ... // ⑤ 解引用 → 一个可写的 4 字节整数左值
= 95; // ⑥ 赋值
v4 类型是 __tree_node_base<void*> *,sizeof(*v4) = 0x20。
v4[1] = (char*)v4 + 0x20。
.__is_black_ 在基类里偏移 +0x18。
所以地址 = (char*)v4 + 0x20 + 0x18 = (char*)v4 + 0x38 = +56。

下面是find的实现#

v50.__r_.__value_.__s.0 = (std::string::__short::$5662C175DA7CC34792E979125A88A1DA)6;
*(_DWORD *)&v50.__r_.__value_.__l.gap0[1] = 6451042;//6451042就是bob
v37.__ptr_ = std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string>,true>,std::allocator<std::__value_type<std::string,int>>>::find<std::string>(
this: &v51,
__v: &v50).__ptr_;//查到结果的指针。
if ( (*(_BYTE *)&v50.__r_.__value_.__s.0 & 1) != 0 )
if (&v51.__pair1_ != v37.__ptr_) //相当于if (it != scores.end())

迭代器遍历#

begin_node = v51.__begin_node_;//相当于 auto it = scores.begin();
if ( (std::__compressed_pair<std::__tree_end_node<std::__tree_node_base<void *> *>> *)v51.__begin_node_ != &v51.__pair1_ )
{
do
{
left_low = LOBYTE(begin_node[4].__left_);
v40 = (left_low & 1) == 0;
v41 = left_low >> 1;
if ( v40 )
left = (char *)&begin_node[4].__left_ + 1;
else
left = (const char *)begin_node[6].__left_;
if ( v40 )
v43 = v41;
else
v43 = (size_t)begin_node[5].__left_;
//上面在处理字符串的sso
v44 = std::__put_character_sequence[abi:v170000]<char,std::char_traits<char>>(
__os: (std::ostream *)&std::cout,
__str: left,
__len: v43);
v45 = std::__put_character_sequence[abi:v170000]<char,std::char_traits<char>>(__os: v44, __str: ": ", __len: 2u);
v46 = (std::ostream *)std::ostream::operator<<(a1: v45, a2: LODWORD(begin_node[7].__left_));
std::__put_character_sequence[abi:v170000]<char,std::char_traits<char>>(__os: v46, __str: "\n", __len: 1u);
//输出。
p_left = &begin_node[1].__left_->__left_;
if ( p_left != nullptr )
{
do
{
v48 = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > > *)p_left;
p_left = (_QWORD *)*p_left;
}
while ( p_left != nullptr );
}
else
{
do
{
v48 = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > > *)begin_node[2].__left_;
v40 = v48->__begin_node_ == begin_node;
begin_node = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > >::__iter_pointer)v48;
}
while ( !v40 );
}
begin_node = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > >::__iter_pointer)v48;
}
while ( v48 != (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > > *)&v51.__pair1_ );
}
}
把最后一部分简化下:寻找中序遍历的下一个节点。
p_left = &begin_node[1].__left_->__left_;//p_left = node->right->left; // 右子树的左孩子
if (p_left != nullptr)
{
do
{
v48 = ... p_left;
p_left = (_QWORD *)*p_left;
}
while (p_left != nullptr);
}
else
{
do
{
v48 = ... begin_node[2].__left_;
v40 = v48->__begin_node_ == begin_node;
begin_node = ... v48;
}
while (!v40);
}

情况 A:当前节点有右子树

  1. 先进入当前节点的右孩子。
  2. 再沿着左孩子一直向下。
  3. 最后到达的节点就是下一个节点。

情况 B:当前节点没有右子树

  1. 沿着父节点向上走。
  2. 如果当前节点是父节点的左孩子,那么父节点就是下一个节点。
  3. 如果当前节点是父节点的右孩子,就继续向上。
  4. 一直走到满足条件的位置,或者走到 end 哨兵。

这就是红黑树迭代器 ++it 的典型实现思路。

三、std::unordered_map/set:哈希表#

#include <unordered_map>
int test_hash() {
std::unordered_map<int, int> mp;
mp[100] = 1;
mp[200] = 2;
return mp[100];
}

和 map 的区别:

对比mapunordered_map
典型结构红黑树哈希表
是否有序有序不保证顺序
查找(O(\log n))平均 (O(1))
关键逻辑比较器hash + key_equal

哈希表工作流程#

key = 100
│
▼
hash(100)
│
▼
计算 bucket 下标
│
▼
找到对应 bucket
│
▼
遍历候选节点
│
▼
比较 key
│
▼
返回 value

你在 IDA 中可能看到:

  • 哈希函数;
  • 取模或位掩码;
  • bucket 数组;
  • 节点链表或其他冲突处理结构;
  • key 比较。

这些是识别哈希表的重要线索。

注意:不同 libc++ 版本的哈希表结构存在差异,不要把它固定成某一种链表布局。

ida实战#

源代码#

#include <unordered_map>
#include <unordered_set>
#include <string>
int main()
{
std::unordered_map<std::string, int> u;
u["alice"] = 95;
u["bob"] = 88;
auto it = u.find("alice"); // 平均 O(1)
u.count("alice"); // 0 或 1
std::unordered_set<int> s = {3, 1, 2};
s.insert(5);
s.erase(3);
}

反编译后的伪代码很多,就不全部放上来了,感兴趣的佬可以自己编译一个so看看。

赋值#

u.__p3_.__value_ = 1.0;
strcpy((char *)&v15, "\nalice");
memset(&u, 0, 32);
v13.__base_.__value_ = (std::string *)&v15;

这里u是哈希表,__hash_table<std::__hash_value_type<std::string,int>简化一下,类型可以这么看,键是string,值是int。

接着看看赋值逻辑。

v18.__node_ = __emplace_unique_key_args(
this: &u,
__k: (const std::string *)&v15,
...
).first;
LODWORD(v18.__node_[5].__next_) = 95;//对相应的键进行赋值。
//这里的内存布局我们稍后来看,为什么会在这里赋值。

它和之前分析 map 时的 __find_equal 有相似之处。

这里的:

__emplace_unique_key_args

是一个很重要的函数。

它的意思大致是:

  • 根据 key 查找是否已有对应元素。
  • 如果已经存在,就返回已有节点。
  • 如果不存在,就创建节点并插入。
  • 返回一个迭代器和插入状态。

注意,unordered_map 的底层实现不需要像红黑树那样寻找父节点和左右孩子,而是需要通过哈希值确定 bucket。

又见SSO#

if ( (value & 1) != 0 )
operator delete(a1: v15.__table_.__p1_.__value_.__next_);
LOBYTE(v15.__table_.__bucket_list_.__ptr_.__value_) = 6;
*(_DWORD *)((char *)&v15.__table_.__bucket_list_.__ptr_.__value_ + 1) = 6451042;
v13.__base_.__value_ = (std::string *)&v15;

嘿!这里是不是见过?SSO对吧!

先判断是否是长字符串,如果不是长字符串就删除掉这个拷贝值。

然后对这个值进行更新,也就是后面要插入的bob。

find的逻辑#

这个差不多啊,没啥好讲的。

if ( (v4 & 1) != 0 )
operator delete(a1: v15.__table_.__p1_.__value_.__next_);
strcpy((char *)&v15, "\nalice");
std::__hash_table<std::__hash_value_type<std::string,int>,std::__unordered_map_hasher<std::string,std::__hash_value_type<std::string,int>,std::hash<std::string>,std::equal_to<std::string>,true>,std::__unordered_map_equal<std::string,std::__hash_value_type<std::string,int>,std::equal_to<std::string>,std::hash<std::string>,true>,std::allocator<std::__hash_value_type<std::string,int>>>::find<std::string>(
this: &u,
__k: (const std::string *)&v15);
if ( ((__int64)v15.__table_.__bucket_list_.__ptr_.__value_ & 1) != 0 )
operator delete(a1: v15.__table_.__p1_.__value_.__next_);

set初始化#

v5 = 0;
v13.__base_.__value_ = (std::string *)0x100000003LL; // 两个 int 打包:低32=3, 高32=1
v14 = 2; // 第三个 int = 2
memset(&v15, 0, 32);
v15.__table_.__p3_.__value_ = 1.0; // load factor
do {
__emplace_unique_key_args<int,int const&>(
&v15.__table_,
__k: (int*)((char*)&v13 + v5), // 第 v5/4 个 int
__args: 同);
v5 += 4;
} while (v5 != 12); // 3 个 int,12 字节

0x100000003 拆成 32 位:低 = 0x00000003 = 3,高 = 0x00000001 = 1。加上 v14 = 2,得到数组 {3, 1, 2}。

后面的循环插入很有意思,源代码里我们只是直接赋值了set,这里是循环插入。

后面又单独插入了5和删除3,同质化,这里不看,直接跳过。

析构set#

next = v15.__table_.__p1_.__value_.__next_; // 链表头(所有节点串成单链表)
while (next) {
v7 = next->__next_;
operator delete(next); // 释放节点
next = v7;
}
v8 = v15.__table_.__bucket_list_.__ptr_.__value_;
v15.__table_.__bucket_list_.__ptr_.__value_ = nullptr;
if (v8) operator delete(v8); // 释放 bucket 数组

这两个成员值得重点关注。

从名字和使用方式来看:

成员作用
__p1_.__value_.__next_哈希节点链表的入口相关指针
__bucket_list_.__ptr_.__value_bucket 数组相关指针

析构map#

v9 = u.__p1_.__value_.__next_;
while (v9) {
v12 = v9->__next_;
if (((__int64)v9[2].__next_ & 1) != 0) // 节点里 string key 是长串 → 释放堆
operator delete(v9[4].__next_);
operator delete(v9); // 释放节点
v9 = v12;
}
v10 = u.__bucket_list_.__ptr_.__value_;
u.__bucket_list_.__ptr_.__value_ = nullptr;
if (v10) operator delete(v10); // 释放 bucket 数组

内存布局#

std::__hash_table<int, ...> // sizeof = 0x28
std::__hash_table<std::__hash_value_type<std::string,int>, ...> // sizeof = 0x28
两个结构体都是0x28
区别最大的点就是存储类型,一个是int,另一个是pair。

整体布局如下

struct __hash_node_base<...> { // 基类,+0x00
__next_pointer __next_; // +0x00 链指针(8 字节)
};
struct __hash_node<int,void*> : __hash_node_base<...> { // sizeof=0x18
size_t __hash_; // +0x08 缓存 hash(8 字节)
int __value_; // +0x10 存的 int(4 字节)
// +0x14..0x17 padding(对齐到 8)
};
合并成下面
struct __hash_node<int> { // 堆上,单独分配
__next_pointer __next_; // +0 链到下一个节点
size_t __hash_; // +8 缓存的 hash 值
int __value_; // +16 ← insert 的 int 在这
};
std::__hash_table<int, hash<int>, equal_to<int>, alloc<int>> // sizeof=0x28
+0x00 __bucket_list_ // 16 字节:哈希桶数组管理信息
//哈希核心思路就是hash = hash_function(key) index = hash % bucket_count;
+0x10 __p1_ <__hash_node_base<...>> // 8 字节:全局单链表头(所有节点串起来)
+0x18 __p2_ <unsigned long, hash<int>>// 16 字节:元素个数 size + 哈希函数
+0x20 __p3_ <float, equal_to<int>> // 8 字节:max_load_factor + 空相等比较器
+0x24..0x27 padding
struct __hash_node_base {
__next_pointer __next_; // +0x10:全局单链表头
};

每个节点的内存布局

偏移字段作用
+0x00__next_串链表(桶内链 + 全局链,同一个字段)
+0x08__hash_缓存该 key 的 hash 值
+0x10__value_真正存的数据 int
+0x14~0x17padding对齐到 8 字节

+0x10 是全局链表头指针:所有节点通过各自的 __next_ 串成一条单链表(不是按桶分,是全局一条)。伪代码里:

next = v15.__table_.__p1_.__value_.__next_; // 取链表头
while (next) { v7 = next->__next_; delete(next); next = v7; }

就是遍历这条全局链表释放所有节点。空表时它指向 &__p1_ 自身(sentinel)。

注意:bucket 数组不是直接存放 int 元素的数组,而是存放节点入口相关指针的管理结构。

因为 std::hash<int> 是一个无状态的空类,没有需要保存的数据成员。libc++ 使用 __compressed_pair 的压缩存储机制,可以让这个空对象不额外占用空间。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

cpp(十二)——常见数据结构内存布局
https://fatdog.20060113.xyz/posts/cpp-again12/
作者
神秘大胖狗
发布于
2026-11-05
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00