CPP 内存管理

C++ 类内存模型

C++类的内存模型涉及多个方面,包括对象的布局、成员的存储、继承、多态等。为了更好地理解C++类的内存模型,我们将从以下几个关键点进行探讨:

1. 对象的布局

一个C++类的对象通常由以下几个部分组成:

  • 数据成员:存储类的成员变量。这些数据成员按照它们在类定义中的顺序排列,如果有继承发生,基类的成员通常存储在派生类成员之前。
  • 虚函数表指针(vptr):如果类包含虚函数,编译器会为类创建一个虚函数表(vtable),对象中会包含一个指向这个虚函数表的指针。
  • 其他内部数据:例如,用于实现特定于编译器的数据,如类信息、类型信息等。

2. 成员的存储

  • 成员变量:类的成员变量按照它们在类定义中的顺序存储在对象内存中。非静态成员变量存储在对象的内存布局中,而静态成员变量存储在程序的数据段中,不占用对象的内存。
  • 对齐:数据成员的存储通常会遵循一定的对齐规则,以确保对象的大小是成员最大对齐要求的倍数。这有助于提高内存访问效率。

3. 继承

  • 基类子对象:在派生类对象的内存布局中,基类的成员首先被存储,然后是派生类自己的成员。这种存储方式称为“对象切片”(object slicing)。
  • 虚继承:如果存在多重继承,且多个派生类共享同一个基类,C++使用虚继承来避免产生多个基类子对象的副本。在这种情况下,基类的成员只存储一次,并通过虚继承指针访问。

4. 多态

  • 虚函数表:包含虚函数的类会有一个虚函数表,该表存储了类中所有虚函数的地址。对象中包含一个指向这个虚函数表的指针,这允许在运行时确定调用哪个函数。
  • 动态绑定:通过基类的指针或引用调用派生类对象的虚函数时,编译器会通过虚函数表来动态查找并调用正确的函数版本。

5. 构造和析构

  • 构造函数:创建对象时,构造函数按照声明顺序被调用,基类构造函数先于派生类构造函数执行。
  • 析构函数:销毁对象时,析构函数按照继承的逆序执行,派生类析构函数先于基类析构函数执行。

6. 内存对齐和填充

  1. 现代计算机中内存空间都是按照 字节(byte)划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数 k(通常它为4或8)的倍数,这就是所谓的内存对齐;
  2. 内存对齐的原因:
    1. 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的。某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常;
    2. 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问,而对齐的内存访问仅需要一次访问;
  3. 内存对齐的规则:
    1. 每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。可以通过预编译命令 #pragma pack(n),n = 1,2,4,8,16 来改变这一系数;
    2. 有效对其值:是给定值 #pragma pack(n) 和结构体中最长数据类型长度中较小的那个,有效对齐值也叫对齐单位;
    3. 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节;
    4. 结构体的总大小为有效对齐值的整数倍,如有需要编译器会在最末一个成员之后加上填充字节;

7. 内存大小

对象的内存大小是由其数据成员的大小、内存对齐要求以及可能的填充字节共同决定的。

通过理解C++类的内存模型,程序员可以更好地管理内存、优化对象布局,并编写出更高效的代码。然而,具体的内存布局可能会因编译器的不同而有所差异,因此在进行跨编译器或跨平台编程时,需要特别注意这些差异。

c++ 程序的内存模型

C++ 的内存分区主要有:代码区、未初始化数据区(BSS)、已初始化数据区(DATA)、栈区(Stack)、堆区(Heap)

1. 代码区

加载的是可执行文件代码段,所有的可执行代码都加载到代码区,这块内存是不可以在运行期间修改的;

2. 未初始化数据区

加载的是可执行文件 BSS 段,位置可以分开也可以紧靠数据段,存储于数据段的数据(全局未初始化,静态未初始化数据)的生存周期为整个程序运行过程;

3. 已初始化数据区(全局初始化数据区/静态数据区)

加载的是可执行文件数据段,存储于数据段(全局初始化,静态初始化数据,文字常量(只读))的数据的生存周期为整个程序运行过程;

4. 栈区

栈是一种先进后出的内存结构,由编译器自动分配释放,存放函数的参数值、返回值、局部变量等。在程序运行过程中实时加载和释放,因此,局部变量的生存周期为申请到释放该段栈空间;

5. 堆区

堆是一个大容器,它的容量要远远大于栈,但没有栈那样先进后出的顺序,用于动态内存分配。堆在内存中位于BSS区和栈区之间。一般由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收。

什么是内存泄露,如何检测

  1. 内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果;
  2. 避免内存泄露的方法主要就是要有良好的编码习惯,动态开辟内存空间,及时释放内存。也可以采用智能指针来避免内存泄露;
  3. 可以采用静态分析技术、源代码插装技术等进行检测。常见的一些检测工作有:LCLink、ccmalloc、Dmalloc、Electric Fence、Leaky、LeakTracer、MEMWATCH、Valgrind、KCachegrind等等。

什么是野指针,怎么产生的,如何避免

  1. 野指针是指指向的位置是随机的、不可知的、不正确的;
  2. 野指针产生的原因:
    1. 指针变量未初始化或者随便赋值:指针变量没有初始化,其值是随机的,也就是指针变量指向的是不确定的内存,如果对它解除引用,结果是不可知的;
    2. 指针释放后未置空:有时候指针在释放后没有复制为 nullptr,虽然指针变量指向的内存被释放掉了,但是指针变量中的值还在,这时指针变量就是指向一个未知的内存,如果对它解除引用,结果是不可知的;
    3. 指针操作超出了变量的作用域:函数中返回了局部变量的地址或者引用,因为局部变量出了作用域就释放了,这时候返回的地址指向的内存也是未知的。
  3. 如何避免野指针:
    1. 指针变量一定要初始化,可以初始化为 nullptr,因为 nullptr 明确表示空指针,对 nullptr 操作也不会有问题;
    2. 释放后置为 nullptr。

new 的实现原理,new 和 malloc 的区别

  1. new 的实现原理:
    1. 简单类型,则直接调用 operator new(),在 operator new() 函数中会调用 malloc() 函数,如果调用 malloc() 失败会调用 _callnewh(),如果_callnewh() 返回 0 则抛出 bac_alloc 异常,返回非零则继续分配内存;
    2. 复杂类型,先调用 operator new()函数,然后在分配的内存上调用构造函数;
  2. new 和 malloc 的区别:
    1. new 是操作符,而 malloc 是函数;
    2. 使用 new 操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算,而 malloc 则需要显式地指出所需内存的尺寸;
    3. new 分配失败的时候会直接抛出异常,malloc 分配失败会返回 NULL;
    4. 对于非简单类型,new 在分配内存后,会调用构造函数,而 malloc 不会;
    5. new 分配成功后会返回对应类型的指针,而 malloc 分配成功后会返回 void * 类型;
    6. malloc 可以分配任意字节,new 只能分配实例所占内存的整数倍数大小;
    7. new 可以被重载,而 malloc 不能被重载;
    8. new 操作符从自由存储区上分配内存空间,而 malloc 从堆上动态分配内存;
    9. 使用 malloc 分配的内存后,如果在使用过程中发现内存不足,可以使用 realloc 函数进行内存重新分配实现内存的扩充,new 没有这样直观的配套设施来扩充内存;

delete 和 free 的区别

  1. delete 是操作符,而 free 是函数;
  2. delete 用于释放 new 分配的空间,free 有用释放 malloc 分配的空间;
  3. free 会不会调用对象的析构函数,而 delete 会调用对象的析构函数;
  4. 调用 free 之前需要检查要释放的指针是否为 NULL,使用 delete 释放内存则不需要检查指针是否为 NULL;

C++ 中智能指针和指针的区别是什么?

智能指针

如果在程序中使用 new 从堆(自由存储区)分配内存,等到不需要时,应使用 delete 将其释放。C++ 引入了智能指针 auto_ptr,以帮助自动完成这个过程。随后的编程体验(尤其是使用STL)表明,需要有更精致的机制。基于程序员的编程体验和 BOOST 库提供的解决方案,C++11 摒弃了 auto_ptr,并新增了三种智能指针:unique_ptr、shared_ptr 和 weak_ptr。所有新增的智能指针都能与 STL 容器和移动语义协同工作;

指针

C 语言规定所有变量在使用前必须先定义,指定其类型,并按此分配内存单元。指针变量不同于整型变量和其他类型的变量,它是专门用来存放地址的,所以必须将它定义为“指针类型”;

区别

在于智能指针实际上是对普通指针加了一层封装机制,区别是它负责自动释放所指的对象,这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期。指针是一种数据类型,用于保存内存地址,而智能指针是类模板。

shared_ptr和weak_ptr

1. shared_ptr 引用计数何时增加和减少

  1. 增加:初始化、拷贝构造/赋值;
  2. 减少:离开作用域;

2. shared_ptr线程安全?

shared_ptr 智能指针的引用计数在手段上使用了 atomic 原子操作,只要 shared_ptr 在拷贝或赋值时增加引用,析构时减少引用就可以了。首先原子是线程安全的,所有 shared_ptr 智能指针在多线程下引用计数也是安全的,也就是说 shared_ptr 智能指针在多线程下传递使用时引用计数是不会有线程安全问题的。 但是指向对象的指针不是线程安全的,使用 shared_ptr 智能指针访问资源不是线程安全的,需要手动加锁解锁。智能指针的拷贝也不是线程安全的。

3. 智能指针有没有内存泄露的情况

智能指针有内存泄露的情况:当两个类对象中各自有一个 shared_ptr 指向对方时,会造成循环引用,使引用计数失效,从而导致内存泄露。 为了解决循环引用导致的内存泄漏,引入了弱指针weak_ptr,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问。

4. weak_ptr 如何解决 shared_ptr 的循环引用问题?

weak_ptr是为了配合 shared_ptr而引入的一种智能指针,它指向一个由 shared_ptr 管理的对象而不影响所指对象的生命周期,也就是将一个 weak_ptr绑定到一个 shared_ptr不会改变 shared_ptr的引用计数,依此特性可以解决 shared_ptr的循环引用问题。 weak_ptr没有解引用 * 和获取指针 -> 运算符,它只能通过 lock成员函数去获取对应的 shared_ptr智能指针对象,从而获取对应的地址和内容。 不论是否有 weak_ptr指向,一旦最后一个指向对象的 shared_ptr被销毁,对象就会被释放。