
在平常的C++开发过程之中,你有没有遭遇到这类令人心烦的情况?程序运行了好一会儿之后,所占据的内存变得越来越多,最终直接就出现崩溃的状况,一检查原来是内存泄漏。要不就是程序突然变得极其卡顿,响应缓慢得厉害,严重地影响到用户体验。
很多时候这类问题都和内存的分配相关联,特别是堆内存的分配。就拿我们经常使用的new操作符来说吧,它在堆上给我们进行内存的分配,表面上看起来简单,但是背后隐藏着不少的开销以及复杂的机制。要是想要深入地弄明白这类问题,解决内存方面的疑难之处,那就要先搞清楚Linux进程虚拟地址空间的奥秘,还有new堆内存分配的底层原理。
在Linux系统当中,每一个进程都具备其自身的虚拟地址空间。此虚拟地址空间并非是实实在在的物理内存。简单来讲它就好似进程所看到的内存空间一般。打个比方进程会觉得自己能够使用从0起始到非常大的数值(例如在32位系统里是0到4GB的范围)的连续内存地址。
但是实际上这些地址是虚拟的,它们依靠一种映射机制和真实的物理内存建立起联系。这如同你在地图上找寻地方,地图上标注的地址(虚拟地址)得通过实际的道路(映射机制)才能够找到真实的地方(物理内存)。每一个进程的虚拟地址空间相互之间是独立的。进程A的虚拟地址0x1000和进程B的虚拟地址0x1000,从表面上看是一样的,可是它们所映射到的物理内存位置大多数是不一样的,这就确保了进程之间的内存不会相互产生干扰。
Linux 进程的虚拟地址空间并非一块无差别的连续内存,而是按照功能严格划分成多个独立区域,每个区域负责存储不同类型的程序数据和指令,拥有专属的内存管理规则。我们日常编程用到的栈内存、堆内存,都隶属于这些分区。
下面逐一介绍虚拟地址空间的核心区域及各自的作用、存储内容:
代码区:在这里存在着进程的可执行代码。也就是我们所编写的程序经过编译之后生成的机器指令。代码区是处于只读状态的。这也就表明程序在运行的时候不能够对自身的代码进行修改。这样便可以保障程序运行的稳定与安全。倘若代码区是可写的,程序在运行的时候不小心修改了自身的代码,那么程序肯定就会出现混乱的情况,有可能直接就崩溃掉。比如说一个简单的C程序,main函数以及其他自定义函数的指令就放置在代码区当中。
数据区:数据区域存在着两部分内容,分别是初始化数据区域以及未初始化数据区域也就是BSS段。初始化数据区域用来放置已经进行了初始化操作的全局变量以及静态变量。举例来说像int a = 10这样的情况,如果变量a是全局的或者是静态的,那么就会被放置到初始化数据区域当中。BSS段则是用来放置很多没有进行初始化操作的全局变量以及静态变量。比如int b这样的情况,这个没有进行初始化操作的全局变量b就处于BSS段里面。在程序进行加载的时候,BSS段里面的变量会自动地被初始化为0。
堆区:堆区主要是用于动态地进行内存的分配。由程序员手动去对内存的分配以及释放进行管理。在C++之中,通过new操作符所分配的内存是来自于堆区的。例如int* ptr = new int;,这就是在堆区之中分配了存储int类型数据的空间。在之后不再使用这块内存的时候,就需要使用delete ptr;来进行释放,要不然就会出现内存泄漏的情况。堆区的内存分配具有较为灵活的特点,能够依据程序运行的需求来动态地对大小进行调整。但是也正是因为这样,如果管理得不好就很容易出现问题。
栈区:栈区主要存储函数调用相关的信息,包括函数的参数、局部变量、返回地址等。每次函数调用时,相关信息就会被压入栈中,函数返回时,这些信息从栈中弹出。栈区的内存管理由操作系统自动完成,遵循后进先出(LIFO)的原则,比如下面这个简单的函数调用:
void func(int a) { int b = a + 1;}int main() { int num = 5; func(num); return 0;}当于main函数之中去调用func函数的时候,将num当作实参压入到栈中。而后在func函数里面,局部变量b也同样是在栈区去进行空间的分配。等到函数执行完毕进行返回的时候,栈上面的这些相关信息就会被予以清理掉。
其他区域:除了那几个主要的区域之外,还有只读的数据区域。只读的数据区域存储着常量的数据,比如说像const int c = 20这样的情况,这个常量c就处在只读的数据区域当中。还有共享的内存区域,多个进程可以通过共享内存区域来共享数据,这样子能够提升数据交互的效率,还能够减少内存的占用。
要想要虚拟地址可以访问到实际的物理内存,那就得依靠页表来进行转换才行。Linux系统运用分页这样的机制,把虚拟地址空间还有物理内存都划分成固定大小的页(默认情况下是4KB)。虚拟地址空间里面的页被称作虚拟页,物理内存里面的页被叫做物理页框。内核维护着那么一张页表,这一张页表记录着虚拟页号和物理页框号之间的对应关联关系。
当进程去访问一个虚拟地址的时候,CPU当中的内存管理单元(MMU)就会接收到这个虚拟地址。紧接着MMU把虚拟地址给拆分成虚拟页号以及页内偏移。在这之后MMU通过页表去查找对应的物理页框号。在找到物理页框号之后,把它和页内偏移进行拼接起来,就获取到最终的物理地址。整个这样的过程是硬件自动去完成的,进程完完全全感觉不到(这样的过程)。
但是有的时候,页表当中标记着某个虚拟页所对应的物理页是不存在的。比如说进程第一次去访问某一块内存,或者是这块物理页之前已经被换出到磁盘。这时候CPU就会触发缺页中断。内核接收到缺页中断之后,先去看看物理内存里边有没有空闲的页框。要是有的话把磁盘上对应的页(像程序的代码页、数据页这类的)加载到空闲页框里边。要是没有空闲页框的话,就使用页面置换算法(比如说LRU,也就是最近最少使用算法)来淘汰一个不常用的物理页。要是这个被淘汰的页之前被修改过,得先把它写回到磁盘去。然后再把磁盘上需要的页加载进来。加载完成之后,内核更新页表,把新的物理页号写进去,还标记该页是存在的。之后重新执行触发缺页中断的指令,这一回就能够成功访问到物理内存。
在C++这个编程语言当中,new操作符是非常关键的。它主要的作用就是在堆区去进行内存的分配并且创建对象。打个比方来说,当写出int* ptr = new int(5);这么一行代码的时候,这一行代码干了两件事儿:一是在堆区分配一块能够存放一个int类型数据的内存空间,二是把这块空间给初始化成5,然后返回指向这块内存的指针ptr。要是想要创建自定义类的对象,比如说class MyClass { /* 类的成员和方法 */ }; MyClass* obj = new MyClass();,new就会先在堆区给MyClass对象分配足够的内存,紧接着调用MyClass的构造函数来初始化这个对象,最后把指向已经初始化好的对象的指针返回给obj 。
new 看似是简单的内存分配操作,但其底层并不是直接向操作系统申请内存,而是需要经过多层校验、内存选型、并发防护、内存初始化等一系列固定流程,每一步都会产生对应的性能开销。下面完整拆解 new 堆内存分配的底层执行步骤:
类加载检查:在实际去分配内存之前,Java虚拟机(JVM)得要先去检查一下所要创建对象的那个类有没有被加载、有没有被链接以及有没有被初始化。这如同你要是想要去盖房子得先确保建筑图纸已经准备好了一样。要是类还没有被加载的话,JVM就会启动类加载的过程。从字节码文件当中去读取类的相关信息,把它加载到内存里面,完成验证、准备还有解析这些操作,保证类是正确而且完整的,然后才会到内存分配那一个步骤。
内存分配方式指针碰撞:假定堆内存是整齐规整的,把已经使用过的内存和空闲着的内存是分开放置的,中间有一个指针当作分界线。那么分配内存就会比较简单,将这个指针朝着空闲内存的方向移动与要分配的对象大小相同的距离就可以了,这被称作指针碰撞。举个例子来说,堆内存如同一条街道,一边是已经建造好了房子的区域(也就是已使用的内存),另一边是空旷的地方(也就是空闲的内存)。分配内存就好像是在空地上划分出一块指定大小的地皮(也就是对象的大小),然后移动分隔的界线(也就是指针)来标识这块地皮已经被占用了。这种方式适用于Serial和ParNew等不会产生内存碎片的垃圾收集器,由于它们可以使得堆内存维持规整的状态。
空闲列表:要是堆内存不规整,已运用的内存和空闲的内存彼此交错在一起,那就没有办法运用指针碰撞了。这时候虚拟机得保护一个列表,记载哪些内存块是能够运用的,还有它们的巨细。分配内存的时分,得从这个列表里边找一块巨细适宜的空间给目标实例,分配完还要更新列表里边的记载,这便是空闲列表方法。打一个比如就好像街道上房子盖得杂乱无章,没有显着的空地和已建房区域区分,要找一块地建新房子(分配内存),就必须得有一份具体的地图(空闲列表),上面标明着每块空地(可用内存块)的方位和巨细。这种方法适用于CMS等或许发生内存碎片的垃圾收集器,由于它们回收垃圾之后,内存不会收拾得规整。
并发问题解决CAS 失败重试:创建对象呈现出很频繁的状况。在多线程的情境之下,极有可能出现多个线程同时尝试去进行内存的分配,这样就会产生并发方面的问题。虚拟机通常采用比较并交换(CAS)再加上失败重试的方式来确保内存分配操作的原子性。简单来讲CAS操作会先对内存里某个值(例如空闲内存指针的值)和预期的值是否相同进行比较。倘若相同便把这个值更新成为新的值(比如移动之后的空闲内存指针值),以此来完成内存的分配。要是在比较的时候发现值已经被其他线程给修改了(预期值和内存实际的值不一致),那就重新去尝试,一直到成功完成内存的分配为止。
TLAB:还有一种用来解决并发问题的办法乃是将内存分配依线程划分到不同的空间,此便是本地线程分配缓冲(TLAB)。每一个线程在Java堆里面事先预先分配那么一小片私有内存,这一小片内存被称作TLAB。要是设置了虚拟机方面的参数 - XX:UseTLAB,线程在初始化的时候就会去申请一块特定指定大小的TLAB内存。只有当前的这个线程才能够在这块内存之上进行分配对象,如此便避免了多线程之间的竞争,能够极大地大大提高分配的效率。当某一个线程的TLAB使用完毕用完了之后,就再重新地从堆的Eden区域去申请一块全新的TLAB接着进行使用。
初始化与设置:内存进行分配完毕之后,紧接着就要去初始化这一块内存空间。要把内存里边的值都设定成为零或者特定的默认值。如此一来对象的实例字段在Java代码当中就可以直接去使用默认值,而不用去进行显式的初始化。接下来就是去设置对象头。对象头包含着两部分重要的信息。其中一部分是对象自身在运行时候的数据,像是哈希码、分代年龄、锁状态标志等等这些。另一部分是类型指针,它指向对象的类型元数据,通过这个类型指针就能够去找到对象所属的类的信息。要是创建的是数组对象的话,对象头里边还得有一块儿区域来存储数组的长度。在把这些都完成之后,最后一个步骤就是去执行对象的构造方法。按照我们在类里边所定义的逻辑来对对象进行初始化,让对象变成我们所想要的那个状态 。
#include <iostream>#include <vector>int main() { std::vector<int*> smallMemList; // 1. 批量分配大量小块堆内存 for (int i = 0; i < 10000; ++i) { int* p = new int(1); smallMemList.push_back(p); } // 2. 释放偶数位置的小块内存,产生零散空闲块 for (int i = 0; i < smallMemList.size(); i += 2) { delete smallMemList[i]; smallMemList[i] = nullptr; } // 3. 尝试分配大块连续内存 char* bigBuf = new char[1024 * 100]; delete[] bigBuf; // 统一释放剩余内存 for (int* p : smallMemList) { if (p != nullptr) delete p; } return 0;}代码首先大量地去申请离散的小块堆内存,接着间隔着释放一部分内存,如此一来原本连续的堆内存被分割成了许多零散的空闲块。这时候系统总的空闲内存总量还是足够的,但是却没有足够连续的内存空间,后续在申请大块内存的时候,分配的效率就会明显地降低,在极端的情况下会出现分配失败的情况,完整地重现了因为频繁进行小块内存操作而导致的内存碎片问题。
为量化直观观测 new 操作的累积时间开销,以下通过高精度计时代码,测试频繁小块内存分配的性能损耗:
#include <iostream>#include <chrono>#include <vector>int main() { auto start = std::chrono::high_resolution_clock::now(); std::vector<char*> memList; // 频繁执行小块内存new分配 for (int i = 0; i < 500000; ++i) { char* p = new char[32]; memList.push_back(p); } auto end = std::chrono::high_resolution_clock::now(); auto cost = std::chrono::duration_cast<std::chrono::milliseconds>(end - start); std::cout << "频繁new总耗时:" << cost.count() << "ms" << std::endl; // 统一释放内存 for (char* p : memList) { delete[] p; } return 0;}代码运用高精度计时器,去统计几十万次小块堆内存分配的总的耗时情况。单次小块的new操作所花费的时间是非常低的。可是当高频循环进行调用的时候,底层的内存查找、数据结构更新、内存对齐等操作就会不断地累积起开销。最终就产生了明显的性能耗时情况。这很直观地证明了频繁的new操作会带来时间开销方面的问题。
#include <iostream>#include <vector>int main() { std::vector<int*> smallMem; // 方式1:多次分配小块内存 for (int i = 0; i < 10000; ++i) { smallMem.push_back(new int(0)); } // 方式2:一次性分配同等容量大块连续内存 int* bigMem = new int[10000]; std::cout << "多次小块分配,元数据开销占比极高" << std::endl; std::cout << "单次大块分配,内存利用率大幅提升" << std::endl; // 释放内存 for (int* p : smallMem) delete p; delete[] bigMem; return 0;}两段的分配方式最终所得到的有效内存容量是相同的。但是在逻辑方面却存在着很大的差别。多次进行小块的 new 操作会为每一块内存各自生成元数据,累积起不少额外空间的开销。而一次性进行大块的分配则只需要维护一份元数据,几乎不存在冗余的开销。此代码直接证实了:频繁地进行小块堆内存的分配,会由于大量的元数据而使得内存的利用率大幅度地降低 。
假设我们正在开发一个简单的日志记录系统,为了记录不同类型的日志信息,我们设计了一个LogEntry类,包含日志级别、时间戳和日志内容等信息。为了方便管理和扩展,我们使用std::vector来存储这些日志记录。但在实际记录日志时,由于业务需求,每次有新的日志产生,我们都在std::vector中push_back一个新的LogEntry对象,而LogEntry对象内部的日志内容是通过new在堆上分配内存来存储的,因为日志内容长度不确定。示例代码如下:
#include <iostream>#include <vector>#include <string>#include <ctime>class LogEntry {public: LogEntry(int level, const std::string& message) : logLevel(level), timeStamp(std::time(nullptr)), logMessage(new std::string(message)) {} ~LogEntry() { delete logMessage; }private: int logLevel; time_t timeStamp; std::string* logMessage;};class Logger {public: void log(int level, const std::string& message) { entries.push_back(LogEntry(level, message)); }private: std::vector<LogEntry> entries;};int main() { Logger logger; for (int i = 0; i < 100000; ++i) { std::string message = "This is log message " + std::to_string(i); logger.log(1, message); } return 0;}在运行程序的时候,伴随日志记录数量的不断增多,内存的占用量也在持续地上升。程序运行了一段时间之后,能够很明显地感觉到运行的速度在慢慢地变缓。这是由于在LogEntry构造函数里面频繁地使用new来进行内存的分配,使得堆内存的碎片化情况变得很严重。
每一次进行内存分配的时候,内存管理系统查找合适的空闲内存块所花费的时间就变长了。而且因为碎片化的缘故,有可能出现总的空闲内存是足够的,但是却分不出来能够满足需求的连续内存块的这种情况,使得内存分配的效率大幅度地降低,进而对整个程序的性能产生影响 。
在这个案例中,性能问题的关键就在于LogEntry中logMessage频繁的new堆内存分配。为了优化这个问题,我们可以采取以下几种方法:
减少不必要的内存分配:尽可能不要在循环当中频繁地进行对象的创建以及销毁操作。打个比方在那个日志系统里面,要是存在某些日志内容是重复的或者是可以进行复用的情况,那就不要每一次都重新去进行内存的分配来弄新的std::string对象。先去检查是否有内容相同的日志,如果有的话就直接加以运用,如此便能够减少内存的分配情况。
采用内存池技术:针对LogEntry这个对象,可以去创建一个内存池。在程序初始化的时候,一次性地从堆里面去分配一大块内存,然后把这一大块内存划分成好多个固定大小的内存块,每一个内存块是用来存放一个LogEntry对象的。当需要去创建LogEntry对象的时候,就直接从内存池里面拿一个空闲的内存块就好,不用每次都去调用new。当释放LogEntry对象的时候,不是直接去delete,而是把内存块归还给内存池,这样子就能够有效地减少内存碎片化,还能够提升内存分配和释放的效率 。
使用智能指针:将LogEntry里的std::string*类型的logMessage替换成智能指针,比如说std::unique_ptr<std::string>。智能指针可以自动地对内存的释放进行管理,不会由于忘记去释放内存而出现内存泄漏这样的状况。并且智能指针还能够在一定的程度上使得内存管理方面的代码变得简单些。
优化数据结构:重新来对Logger里面所使用的std::vector是不是最为合适的选择进行审视。要是日志记录的顺序并不是那么的重要,而且还需要频繁地进行插入和删除操作的话,那么就可以去考虑使用std::list。由于std::list在进行插入和删除的时候不需要去挪动好多的元素,这样就能够减少因为内存操作而带来的开销。
在 Linux 操作系统体系当中,每一个进程都拥有着各自独立的虚拟地址空间。此空间涵盖了代码区域、数据区域、堆区域、栈区域等不同的区域。虚拟地址需要借助页表来映射到物理地址,这里面存在着分页机制、MMU 地址转换、缺页中断处理等许多繁杂的流程。
在 C++程序设计与编写当中,new 操作符被用于在堆区域分配内存以及创建对象。它底层所涉及的步骤包含类加载的检查、内存的分配(采用指针碰撞或者空闲列表这样的方式)、解决并发方面的问题(运用 CAS 失败重试或者 TLAB 这样的方式)、对内存进行初始化以及设置对象的头部,最后执行构造方法。new 进行堆内存的分配存在着内存碎片、时间开销、空间开销等方面的问题,这些问题将会严重地对程序的性能产生影响。
end
如果这篇文章对你有所启发,欢迎点赞、在看,转发三连。星标⭐账号,还可以第一时间收到推送,感谢你的收看,我们下期再见~
往期干货推荐