C/C++内存管理详解

x33g5p2x  于2021-10-25 转载在 C/C++  
字(8.9k)|赞(0)|评价(0)|浏览(687)

内存的静态分配和动态分配的区别主要是 两个

一是时间不同。静态分配发生在程序编译和连接的时候。动态分配则发生在程序调入和执行的时候。

二是空间不同。堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由函数malloc进行分配。不过栈的动态分配和堆不同,他的动态分配是由编译器进行释放,无需我们手工实现

C/C++内存分布

栈区:就是那些由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。函数调用建立栈帧,参数、函数中局部变量都存在栈帧中,栈是向下增长的,向下增长的意思是:从栈申请的内存地址会越来越小,在栈区当中是先使用高地址再使用低地址:

void f()
{
    int b = 0;
    cout << &b << endl;
}
int main( )
{
    int a = 0;
    cout << &a << endl;
    f();
    return 0;
}

可以看到先使用高地址再使用低地址。

堆区

一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。理论上而言,后malloc的内存地址比先malloc的要大,但是也不一定,但是不一定,因为有可能下一次申请的是之前其他空间释放回来的。

数据段(全局数据,静态数据)

全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的(DATA段)和未初始化的(BSS段),在C++里面没有这个区分了,它们共同占用同一块内存区。

代码段

可执行代码其实就是二进制指令,这里是存放二进制代码的。通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序。 在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。

内核空间

是给操作系统用的

这几个区域划分的特点:

1、这几个区域中堆是很大的,比如有32位-4G空间,内核空间占用1G,而堆差不多就占用了快3G

2、实际上,栈是很小的,Linux下一般只有8M,所以递归调用深度太深会导致栈溢出

3、数据段和代码段也不是很大,因为没有多少数据

下面我们来看一下下面代码的数据是分别存储在哪里的?

int globalVar = 1;//全局变量
static int staticGlobalVar = 1;//静态数据
void Test()
{
    static int staticVar = 1;//静态数据
    int localVar = 1;//栈帧里面
    int num1[10] = {1, 2, 3, 4};//栈
    char char2[] = "abcd";
    char* pChar3 = "abcd";
    int* ptr1 = (int*)malloc(sizeof (int)*4);
    int* ptr2 = (int*)calloc(4, sizeof(int));
    int* ptr3 = (int*)realloc(ptr2, sizeof(int)*4);
    free (ptr1);
    free (ptr3);
}

数据段 数据段 数据段 栈区 栈区 栈区 栈区 栈区 代码段 栈区 堆区

1、选择题
选项: A.栈 B.堆 C.数据段 D.代码段
globalVar在哪里?____ staticGlobalVar在哪里?____
staticVar在哪里?____ localVar在哪里?____
num1 在哪里?____
char2在哪里?____ *char2在哪里?___
pChar3在哪里?____ *pChar3在哪里?____
ptr1在哪里?____ *ptr1在哪里?____

globalVar在哪里?

globalVar是全局变量所以它在数据段

staticGlobalVar在哪里?

staticGlobalVar是静态变量所以它在数据段

staticVar在哪里?

staticVar是静态变量所以它在数据段

localVar在哪里?

localVar创建在test函数栈帧中,它是局部变量,在栈区

num1 在哪里?

num1是数组,在函数栈帧中创建,存储在栈区

char2在哪里?

char2是数组,在函数栈帧中创建,存储在栈区

/*char2在哪里?

**/char2拿到的是字符a的地址,因为char2是数组,里面的元素也是存储在栈帧里面的,所以/char2也在栈区

pchar3在哪里?

pchar3是指针变量,存储在栈区,它指向字符串的首字符地址

/*pchar3在哪里?

/*pchar3是常量字符a,常量字符串是存储在常量区的,就是在代码段

ptr1在哪里?

ptr1是指针变量,他存储在栈区

/*ptr1在哪里?

*ptr1指向的内存空间是malloc出来的,所以/ptr1是在堆区

C语言中动态内存管理方式

void Test ()
{
    int* p1 = (int*) malloc(sizeof(int));
    free(p1);
    // 1.malloc/calloc/realloc的区别是什么?
    int* p2 = (int*)calloc(4, sizeof (int));
    int* p3 = (int*)realloc(p2, sizeof(int)*10);
    // 这里需要free(p2)吗?
    free(p3);
}

calloc等价于malloc+memest(0),开空间+初始化为0

realloc是对malloc或calloc的空间进行扩容

C兼容C,c语言这一套动态内存申请与释放在C中都可以用

C++中动态内存管理

C语言内存管理方式在C中可以继续使用,但有些地方就无能为力而且使用起来比较麻烦,因此C又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理。

注意:

malloc/free是库函数,而new/delete是操作符

void Test()
{
    //C语言 malloc等是库函数
    int* p1 = (int*)malloc(sizeof(int));
    free(p1);
    
    //C++ new/delete是操作符
    // 动态申请一个int类型的空间
    int* ptr4 = new int;
    // 动态申请一个int类型的空间并初始化为10
    int* ptr5 = new int(10);
    delete ptr4;
    delete ptr5;
}

这里new/delete和malloc/free有没有什么区别呢?

如果动态申请的对象是内置类型,用malloc和new没有区别,如果动态申请的对象是自定义类型,有区别,例如:

class A
{
public:
    A(int a=0)
        :_a(a)
    {
        cout<<"A()"<<endl;
    }
    ~A()
    {
        cout<<"~A()"<<endl;
    }
private:
    int _a;
};
int main()
{   
    //C语言自定义类型开辟空间
    A* p3 = (A*)malloc(sizeof(A));//没有初始化
    free(p3);
    
    //C++自定义类型开辟空间
    A* p4 = new A;//调用构造函数初始化
    delete p4;//调用析构函数
    
}

new和delete不仅仅会开空间/释放空间,还会调用构造函数和析构函数

使用malloc没有初始化:

使用new进行了初始化:

这里其实是调用默认的构造函数进行了初始化,并且delete时调用了析构函数:

那我们想给new的对象传参怎么写呢?这样写:

A* p4 = new A(10);//调用构造函数初始化

我们还可以new数组:

int *ptr = new int[10];
delete[] ptr;
A *ptr1 = new A[10];//new了10个对象,调用了10次构造函数
delete[] ptr1;

注意在new数组时,delete时需要加[]

对于内置类型用malloc和new没什么区别,但是自定义类型,用malloc和new有区别,new和delete还会调用构造函数和析构函数,一定要匹配使用,否则可能会崩溃,在C++中,建议使用new和delete,malloc和free能做到的,new和delete都能做到,new和delete能做到的,malloc和free不一定能做到,并且new和delete更方便一些,为什么使用new和delete更方便呢?例如我们在写链表时:

struct ListNode
{
    int _val;
    ListNode* _next;
    
    ListNode(int val):_val(val),_next(nullptr)
    {}
};

用C创建节点并初始化它:

//C
ListNode* n1 = (ListNode*)malloc(sizeof(ListNode));
n1->val = 1;
n1->next = nullptr;

用C++创建节点并初始化它:

//C++
ListNode* n2 = new ListNode(1);//一次性完成了开辟空间和初始化

C++一次性就完成了开辟空间和初始化,所以new和delete更方便。

接下来我们来看一下new和delete到底时怎么样的原理:

operator new与operator delete函数

new和delete是用户进行动态内存申请和释放的操作符,operator new 和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间。

注意:

它们两个只是全局的库函数,它们并不是new和delete的重载

我们在new一个对象T时,其实编译器会这样做:

1、申请内存,调用operator new(底层其实是将malloc的封装实现)

2、调用构造函数

在delete时,编译器会这么做:

1、调用T的析构函数

2、调用operator delete(底层其实是将free的封装实现)

我们之前在使用malloc时,malloc失败会返回NULL:

int main()
{
    //malloc失败,返回NULL
    char* p1 = (char*)malloc((size_t)2*1024*1024*1024);
    if(p1==NULL)
    {
        printf("malloc fail\n");
    }
    else
    {
        printf("malloc success\n");
        
    }
   
    return 0;
}

那么new操作符在new失败时会做什么处理呢?

int main()
{
    char* p2 = new char[0x7fffffff];
    //并没有执行
    if(p2==NULL)
    {
        printf("new fail\n");
    }
    else
    {
        printf("new success\n");
        
    }
    return 0;
}

我们可以看到new失败时,并没有执行if语句就出错了,为什么呢?是因为new和malloc不一样,申请空间失败了,它会抛异常,下面我们用一段捕获异常的代码来说明:

int main()
{
    //new和malloc不一样,申请空间失败了,会抛异常
    try
    {
    	char* p2 = new char[0x7fffffff];//如果出错抛异常,跳到捕获异常的位置,没有捕获会报错 
    }
    catch(const exception& e)
    {
        cout<<e.what()<<endl;
    }
}

operator new其实就是对malloc的封装,如果申请内存失败了,抛异常,封装malloc+抛异常,我们进行调试反汇编可以看到执行new时,他要调用operator new函数和A的构造函数:

有operator new,那么也有operator delete,operator deltete也是对free进行封装。
通过上述两个全局函数的实现知道,operator new 实际也是通过malloc来申请空间,如果malloc申请空间成功就直接返回,否则执行用户提供的空间不足应对措施,如果用户提供该措施就继续申请,否则就抛异常。operator delete 最终是通过free来释放空间的。

我们也可以直接用operator new和operator delete函数

int* p3 = (int*)operator new(sizeof(int)*10);
operator delete(p3);

operator new与operator delete的类专属重载

假设我们写了一个链表的程序,怎么检测一下有没有ListNode的节点没有释放?

我们可以重载一个类ListNode专属的operator new和operator delete成员函数

我们在new一个ListNode时,那么申请空间就会调用专属的operator new,delete时,释放空间调用专属的operator delete,我们可以定义一个静态变量_count来记录,申请时_count++,释放_count–:

struct ListNode
{
	int val;
	struct ListNode* next;

	static int _count;

	ListNode(int x)
		:val(x)
		, next(nullptr)
	{
		cout << "ListNode()" << endl;
	}

	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}

	void* operator new(size_t n)
	{
		++_count;
		return ::operator new(n);
		// 去内存池
	}

	void operator delete(void* p)
	{
		--_count;
		return ::operator delete(p);
		// 释放去内存池
	}
};

int ListNode::_count = 0;
struct ListNode* removeElements(struct ListNode* head, int val) 
{
	struct ListNode* prev = NULL, *cur = head;
	while (cur)
	{
		if (cur->val == val)
		{
			// 1、头删
			// 2、中间删除
			prev->next = cur->next;
			delete cur;
			cur = prev->next;
		}
		else
		{
			// 迭代往后走
			prev = cur;
			cur = cur->next;
		}
	}
	return head;
}

// 检测一下有没有ListNode的节点没有释放
int main()
{
	ListNode* n1 = new ListNode(1);
	ListNode* n2 = new ListNode(2);
	ListNode* n3 = new ListNode(2);
	ListNode* n4 = new ListNode(3);
	ListNode* n5 = new ListNode(4);
	ListNode* n6 = new ListNode(2);
	n1->next = n2;
	n2->next = n3;
	n3->next = n4;
	n4->next = n5;
	n5->next = n6;
	n6->next = nullptr;

	ListNode* list = removeElements(n1, 2);

	cout <<"没有释放节点数量:" <<ListNode::_count << endl;

	return 0;
}

这个在实际中并不常用,了解一下即可。

new和delete的实现原理

内置类型

如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。

自定义类型

  • new的原理
  1. 调用operator new函数申请空间
  2. 在申请的空间上执行构造函数,完成对象的构造
  • delete的原理
  1. 在空间上执行析构函数,完成对象中资源的清理工作
  2. 调用operator delete函数释放对象的空间
  • new T[N]的原理
  1. 调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申
  2. 在申请的空间上执行N次构造函数
  • delete[]的原理
  1. 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
  2. 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间
int main()
{
    Stack st;
    Stack* ps = new Stack;
    delete ps;
    retunr 0;
}

直接创建对象和new对象有什么区别呢?下面我们画图讲解:

定位new表达式(placement-new)

定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象

使用格式:
new (place_address) type或者new (place_address) type(initializer-list)
place_address必须是一个指针,initializer-list是类型的初始化列表
使用场景:
定位new表达式在实际中一般是配合内存池使用。因为内存池分配出的内存没有初始化,所以如果是自定义类型的对象,需要使用new的定义表达式进行显示调构造函数进行初始化。

构造函数调用什么时候自动调用呢?

1、创建对象时

2、new一个对象时

//显式对一块空间调用构造函数初始化
class Test
{
public:
    Test()
    : _data(0)
    {
        cout<<"Test():"<<this<<endl;
    }
    ~Test()
    {
        cout<<"~Test():"<<this<<endl;
    }
private:
	int _data;
};
void Test()
{
    // pt现在指向的只不过是与Test对象相同大小的一段空间,还不能算是一个对象,因为构造函数没有执行
    Test* pt = (Test*)malloc(sizeof(Test));
    new(pt) Test; // 注意:如果Test类的构造函数有参数时,此处需要传参
}

pt现在指向的只不过是与Test对象相同大小的一段空间,还不能算是一个对象,因为构造函数没有执行,显式对一块空间调用构造函数初始化,new(pt) Test;

如果我们复制一份a数组到另外一块空间b数组,怎么复制呢?

也许你想的是这样,通过一个循环来拷贝:

int main()
{
    A a[5];
    //复制一份a数组到另外一块空间b
    A* pb1 = new A[5];
    for(int i =0;i<5;++i)
    {
        b[i]=a[i];
    }
    cout<<endl;
    //代价大 构造+赋值 
    return 0;
}

但是这样代价大,经过了构造+赋值重载,那么能不能直接构造呢?通过定位new表达式就可以直接构造:

int main()
{
    A a[5];
    //能不能直接构造?
    A *pb = (A*)malloc(sizeof(A)*5);
    for(int i =0;i<5;++i)
    {
        new(pb+i)A(a[i]);
    }//只有构造
    return 0;
}

常见面试题

malloc/free和new/delete的区别

1、特点和用法
malloc和free是函数,new和delete是操作符;malloc申请空间时,需要手动计算空间大小并传递,new只需在后面跟上空间的类型即可,如果是多个对象,[]中指定对象个数即可;malloc的返回值是void/*,在使用时需要强转,new不需要,因为new后面跟的是空间的类型

2、底层原理区别

申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数和析构函数,而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理

3、处理错误的方式

malloc申请空间失败时,返回NULL,因为使用时需要判空,new不需要,但是new需要捕获异常

内存泄漏

什么是内存泄漏呢?

在堆上申请了的空间,在我们不用了以后也没有释放,就存在内存泄漏,因为你不用了,也没有还给系统,别人也用不了。俗话说:占着茅坑不拉屎

int main()
{
    char* p = new char[1024*1024*1024];
    return 0;
}

运行结束后:

上面程序存在内存泄漏,一次泄漏1G,但是程序运行结束后,对我们系统也好像没有影响,好像会将我们申请的内存自动还回去,实际上一个进程正常结束后,会把映射的内存都会释放掉,所以上面的程序,我们没有主动释放,但是进程结束也释放,那么内存泄漏好像也没啥事,因为进程正常结束都会释放。

其实并不是,你在看看下面的场景:

1、要是进程没有正常结束呢!僵尸进程,就可能存在一些资源没有释放

2、长期运行的服务器程序。比如王者荣耀后台服务,长期运行,只有维护升级的时候才会停,内存泄漏会导致可用内存越来越少,程序越来越慢,甚至挂掉。事故

3、物联网设备:扫地机器人、冰箱等等,内存很小,也会经不起内存泄漏折腾

在C++中我们需要主动释放内存,而Java不需要主动释放内存,Java后台有垃圾回收器,接管了内存释放

那么我们如何预防内存泄漏呢?

1、智能指针

2、内存泄漏的检测工具

如何申请4G的内存呢?将操作系统换为64位就可以了:

int main()
{
	try
	{
		char* p = new char[0x7fffffff];
		//char* p = new char[0xffffffff];
		printf("%p\n", p);
	}
	catch (const exception& e)
	{
		cout << "内存申请失败" << endl;
	}

	return 0;
}

我们在32位的操作系统上申请,会申请失败:

将他改为64位就能申请成功:

可以看到申请成功了

欢迎大家学习交流!

相关文章