C++ 内存分区
栈:存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放。
堆:动态申请的内存空间,就是由 malloc 分配的内存块,由程序员控制它的分配和释放,如果程序执行结束还没有释放,操作系统会自动回收。
全局区/静态存储区(.bss 段和 .data 段):存放全局变量和静态变量,程序运行结束操作系统自动释放,在 C 语言中,未初始化的放在 .bss 段中,初始化的放在 .data 段中,C++ 中不再区分了。
常量存储区(.data 段):存放的是常量,不允许修改,程序运行结束自动释放。
代码区(.text 段):存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。
从操作系统的本身来讲,以上存储区在内存中的分布是如下形式(从低地址到高地址):.text 段 --> .data 段 --> .bss 段 --> 堆 --> unused --> 栈 --> env
// 程序示例
#include <iostream>
using namespace std;
/*
说明:C++ 中不再区分初始化和未初始化的全局变量、静态变量的存储区,如果非要区分下述程序标注在了括号中
*/
int g_var = 0; // g_var 在全局区(.data 段)
char *gp_var; // gp_var 在全局区(.bss 段)
int main()
{
int var; // var 在栈区
char *p_var; // p_var 在栈区
char arr[] = "abc"; // arr 为数组变量,存储在栈区;"abc"为字符串常量,存储在常量区
char *p_var1 = "123456"; // p_var1 在栈区;"123456"为字符串常量,存储在常量区
static int s_var = 0; // s_var 为静态变量,存在静态存储区(.data 段)
p_var = (char *)malloc(10); // 分配得来的 10 个字节的区域在堆区
free(p_var);
return 0;
}
栈和堆的区别
1. 申请方式:栈是系统自动分配的内存,堆由程序员控制。
2. 申请后系统响应:分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出。分配堆空间,堆在内存中的呈现方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中,该块空间的首地址存放的是本块空间的大小,便于释放,将剩余的内存空间接回链表。
3. 栈在内存中是连续的一块空间(向低地址存储),最大容量是系统预定好的;而堆在内存中的空间是不连续的(向高地址扩展)。
4. 申请效率:栈是系统自动分配的,效率高,但程序员无法控制;堆由程序员分配,一般速度比较慢,使用起来方便但容易产生碎片。
5. 存储内容:栈存放的是局部变量,函数参数,返回地址和返回数据;堆存放的的内容由程序员控制。
内存泄漏
内存泄漏:由于疏忽或错误导致的程序未能释放已经不再使用的内存。
并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。
防止内存泄漏的方法:
- 内部封装:将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存。
#include <iostream>
#include <cstring>
using namespace std;
class A
{
private:
char *p;
unsigned int p_size;
public:
A(unsigned int n = 1) // 构造函数中分配内存空间
{
p = new char[n];
p_size = n;
};
~A() // 析构函数中释放内存空间
{
if (p != NULL)
{
delete[] p; // 删除字符数组
p = NULL; // 防止出现野指针
}
};
char *GetPointer()
{
return p;
};
};
void fun()
{
A ex(100);
char *p = ex.GetPointer();
strcpy(p, "Test");
cout << p << endl;
}
int main()
{
fun();
return 0;
}
说明:但这样做并不是最佳的做法,在类的对象复制时,程序会出现同一块内存空间释放两次的情况,请看如下程序:
void fun1()
{
A ex(100);
A ex1 = ex;
char *p = ex.GetPointer();
strcpy(p, "Test");
cout << p << endl;
}
简单解释:对于 fun1 这个函数中定义的两个类的对象而言,在离开该函数的作用域时,会两次调用析构函数来释放空间,但是这两个对象指向的是同一块内存空间,所以导致同一块内存空间被释放两次,可以通过增加计数机制来避免这种情况,看如下程序:
#include <iostream>
#include <cstring>
using namespace std;
class A
{
private:
char *p;
unsigned int p_size;
int *p_count; // 计数变量
public:
A(unsigned int n = 1) // 在构造函数中申请内存
{
p = new char[n];
p_size = n;
p_count = new int;
*p_count = 1;
cout << "count is : " << *p_count << endl;
};
A(const A &temp)
{
p = temp.p;
p_size = temp.p_size;
p_count = temp.p_count;
(*p_count)++; // 复制时,计数变量 +1
cout << "count is : " << *p_count << endl;
}
~A()
{
(*p_count)--; // 析构时,计数变量 -1
cout << "count is : " << *p_count << endl;
if (*p_count == 0) // 只有当计数变量为 0 的时候才会释放该块内存空间
{
cout << "buf is deleted" << endl;
if (p != NULL)
{
delete[] p; // 删除字符数组
p = NULL; // 防止出现野指针
if (p_count != NULL)
{
delete p_count;
p_count = NULL;
}
}
}
};
char *GetPointer()
{
return p;
};
};
void fun()
{
A ex(100);
char *p = ex.GetPointer();
strcpy(p, "Test");
cout << p << endl;
A ex1 = ex; // 此时计数变量会 +1
cout << "ex1.p = " << ex1.GetPointer() << endl;
}
int main()
{
fun();
return 0;
}
程序运行结果:
count is : 1
Test
count is : 2
ex1.p = Test
count is : 1
count is : 0
buf is deleted
- 智能指针:是 C++ 中已经对内存泄漏封装好了一个工具,可以直接拿来使用。
内存泄漏检测工具的实现原理:
内存检测工具有很多,这里重点介绍下 valgrind 。
valgrind 是一套 Linux 下,开放源代码(GPL V2)的仿真调试工具的集合,包括以下工具:
Memcheck
:内存检查器(valgrind 应用最广泛的工具),能够发现开发中绝大多数内存错误的使用情况,比如:使用未初始化的内存,使用已经释放了的内存,内存访问越界等。Callgrind
:检查程序中函数调用过程中出现的问题。Cachegrind
:检查程序中缓存使用出现的问题。Helgrind
:检查多线程程序中出现的竞争问题。Massif
:检查程序中堆栈使用中出现的问题。Extension
:可以利用 core 提供的功能,自己编写特定的内存调试工具。
Memcheck 能够检测出内存问题,关键在于其建立了两个全局表:
- Valid-Value 表:对于进程的整个地址空间中的每一个字节(byte),都有与之对应的 8 个 bits ;对于 CPU 的每个寄存器,也有一个与之对应的 bit 向量。这些 bits 负责记录该字节或者寄存器值是否具有有效的、已初始化的值。
- Valid-Address 表:对于进程整个地址空间中的每一个字节(byte),还有与之对应的 1 个 bit,负责记录该地址是否能够被读写。
检测原理:
- 当要读写内存中某个字节时,首先检查这个字节对应的 Valid-Address 表中对应的 bit。如果该 bit 显示该位置是无效位置,Memcheck 则报告读写错误。
- 内核(core)类似于一个虚拟的 CPU 环境,这样当内存中的某个字节被加载到真实的 CPU 中时,该字节在 Valid-Value 表对应的 bits 也被加载到虚拟的 CPU 环境中。一旦寄存器中的值,被用来产生内存地址,或者该值能够影响程序输出,则 Memcheck 会检查 Valid-Value 表对应的 bits,如果该值尚未初始化,则会报告使用未初始化内存错误。