九.内存分区模型

9 内存分区模型

作用:C++ 程序在执行时,将内存大方向划分为 4 个区域,分别是:代码区、全局区、栈区、堆区。==不同区域存放的数据,赋予不同的生命周期==,给我们带来更大的灵活编程空间。

  • 代码区:存放函数体的二进制代码,由操作系统进行管理
  • 全局区:存放全局变量、静态变量以及常量
  • 栈区:由编译器自动分配释放,存放函数的参数值、局部变量等
  • 堆区:由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收

提示:内存分区模型是 C++ 核心编程的地基,后面学习的引用、类和对象等内容,都建立在对内存四区的理解之上,请务必把这一章学扎实。

9.1 程序运行流程

作用:弄清楚程序从源代码到运行,各阶段分别使用了哪些内存区域,是学习内存分区模型的第一步。一个 C++ 程序从编写到运行,大致经过下面的流程:

  • 编写源代码,保存为 .cpp 源文件;
  • 编译器将源代码翻译成机器指令,链接器把目标文件和用到的库链接成可执行程序;
  • 运行可执行程序前:操作系统把程序加载进内存,划分出代码区和全局区;
  • 运行可执行程序后:程序开始执行,又划分出栈区和堆区。

作用:四个区域各司其职,存放的数据和由谁管理都不相同:

  • 代码区:存放 CPU 执行的机器指令,由操作系统进行管理;
  • 全局区:存放全局变量、静态变量、常量,数据在程序结束后由操作系统释放;
  • 栈区:由编译器自动分配释放,存放函数的参数值、局部变量等;
  • 堆区:由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收。

示例:下面的程序把函数、全局变量、局部变量和 new 出来的数据所在的地址全部打印出来,先对四个区域在内存中的位置有个直观印象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
using namespace std;

// 全局变量 —— 存放在全局区
int g_num = 10;

// 函数体经过编译就是一段 CPU 指令,存放在代码区
void show() {
cout << "show 函数正在执行" << endl;
}

int main() {

// 局部变量 —— 存放在栈区
int localNum = 10;

// new 出来的数据 —— 存放在堆区,指针 heapNum 本身存放在栈区
int* heapNum = new int(10);

cout << "show 函数的地址(代码区):" << (void*)show << endl;
cout << "全局变量 g_num 的地址(全局区):" << &g_num << endl;
cout << "局部变量 localNum 的地址(栈区):" << &localNum << endl;
cout << "heapNum 指向的地址(堆区):" << heapNum << endl;

// 堆区的数据用完要手动释放,new 和 delete 的用法后文会详细讲解
delete heapNum;
heapNum = nullptr;

system("pause");

return 0;
}

注意:不同编译器、不同系统下,地址的具体数值和显示格式都不一样,重点观察==哪些地址落在相近的区间==,而不是记住具体数字。

9.2 内存四区

作用:把数据放进合适的区域,本质上是在为数据选择合适的生命周期。下面逐个介绍四个区域里存放什么数据、由谁管理。

9.2.1 代码区

作用:存放 CPU 执行的机器指令。我们写下的每一个函数体,经过编译后都变成一条条二进制指令,存放在代码区。代码区有两个特点:

  • 共享的:对于频繁被执行的程序,只需要在内存中有一份代码即可,比如同时打开同一个程序的两个窗口,运行的数据有两份,但代码在内存中只有一份;
  • 只读的:把代码区设置为只读,是为了防止程序意外地修改了它的指令。

示例:函数名就是函数的入口地址,把函数的地址打印出来,就能观察到代码区在内存中的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

// 函数体经过编译,就是一段 CPU 指令,存放在代码区
void showHello() {
cout << "hello c++" << endl;
}

int main() {

// 函数名就是函数的入口地址,打印出来观察代码区的位置
cout << "showHello 函数的地址:" << (void*)showHello << endl;

system("pause");

return 0;
}

注意:代码区由操作系统管理,程序员不需要、也不能对代码区进行读写操作。

9.2.2 全局区

作用:存放全局变量、静态变量和常量,全局区的数据在程序结束后由操作系统释放。全局区中常见的成员有:

  • 全局变量:定义在函数外部的变量;
  • 静态变量:用 static 关键字修饰的变量;
  • 字符串常量:用双引号括起来的一串字符,例如 "hello world";
  • const 修饰的全局变量。

示例:把局部变量、全局变量、静态变量、常量的地址都打印出来,验证它们到底存放在哪个区域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
using namespace std;

// 全局变量 —— 存放在全局区
int g_a = 10;
int g_b = 10;

// const 修饰的全局变量 —— 也存放在全局区
const int c_g_a = 10;
const int c_g_b = 10;

int main() {

// 局部变量 —— 存放在栈区
int a = 10;
int b = 10;

cout << "局部变量 a 的地址:" << &a << endl;
cout << "局部变量 b 的地址:" << &b << endl;

// 全局变量
cout << "全局变量 g_a 的地址:" << &g_a << endl;
cout << "全局变量 g_b 的地址:" << &g_b << endl;

// 静态变量:在普通变量前加 static 关键字修饰,也存放在全局区
static int s_a = 10;
static int s_b = 10;

cout << "静态变量 s_a 的地址:" << &s_a << endl;
cout << "静态变量 s_b 的地址:" << &s_b << endl;

// 常量
// 字符串常量 —— 存放在全局区
cout << "字符串常量的地址:" << (void*)"hello world" << endl;

// const 修饰的全局变量 —— 存放在全局区
cout << "全局常量 c_g_a 的地址:" << &c_g_a << endl;
cout << "全局常量 c_g_b 的地址:" << &c_g_b << endl;

// const 修饰的局部变量 —— 不在全局区,仍存放在栈区
const int c_l_a = 10;
cout << "局部常量 c_l_a 的地址:" << &c_l_a << endl;

system("pause");

return 0;
}

注意:观察运行结果:局部变量 a、b 和局部常量 c_l_a 的地址与其他地址相差很远,说明它们不在全局区;而==全局变量、静态变量、字符串常量、const 修饰的全局变量==的地址都集中在相近的区间,说明它们都存放在全局区。

总结:全局区存放全局变量、静态变量和常量(字符串常量、const 修饰的全局变量);const 修饰的局部变量不在全局区,仍然存放在栈区。

9.2.3 栈区

作用:存放函数的参数值(形参)和局部变量等,由编译器自动分配和释放,==函数执行完后,栈区的数据自动释放==。栈区还有两个特点:

  • 栈区的开辟和释放由编译器自动完成,不需要程序员操心;
  • 栈区空间有限(通常只有几 MB),不适合存放特别大的数据。

注意:不要返回局部变量的地址!栈区的数据由编译器自动释放,函数执行完后,局部变量所占的内存就被回收了,再拿着返回的地址去访问,得到的就是垃圾值。

示例:下面这段代码是一个反面教材,专门演示返回局部变量地址会发生什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
using namespace std;

// 栈区的注意事项 —— 不要返回局部变量的地址
// 栈区的数据由编译器自动开辟、自动释放

int* func(int b) { // 形参 b 也存放在栈区
int a = 10; // 局部变量 a 存放在栈区,func 执行完后自动释放
return &a; // 返回局部变量的地址 —— 错误做法,仅作演示
}

int main() {

// 接收 func 函数的返回值,它指向栈区中已经被释放的变量 a
int* p = func(1);

// 第一次可以打印正确的数字,是因为编译器做了保留,给程序员一次机会
cout << *p << endl;

// 第二次这个数据就不再保留了,打印出来的是乱码
cout << *p << endl;

system("pause");

return 0;
}

注意:第一次能打印出 10,是编译器”仁慈”地保留了一次数据,纯属碰运气;第二次访问打印的就是乱码,因为 a 的内存已经被释放。这种写法是错误的,不要在程序中返回局部变量的地址。

9.2.4 堆区

作用:由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收。C++ 中主要利用 new 关键字在堆区开辟内存。

示例:利用 new 把数据开辟到堆区,函数结束后,堆区的数据依然存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

int* func() {
// 利用 new 关键字,可以把数据开辟到堆区
// 指针 p 本身是局部变量,存放在栈区;它保存的地址,指向堆区
int* p = new int(10);
return p;
}

int main() {

// 在堆区开辟数据,用指针接住 new 返回的地址
int* p = func();

cout << *p << endl;
cout << *p << endl;
cout << *p << endl; // 函数结束后,堆区的数据依然可以正常访问

system("pause");

return 0;
}

注意:对比上一小节的反面教材:同样是”在函数里创建数据、到函数外使用”,存放在栈区的数据函数一结束就消失了,而利用 new 存放到==堆区==的数据,只要程序员不释放,就一直存在。

总结:栈区和堆区的核心区别是==栈区由编译器自动管理,堆区由程序员手动管理==。堆区的内存用完要记得释放,否则会造成内存泄漏。

9.3 new 操作符

作用:在堆区开辟内存。C++ 中利用 new 在堆区开辟数据,利用 delete 释放堆区数据。

语法:

  • 开辟单个变量:new 数据类型(初始值);,new 返回的是==该数据类型的指针==;
  • 释放单个变量:delete 指针;;
  • 开辟数组:new 数据类型[元素个数];;
  • 释放数组:delete[] 指针;,释放数组时必须加上方括号。

示例:分别演示利用 new 开辟单个变量和数组,再用 delete 释放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
using namespace std;

// 1、new 的基本语法
int* func() {
// 在堆区创建整型数据
// new 返回的是 该数据类型的指针
int* p = new int(10);
return p;
}

void test01() {
int* p = func();
cout << *p << endl;
cout << *p << endl;
cout << *p << endl;

// 堆区的数据由程序员管理开辟,也由程序员管理释放
// 想释放堆区的数据,使用关键字 delete
delete p;

//cout << *p << endl; // 内存已经被释放,再访问就是非法操作,会报错
}

// 2、在堆区利用 new 开辟数组
void test02() {
// 创建一个能存放 10 个整型数据的数组,开辟在堆区
int* arr = new int[10]; // 10 代表数组有 10 个元素

for (int i = 0; i < 10; i++) {
arr[i] = i + 100; // 给数组元素赋值,从 100 到 109
}

for (int i = 0; i < 10; i++) {
cout << arr[i] << " ";
}
cout << endl;

// 释放堆区数组,释放数组时要加 []
delete[] arr;
arr = nullptr; // 释放后把指针置空,防止变成野指针
}

int main() {

test01();
test02();

system("pause");

return 0;
}

注意:new 和 delete 必须成对出现:new int(10) 开辟的单个变量用 delete p; 释放,new int[10] 开辟的数组用 delete[] arr; 释放;形式用错了,程序可能出现不可预期的错误。另外,内存被 delete 释放后就不要再访问了,像示例中那样把不再使用的指针置为 nullptr,可以有效防止野指针。

总结:堆区内存的使用分三步:new 开辟 → 使用 → delete 释放。new 的返回值是该数据类型的指针;释放单个变量用 delete,释放数组时 delete 后面要加方括号。