C++ 中 new 是如何工作的呢?

创建对象数组示例

以创建一个对象数组举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

class Test {
public:
int value;
Test(int v) : value(v) { std::cout << "ctor" << value << std::endl; }
~Test() { std::cout << "dtor" << value << std::endl; }
};

int main() {
Test *arr = new Test[5]{1, 2, 3, 4, 5};
delete[] arr;
return 0;
}

arr 拿到的地址是 0x000055555556b2b8,指向的是第一个对象。

内存布局

内存布局示例

可以看到小端序存储的五个 value。这就是用户通过调用 new 拿到的指针,但这并不是 new 申请的全部内存。

向前看有一个 8 字节的 cookie 值:0x5

GCC 通过如下代码判断是否需要 cookiegcc/cp/class.cc 第 6219-6266 行),如果有非平凡析构函数,就需要 cookie

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
static bool
type_requires_array_cookie (tree type)
{
tree fns;
bool has_two_argument_delete_p = false;

gcc_assert (CLASS_TYPE_P (type));

/* If there's a non-trivial destructor, we need a cookie. In order
to iterate through the array calling the destructor for each
element, we'll have to know how many elements there are. */
if (TYPE_HAS_NONTRIVIAL_DESTRUCTOR (type))
return true;

/* If the usual deallocation function is a two-argument whose second
argument is of type `size_t', then we have to pass the size of
the array to the deallocation function, so we will need to store
a cookie. */
fns = lookup_fnfields (TYPE_BINFO (type),
ovl_op_identifier (false, VEC_DELETE_EXPR),
/*protect=*/0, tf_warning_or_error);

/* If there are no `operator []' members, or the lookup is
ambiguous, then we don't need a cookie. */
if (!fns || fns == error_mark_node)
return false;

/* Loop through all of the functions. */
for (lkp_iterator iter (BASELINK_FUNCTIONS (fns)); iter; ++iter)
{
tree fn = *iter;

/* See if this function is a one-argument delete function. If
it is, then it will be the usual deallocation function. */
tree second_parm = TREE_CHAIN (TYPE_ARG_TYPES (TREE_TYPE (fn)));
if (second_parm == void_list_node)
return false;

/* Do not consider this function if its second argument is an
ellipsis. */
if (!second_parm)
continue;

/* Otherwise, if we have a two-argument function and the second
argument is `size_t', it will be the usual deallocation
function -- unless there is one-argument function, too. */
if (TREE_CHAIN (second_parm) == void_list_node
&& same_type_p (TREE_VALUE (second_parm), size_type_node))
has_two_argument_delete_p = true;
}

return has_two_argument_delete_p;
}

cookie 的大小通过如下代码计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// cookie_size = max(sizeof(size_t), alignof(type))
// 64 位系统: max(8, 4) = 8 字节

tree
default_cxx_get_cookie_size (tree type)
{
tree cookie_size;

/* 我们需要分配一个额外的 max (sizeof (size_t), alignof (true_type)) 字节。*/
tree sizetype_size;
tree type_align;

sizetype_size = size_in_bytes (sizetype);
type_align = size_int (TYPE_ALIGN_UNIT (type));

if (tree_int_cst_lt (type_align, sizetype_size))
cookie_size = sizetype_size;
else
cookie_size = type_align;

return cookie_size;
}

cookie 的值就是对象的数目,例子中就是 5

汇编代码分析

例子中 Test *arr = new Test[5]{1, 2, 3, 4, 5}; 生成的汇编如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
mov    edi,0x1c  // 参数: 0x1c = 28 字节 (8 cookie + 5*4 数据)
call 0x5555555550c0 <operator new[](unsigned long)@plt>
mov r13,rax // new 返回的地址放入 r13
mov QWORD PTR [r13+0x0],0x5 // 写入 cookie = 5
lea r12,[r13+0x8] // 跳过 cookie
mov ebx,0x4 // 剩余待构造数量
mov esi,0x1 // 第二个参数 value = 1
mov rdi,r12 // 第一个参数 this 指针就是 &arr[0]
call 0x5555555553f2 <Test::Test(int)> // 调用构造函数
lea r14,[r12+0x4]
sub rbx,0x1
mov esi,0x2
mov rdi,r14
call 0x5555555553f2 <Test::Test(int)> // 构造第二个
add r14,0x4
sub rbx,0x1
mov esi,0x3
mov rdi,r14
call 0x5555555553f2 <Test::Test(int)> // 构造第三个
add r14,0x4
sub rbx,0x1
mov esi,0x4
mov rdi,r14
call 0x5555555553f2 <Test::Test(int)> // 构造第四个
lea rax,[r14+0x4]
sub rbx,0x1
mov esi,0x5
mov rdi,rax
call 0x5555555553f2 <Test::Test(int)> // 构造第五个

深入 operator new[]

这个流程基本清楚了,我们看看 operator new[] 里面到底做了什么。

operator new[] 源码

源码如下,直接调用 new,这里没有任何区别:

1
2
3
4
5
6
7
// libstdc++-v3\libsupc++\new_opv.cc

_GLIBCXX_WEAK_DEFINITION void*
operator new[] (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
return ::operator new(sz);
}

operator new 源码

new 源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// libstdc++-v3\libsupc++\new_op.cc

_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
void *p;

/* malloc (0) is unpredictable; avoid it. */
if (__builtin_expect (sz == 0, false))
sz = 1; // 如果传入的申请 size 是 0,改为 1

while ((p = malloc (sz)) == 0) // 循环调用 malloc,除非分配成功或者抛出异常 bad_alloc
{
new_handler handler = std::get_new_handler (); // 用户可以注册回调函数,用来清理一些内存。在这里 malloc 分配不出来,返回 NULL,new 就会尝试获取这个回调函数。

if (! handler) // 如果没有设置,就抛出异常
_GLIBCXX_THROW_OR_ABORT(bad_alloc());

handler (); /* 调用回调,这里的回调必须做以下之一,否则这里可能导致无限循环:
释放内存,让下次 malloc 成功
抛出 bad_alloc 异常(或派生类)
调用 std::abort() 或 std::exit() 终止程序
调用 std::set_new_handler(nullptr) 移除自己,下次循环就会抛异常*/
}

return p;
}

Malloc 的实现

到这里 new 就结束了,下一步是查看 malloc

Windows vs Linux 的差异

glibcmallocucrt 不一样,它是直接就进入到了内存分配的逻辑了,ucrt 是又包装了一层,然后调用的 HeapAlloc,在 HeapAlloc 中进行实际的内存分配,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
extern "C" __declspec(noinline) _CRTRESTRICT void* __cdecl _malloc_base(size_t const size)
{
// Ensure that the requested size is not too large:
_VALIDATE_RETURN_NOEXC(_HEAP_MAXREQ >= size, ENOMEM, nullptr);

// Ensure we request an allocation of at least one byte:
size_t const actual_size = size == 0 ? 1 : size;

for (;;)
{
void* const block = HeapAlloc(__acrt_heap, 0, actual_size);
if (block)
return block;

// Otherwise, see if we need to call the new handler, and if so call it.
// If the new handler fails, just return nullptr:
if (_query_new_mode() == 0 || !_callnewh(actual_size))
{
errno = ENOMEM;
return nullptr;
}

// The new handler was successful; try to allocate again...
}
}

Linux 堆管理器

说回 glibcmalloc,它的功能就和 HeapAlloc 基本一样了,就是一个堆管理器。

它们的逻辑大体都是根据不同的 size 阈值,去 tls 缓存、堆的前端、后端拿内存,堆不够就 sbrk 拓展,大内存就 mmap / RtlpHpLargeAlloc。正是因为有堆管理器,使得绝大部分的堆分配都不需要进内核,只需要后端批发内存,切分给前端,供调用者使用。

Linux 内存分配流程图

Windows 内存分配流程

Windows 上大概的流程是:LFH 低碎片堆 -> VS Allocator -> 后端 RtlpHpAllocateHeapBackend -> RtlpHpLargeAlloc 分配大页,降低 TLB 消耗。

然后进入内核,分配一个 VAD,插入这个进程的 EPROCESS。分配到这里就结束了,但是还没有构建有效 pte。真正的物理内存分配被推迟到了内存访问的时候,触发缺页异常,拿到异常信息后去 vad 中寻找该地址,如果已经注册,那么就去零页(堆 / VirtualAlloc 初始化的时候使用)/ 空闲页的 pfn 链表中拿一个页面,挂在这个虚拟地址上。

Malloc 的 Chunk 结构

我们再来看一下 glibc 中添加的头信息,它和 new[] 一样,也会加一个头,因为 free 的时候不传入大小,它肯定有地方保存申请的大小。

malloc 申请的 chunk 结构如下:

1
2
3
4
5
6
7
8
9
10
11
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

Chunk 字段详解

这两个字段就是 header

mchunk_prev_size (8 字节)

  • 前一个 chunk 空闲时:存储前一个 chunk 的大小,用于向前合并
  • 前一个 chunk 在用时:这块空间被前一个 chunk 当用户数据用

mchunk_size (8 字节)

  • 高位:当前 chunk 的总大小
  • 低 3 位:三个标志位

标志位说明

三个标志位:

  • P (PREV_INUSE = 0x1):前一个相邻 chunk 正在使用中
    • P=1:前一个 chunk 在用,prev_size 字段无效
    • P=0:前一个 chunk 空闲,prev_size 存储其大小,可用于合并
  • M (IS_MMAPPED = 0x2):这个 chunk 是通过 mmap() 分配的
    • M=1mmap 分配,释放时直接 munmap()
    • M=0sbrk / heap 分配
  • A (NON_MAIN_ARENA = 0x4):这个 chunk 不属于主 arena
    • A=1:来自非主 arena(多线程场景)
    • A=0:来自 main_arena

后面的这些字段只在 chunk 空闲时使用,用于把空闲块串成链表,当这个 chunk 被使用的时候,这些字段会被用户数据覆盖:

  • fd / bk (所有空闲 chunk 都用)

    • fd = forward → 指向链表中下一个空闲 chunk
    • bk = backward → 指向链表中上一个空闲 chunk
    • 用于 smallbinunsorted binlarge bin 的双向循环链表。
  • fd_nextsize / bk_nextsize (仅 large bin 用)

    • fd_nextsize → 指向下一个不同大小的空闲 chunk
    • bk_nextsize → 指向上一个不同大小的空闲 chunk

完整内存布局示例

再看之前的例子:

内存布局示例

根据上面的学习,就可以很清晰地分析出它的内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
chunk ──→ ┌─────────────────┐ +0x00
│ prev_size = 0 │ 8B
├─────────────────┤ +0x08
│ size = 0x31 │ 8B (48 | PREV_INUSE)
mem ────→ ├─────────────────┤ +0x10 ← malloc 返回这里
│ cookie = 5 │ 8B
arr ────→ ├─────────────────┤ +0x18 ← new[] 返回这里
│ arr[0] = 1 │ 4B
│ arr[1] = 2 │ 4B
│ arr[2] = 3 │ 4B
│ arr[3] = 4 │ 4B
│ arr[4] = 5 │ 4B
│ padding │ 4B
└─────────────────┘ +0x30

Delete[] 的配对问题

同样的 delete[],就会读取 cookie,调整指针,调用多次析构函数再调用 freefree 也会读取头信息,决定释放多大的内存。

不配对的后果

new[]delete[] 不配对的时候,会出现如下情况:

  • new[]delete:只调用一次析构函数,并且不调整指针(它多申请了 cookie,调用 free 的时候要移动回去),会出现未定义行为(页面回收了就 segment fault,没回收就堆损坏等)。

  • newdelete[]:读取 p-8 位置的垃圾值当作元素个数 n,尝试调用 n 次析构函数(可能是天文数字导致越界访问),然后调用 free(p-8) 传入错误地址,会出现未定义行为(段错误、堆损坏、无限循环调用析构等)。

为什么有时能正常工作

那么为什么有时候 new[]delete[] 不配对,可以正常工作呢?

之前提到,类有非平凡析构函数时,才会生成 cookie,所以如果是 new int[10],或者使用默认析构函数的时候,即使使用 new[],也不会生成 cookie,这样 delete 也不会报错了。

但是即使能碰巧工作,在编译器层面属于未定义行为,必须避免出现。