文章目录

本文谈谈C++的内存分配与管理,主要包括内存布局,分配,管理,解配,以及内存错误和防范措施。
漫谈漫谈,想到什么谈什么,不要在意前后衔接。

首先要了解程序占用的内存布局,顺便可以了解下对应的操作系统上的真实存储布局。

通常一个由 C/C++ 编译的程序占用的内存分为以下 5 个部分:

1) 栈区(stack): 由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
2) 堆区(heap): 一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
3) 全局区(静态区)(static): 全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。
4) 文字常量区: 常量字符串等只读数据放在这里的。程序结束后由系统释放。
5) 程序代码区: 存放函数体的二进制代码。

同时,一个linux进程的虚拟存储器结构如下:(从高地址到低地址)

1) 内核虚拟存储器,包括与进程相关的数据结构(页表,task_struct, mm_struct, 内核栈等),物理存储器,内核代码和数据
2) 用户栈 —对应程序栈区
3) 共享库的存储器映射区域 —从系统中加载共享库的存储区
4) 运行时堆 —对应程序堆区
5) 未初始化数据和已初始化数据 —从可执行文件中加载,对应静态区
6) 程序文件(代码) —从可执行文件中加载,对应程序代码区

其中这些区域中,最重要的,最常用到的就是堆区和栈区了,他们的区别如下:

C++中堆和栈的区别:

管理方面,需要自己分配/清除
空间大小方面,堆最大可达4G(32位),而栈大小有限制,一般8M
碎片方面:堆分配和回收一段时间后可能产生碎片,栈一定不会
生长方向:栈往低地址生长,堆往高地址生长
分配方式:栈可动态分配也可静态分配,堆只能动态分配
分配效率:栈是机器系统提供的数据结构,而堆是语言层提供的数据结构,效率不一样

栈其实要比堆快,原因在于:

1) 栈是本着LIFO原则的存储机制, 对栈数据的定位相对比较快速, 而堆则是随机分配的空间, 处理的数据比较多, 无论如何, 至少要两次定位.
2) 栈是由CPU提供指令支持的, 在指令的处理速度上, 对栈数据进行处理的速度自然要优于由操作系统支持的堆数据.
3) 栈是在一级缓存中做缓存的, 而堆则是在二级缓存中, 两者在硬件性能上差异巨大.
4) 各语言对栈的优化支持要优于对堆的支持, 比如swift语言中, 三个字及以内的struct结构, 可以在栈中内联, 从而达到更快的处理速度.

内存的布局有了,那么怎么分配内存来存储程序中的数据结构呢?这就涉及到了 malloc/free 库函数和 new/delete 操作符。

malloc/free 和 new/delete 区别如下:

a) malloc/free是库函数,new/delete是操作符
b) malloc/free只分配内存,new/delete还负责构造/析构对象
c) malloc/free分配失败会返回NULL,new/delete分配失败会返回bad_alloc
d) new/delete分配可以自动计算需要的字节数,malloc/free需要人为指定

另外一个比较重要的区域是静态区,C++的对象有三种:栈区对象,堆区对象和静态区对象,他们有什么异同呢?

三种内存对象的比较:

栈对象:
1.栈对象的优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;
2.栈对象的创建速度一般较堆对象快。因为分配堆对象时,会调用operator new操作,operator new会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。
3.通常栈空间容量比较小,一般是1MB~2MB,所以体积比较大的对象不适合在栈中分配。
4.特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。

堆对象:
1.其产生时刻和销毁时刻都要程序员精确定义
2.相比于栈空间,堆的容量要大得多

静态对象:
1.全局对象:全局对象为类间通信和函数间通信提供了一种最简单的方式
2.类的static成员:属于类,为所有类对象所共享
3.局部静态对象:主要可用于保存该对象所在函数被屡次调用期间的中间状态

内存分配的时候,C/C++ 会注重内存对齐,比如说要求 struct, union, enum 等数据结构都要内存对齐。

那么为什么要内存对齐呢?

《Windows核心编程》里这样说:当CPU访问正确对齐的数据时,它的运行效率最高,当数据大小的数据模数的内存地址是0时,数据是对齐的。例如:WORD值应该是总是从被2除尽的地址开始,而DWORD值应该总是从被4除尽的地址开始,数据对齐不是内存结构的一部分,而是CPU结构的一部分。当CPU试图读取的数值没有正确的对齐时,CPU可以执行两种操作之一:产生一个异常条件;执行多次对齐的内存访问,以便读取完整的未对齐数据,若多次执行内存访问,应用程序的运行速度就会慢。

内存对齐的原则:

结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的”最宽基本类型成员”的整数倍。不足的要补齐。(基本类型不包括struct/class/uinon)。
对于union:sizeof(union),以结构里面size最大元素为union的size,因为在某一时刻,union只有一个成员真正存储于该地址。

C++ 内存分配和解配中,很容易发生错误,常见的内存错误有:

1、返回局部变量地址将引起内存错误

2、临时空间过大:操作系统在加载某个应用程序时,都将为其分配一定大小的栈空间,若申请过大的局部变量,可能会引起栈溢出问题。(PC机上Windows和Linux系统一般不必担心这个,因为有虚拟内存机制,但嵌入式开发就要特别注意这个了——资源有限,一旦栈溢出造成程序错误又是很难查的~~~)

3、src 和 dst 内存覆盖:在进行字节内存复制时,常会出现这一问题。因为部分系统库函数并没有提供内存覆盖的检测功能,从而导致错误。

4、动态内存管理错误
动态申请的堆内存空间需要自己管理,这可能会导致:
a) 申请和释放不一致:由于C++兼容C,而C与C++的内存申请和释放方式不一样,因此在C++程序中,就有两套动态内存管理函数。正常应该是采用C方式申请就用C方式释放,用C++申请的就要用C++方式释放
b) 申请和释放量不匹配:申请了多少内存,在使用完成后就要释放多少。若果没有释放或者少释放了就是内存泄露,多释放了也会产生问题。(虽然程序退出后系统会回收堆空间,但严格的应该申请的堆空间要在程序中释放掉。

5、内存泄漏
广义的说,内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态HANDLE,GDI Object,SOCKET, Interface等,这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。而且,某些对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。所以相比之下,系统资源的泄漏比堆内存的泄漏更为严重。

其中,最常见的应该属内存泄漏了吧,那么内存泄漏如何检测呢?

检测内存泄漏的关键是要能截获住对分配内存和释放内存的函数的调用。截获住这两个函数,就能跟踪每一块内存的生命周期。比如,每当成功的分配一块内存后,就把它的指针加入一个全局的list中;每当释放一块内存,再把它的指针从list中删除。这样,当程序结束的时候,list中剩余的指针就是指向那些没有被释放的内存。

常见内存泄漏的检测工具有:

Linux下的检测工具:Mtrace、Memwatch、LeakTracer、Valgrind、tcmalloc
Windows下的检测工具:windbg工具、MS C-Runtime Library、BoundsChecker、Performance Monitor

为了最大可能的降低内存泄漏的几率,C++提出了智能指针的解决方案。

如何理解智能指针?

裸指针容易造成内存泄漏(忘记释放)、二次释放、程序异常时的处理(参见问题20构造函数能够抛出异常)等问题,使用智能指针能更好的管理堆内存,智能指针也是RAII的一种应用,用于动态管理资源。利用对象离开作用域自动析构的特性,将释放内存的操作托管给一个对象。

有几种常用的智能指针,shared_ptr, unique_ptr, weak_ptr。

顾名思义,shared_ptr指向的对象是可以被共享的,只有再也不被指向的对象会被销毁,而unique_ptr指针不能被共享,只能改变指向,一旦改变,原对象会被销毁。

而weak_ptr具有如下特征:
a) weak_ptr不能独立存在,只能从一个shared_ptr产生,其指向shared_ptr指向的内存,但并不拥有该内存,不改变引用计数。
b) weak_ptr通过lock()成员可以返回指向该内存的shared_ptr对象,如果已经无效则返回空
c) 使用weak_ptr是为了解决循环引用的问题,如果两个对象中都分别包含对对方的引用,则会产生循环引用,计数无法降为0,使用weak_ptr可以解决这个问题,因为其不会增加引用计数。

智能指针内部会维护一个引用计数,即使用指向资源的智能指针个数,如果降到0,即不再有指针指向该对象,那么自动销毁该对象。

引用计数基本思想:对被管理的资源进行引用计数,当一个shared_ptr对象要共享这个资源的时候,该资源的引用计数加1,当这个对象生命期结束的时候,再把该引用计数减少1。这样当最后一个引用它的对象被释放的时候,资源的引用计数减少到0,此时释放该资源。

引用计数改变的情况:
作为函数参数:传值则引用计数加1,传引用则引用计数不变 3 作为函数返回值:如果返回值作为右值进行拷贝,则引用计数加1,否则不变

还有一个有趣的问题:C++ 为什么不支持垃圾回收?

C和C++中的垃圾收集都是困难的主题,原因如下:

-2) C and C++ are languages that are created to support all possible use cases. 通用性

-1)C++ 的哲学是”close to the metal”,不愿意加上一些性价比不高的features

0)GC Stop-the-World 带来的性能延迟,有时是不可忍受的,尤其在实时应用中

1)指针可以被转换为整数,反之亦然。 这意味着我可以有一个内存块只能通过一个整数,类型转换为指针,然后解除引用。 一个垃圾收集器必须小心,不要认为块是无法到达的,当它仍然可以达到。
GC 必须能够准确识别指针和非指针。

2)指针不是不透明的。 许多垃圾收集器,如停止和复制收集器,喜欢移动内存块或压缩它们以节省空间。 由于您可以明确地查看C和C++中的指针值,因此难以正确实现。 您必须确定,如果某人正在使用类似于整数的方式进行一些棘手的操作,那么如果您移动了一块内存,则正确更新了整数。

3)内存管理可以明确完成。任何垃圾收集器都需要考虑到用户可以随时显式释放内存块。

4)在C++中,分配/取消分配和对象构造/销毁是分开的。 一块内存可以分配足够的空间来容纳一个对象,而不需要在那里实际构造任何对象。 一个好的垃圾回收器在回收内存时需要知道是否调用可能在那里分配的任何对象的析构函数。 对于标准库容器尤其如此,因为出于效率原因,通常使用std::allocator来使用这个技巧。

5)内存可以从不同的地方分配。 C和C++可以通过内置的freestore(malloc / free或new / delete),也可以通过mmap或其他系统调用从OS获得内存,对于C++,可以从get_temporary_bufferreturn_temporary_buffer获得内存。 程序也可能从一些第三方库中获取内存。 一个好的垃圾收集器需要能够跟踪对这些其他池中的内存的引用,并且(可能)必须负责清理它们。

6)指针可以指向对象或数组的中间。 在像Java这样的垃圾收集语言中,对象引用总是指向对象的开始。 在C和C++中,指针可以指向数组的中间,而在C++中指向对象的中间(如果使用多重继承的话)。 这可能会使检测仍然可达的逻辑复杂化。

所以,简而言之,为C或C++构建一个垃圾收集器是非常困难的。 大多数使用C和C++进行垃圾回收的库在方法上都非常保守,在技术上是不健全的 - 例如,他们认为你不会拿一个指针,把它转换为一个整数,写入磁盘,然后加载它稍后再回来。 他们还假定内存中任何一个指针大小的值都可能是一个指针,所以有时候会拒绝释放无法访问的内存,因为有一个指向它的非零的机会。

正如其他人所指出的那样, Boehm GC确实为C和C++做垃圾回收,但受上述限制。

有趣的是,C++ 11包含了一些新的库函数,允许程序员在未来的垃圾收集工作中将内存区域标记为可达和不可达。 将来有可能用这种信息构建一个非常好的C++ 11垃圾收集器。 与此同时,你需要非常小心,不要违反上述规定。

编程语言 | Program Lang.