候捷C++内存管理学习笔记
1.关于delete和delete[]的区别delete用于释放new运算符分配的单个对象的内存。会调用该对象的析构函数然后释放该对象所占用的内存。delete[]用于释放new运算符分配的数组的内存。会依次调用数组中所有元素的析构函数然后释放整个数组所占用的内存。如果对数组进行delete而不使用delete[]会发生内存泄漏原因是只对数组的第一个元素调用了一次析构函数数组中剩余的元素所new分配出来的内存没有被正确析构但是这个delete仍会清理数组中所有元素本身。所以对于不存在指针数据的类的数组使用delete而非delete[],在理论上是可行的。2.关于new和deleteFoo* p new Foo; ... delete p;上面这两行代码实际上被叫做new和delete表达式和下面可以重载的operator new和operator delete操作符不同表达式无法被重载。new实际上执行了三个步骤先使用void类型的指针通过operator new内部使用malloc(n)分配对应的内存空间然后使用static_cast将void类型的指针转换为相应类型的指针最后在调用类的构造函数。delete实际上执行了两个步骤先调用了类的析构函数清理手动动态分配的内存然后再使用operator delete内部使用free(p)将字符串本身这个指针清理掉。//new表达式 try{ void* mem operator new(sizeof(Foo)); //这里调用的是new操作符如果调用的类中没有重载operator new //那么就会调用全局的operator new p static_castFoo*(mem); p-Foo::Foo(); //直接调用构造函数使用者是不能直接调用构造函数的 //只有编译器可以直接调用构造函数 } //全局的operator new操作符 void* operator new(size_t size) noexcept{ void *p; while((p malloc(size)) 0){ //如果不等于0代表取到了内存直接退出循环返回p //如果进入循环则代表内存不够用了 if(_callnewh(size) 0) break; //调用_callnewh从其他地方释放掉内存如果取不到直接break退出循环 //不等于0代表要到了内存进入下一轮循环继续给p分配内存 _CATCH(std::bad_alloc) return (0); //捕获内存分配失败的异常返回0 _CATCH_END } return(p); } //delete表达式 p-~Foo(); operator delete(p); //同样调用的是delete操作符 //全局的operator delete操作符 void* operator delete(){ pc-~Complex(); operator delete(pc); }这里的_callnewh是指调用自己设定的new handler来处理内存空间不足的情况。而上面的operator new和operator delete可以被重载这里重载的是成员函数它们必须是静态函数因为实际在调用这两个函数的时候对象正在创建或销毁过程中我们无法通过该对象的实例去调用其中的函数所以要写成静态成员函数静态成员函数实际并不属于这个类实际调用的是下面的代码class Foo{ public: static void* operator new(size_t size); static void* operator new[](size_t size); static void operator delete(void* phead,size_t size); //size_t这个参数可有可无 //由编译器传入与我们无关 static void operator delete[](void* phead,size_t size); }或者是对数组的重载Foo* p new Foo[N]; ... delete p; try{ void* mem operator new(sizeof(Foo)*N 4); p static_castFoo*(mem); p-Foo::Foo();//执行N次 } p-~Foo();//N次 operator delete(p); class Foo{ public: void* operator new(size_t); void operator delete(void*,size_t);//size_t这个参数可有可无 }为什么在new的时候需要将大小再加4因为在编译器中数组的开头会有一个4字节的int型变量来存储数组的大小存储数组大小的这块空间被称为cookie方便编译器了解需要调用构造函数和析构函数的次数。调用构造函数和析构函数的顺序如下所示两者调用的方向是相反的值得一提的是一个变量的大小还取决于它的类中是否有虚函数如果有虚函数那么该变量中就多了一根虚指针。假设一个具有虚函数的类中拥有一个int型的数据那么该类的一个变量的大小就是448字节。这个类的一个元素个数为5的数组的大小为(44)*5444字节。在调用new和delete时C提供了一个语法可以绕过使用者重载的函数,调用全局的operator newFoo* pf ::new Foo; ... ::delete pf;这样写调用的是全局的new和delete函数如果没有重载的成员函数会自动调用全局的函数这里对成员函数的重载和对全局函数的重载是不一样的重载全局函数意味着所有没有重载成员函数的类都会受到影响void* ::operator new(size_t); void ::operator delete(void*); //如果想要重载成员函数那么要写成以下形式 //且不能被声明于一个命名空间内 inline void* operator new(size_t size){ malloc(size); } inline void* operator new[](size_t size){ malloc(size); } inline void* operator delete(void* ptr){ free(ptr); } inline void* operator delete[](void* ptr){ free(ptr); }对new进行重载可以有多个参数第一个参数必须为size_t每一个不同的new也必须有唯一的参数列表。//placement new和new表达式使用的操作大致上是一样的 try{ void mem* operator new(sizeof(Foo),buf); p static_castFoo*(mem); p-Foo::Foo(); } //不过使用的operator new操作符不一样会传入第二个参数 //标准库提供的placement new()的重载形式 void* operator new(size_t size, void* start){ return start; } //因为传入的是已经分配好内存的指针所以operator new不进行内存分配 //重载的placement new() void* operator new(size_t size, long extra, char init){ return malloc(size extra); } int main(){ Foo* pf new(300,c)Foo; }placement new也是可以重载的不过重载的时候第一参数必须是size_t因为当不使用new()而直接使用new表达式的时候该类的大小会被作为参数传入new使用时传入的参数就是除了size_t之外我们自己指定的参数。正常情况下我们是无法直接调用构造函数的但是我们可以使用placement new来间接调用构造函数。placement new的原理是在一个已经分配好内存的地方调用构造函数去创建一个新的元素。调用时使用new(参数)classname这样被称为placement new还有operater new和array new还可以用来给数组赋值A* buf new A[size]; A* tmp buf; //tmp指向数组的开头 for(int i 0;isize;i){ new(tmp)A(i); }对delete重载也是一样的第一个参数必须是void*但它们绝不会被delete调用。只有在new所调用的构造函数抛出异常时才会调用对应的delete重载来处理异常不写代表不处理异常。因为new在调用时先分配了内存空间再调用构造函数所以当构造函数出现异常时需要将先前预先分配好的空间释放掉默认的new会自动释放内存但重载的new需要重载对应的delete来处理。对delete进行重载时使用的参数要与重载的new对应//一般的operator new重载 void* operator new(size_t size){} //对应的一般的operator delete重载 void operator delete(void*,size_t){} void* operator new(size_t size,void* start) void operator delete(void*,void*) void* operator new(size_t size,long extra) void operator delete(void*,long) void* operator new(size_t size,long extra,char init) void operator delete(void*,long,char)3.new handler若内存空间不足以分配给一个新创建的对象编译器会在抛出异常之前调用我们自定义的new handler交由我们处理。要么从别处释放内存空间要么调用abort()或exit()终止程序。void noMoreMemory(){ cerr out of memory; abort(); } int main(){ set_new_handler(noMoreMemory); }需要调用set_new_handler来设定对应的new handler。cout 和 cerr的区别cout默认行缓冲数据会先被存在缓冲区中需要等待缓冲区满或换行符才会进行输出。cerr默认无缓冲会立即刷新缓冲区直接输出。用于输出异常或调试信息。4.内存对齐参考文献https://blog.csdn.net/2301_80030290/article/details/142764657?spm1001.2014.3001.5502内存对齐本质上是空间换时间为什么我们需要内存对齐如果不使用内存对齐的话编译器在访问内存空间中的数据时可能会进行两次内存访问因为同一个数据可能会被放在两个大小为8字节的内存块中假设编译器一次访问8个字节而如果使用了内存对齐那么只需要进行一次内存访问即可。对齐规则1默认对齐数默认对齐数是编译器用于内存对齐的基本单位大小通常取决于平台或编译器的设置。VS 的默认对齐数是8个字节。(x86和x64都相等) Linux中 gcc 没有默认对齐数对齐数就是成员自身的大小。2(成员)对齐数它是默认对齐数 与 该成员变量大小的较小值。即“ 对齐数 min{ 默认对齐数成员变量的数据类型大小 } ”3最大对齐数结构体中每个成员变量都有⼀个对齐数它是所有对齐数中最大的。即“ 最大对齐数 max{对齐数1对齐数2…… 对齐数k } ”我们可以使用#pragma 这个预处理指令修改编译器的默认对齐数。#pragma pack(4)把默认对齐数修改为4.#pragma pack()恢复默认对齐数结构体的内存对齐规则1结构体的第一个成员对齐到和结构体变量起始位置偏移量(序数)为0的地址处。2其他成员变量要对齐到偏移量(序数)为对应(成员)对齐数的整数倍的地址处。【每个成员的对齐数由自身数据类型 与 默认对齐数比较得来】3结构体总大小为最大对齐数的整数倍字节数(个数)。4如果嵌套了别的结构体嵌套的结构体对齐到自己的最大对齐数的整数倍处(序数)结构体的整体大小就是所有最大对齐数含嵌套结构体中成员的对齐数的整数倍(个数)。联合体的内存对齐规则1联合的大小至少是最大成员的大小。2当最大成员大小不是最大对齐数的整数倍的时候就要对齐到最大对齐数的整数倍。struct S1 { char c1; int i; char c2; }; struct S2 { char c1; char c2; int i; };这两个结构体虽然数据成员完全相同但是实际的存储空间却不一样s1所占内大小是12字节s2所占内存大小是8字节。s1c1一字节对齐到结构体内存地址偏移量0处i四字节对齐到4处前面填充三个字节这时候大小是8字节再加上c2就变成了9字节因为必须是最大对齐数的整数倍所以必须调整为4的倍数此时就变成了12字节。s2相当于是把c2放在了c1后面填充的3个字节中所以大小是8个字节。所以我们在创建结构体的时候最好把数据放在一起且把内存较大的数据放在尾部。5.大小端字节序对于32位4字节的整数可以使用8个十六进制数表示为0x123456784个二进制数表示1个十六进制数。而1字节8bit可以使用2个十六进制数表示那么这个整数的每一个字节可以表示为0x120x340x560x78。对于大小大于1字节的数据在存储时就会产生数据字节的排列顺序问题。按照不同的数据存储顺序可以分为大端字节序储存和小端字节序储存大端储存模式指的是数据的低位字节内容保存在内存的高地址处。高位低地址数据0x12 0x34 0x56 0x78地址0x100 0x101 0x102 0x103小端储存模式指的是数据的低位字节内容保存在内存的低地址处。高位高地址数据0x78 0x56 0x34 0x12地址0x100 0x101 0x102 0x103visual studio2022采用的是小端字节序储存。以下这个程序可以判断编译器的大小端字节序int check_sys() { int i 1; return (*(char*)i); } int main() { int ret check_sys(); if (ret 1) printf(小端\n); else printf(大端\n); return 0; }对i取地址然后转换为char*类型的指针步长为1字节int*型指针步长为4字节这时候就可以通过*(char*)i解引用获取i中存储的第一个字节的值如果是大端存储的值应该是0如果是小端存储的值应该是1。int main() { int arr[4] { 1, 2, 3, 4 }; int* ptr1 (int*)(arr 1); int* ptr2 (int*)((int)arr 1); printf(%x\n%x, ptr1[-1], *ptr2); return 0; } //输出4 2000000数组在内存中的存储结构是这样的01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00arr将数组转换为数组指针步长为一整个数组16字节所以对应的1操作会直接跳到下一个数组的头部即该数组末尾的下一个字节。所以ptr[-1]取到的是数组的末尾元素。(int)arr将数组的头部地址转换为int型此时1相当于前进一个字节所以*ptr2取出来的数据是00 00 00 02这在小端的字节序下相当于是02 00 00 00被解释为2000000。6.reinterpret_caststatic_castdynamic_caststatic_cast在编译时进行类型检查而dynamic_cast在运行时进行类型检查因为dynamic_cast在运行时需要维护类型信息依赖于虚函数表会带来额外开销而static_cast在编译时进行不会带来额外开销。reinterpret_cast不进行类型检查直接对二进制表示进行重新解释需要使用者自己保证类型安全。1static_caststatic_cast可以用于基本类型的转换安全的向上转型。//基本类型的转换 double d 3.14; int i static_castint(d); // 截断小数部分结果为3 //继承体系中的向上转型 class Base {}; class Derived : public Base {}; Derived d; Base* b static_castBase*(d); // 安全但是在向下转型时是有限制的因为只在编译时进行类型检查与转换在运行时并不维护虚函数表不会验证基类指针实际所指向的类型所以在向下转型时会有安全问题产生。class Base {}; class Derived1 : public Base {}; class Derived2 : public Base {}; Base* b new Derived1(); Derived2* d2 static_castDerived2*(b); // 逻辑错误b实际指向Derived1对象 d2-someDerived2Method(); // 未定义行为可能崩溃或数据损坏或者是一个派生类继承多个基类的情况class Base1 {}; class Base2 {}; class Derived : public Base1, public Base2 {}; Base1* b1 new Derived(); Derived* d static_castDerived*(b1); // 正确直接转换 // 基类必须指向派生类才可进行类型转换 Base1* wrong new Base1(); Derived* d static_castDerived*(wrong); // 未定义行为如果static_cast转换失败那么会指向一块无效的内存空间而不是像dynamic_cast一样返回nullptr。2dynamic_cast除非能够确保类型向下转换一定不会出错否则要使用dynamic_cast保证类型安全Base* b new Derived(); Derived* d1 static_castDerived*(b); // 开发者需确保b实际指向Derived对象 Derived* d2 dynamic_castDerived*(b); // 安全自动检查类型失败返回nullptr前面说到dynamic_cast在运行时需要通过类型信息来验证类型转换的合法性而类型信息的获取又依赖于虚函数表在一个类中至少有一个虚函数才会产生虚指针和虚函数表所以如果要使用dynamic_cast来进行向下转型基类至少得具有一个虚函数。dynamic_cast可以用于多继承情况下两个基类之间的类型转换class Base1 {}; class Base2 {}; class Derived : public Base1, public Base2 {}; Base1* b1 new Derived(); Base2* b2 dynamic_castBase2*(b1); // 成功将前面代码中的static_cast替换为dynamic_cast编译通过但是此时d指向的是nullptr 。Base1* wrong new Base1(); Derived* d dynamic_castDerived*(wrong);3reinterpret_cast不进行任何类型检查或编译期验证需要操作者自己保证内存布局兼容类型大小要相同内存对齐方式要相同使用场景底层系统编程操作硬件寄存器、内存映射I/O时需将整数地址转为指针。序列化/反序列化将对象指针转为整数以便网络传输接收端再转回指针。实现自定义内存池在裸内存块上构造对象时需将内存块地址转为对象指针。因为使用reinterpret_cast并不安全仅推荐在操作二进制数或无关类型时使用。int* intPtr new int(0x12345678); char* charPtr reinterpret_castchar*(intPtr); // 按字节访问内存 int* ptr new int; uintptr_t addr reinterpret_castuintptr_t(ptr); // 指针转整数 int* newPtr reinterpret_castint*(addr); // 整数转指针 void* buffer malloc(1024); int* intBuffer reinterpret_castint*(buffer); // 强制类型转换7.union联合体/共用体联合体中的所有成员公用同一块内存空间且给联合体中的一个成员赋值其他成员的值也会跟着改变。可以认为当我们给union中的一个成员赋值时其余的成员都处于未定义状态。有了联合体就可以更加方便的判断大小端union Un { char c; int i; }; Un u; u.i 1; if (u.c 1) { cout 小端 endl; }因为char型只占一个字节所以只能访问到i的第一个字节的数据。8.内存池1per-class allocator1class Screen { public: Screen(int x) :data(x){} int getData(){return data;} static void* operator new(size_t size); //这里的size是Screen的大小 static void operator delete(void* p); private: int data; Screen* next; static Screen* freeStore; static const int screenChunk; }; Screen* Screen::freeStore nullptr; const int Screen::screenChunk 24; //分配24块内存空间可以存放24个Screen对象void* Screen::operator new(size_t size)//这里的size是Screen的内存大小 { Screen* p; if (!freeStore)//当内存池为空时触发内存分配 { size_t chunk screenChunk * size;//分配24块大小为sizeof(Screen)的内存块 freeStore p reinterpret_castScreen*(new char[chunk]);//分配原始内存 for (; p! freeStore[screenChunk - 1]; p)//遍历到倒数第二个元素 { p-next p 1;//当前节点的next置为下一个节点 } p-next nullptr;//最后一个元素特殊处理指向空指针 } p freeStore;//从链表头部取出内存块 freeStore freeStore-next;//将链表指向下一个空闲内存块 return p;//返回取出的内存块 }这里使用new char分配一块未初始化的原始内存空间因为char是C中最小的地址单位占用的大小是1字节相较于直接new Screen[screenChunk]避免了构造函数的调用因为我们要管理的是原始的内存空间而且使用char得到的内存会自动对齐满足Screen的对齐要求。使用reinterpret_cast将char*类型的内存重新解释为Screen*类型的内存这时会将分配的得到的内存切割成sizeof(Screen)的大小p和freeStore的步长就变成了sizeof(Screen)每次1都会移动到下一个Screen元素。使用reinterpret_cast是因为char是和Screen完全无关的类型只能通过reinterpret_cast来转换。void Screen::operator delete(void* p){ (static_castScreen*(p))-next freeStore; freeStore static_castScreen*(p); }这里的operator delete没有释放new出来的内存而是将分配出去的内存块重新指向了单向链表用于维护内存块的头部然后再将这个内存块置为单向链表的头指针完成内存块的回收这里并不算是内存泄露。内存池的理念就是不立即释放内存给系统而是将回收的内存块存在内存池中。后续分配内存会优先从池中复用已经释放的内存这样就大大减少了调用malloc和free的次数malloc的效率很快可以忽略不计最主要的是大大减少了cookie的内存开销只有一开始分配的一大块内存空间携带了cookie被切割的内存块不携带cookie。为什么要回收的时候是插入链表头部因为这里的单项链表不是环状的如果要插入尾部需要重新遍历整个链表所以这里在链表的头部插入。2per-class allocator2class plane { private: struct planeRep { unsigned long miles; char type; }; union { planeRep rep; plane* next; }; public: static void* operator new(size_t size); static void operator delete(void*,size_t size); static const int BLOCK_NUMBER; static plane* headOfFreeList; }; const int plane::BLOCK_NUMBER 512; plane* plane::headOfFreeList nullptr;这里使用union优化了内存开销plane的数据部分和next指针共用一个内存空间不必再存储一个额外的4字节指针。这种操作被称为嵌入式指针。在空闲内存空间状态下内存空间被解释为next指针当我们在外部使用new plane创建一个plane对象时会调用构造函数给planeRep赋值这样内存空间就被解释为了planeRep。void* plane::operator new(size_t size) { if (size ! sizeof(plane)) { return ::operator new(size); } plane* p headOfFreeList; if (p) { headOfFreeList headOfFreeList-next; }else { plane* newBlock static_castplane*(::operator new(BLOCK_NUMBER * sizeof(plane))); for (int i 0; i BLOCK_NUMBER - 1; i) { newBlock[i].next newBlock[i 1]; } newBlock[BLOCK_NUMBER - 1].next nullptr; p newBlock; headOfFreeList newBlock[1]; } return p; }if (size ! sizeof(plane))如果有一个派生类继承了 plane 且增加了新成员导致 sizeof(Derived) sizeof(plane)如果没有这行检查派生类会错误地从 plane 的内存池中取出一块太小的内存导致内存溢出。此时应回退到全局的 ::operator new。实际上上面的per-class allocator1也应该实现这个判断否则如果继承自Screen的子类没有实现new的重载就会调用父类Screen的new造成内存池越界。这里使用::operator new分配原始内存返回void*然后使用static_cast进行类型转换和Screen类使用new char分配原始内存reinterpret_cast进行类型转换本质上是一样的。不过在这里使用static_cast能够保证类型安全。void*是无类型指针可以指向任意数据类型的内存地址但不保存类型信息。允许使用static_cast将void*转换为任意类型的指针。void plane::operator delete(void* p,size_t size){ if (p nullptr) return; if (size ! sizeof(plane)) { ::operator delete(p); return; } plane* carcass static_castplane*(p); static_castplane*(p)-next headOfFreeList; headOfFreeList carcass; }在回收的时候next会被重新赋值内存空间再次被定义为next指针释放回内存空间。3static allocator3allocator实际上就是将上述的operator new和operator delete封装成一个类持有管理内存的链表以便于复用其他类在需要进行内存管理时只需要持有一个allocator并非持有static的然后将内存管理的操作全权交给allcator即可只需要调用allocator中的函数allcoate()和deallocate()。4macro for static allocator4#define DECLARE_POOL_ALLOC()\ public:\ void* operator new(size_t size){return myAlloc.allocate();}\ void operator delete(void *p){return myAlloc.deallocate();}\ protected:\ static allocator myAlloc; #define IMPLEMENT_POOL_ALLOC(class_name)\ allocator class_name::myAlloc;class Foo{ DECLARE_POOL_ALLOC() public: int data; } IMPLEMENT_POOL_ALLOC(Foo)9.关于内存中的上下cookie使用上下两个cookie使得内存更加容易合并当需要回收一块内存时从这块内存的上cookie在往上4个字节就可以直到上一个区块的大小以及是否空闲最后一位为0代表空闲若空闲就可以合并。同理从下cookie往下4字节就可以知道下一个区块的信息。10.CPP的内存模型由低地址到高地址一共分为5个区域代码区存放着编译好的二进制机器码只读且共享。只读字段/常量区存放着常量包括const且能在编译时就确定的变量同样只读虚表也在这静态/全局变量区分为两个部分已初始化和未初始化。未初始化部分只记录的对应的大小在程序运行时操作系统会清空这部分内存算是一个优化。堆区代码中自己new/malloc出来的变量都存放在这里且堆的内存空间很大必须通过delete/free来手动管理内存否则会发生内存泄露。内存由低地址向高地址生长。内存分布不固定如果频繁进行malloc和free会产生内存碎片。栈区存放局部变量函数参数函数地址等。栈区的内存空间很小如果局部变量过大或者递归过深会发生栈溢出是由编译器自动分配和释放的。内存地址由高地址向低地址生长且内存空间连续。底层由寄存器支持而堆区需要通过操作系统内存块去寻找空闲内存块效率更高。