【C++内存管理】总结篇

点我–>C++语言基础
点我–>面向对象
点我–>STL
点我–>新特性

【操作系统】常见问题汇总及解答

【数据库】常见问题汇总及解答

【计算机网络】常见问题汇总及解答

1. 堆和栈的区别

  • 空间分配不同:栈由操作系统自动分配释放,存放函数的参数值、局部变量的值等。堆一般由程序员分配释放。
  • 缓存方式不同:栈使用的是一级缓存,它们通常都是被调用时处于存储空间中,调用完毕立即释放。堆则是存放在二级缓存中,速度要慢些。
  • 数据存储方式不同:在栈中存储的数据是以“后进先出”的方式存储,这也是栈的常见使用方式。而堆中存储的数据是没有特定的存储顺序的,可以随时被程序访问。
  • 生长方向不同:栈是向着内存地址减小的方向增长的,从内存的高地址向低地址方向增长。堆是向着内存地址增加的方向增长的,从内存的低地址向高地址方向增长。
  • 申请大小限制不同: 栈顶和栈底是预设好的,大小固定,通常在几百 KB 到几 MB 范围内。堆是不连续的内存区域,其大小可以灵活调整。

2. C++ 的内存管理

内存分配方式
在C++中,内存分成 5 个区,它们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

  • 栈:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。
  • 堆:就是那些由 new 分配的内存块,一般一个 new 就要对应一个 delete。
  • 自由存储区:就是那些由 malloc 等分配的内存块,和堆是十分相似的,不过是用 free 来结束自己的生命。
  • 全局/静态存储区:全局变量和静态变量被分配到同一块内存中。
  • 常量存储区:这是一块比较特殊的存储区,里面存放的是常量,不允许修改。

常见的内存错误及其对策
常见错误:

  • 内存分配未成功,却使用了它;
  • 内存分配虽然成功,但是尚未初始化就引用它;
  • 内存分配成功并且已经初始化,但操作越过了内存的边界;
  • 忘记释放内存,造成内存泄露;
  • 释放了内存却继续使用它。

对策:

  • 定义指针时,先初始化为 NULL;
  • 用 malloc 或 new 申请内存之后,应该立即检查指针值是否为 NULL,防止使用指针值为 NULL 的内存;
  • 不要忘记为数组和动态内存赋初值,防止将未被初始化的内存作为右值使用;
  • 避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”的操作;
  • 动态内存的申请与释放必须配对,防止内存泄漏;
  • 用 free 或 delete 释放了内存之后,立即将指针设置为 NULL,防止“野指针”;
  • 使用智能指针。

内存泄露及解决办法
内存泄露:简单地说就是申请了一块内存空间,使用完毕后没有释放掉。

  • new 和 malloc 申请资源使用后,没有用 delete 和 free 释放;
  • 子类继承父类时,父类析构函数不是虚函数;
  • Windows句柄资源使用后没有释放。

解决办法:

  • 良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉;
  • 将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表;
  • 使用智能指针;
  • 一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等。

3. 说说 malloc 和局部变量分配在堆还是栈

  • malloc 是在上分配内存,需要程序员自己回收内存。
  • 局部变量是在中分配内存,超过作用域就自动回收。

4. 说说程序有哪些section,各自的作用,程序启动的过程,怎么判断数据分配在栈上还是堆上

在这里插入图片描述
如上图,从低地址到高地址,一个程序由代码段、数据段、BSS段、堆、共享区、栈等组成。

  • 代码段(text segment):存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。
  • 数据段(data segment):存放程序中已初始化的全局变量和静态变量的一块内存区域。
  • BSS段(bss segment):存放未被初始化的全局变量和静态变量,这些变量默认都被初始化为0。
  • 可执行程序在运行时又会多出两个区域:堆区和栈区。
    • 堆区(heap): 动态分配的内存区域,它的大小可以在程序运行时动态地增加或减少(通过malloc()等函数)。
    • 栈区(stack):函数调用和局部变量的内存区域。每次函数调用时,相关参数和本地变量都会分配在栈上。在函数调用结束时,这些变量的内存空间会被释放。是一块连续的空间。
  • 最后还有一个共享区,位于堆和栈之间。

程序启动的过程

  • 加载程序代码:操作系统将可执行文件读入内存中。
  • 初始化:操作系统为程序分配内存并初始化全局变量和静态变量。
  • 执行 main 函数:程序会执行 main 函数中的代码。
  • 退出:程序执行完 main 函数后退出,操作系统回收内存资源。

怎么判断数据分配在栈上还是堆上

  • 判断数据分配在栈上还是堆上的主要方法是作用域和生命周期。数据的作用域指的是变量在程序中的可访问的范围。如果一个变量被定义在一个函数体内部,它就有一个局部作用域,通常在该函数执行结束时,这些变量也会被释放。这些变量的内存空间会分配在栈中。
  • 而如果变量在函数外部定义,它就有一个全局作用域,也就是整个程序中都可以访问。这些变量的内存空间通常在程序启动时就被操作系统分配并初始化,它们会存在于数据段或者BSS段。
  • 局部变量分配在栈上,而通过 malloc 和 new 申请的空间在堆上。

5. 初始化为 0 的全局变量在 bss 还是 data

在 bss 段。bss 段通常是指用来存放程序中未初始化的或初始化为 0 的全局变量和静态变量的一块内存区域。特点是可读写的,在程序执行之前 bss 段会自动清 0。

6. 简述 atomic 内存顺序

原子(atomic)操作是多线程编程中的重要概念,用于在多线程环境下保证数据的一致性和正确性。在原子操作中,一组操作要么全部完成,要么全部未完成。

原子操作的内存顺序是指在多个线程之间,对共享变量进行读取和写入操作时,这些操作的执行顺序。考虑以下代码示例:

int x = 0;
int y = 0;

假设有两个线程A和B,它们执行的代码如下:
线程A:

y = 1;
x = y;

线程B:

if(x == 1){
    
    
    // do something
}

在上面的示例中,如果线程A和线程B同时执行,就可能出现问题。假设线程A先执行,可以看到它先将y赋值为1,接着将x赋值为y,此时x的值为1。随后,线程B执行,它读取到的x的值为1,因此它会执行if语句中的代码,但实际上y的值并未被更新,这显然是不正确的。

要解决这个问题,我们需要使用原子操作。在C++中,可以使用C++11标准引入的 std::atomic 来进行原子操作。而内存顺序就是 std::atomic 提供的一种机制,用于定义在多个线程之间,对共享变量进行读写操作时的执行顺序。常见的内存顺序有以下几种:

  • memory_order_relaxed:松散内存顺序。对于不依赖于其他原子操作的操作使用此顺序,它是最快的内存顺序,因为它不需要强制其他线程的操作等待。
  • memory_order_consume:memory_order_consume只会对其标识的对象保证该对象存储先行于那些需要加载该对象的操作。
  • memory_order_release:释放内存顺序。此顺序确保当前线程之前的所有其他内存操作都发生在该释放操作之前。
  • memory_order_acquire:获取内存顺序。此顺序确保该获取操作之后的所有其他内存操作都发生在当前线程之前。它与release内存顺序一起使用,构成原子锁的内存顺序。
  • memory_order_acq_rel:memory_order_acq_rel在此内存顺序的读-改-写操作既是获得加载又是释放操作。没有操作能够从此操作之后被重排到此操作之前,也没有操作能够从此操作之前被重排到此操作之后。
  • memory_order_seq_cst:顺序一致性内存顺序。此顺序强制执行一致的执行顺序,可以看作是最保守的内存顺序。它会特别消耗性能,但能够提供强有力的保证。

「注意」:在使用原子操作时,需要根据实际需要选择适当的内存顺序,以保证多线程环境下数据的正确性和一致性。除非你为特定的操作指定一个顺序选项,否则内存顺序选项对于所有原子类型默认都是memory_order_seq_cst。

7. C++ 中内存对齐的使用场景

内存对齐应用于三种数据类型中:struct/class/union。struct/class/union 内存对齐原则有四个:

  • 数据成员对齐规则:结构(struct)或联合(union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小的整数倍开始。
  • 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部"最宽基本类型成员"的整数倍地址开始存储。(struct a里存有struct b,b里有char、int、double等元素,那b应该从8的整数倍开始存储)。
  • 收尾工作:结构体的总大小,也就是 sizeof 的结果,必须是其内部最大成员的"最宽基本类型成员"的整数倍。不足的要补齐。(基本类型不包括struct/class/uinon)。
  • sizeof(union),以结构里面size最大元素为union的size,因为在某一时刻,union只有一个成员真正存储于该地址。

答案解析:

  1. 什么是内存对齐

在 C++ 中,内存对齐是指,编译器为了提高程序的访问速度和效率,会按照特定的规则将变量在内存中的地址调整为某个特定的倍数,从而使不同变量在内存中的地址之间存在一定的间隔,使得 CPU 在读取数据时更加高效。
为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的 int 型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。
比如在32位CPU下,假设一个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个地排放,这就是对齐。

  1. 为什么要字节对齐

需要字节对齐的根本原因在于CPU访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的一个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。
而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。

  1. 字节对齐实例
#include <iostream>
using namespace std;

union example1
{
    
    
	int a[5];
	char b;
	double c;
};
/*
如果以最长20字节为准,内部double占8字节,这段内存的地址0x00000020并不是double的整数
倍,只有当最小为0x00000024时可以满足整除double(8Byte)同时又可以容纳int a[5]的大小,
所以正确的结果应该是 sizeof(example1) = 24
*/
struct example2
{
    
    
	int a[5];
	char b;
	double c;
};
/*
如果我们不考虑字节对齐,那么内存地址0x0021不是double(8Byte)的整数倍,所以需要字节对
齐,那么此时满足是double(8Byte)的整数倍的最小整数是0x0024,说明此时char b对齐int扩充
了三个字节。所以最后的结果是 sizeof(example2) = 32
*/
struct example3
{
    
    
	int a;
	char b;
	double c;
};
/*
字节对齐除了内存起始地址要是数据类型的整数倍以外,还要满足一个条件,那就是占用的内存空间大
小需要是结构体中占用最大内存空间的类型的整数倍,所以20不是double(8Byte)的整数倍,我们还
要扩充四个字节,最后的结果是 sizeof(example3) = 16
*/
int main() 
{
    
    
	cout << "sizeof(example1) = " << sizeof(example1) << endl; // 24
	cout << "sizeof(example2) = " << sizeof(example2) << endl; // 32
	cout << "sizeof(example3) = " << sizeof(example3) << endl; // 16

	return 0;
}

8. new/delete 和 malloc/free 之间的关系

C++ 中的 new 和 delete 是操作符,而 malloc 和 free 是函数。它们都可以用于在程序运行时动态分配和释放内存,但有一些重要的区别:

  • new 和 delete 是 C++ 特有的操作符,而 malloc 和 free 是 C 标准库函数,因此在 C++ 代码中使用 new 和 delete 更为方便和直观。
  • new 和 delete 在执行内存分配和释放的同时还会调用对象的构造函数和析构函数,这是因为在 C++ 中,对象的生命周期与内存的生命周期是紧密相关的。而 malloc 和 free 则只是简单地分配和释放内存,不会涉及到对象的构造和析构。
  • new 和 delete 支持重载,可以通过重载运算符 new 和 delete 实现自定义的内存管理行为。而 malloc 和 free 则不支持重载。

「注意」:使用 new 和 delete 需要程序员自行确保内存的正确释放,否则会导致内存泄漏。而使用 malloc 和 free 则需要程序员手动管理分配的内存块的大小和内存地址,难度较高,容易出现错误。因此,在 C++ 中,推荐使用 new 和 delete,尽量避免直接使用 malloc 和 free。

9. 说说内存块太小导致 malloc 和 new 返回空指针,该怎么处理

当使用 malloc 或 new 申请一块内存时,如果要求分配的内存大小超过了可分配的内存或分配失败,它们会返回一个空指针(指向地址0)。这种情况发生的可能原因有很多,其中一种常见的原因是要求分配的内存块大小太小。

对于这种情况,我们可以采取以下几种处理方式:

  • 检查代码中的内存申请大小。如果出现返回空指针的情况,我们需要仔细检查要求分配的内存大小,看看该大小是否太小。如果确实是这个问题,我们需要重新考虑需要的内存大小并修改代码。
  • 检查是否有足够的可用内存。可能出现 malloc 或 new 返回空指针的原因是可用内存已经用完了。我们可以检查系统的内存使用情况来确认这一点,如果是这种情况的话,我们需要减少正在使用的内存或者增加系统可用的内存。
  • 使用更高效的内存申请方式。有时候 malloc 或 new 会因为一些原因而无法分配所需的内存,但是我们可以尝试使用其他的更高效的内存分配方式来避免这种情况。例如,可以尝试使用栈内存或缓存池来分配内存。
  • 错误处理。如果内存申请失败并返回空指针,我们需要对这种情况进行错误处理,例如输出错误信息或者调用一些错误处理函数。同时,我们需要在释放内存时检查指针是否为空以防止程序崩溃。

10. 如何构造一个类,使得只能在堆上或只能在栈上分配内存

在C++中,我们可以使用特定的关键字和语法来控制对象分配在堆上还是栈上。可以通过在类中添加构造函数、析构函数和拷贝构造函数来实现仅允许在堆上或仅允许在栈上分配内存的类。

要构造一个仅能在堆上分配内存的类,可以使用new运算符实现动态内存分配。该类需要禁用默认构造函数,禁止拷贝构造函数,禁止析构函数外部调用。
例如:

class HeapOnlyClass {
    
    
public:
    // 禁用默认构造函数
    HeapOnlyClass() = delete;

    // 禁用拷贝构造函数
    HeapOnlyClass(const HeapOnlyClass&) = delete;

    // 析构函数只能在类内使用
    ~HeapOnlyClass() {
    
    }

    static HeapOnlyClass* create() {
    
    
        return new HeapOnlyClass();
    }
};

这样,只能通过调用HeapOnlyClass::create()方法在堆上分配内存。由于默认构造函数和拷贝构造函数被禁用,无法在栈上声明该类的对象。

要构造一个仅能在栈上分配内存的类,可以使用placement new语法,这样可以将类对象放置在指定位置。该类需要有一个private的operator new, 拷贝构造函数等。
例如:

class StackOnlyClass {
    
    
private:
    // 定义operator new,因为这个类不能再堆上分配内存
    // 申请自定义内存
    void* operator new(size_t size, void* ptr) {
    
    
        return ptr;
    }

public:

    // 禁用默认构造函数
    StackOnlyClass() = delete;

    // 禁用operator new,使该类不能再堆上分配内存
    void* operator new(size_t) = delete;

    // 定义一个public的内存分配函数,用于在栈上分配对象
    static StackOnlyClass create() {
    
    
        void* stack_mem = alloca(sizeof(StackOnlyClass));
        return StackOnlyClass(stack_mem);
    }

    // 定义一个拷贝构造函数,因为该类没有禁用拷贝构造函数,但是不允许在堆上构造新对象
    StackOnlyClass(const StackOnlyClass&) {
    
    }

    // 析构函数
    ~StackOnlyClass() {
    
    }

private:
    // 私有构造函数,只能由create()方法调用
    StackOnlyClass(void* mem) {
    
    }
};

在这个示例中,我们使用了alloca()函数来在栈上分配内存。create()方法返回一个StackOnlyClass类型的对象,该对象使用内存块构造而成。我们还需要禁用默认构造函数和operator new运算符,以确保该类不能在堆上分配内存。

「注意」:使用“仅在堆上分配内存”和“仅在栈上分配内存”的类应该仔细使用,避免潜在的风险和问题。

11. 静态内存分配和动态内存分配的区别

  • 静态内存分配是在编译时期完成的,不占用CPU资源;动态内存分配是在运行时期完成的,分配和释放需要占用CPU资源。
  • 静态内存分配是在栈上分配的;动态内存分配是在堆上分配的。
  • 静态内存分配不需要指针或引用类型的支持;动态内存分配需要。
  • 静态内存分配是按计划分配的,在编译前确定内存块的大小;动态内存分配是按需要分配的。
  • 静态内存分配是把内存的控制权交给了编译器;动态内存分配是把内存的控制权给了程序员。
  • 静态内存分配的运行效率比动态内存分配高,动态内存分配不当可能造成内存泄漏。

12. delete 与 delete[ ] 的区别

  • 对于简单类型来说,使用 new 分配后,不管是数组数组还是非数组形式,两种方式都可以释放内存:
int *a = new int(1);
delete a;
int *b = new int(2);
delete [] b;
int *c = new int[11];
delete c;
int *d = new int[12];
delete [] d;
  • 对于自定义类型来说,就需要对于单个对象使用 delete,对于对象数组使用delete[ ],逐个调用数组中对象的析构函数,从而释放所有内存; 如果反过来使用,即对于单个对象使用 delete[ ],对于对象数组使用 delete,其行为是未定义的。
  • 所以,最恰当的方式就是如果用了 new,就用 delete;如果用了 new[ ],就用 delete[ ]。

13. 在C++中,使用 malloc 申请的内存能否通过 delete 释放,使用 new 申请的内存能否用 free 释放

在C++中,使用 malloc 申请的内存不能通过 delete 释放,使用 new 申请的内存也不能用 free 释放。这是因为 malloc 函数和 new 运算符在分配内存时使用了不同的方式。

  • malloc 是C语言中的内存分配函数,其申请的内存是在堆区而不是栈区,返回的是void*类型的指针,并且需要手动指定分配的字节数。在C++中,可以使用 malloc 函数进行内存分配,但是要使用 free 函数来释放内存。使用 delete 或 delete[ ] 释放 malloc 分配的内存会导致未定义的行为。
  • new 运算符是C++中的内存分配运算符,其申请的内存是通过调用对象的构造函数进行初始化的,并且返回的是对对象的指针。在C++中,可以使用 new 运算符进行内存分配,并使用 delete 或 delete[ ] 来释放分配的内存。使用 free 函数释放 new 运算符分配的内存将导致未定义的行为。

「注意」:建议在C++中使用 new 运算符进行内存分配,并使用 delete 或 delete[ ] 来释放分配的内存,以确保正确的对象生命周期和避免内存泄漏。如果需要在C++代码中使用C语言中的 malloc 和 free 函数,需要注意保证分配和释放的一致性,避免产生未定义的行为。

猜你喜欢

转载自blog.csdn.net/m0_51913750/article/details/130315070