CPP内存管理
从内核到C++应用
关于C++内存管理方面的面试题有很多,但是这些问题的答案不能仅靠学习C++就能得出比较全面的答案 ,它还涉及C运行库和内核的方方面面。
本文给自己理一下思路,并在最后尝试回答几个常见的关于C++内存管理方面的面试题。
如果在linux开发环境下,整个系统的不同层级有着不同的内存管理器:
- linux内核: 伙伴系统(以页为单位进行管理)、slab分配器(定制化的内存管理器)。
- malloc\free库函数:使用系统调用mmap、sbrk,以及bins管理多个空闲链表(内存池)。有合并空闲块的操作。有两种方式管理内存:
- 如果分配内存大于mmap分配阈值(见下一节),直接使用mmap分配;如果释放的内存大于mmap分配阈值,直接使用mummap释放,直接返还给操作系统
- 否则,通过内存池分配算法分配,且用户层free的内存块,不一定立刻返回给操作系统,而是缓存在内存池中以供后续的请求。
- C++ stl分配器:16个链表,分别管理16种小于128 B的对象(内存池),没有合并空闲块操作,如果分配\释放的内存大于128B直接调用malloc\free交由库函数处理。
glibc 内存管理
参考资料:
glibc的malloc
和free
使用的ptmalloc实现,详见这篇文章的解析:Glibc内存管理 —— 华庭
首先,glibc的设计假设是这样的,我把我觉得比较重要的内容高亮了出来:
“malloc如果分配的内存大于128KB,则使用mmap分配内存,否则使用sbrk分配内存”。正确吗?
这句话并不绝对正确,如上图所示,使用mmap分配的阈值是动态变化的,因此不一定分配128KB的malloc请求一定会使用mmap系统调用。请参考下图,假设malloc的初始mmap阈值为128KB,表示分配内存大于128KB时直接使用mmap而不用通过内存池机制;如果某一时刻释放了一个大于128KB的页,比如256KB,那么mmap阈值将被调整为256KB,表示分配内存大于256KB时才使用mmap,此时如果分配的内存大小为128KB,那么这个请求还是会走内存池机制,而且释放128Kb的内存块时也不会使用mumap直接将其返还给操作系统了。当然mmap阈值不能无限增加,见下图的描述。
而且通过mmap分配的内存如果被释放了,那么这片地址就直接返回给操作系统了
,如果再次引用这篇地址,则会引发segmentation fault
glibc内存池机制
除了使用mmap分配大内存块,glibc还有一种内存池机制,使用一种chunk的数据结构来管理堆内存。chunk结构中包括用户数据,以及为了维护内存池的一些其他信息(比如下一个chunk的指针、本chunk的大小等),在侯捷老师的课程中,这些除用户信息以外的管理信息被称作cookie,在glibc中以32位系统为例,每个chunk的cookie大小位16B。
空闲的chunk使用bins来管理。用户free掉的内存并不会马上归还给操作系统,而是缓存在各种bin中。ptmalloc会统一管理 heap 和 mmap 映射区间的空闲chunk,当用户需求来临时,它首先在bins中找到空闲的chunk返回给用户,如果找不到则使用系统调用再向操作系统申请内存。这样就避免了频繁的系统调用,较少分配开支。
bins有很多种:
- fast bins, 存放大小小于等于64B的chunk, 这个bin是为了加快热点小内存块的分配
- unsorted bins,可以看做是 bins 的一个缓冲区,增加它只是为了加快分配的速度
- small bins,有62种规格,每一种规格相差8B,(16B - 576B),每个small bin管理的chunk大小都相同
- large bins,有63个,每个bin都管理在一定范围内的内存块,也就是说每个large bin种的chunk大小不同,但是会按照大小从大到小排列。
还有两种不属于任何bin的chunk:
- mmaped chunk: 直接使用mmap分配的内存块,在释放时使用ummap直接返还给操作系统
- top chunk: heap最高处的一块空闲chunk,它的大小是随着分配和回收不停变化的。
具体的分配和回收算法参考Glibc内存管理 —— 华庭 3.2.4和3.2.5节。
那么,通过内存池分配的内存会返还给操作系统吗?什么情况下会返还?
还是在上面的参考资料的3.2.5节的free流程中有详细解释:
简单讲,就是在free时会判断释放块前后是否有空闲块,如果有则迭代地进行合并,如果最后合并到到的空闲块与topchunk相邻(topchunk是一定为空闲的),那么将它与topchunk合并,如果合并而得的topchunk大于mmap的收缩阈值,则归还topchunk中的一部分给操作系统。
从这里可以隐隐看出 Glibc内存管理 —— 华庭 这篇文章中提到的内存暴增问题的一个原因了:
-
收缩内存(将内存归还给操作系统)是从topchunk开始的,
如果topchunk相邻的chunk不是空闲的,那么topchunk以下的chunk都不能归还给操作系统
,因此会堆积越来越多的空闲chunk而不返还给操作系统。 -
一种缓解方法:绕过glibc的内存池系统,每次分配的内存都大于等于mmap分配阈值的最大值(在64为系统上是32MB),那么malloc将直接使用mmap分配内存,且free时一定会将内存返还给操作系统。这样的话malloc\free就是mmap和mumap系统调用的简单封装,可以考虑直接使用系统调用mmap和mumap进行内存分配管理。
C++ primitives
参考资料:
- 侯捷老师的视频讲解
new delete表达式
new表达式(new后不加其他任何参数)会被编译器转化为两个步骤
- 调用operator new 分配内存,调用的函数版本为(
operator new(size_t)
),对象大小由编译器算出并自动传入 - 然后调用对象的构造函数,在得到的内存上创建object
此外,operator new 可能抛出异常,需要用try catch处理异常。
而operator new 内部直接调用C运行库的 malloc, 当内存分配失败时,执行_callnewh, 这个函数主要释放一些内存,看看能不能空出一些来给新对象
如果operator new函数分配内存成功了,但是构造函数却抛出异常
。这时候,就要取消第一个步骤中分配的内存,否则就会导致内存泄漏。
这个责任落在C++运行库上,由它调用相应的operator delete(什么叫相应的delete,见effective C++,条款52)取消已经分配的内存。
delete表达式会也被编译器转化为两个步骤
- 调用对象的析构函数,调用的函数版本为(
operator new(size_t)
) - 调用 operator delete
operator delete内部调用C运行库的 free 释放内存
array new
使用了array new 之后就要使用array delete,否则就会只调用一次析构函数,这样有可能发生内存泄漏
,仅限于class with pointer member 的对象,见下图示意。泄漏的内存不是array本身,而是array的元素所指向的内存。
下图的cookie大多是记录了array new 操作分配了多少个objext
如果
int* p = new int[10] // array new
delete p // 但不 array delete
或者向上图那样的无指针成员的对象Complex,不会造成内存泄漏。
palcement new
operator new函数如果接收的参数除了一定会有的哪个size_t之外,如果还有其他参数,那么它就属于placement new(见重载oeprator new 一节)
placement new 等同于直接调用构造函数,不会像new 表达式那样先分配内存。
// 像这样使用placement new
char* buf = new char[sizeof(Complex)]; // 首先分配内存
Complex* p = new(buf)Compelx(1,1) // new后传入参数buf指针,表示在这个地址上构造Complex(1,1)
重载operator new
new表达式内部调用 operator new 函数,侯捷老师说我们可以在两个地方重载operator new 函数来实现自己的内存分配函数。
一开始不理解什么较做“在两个地方”,后来查了些资料明白了这与C++的名称查找规则(Name lookup rule)有关。当C++编译器在class scope内碰到一个name后,首先在class内搜寻这个name,看看有没有它的declaration,如果没有再去全局域找找。
因此,如果我们在类内重载了operator new,那么全局的operator new根本不会被搜寻,更不用说执行了。
相比重载全局的operator new,重载class scope的做法更安全些。
下例摘自 cppreference,注意类内的operator new函数一定是static的,无论定义式加不加static关键词,编译器一律视类内的operator new函数为static
operator new 函数的参数一般是对象的大小,当我们写 new Obj() 时, Obj对象的大小会自动被当作operator new的第一个参数。
struct X
{
static void* operator new(std::size_t count)
{
std::cout << "custom new for size " << count << '\n';
return ::operator new(count); // 调用全局的operator new
}
static void* operator new[](std::size_t count)
{
std::cout << "custom new[] for size " << count << '\n';
return ::operator new[](count);//// 调用全局的operator new
}
};
int main()
{
X* p1 = new X;
delete p1;
X* p2 = new X[10];
delete[] p2;
}
也可以在重载函数中直接调用malloc,而不使用全局operator new,因为operator new只是分配了内存我们自己可以实现它。如果对应构造函数也不抛出异常,那么整个new表达式就不会抛出异常!
(前面说过,new 表达式会转化为 1.operator new,分配内存 + 2.调用构造函数两部,其中全局operator new会有异常的抛出)
struct X
{
static void* operator new(std::size_t count)
{
std::cout << "custom new for size " << count << '\n';
return malloc(count);
}
static void* operator new[](std::size_t count)
{
std::cout << "custom new[] for size " << count << '\n';
return malloc(count);
}
};
我们可以重载多个operator new 版本:
其中第一个版本就是new 表达式(new后不加任何其他参数)会自动转化成的版本
注意当在class内声明operator的各种函数时,可能会被继承增加复杂性
class Base {
public:
static void* operator new(size_t size) throw(std:: badalloc){
// 一些针对Base Class的操作
}
...
};
public Derived {
public:
// 未申明operator new ,从分类继承operator new
}
这样当new一个Derived对象时,调用的是Baseclass中定义的operator new,二这可能带来很多不确定因素。
解决这样的做法是在Baseclass的operator new中加入判断 size是否等于Baseclass大小的逻辑
class Base {
public:
static void* operator new(size_t size) throw(std:: badalloc){
if (size != sizeof(Base)) { // 如果创建的不是Basevlass对象
return ::operator new(size); // 转发至全局的operator new 函数
}
}
...
};
这样当使用BaseClass定义的operator new 函数去分配一个Derived对象时,改用全局operator new 函数,这样可万无一失。
GNUC 2.9版本std::alloc
主要看SGI的实现,有两个空间配置器
- _malloc_alloc_template<0>
- __default_alloc_template<...>
用户可以选择单独使用第一个分配器,或者一起使用两个分配器。
当用户选择使用两个分配器时,编译器会分别将上述两个分配器typedef成 malloc_alloc
和 alloc
, 容器的分配器默认使用alloc,即第二个分配器。
两个配置器的接口都有allocate() deallocate() reallocate(),这里主要聚焦于前两个接口。
第一个配置器(malloc_alloc)的allocate()从typedef的名字上可以看出,它只是简单调用malloc(), deallocate()也只是简单调用free(),唯一的特别之处是,这个配置器能够模拟C++ new 运算符的set_new_handler()以处理内存不足的情况。
而第二个配置器(alloc), 当内存小于128字节时则由自己管理这些内存块
,会自己管理一个内存池;当分配的内存大于128字节时
,直接调用malloc::allocate()。如果系统空间不足,那么也调用malloc::allocate(),因为它有处理程序处理内存不足的情况。
为什么对于小于128bytes的内存块使用内存池来管理?
1.防止内存碎片
2.若使用malloc直接分配的内存,每块都带有一些cookie,若小内存偏分配次数多,那么cookie的占用空间相比于有用空间会很大,空间利用率不高。
alloc配置器如何管理内存池?这里只记个大概,细节看书。
alloc管理一个16个长度的数组,数组的每个元素都指向一个free_list, 每个free list都管理一种大小的空闲数据块。
内存块大小有8bytes 、16bytes、24bytes ... 128bytes。 因此一共要16个free list来进行空闲块的管理。
最初,alloc的内存池空无一物,当有请求来时,比如要申请8字节的空间,就调用malloc向系统申请空间大小为8 * 40,将其中的1块返回给用户,其中19块做切割处理后交给对应的free list管理,剩余的20块留给内存池的备用。当再有8字节申请时,则直接从这个free list中拨给用户空闲空间。当有16字节申请时,从内存池中的备用空间中找空闲内存,如果不够则再调用malloc重复刚刚的操作,但是在当前情景中,确实是存在备用内存的(刚刚分配8字节内存时剩余的20块)。
容器使用该分配器分配内存,而不是直接用malloc向操作系统索要,这样会节省很多存放cookie的内存空间。
处理内存申请时,如果申请的内存块小于128bytes, alloc将从以下几方面递进式地申请内存
- 首先看看对应freelist有无空闲空间
- alloc的内存池的备用内存是否有空余块(end_free - start_free)
- 如果没有,则使用malloc向系统申请内存
- 如果malloc还失败了,那么再看看其他freelist是否还有没有划分出去的内存块
- 最后,已然山穷水尽,调用malloc_alloc(第一个配置器)的allocate()看看它的 handler 处理程序能否空出一些内存
有几个问题值得探讨
- sgi版本的stl管理内存的方式乍一看和linux内核的伙伴系统很像,但是stl内存池根本不涉及连续内存块合并的操作,也就是说没有伙伴的概念
- 内存池管理的内存在程序运行期间,并没有被调用free()库函数,因为如果要free,则必须要传回紧挨着cookie之后的那根指针,但是这根指针在分配过程中早已丢失。所以也更谈不上这些内存会归还给操作系统了。
常见面试题
不停地new\malloc会发生什么?
首先,new和malloc是有区别的,new关键词会先调用malloc分配内存,然后调用构造函数,注意这里的调用构造函数这个步骤,会将对象初始化!而malloc那就仅仅是malloc。
有什么不同?操作系统对内存执行“懒分配”策略,尤其是mmap系统调用,懒到了极致,它在一开始并不会正真地给进程分配内存,甚至页表都不会分配,而是等进程发生缺页中断之后才会进行分配。那么对于malloc分配的内存,如果之后不去动它,那么什么事情也不会发生,但对于new来说,相当于是malloc后又对内存执行了memset操作,这里发生缺页中断,实际分配了内存。
下面来看几组实验,总体上分成new和malloc两组。
实验环境:
- Ubuntu 18
- 4GB内存
new
class test {
public:
+ char a[1000 * 1000 * 33]; // 大于32MB时, 使用这个定义
+ char a[1000 * 33]; // 小于128KB时,使用这个定义
test() {
}
};
实验一
:不停地new大于32MB的内存块,不释放(因为32MB是64为系统的mmap分配阈值的最大值,见上文glibc内存管理)
int main() {
for (unsigned long long i = 0; i < 300000; i++) {
test* a= new test;
}
return 0;
}
结果:由于内存不足,进程被杀死。
实验二
:不停地new小于128KB地内存块,不释放
int main() {
for (unsigned long long i = 0; i < 300000000; i++) { // 多三个0
test* a= new test;
// 直接崩了
}
return 0;
}
结果:由于内存不足,进程被杀死。
实验三
:不停地new大于32MB的内存块,释放
int main() {
for (unsigned long long i = 0; i < 300000; i++) {
test* a= new test;
delete a;
}
return 0;
}
结果: 正常退出,无事发生,用free观察剩余内存容量,也没有明显减少的现象
实验四
:不停地new小于128KB的内存块,释放
int main() {
for (unsigned long long i = 0; i < 300000000; i++) {
printf("i = %lld\n", i);
test* a= new test;
delete a;
}
return 0;
}
结果: 无事发生,用free观察剩余内存容量,也没有明显减少的现象。因为这里分配释放的都是同一大小的内存块,glibc的缓存效应没有体现。
如何体现malloc的缓存效应?见下面的malloc实验
malloc
实验一
:不停地malloc大于32MB的内存块,不操纵内存,不释放
using namespace std;
int main() {
for (unsigned long long i = 0; i < 300000; i++) {
printf("i = %lld\n", i);
char* a = (char*)malloc(1000 * 1000 * 33);
}
return 0;
}
实验结果视overcommit_memory参数的不同而不同,
cat /proc/sys/vm/overcommit_memory
有三种值
#define OVERCOMMIT_GUESS 0
#define OVERCOMMIT_ALWAYS 1
#define OVERCOMMIT_NEVER 2
- OVERCOMMIT_GUESS 表示根据系统当前可用page frame进行判断,如果可用page frame大于申请的虚拟内存,则允许申请虚拟内存;
- OVERCOMMIT_ALWAYS 表示总是允许申请虚拟内存,没有任何限制;
- OVERCOMMIT_NEVER 表示不允许超过系统设置的虚拟内存限制。
可以这样修改这个值:
echo 1 > /proc/sys/vm/overcommit_memory
如果overcommit_memory = 0, 则进程被kill,即时只是分配虚拟内存,在这种情况下也会考虑物理内存的容量。
如果overcommit_memory = 1, 进程没有奔溃,这表示我们可以申请大于物理内存的虚拟内存,但是运行后的可用内存空间急剧减少:
猜想占用的内存空间都是被内核申请的,因为需要大量的数据结构描述本进程对内存的需要,比如 struct mm_struct, struct vm_area等结构
与new的实验一相比, 可以知道,OS执行的是懒分配策略,如果不去操作新分配的内存,那么OS不会为用户进程分配内存空间.
实验二
:不停地malloc小于128KB的内存块,不操纵内存,不释放
using namespace std;
int main() {
for (unsigned long long i = 0; i < 300000000; i++) { // 比实验一多3个0
char* a = (char*)malloc(1000 * 33); // 比实验一少3个0
}
return 0;
}
结果: 进程奔溃,与实验一相比,分配同样的内存,且不操作内存也不释放内存,但实验一没有崩溃,但是实验二奔溃了,这其中差别就在于mmap和brk的差距,实验一分配的内存块大于阈值因此使用mmap分配内存,但实验二分配的内存块较小,使用brk来分配内存。这可以说明mmap"很懒",它只分配必要的数据结构,而brk则显得比较"勤快". 之前看过linux内核源码情景解析中的mmap分析,发现mmap在一开始连页表都没有创建, 但是brk会手动地调用hanle_mm_fault分配物理页面:
// linux2.4 内核源码
// sys_brk() => do_brk() => make_pages_present => handle_mm_fault
int make_pages_present(unsigned long addr, unsigned long end)
{
int write;
struct mm_struct *mm = current->mm;
struct vm_area_struct * vma;
vma = find_vma(mm, addr);
write = (vma->vm_flags & VM_WRITE) != 0;
if (addr >= end)
BUG();
do {
if (handle_mm_fault(mm, vma, addr, write) < 0) // 立刻将物理页面分配
return -1;
addr += PAGE_SIZE;
} while (addr < end);
return 0;
}
而使用mmap分配的内存块,将要等到触发缺页处理时才会调用handle_mm_fault。
实验三
:不停地malloc大于32MB的内存块,操纵内存,释放
与new的对应实验相同. 正常退出
实验四
: 不停地malloc小于128KBMB的内存块,操纵内存,释放
如果内存块大小相同与new的对应实验相同.
在这里试一下不同内存块,看看glibc是否有缓存效应
int main() {
int mod = 1024 * 100; // 随即分配 1 - 100 KB的内存块
for (unsigned long long i = 0; i < 300000000; i++) {
printf("i = %lld\n", i);
char* a = (char*)malloc(rand() % mod);
free(a);
}
return 0;
}
结果:剩余空间会减少,但是每当剩余空间很少时,下一刻的剩余空间又多了起来。这表明了glib的缓存效应,而且当内存不足时,glibc会从fastbins中合并空闲内存块。至于耗尽所有内存的情况,我不能复现出来,因为这种情况的复现还需要避免空闲块与topchunk的合并。
malloc 一共能分配多少内存
对于这个问题,我想面试官主要想问的是最大可以申请多少虚拟内存,之后也可以引申出虚拟内存和实际物理内存之类的话题。
理论上,我们可以申请OS允许的全部虚拟内存(如果仅仅是申请,而不使用它的话)。在64位的linux机器上,规定的用户使用的虚拟内存大小为128T,所以按理说,malloc最多能够分配接近128TB(但一定比理想值小,因为进程本身资源也占用虚拟地址空间,所以最大分配内存约为127TB)的虚拟内存。
但是实际上,这个问题受很多方面的制约。下面开始我自己的简陋实验。
实验环境:
- Ubuntu 18
- 4GB内存
程序:
int main (int argc, char* argv[])
{
unsigned int block_size = 1024*1024*1024;
void* addr = NULL;
unsigned long long i = 0;
while (1) {
addr = malloc(block_size);
// memset(addr, 0, block_size); // 不使用内存 !
if (!addr) break;
i++;
printf("i = %lld\n", i);
}
printf("malloc %lld GB, aroud %lf TB\n", i, i / 1024.0);
getchar();
return 0;
}
-
实际结果受overcommit_memory参数的影响(见上一个实验),该参数为0时 ,我们在4GB的内存上怎么都不可能分配127TB的虚拟空间。
当该参数为1时,可得总共的可申请虚拟内存空间为127TB左右:
-
但是,仅从这个现象得出“64位linux上,最多能分配127TB虚拟空间”的结论并不严谨。
通过上一个实验,我们知道即使我们只分配空间但不去使用它,也会消耗实际物理空间,这些实际物理空间是内核使用的,用来记录用户的对内存的需求。
可以使用free查看程序运行前后的剩余地址空间:
可以看到程序后的剩余空间发生急剧变化,内核大约使用了2GB的物理内存来记录用户对内存的需求。
由于我的实验环境的内存大小为4GB,因此能够顺利地分配127TB的虚拟空间,但假设使用的环境的内存只有2GB,那么用户能够分配的最大的虚拟空间应该是不足127TB的,因为内核没有办法申请更多的物理内存去记录进程对内存的需求了。
上述讨论的大前提是用户只剩请空间但不使用,但在绝大部分情况下,我们是要去使用的。
int main (int argc, char* argv[])
{
unsigned int block_size = 1024*1024*1024;
void* addr = NULL;
unsigned long long i = 0;
while (1) {
addr = malloc(block_size);
memset(addr, 0, block_size); // 使用内存 !
if (!addr) break;
i++;
printf("i = %lld\n", i);
}
printf("malloc %lld GB, aroud %lf TB\n", i, i / 1024.0);
getchar();
return 0;
}
在这种情况下,根据之前的实验,我们应该只能申请真实物理内存 + swap空间大小的空间。
在运行程序前查看可用的空间,剩余内存 + swap空间的可用大小大约有3GB左右。
然后运行程序:
分配了3次,也就是3GB后,被OS杀死,符合我的猜想。
此外,分配内存块的大小也影响实验结果,下面的代码每次分配1KB的内存,且不去使用它:
int main (int argc, char* argv[])
{
unsigned int block_size = 1024; // 1 KB 小内存块
void* addr = NULL;
unsigned long long i = 0;
while (1) {
addr = malloc(block_size);
// memset(addr, 0, block_size); // 不使用内存 !
if (!addr) break;
i++;
printf("i = %lld\n", i);
}
printf("malloc %lld GB, aroud %lf TB\n", i / 1024/ 1024, i / 1024.0/ 1024/1024);
getchar();
return 0;
}
在运行程序前查看可用的空间,剩余内存 + swap空间的可用大小大约有1.5GB左右:
然后运行程序:
当i增加到1586608时,进程被杀,换算一下也就是分配1.5GB左右的内存。也就说我们只能申请真实物理内存 + swap空间大小的空间。为什么?我们明明没有去使用这块内存,操作系统却已经把对应内存加载了?
这就是brk与mmap的区别。当申请小内存块时,我们首先向GLIBC库的缓冲中取得空闲块,如果GLIBC缓冲内有空闲块,则会调用brk申请内存。而brk不同于mmap,它真的会建立虚实地址映射关系,然后将物理内存载入。这也就是为什么,用户代码明明没有去操作分配的内存块,但是分配内存大小却受限于真实物理内存大小。
总结
对于malloc最大能够分配的多少虚拟内存这个问题,理论上64位Linux操作系统最多能够分配128TB的虚拟内存内存,但是实际上又受很多限制:
- 分配内存块的大小,小内存块通过brk分配,大内存块通过mmap分配,这两个系统调用的区别较大。
- malloc内存块后是否会使用这块内存。
- 物理内存的大小。是的物理内存确实也能限制虚拟内存的最大总数,如果物理内存太小,以至于内核不能申请vm_area结构来表达用户进程的内存需求,那么进程能够申请的最大虚拟内存同样会减小。