深入浅出理解DeepSeek 3FS (3) 步步引导轻松理解内存管理,面试必看
文章目录
🌟 大家好,我是小王同学,
今天带你们一起探秘内存分配的奥秘!
本文主要描述了如何为一个类自定义new实现
-
大胆猜测,小心求证过程中
C++的new实现其实是分层设计的:
➤ 第一层:调用C++标准库中的operator new
➤ 第二层:operator new内部调用C语言标准库中的malloc
➤ 第三层:最终由操作系统提供接口完成实际内存分配 -
模块化替换
🔧 在实际开发中,灵活替换默认内存分配方案非常关键:
➤ 链接阶段:C++标准库中的operator new被声明为弱符号,用户只要自定义同名的强符号,就能在链接时自动替换!
➤ 运行阶段:通过设置LD_PRELOAD环境变量,可以让自定义的malloc函数以更高的优先级替换原有实现。
这种模块化替换机制不仅让开发者能随时调控内存分配策略,更带来无限可能!✨ -
3FS 怎么实现的
🚀 3FS的实现方式相当直观:
➤ 通过定义OVERRIDE_CXX_NEW_DELETE编译开关,默认情况下采用operator new调用malloc;
➤ 如果开启开关,自定义分配器,- 可能自定义实现的,
- 可能第三方库的Jemalloc,TCMalloc
➤ 项目中定义了一个统一的全局变量gAllocator,用于管理所有内存分配接口;
➤ 对于第三方分配器,通过dlsym动态加载函数地址(如getMemoryAllocatorFunc = (GetMemoryAllocatorFunc)::dlsym(mallocLib, GET_MEMORY_ALLOCATOR_FUNC_NAME)), 实现了灵活且扩展性极强的内存管理方案。
这种设计既清晰又充满智慧,让内存管理不再神秘,而是充满了DIY的乐趣! 理解如何深刻 ,这也是为选择看3fs原因,其他项目可不会这么清晰
Happy Coding~💖
一 越老生常谈,越难超出期望,换个问法就不会了
故事的背景
工作8年的小王 同学 为了准备c++面试, 很早 就从脉脉上搜索过类似题目,信心满满而去, 什么返回值区别,重载区别?还有函数用法区别?
结果吊打一顿回来,在中午吃饭的时候,被老王看到了。
哈哈哈,又拒绝一家公司是对吧? 来说说这次发生了什么,有什么意想不到事情。
当时对话是这样的
面试官:请说说 new 和malloc区别?
小王脑中2小人开始不假思索推理 开来?
- 操作系统内存管理这个我不熟悉呀?网上看过很多文章和书籍讲虚拟内存看到迷糊,怎么回答。
- new 有什么可回答的, 分配内存,调用构造函数,10年前我知道了
于是我回答了
new 是运算符,运算符就支持重载,因为operator new 。。。。(这个回答是错误的)
老王:
从你回答慢了 2-3秒 速度来看 , 你思路产生混乱,被题目本身限制住了 更没有和日常开发工作有效结合起来。
- 操作系统层面事情,内存管理事情,你不会,别人不会,不需要在这个方面思考
- new 实现过程 ,但是还是停留在10年前,这期间没看到你进步 你可能了解,别人也了解事情 你需要探索背后原理
划重点: 你知道,别人也知道,你了解还是10年前认知。没长进呀。 这个才是重点探索内容。
二、大胆猜测,小心求证
1. 问题能描述清楚吗?
-
new和malloc 区别
-
一个类如何定制自己的new和delete,代替默认的 new 和delete?(这个才是真正马甲)
小提示
- new 是怎么调用operator new,是全局的operator new函数,还是每个类的operator new函数 ?
2. 为了解决这个问题准备哪些事情(技术路线)
step1 c++学习路线和资料(想要电子书关注 回复 电子书)
- 【Effective C++】条款49~52:定制new和delete
- More Effective C++ Item 8: Understand the different meanings of new and delete
step2 参考源码
(1)C libstdc++**: https://gcc.gnu.org/libstdc++/
- LLVM libc++: https://libcxx.llvm.org/
在标准库实现中,你可以找到 std::allocator
、std::unique_ptr
和 std::shared_ptr
的实现方式。
step3 开源项目
一些高性能的 C++ 项目也有自定义 new/delete
的实现,你可以参考它们的代码:
- Google TCMalloc(高性能内存分配器)
- oceanbase
- 3FS
开始 干活
演示代码:
|
|
小王疑问1:new语法 :是c++语法的高级抽象,既然抽象根本不知道内部怎么实现的?
老王说:借助https://godbolt.org/z/YWcecfG7b 这个工具
看不懂汇编没关系,直接让大模型来解释
以下是添加了详细注释的汇编代码,解释每一条指令的作用:
|
|
call 指令 就是调用函数
new 实现调用2个函数
-
内存分配 调用全局的 operator new 函数为对象分配足够大小的内存。(4字节)
-
调用 Foo构造对象
-
返回对象指针 构造完毕后返回分配并初始化后的对象指针。
划重点: More Effective C++ Item 8: Understand the different meanings of new and delete.
This operator is built into the language and, like sizeof, you can’t change its meaning: it always does the same thing.
What it does is twofold.
-
First, it allocates enough memory to hold an object of the type requested.
-
Second, it calls a constructor to initialize an object in the memory that was allocated.
The new operator always does those two things; you can’t change its behavior in any way
小王疑问2:new 是c++语法 operator new是一个函数?new 内部实现自动operator new函数?他们直接关系是什么
老王:根据上面分析结果 不妨大胆猜测一下。
+———————-+ | new 关键字 (C++) | [libstdc++.so] | 调用 operator new | +———————-+ | v +———————-+ | std::operator new | [libstdc++.so] | (分配内存, 调用 malloc) | +———————-+ | v +———————-+ | malloc() (glibc) | [libc.so] | 分配内存 根据大小判断 | +———————-+ | +—–+——+ | | v v +———————-+ +———————-+ | brk() (堆扩展) | | mmap() (大块内存) | | size ≤ 128 KB | | size > 128 KB | | [syscall to kernel] | | [syscall to kernel] | +———————-+ +———————-+ | | v v +———————-+ +———————-+ | Linux 内核管理 | | Linux 内核管理 | | 分配物理内存 | | 分配独立映射区域 | +———————-+ +———————-+
各函数对应动态库
函数 | 作用 | 所在动态库 |
---|---|---|
new |
申请对象并调用构造函数 | libstdc++.so |
std::operator new |
内存分配函数(调用 malloc ) |
libstdc++.so |
malloc |
申请动态内存 | libc.so |
brk |
调整堆(小内存) | libc.so (syscall) |
mmap |
申请大块内存(独立映射) | libc.so (syscall) |
free |
释放内存 | libc.so |
小王疑问3:我还是不太相信new语法,用到libstdc++,这个不是一体的吗?
请 回到 演示代码部分,编译代码 ldd 查看,new 确实调用 libstdc++.so.6
ldd a.out linux-vdso.so.1 (0x00007fff6f9c0000) libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f5117c52000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5117a29000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f5117942000) /lib64/ld-linux-x86-64.so.2 (0x00007f5117e8c000) libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f511792200
老王:格局没打开 看看这个
小王疑问4:g++ 命令 编译时候为什么默认自动连接libstdc++库吗?
简单来说,GCC是编译器,它负责将C++源代码编译成可执行程序。 在这个过程中,GCC会链接到Libc和Libstdc++这两个库。 Libc为GCC提供了底层的C语言接口, 而Libstdc++则为GCC提供了C++标准库的实现
libc是Linux下原来的标准C库,也就是当初写hello world时包含的头文件#include < stdio.h> 定义的地方。
后来逐渐被glibc取代
那glibc都做了些什么呢? glibc是Linux系统中最底层的API,几乎其它任何的运行库都要依赖glibc。 glibc最主要的功能就是对系统调用的封装,你想想看,你怎么能在C代码中直接用fopen函数就能打开文件? 打开文件最终还是要触发系统中的sys_open系统调用,而这中间的处理过程都是glibc来完成的。
- glibc是运行时库(Runtime Library)
- 对于系统级别的事件,libstdc++首先是会与glibc交互,才能和内核通信。相比glibc来说,libstdc++就显得没那么基础了。
特性 | glibc (libc) | libc++ | libstdc++ |
---|---|---|---|
用途 | 提供 C 语言标准库实现 | 提供 C++ 标准库实现 | 提供 C++ 标准库实现 |
支持平台 | 主要用于 Linux 操作系统 | 主要与 Clang 编译器配合使用 | 主要与 GCC 编译器配合使用 |
内容 | 包括所有 C 标准库函数,操作系统接口、线程支持等 | 提供 C++ 标准库的模板库(STL)、IO流等 | 提供 C++ 标准库的模板库(STL)、IO流等 |
库功能 | 系统调用、内存管理、线程库、文件操作等 | C++ STL,算法,IO流等 | C++ STL,算法,IO流等 |
重入性与线程安全 | 提供线程安全的库支持,包含锁等线程控制功能 | 提供线程安全支持(但不一定是内建的) | 提供线程安全支持,包含多线程功能 |
自然会:
在 C++ 开发中,大多数程序都会使用标准库。g++
自动链接 libstdc++
,(你不说我就不知道的事情)
可以减少用户的额外操作,提高编译的便捷性,同时避免因忘记手动链接库而导致的错误。因此,这是一种用户友好的设计选择
- 动手实验:使用gcc编译cpp代码,gcc默认不使用c++标准库libstdc++
|
|
- 动手实验:使用g++命令编译cpp代码,默认链接c++标准库
g++ -v test.cpp -o test COLLECT_GCC=g++ … /usr/lib/gcc/x86_64-linux-gnu/9/collect2 … … -lstdc++ …
小王疑问5: 我相信了 new 与 std::operator new
有关系, 但是他真实一个函数吗?
operator是C++的关键字,它和运算符一起使用,表示一个运算符函数。
再次表示 new 运算符是不可以重载的,网上说错误的。
代码:
https://github.com/gcc-mirror/gcc/blob/master/libstdc++-v3/libsupc++/new_op.cc
|
|
划重点: **为什么
new
被视为高级抽象?,operator new 人为第水平管理内存方式
-
内存管理的封装:
-
在 C 中,如果你想在堆上分配内存,你通常会使用
malloc
或calloc
,然后需要手动管理内存,包括初始化对象。 -
在 C++ 中,
new
运算符提供了一个更高级的抽象,不仅仅是内存分配,还自动调用对象的构造函数来初始化对象。 -
这种封装隐藏了底层内存管理的细节,简化了内存分配的使用,使开发者不需要关注内存分配与对象初始化的分离。
-
-
异常安全:
- 使用
new
时,如果内存分配失败,它会抛出一个std::bad_alloc
异常,这使得开发者不需要显式地检查malloc
返回的NULL
值。这样的异常处理提供了一种更加安全和优雅的内存管理方式。
- 使用
-
对内存分配的透明管理:
- 虽然底层的
operator new
函数可以被重载,但 C++ 语言本身提供了透明的内存分配接口,允许开发者使用new
运算符而不关心内存分配的细节。这种抽象化让开发者集中于高层次的程序逻辑,而不是低级的内存管理。
- 虽然底层的
https://en.cppreference.com/w/cpp/memory https://en.cppreference.com/w/cpp/memory/new
特性 | malloc (函数) |
new (运算符) |
---|---|---|
是否是函数? | ✅ 是普通函数 | 不是函数,是运算符 |
是否调用构造函数? | 不会调用 | ✅ 会调用构造函数 |
返回类型 | void* ,需要强制转换 |
直接返回正确的指针类型 |
是否可以重载? | ❌ 不能重载 | ✅ operator new 可以被重载 |
是否支持类型安全? | ❌ 需要手动转换类型 | ✅ 自动匹配类型 |
释放方式 | free(ptr); |
delete ptr; |
失败时返回 | nullptr / NULL |
抛出异常(std::bad_alloc ) |
小王疑问6: call operator new(unsigned long)@PLT PLT 是什么? operator new 函数 weak definitions 又是什么
老王:
推荐看一本书 程序员的自我修养—链接、装载与库 补一补。
最喜欢一句话 Any problem in computer science can be solved by anotherlayer of indirection.”
这句话几乎概括了计算机系统软件体系结构的设计要点,整个体系结构从上到下都是按照严格的层次结构设计的
@PLT 含义是什么
PLT 是 ELF 文件中的一块代码区域,用于动态链接时的函数调用延迟绑定。
也就是说,当程序第一次调用一个外部函数(如来自共享库的函数)时, 实际调用的是 PLT 中的一个“跳板”入口,经过解析后再跳转到真正的函数地址;
而后续调用则直接通过更新过的 GOT 表项(全局偏移表)跳转到目标函数。
动手验证:例子越简单越好
假设有下面的 C 程序
|
|
当你编译这个程序(使用动态链接库,例如 libc)后,编译器不会直接把 printf 的真实地址写进代码中,而是生成一条调用“printf@PLT”的指令。
符号(Symbol)这个概念随着汇编语言的普及迅速被使用,它用来表示一个地址,这个地址可能是一段子程序(后来发展成函数)的起始地址,也可以是一个变量的起始地址。
也就是说,程序在运行时会先跳转到 PLT 表中的一个“跳板”入口,而不是直接跳转到 libc 中的 printf 函数。
#动态链接中PLT与GOT工作流程
-
第一次调用时(延迟绑定): 当程序第一次调用 printf 时,实际执行的是 printf@PLT 中的代码。该代码的第一条指令通常是跳转到 printf 对应的 GOT 表项(Global Offset Table,全球偏移表)。由于这是第一次调用,这个 GOT 表项里并没有存储真实的 printf 地址,而是保存了一个指向特殊解析代码的地址(通常会指向 PLT 的第一项,也称为共公 PLT 表项)。
-
解析函数地址: 随后,这个特殊的解析代码会调用动态链接器(如 _dl_runtime_resolve),根据传入的信息(例如函数的编号、模块信息等),找到真正的 printf 地址,并将这个地址写入到 GOT 表中对应的 printf 表项。
-
后续调用直接跳转: 此后,再次调用 printf 时,程序跳转到 printf@PLT,这时通过 GOT 表查到的地址已经是解析后的 printf 真实地址,直接跳转过去执行,从而省去了再次解析的步骤
大局观
首先, 我们要知道, GOT和PLT只是一种重定向的实现方式. 所以为了理解他们的作用, 就要先知道什么是重定向, 以及我们为什么需要重定向
比如某个公司开发完成了某个产品,它按照一定的规则制定好程序的接口,其他公司或开发者可以按照这种接口来编写符合要求的动态链接文件。
该产品程序可以动态地载入各种由第三方开发的模块,在程序运行时动态地链接,实现程序功能的扩展
弱符号的用途
弱符号主要用于:
-
库函数的默认实现
operator new
就是一个典型案例,标准库提供 默认的弱定义,用户可以 提供自己的强定义 进行替换。
-
可选的全局变量
- 在某些场景下,一个库可能会提供一个 默认变量,但允许应用程序提供自己的变量:
三、这才是刚刚开始,3FS还到呢,怎么为类定制的new操作
动手验证:
|
|
阅读代码:3FS\src\memory\common\OverrideCppNewDelete.h
|
|
四、彩蛋呢,没有彩蛋就不算惊喜
小王疑问8:malloc 也能替换吗
上面三个章节 解释了 int* ptr =new int(10) 这个高级抽象
调用c++标准库 operaotr new函数–>c语言标准库 malloc –glibc 和os底层先不考虑。
|
|
C++ new
操作的完整调用路径
|
|
代码位置: https://github.com/lattera/glibc/blob/master/malloc/malloc.c
老王:
在使用GCC编译器时,如果不想工程使用系统的库函数, 例如在自己的工程中可以根据选项来控制是否使用系统中提供的malloc/free函数,可以有两种方法:
(1). 使用LD_PRELOAD环境变量:可以设置共享库的路径,并且该库将在任何其它库之前加载,即这个动态库中符号优先级是最高的。
(2). 使用GCC的–wrap选项:
下面用简单的语言解释一下 GCC 的 –wrap 选项是如何工作的,以及如何用它来替换(拦截)一个函数,比如 malloc:
1. 什么是 –wrap 选项?
- 基本概念:
--wrap=symbol
是一个链接选项。它的作用是在链接阶段“拦截”对某个函数(symbol)的调用,让这些调用转而去调用另一个函数(包装函数)。
Use a wrapper function for symbol. Any undefined reference to symbol will be resolved to __wrap_
symbol
- 如何实现:
当你使用--wrap=malloc
时,所有对malloc
的调用都会被重定向到__wrap_malloc
。这就好像你给malloc
装上了一个“包装纸”,让调用者实际使用的是包装后的版本。
2. 包装函数(wrapper function)的工作机制
-
实际操作:
-
重定向调用:
当程序中调用malloc
时,链接器把它转换成对__wrap_malloc
的调用。 -
调用原始函数:
如果你在你的包装函数里需要调用真正的malloc
,你可以直接调用__real_malloc
。链接器会把__real_malloc
转换回真正的malloc
实现。
-
-
简单示例:
假设我们想对malloc
进行包装:
// wrap.c
#include <stdio.h>
#include <stdlib.h>
void* __real_malloc(size_t size); // 只声明不定义__real_malloc
void* __wrap_malloc(size_t size) // 定义__wrap_malloc
{
printf("__wrap_malloc called, size:%zd\n", size); // log输出
return __real_malloc(size); // 通过__real_malloc调用真正的malloc
}
下面是测试用例:
// test.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
char* c = (char*)malloc(sizeof(char)); // 调用malloc
printf(“c = %p\n”, c);
free(c); // 调用free,防止内存泄漏
return 0;
}
下面是编译链接过程:
gcc -c wrap.c test.c
gcc -Wl,–wrap,malloc -o test wrap.o test.o // 链接参数-Wl,–wrap,malloc12
结果查看:
./test
|
|
- allocate 如何实现加载不同的内存分配器
|
|
- 自定义分配器如何实现的
static MemoryAllocatorInterface *gAllocator = nullptr
|
|
代码位置:3FS\src\memory\jemalloc
|
|
- 如何第三方库获取分配器的实例
基本概念:https://www.cnblogs.com/Anker/p/3746802.html
|
|
要优缺点:
编译时链接 :
-
优点:运行更快,不需要符号查找
-
缺点:可执行文件更大,更新库需要重新编译 运行时链接 :
-
优点:可执行文件更小,可以动态切换不同的库实现
-
缺点:有运行时开销,需要处理加载失败的情况 在当前项目中,JemallocLib 本身是作为动态库编译的,但它与 jemalloc 是编译时链接的关系,而 GlobalMemoryAllocator 则是在运行时动态加载 JemallocLib。
生命周期 :
- 加载:首次使用时
- 使用:程序运行期间
- 卸载:程序退出时通过 shutdown 函数清理
小总
__attribute__((weak))
是 GCC 和 Clang 提供的一个特性,
用于声明一个符号为 “弱符号”(weak symbol),
在链接层面提供更多的灵活性。
注意并不是 C 语言标准的一部分
https://fenglielie.top/p/36786e00/
链接我
如果对上面提到c++学习路径 推荐书籍感兴趣
关注公共号:后端开发成长指南 回复电子书
如果更进一步交流 添加 微信:wang_cyi
我是小王同学,
希望帮你深入理解分布式存储系统3FS更进一步 , 为了更容易理解设计背后原理,这里从一个真实面试场故事开始的。
阅读对象(也是我正在做事情)
1. 目标:冲击大厂,拿百万年薪
- 想进入一线大厂,但在C++学习和应用上存在瓶颈,渴望跨越最后一道坎。
2. 现状:缺乏实战,渴望提升动手能力
-
公司的项目不会重构,没有重新设计的机会,导致难以深入理解需求。
-
想通过阅读优秀的源码,提高代码能力,从“不会写”到“敢写”,提升C++编程自信。
-
需要掌握高效学习和实践的方法,弥补缺乏实战经验的短板。
3. 价值:成为优秀完成任务,成为团队、公司都认可的核心骨干。
优秀地完成任务= 高效能 + 高质量 + 可持续 + 可度量
错误示范:
- 不少同学工作很忙,天天加班,做了很多公司的事情。 但是 不是本团队事情,不是本部门事情,领导不认可,绩效不高
- 做低优先级的任务,无法利他,绩效不高
- 招进来最后变成了随时被裁掉的一些征兆
- 刻意提高工作难度
- 工作中不公平对待
- 制造恶性竞争
- 捧杀
- L1 cache reference 0.5 ns
- Branch mispredict 5 ns
- L2 cache reference 7 ns
- Mutex lock/unlock 100 ns
- Main memory reference 100 ns
- Compress 1K bytes with Zippy 10,000 ns
- Send 2K bytes over 1 Gbps network 20,000 ns
- Read 1 MB sequentially from memory 250,000 ns
- Round trip within same datacenter 500,000 ns
- Disk seek 10,000,000 ns
- Read 1 MB sequentially from network 10,000,000 ns
- Read 1 MB sequentially from disk 30,000,000 ns
- Send packet CA->Netherlands->CA 150,000,000 ns