程序员应如何理解内存:上篇

https://mp.weixin.qq.com/s/U7F5LyzZ07KVOFYJF74LtQ 本节是操作系统系列教程的第三篇文章,属于操作系统第一章即基础篇,在真正开始操作系统相关章节前在这一部分回顾一些重要的主题,算是温故知新吧,以下是目录,由于本文篇幅较多因此接下来会分三次发布,目录中黑体为本篇内容。


什么是内存

C/C++ 内存模型

堆区与栈区的本质

Java、Python 等内存模型

Java 内存模型

Jave 中的堆区与栈区是如何实现的

Python 内存模型

指针与引用

进程的内存模型

幻想大师 - 操作系统

总结


什么是内存

0 和 1 这两个简单的数字能做什么?在其它学科中也许什么都做不了,但是在计算机科学中这就是全部。精彩纷呈的计算机世界正是构筑在这样两个简单数字之上。

内存本身其实非常简单,内存的作用就是用来装数字 0 和数字 1 的,如图所示,图中的一个盒子就是内存的一个基本单元,装的不是 0 就是装的 1。

内存由一大堆的 “盒子” 组成,每个盒子中要么是 0 要么是 1,其中 8 个盒子被称之为一个“字节”,每 8 个盒子也就是一个字节都有一个编号,这些编号就是简单的从 0 开始依次累加的,这个编号就被称之为“内存地址”。如图所示,你可以把内存理解为下面这张图,其中左边的数字是内存地址,每一排是一个字节,图中展示的就是一个 8 字节大小的内存。

而对于我们平时使用的比如 2G、4G 甚至 8G 大小的内存来说,只不过就是 “盒子” 多一点能装的 01 多一点而已,本质上和我们在这里展示的 8 字节大小的内存没有任何区别。

在后面的章节中我将用右图来表示内存,但是你的大脑里一定要有左图这样一个概念。当计算机在执行我们的程序时,无论是我们的机器指令还是机器指令操作的数据,都需要存放在这些小盒子中 (内存)。

以上就是从硬件角度来看内存,那么从编程语言上来看,程序员应该如何理解内存呢?

C/C++ 内存模型

对于 C/C++ 程序员来说,常用的 int,char 等变量都被装在盒子中,char 值只需要一排盒子就能装下 (8bit),一个 int 值一般需要四排盒子才能装得下。连续几排装有同样类型变量的盒子就是数组 (array),连续几排装有不同类型变量的盒子就是结构体 (struct),C/C++ 语言中不管多么复杂的数据结构都是在此基础上构建出来的,都需要装在这些盒子里,没什么大不了的。

现在你已经知道了对于 C/C++ 程序员来说,我们使用的变量是直接放在内存中的 (盒子),每一排盒子的地址就是我们熟知的 “指针”,请记住,指针就是你使用的变量在内存中的地址,仅此而已。

C/C++ 程序在被执行时,需要在内存中划出两段区域用于存放数据,这两个区域就是我们熟悉的堆 (Heap) 和栈(Stack),也称堆区和栈区,如图所示,其中数据段和代码段我们已经熟悉了(不熟悉的同学请参见链接器系列文章),在这里我们将进一步完善 C/C++ 程序在内存中的样子,如图所示,其中堆区紧邻数据段,在数据段之上,而栈在最上方,栈和堆之间是尚未被使用的内存,随着程序的运行,当程序申请内存时栈区和堆区之间的空隙会减小,当程序释放内存后空隙会扩大,这就是 C/C++ 程序的内存模型。

每个函数运行时都会在栈区上占用一块内存,这块内存中保存的是调用函数的参数以及函数中的定义的局部变量,这些变量在函数调用完成后会被释放。从这里可以看出栈上的变量无需程序员关心其释放问题,当函数调用完毕后会自动释放所占用的空间。

和栈上的变量不同的是,堆上分配的内存不会像栈一样被自动释放,在堆上分配的内存需要程序员手动释放,如果程序员在堆上分配了一块内存,但在使用完后忘记释放,这种情况就被称之为 “内存泄漏”,所谓“内存泄漏” 就是使用完毕后的内存没有释放掉,但是这块内存也不能被用作其它地方从而导致堆占用的内存不断增大,表现出来的就是如果我们去检测程序所占用的内存,会发现程序所占用的内存不断增大,当操作系统是不可能坐视某个进程不断吞噬掉系统内存的,当出现系统内存资源不足时将触发操作系统的保护机制,这在 Linux 中就是著名的 OOM Killer,即 Out Of Memory Killer,OOM Killer 会根据一些策略 Killer 有问题的进程,这个进程通常都是占用内存最多的那个。

下面我们用一小段 C 代码来实际演示一变量是如何在堆区栈区上分配的,不用担心,这段代码非常简单:

include <stdlib.h>

void f2() {
    int c;
    int* heap;

    c = 3;
    heap = (int *)malloc(sizeof(int));
    *heap = 4;
}

void f1() {
    int b;

    b = 2;
    f2();
}

int main() {
   int a;
   a = 1;

   f1();
   return 0;
}

如图所示,这就是以上代码运行过程中的样子,你会发现,每个函数在被执行的时候都在栈区上占有一小段,在这一小段中存放当前函数中定义的局部变量和传入函数的参数。每个函数所占用的这一段内存有一个很形象的名字,叫做 “栈帧 (stack frame)”,原因就在于栈是随着函数调用一帧一帧增加的,每个函数在被调用时都会在栈上分配一帧,所以就叫栈帧。这个词请大家不必去深究,每个被调函数在栈区上做占用的内存总要有个名字,栈帧只不过比较形象而已。

这段代码中,main 函数会调用函数 f1,f1 会调用函数 f2(),其中变量 a,b,c 以及 heap 依次被放在各自函数的栈帧中,值得注意的一点在于,heap 这个变量本身是在栈上的,但是 heap 所指向的内存是分配在堆上的,heap 本身仅仅保存的是 4 这个值在内存中的位置,比如这里的 0x10,表示的就是 4 这个值放在了内存 0x10 的这个位置上,heap 就是 C/C++ 语言中所谓的指针。如图所示:

你会发现随着函数的调用,栈是不断在扩大的,当 f2,f1 执行完毕返回 main 时就是如下图所示的样子。

从图中我们可以看出,f2 在执行完毕后,f2 所占用的内存就被回收了,所谓 “回收” 就是这块内存又可以用作其它用途了。f1 执行完毕后所占用的内存同样也被回收,这样我们就又回到了 main()函数中。

这个过程中我们还会发现一个很有意思的现象就是最先被使用的栈帧其实是最后才被释放的,这种先进后出的性质就被称之为 “栈”,如下图所示。所以你会看到 “栈 “这个词更多的是指顺序上的先进后出,只不过函数调用时所占用的内存在使用方式上也是先进后出的,所以这块内存就被称之为栈区了。

在讲解完栈之后,我们来看看堆,不同于像 a,b,c 这样存在于栈区上的变量,栈区上的变量可以在函数执行完成后被自动释放掉,在堆区上的分配内存除非程序员手动调用 free,delete 明确的告知内存使用完毕,否则这块内存就会一直被占用而不能用作其它用途,这就是堆区。

你可能会问,什么样的变量在需要在堆上分配呢,我们知道,函数调用完成后栈上的分配的局部变量会因为栈帧被释放而不再可用,堆区的存在就是为了解决这个问题,堆区中申请的内存不会因为栈帧的释放而不再可用,使得变量的生命周期不再局限于某个函数,其生命周期是靠程序员用 malloc(new) 以及 free(delete) 来控制的,这样的变量在使用时可以跨越函数调用。

另外一点值得注意的是,f2 函数中我们在堆上申请了一块内存用来存放整数,但是 f2 执行完成后并没有去释放这块内存,根据堆的性质我们知道这块函数在接下来的运行过程中无法再被使用,就好像这块内存被遗忘了一样,这就是内存泄漏。

接下来的内容将在《程序员应如何理解内存:中篇》继续。


操作系统系列 https://mp.weixin.qq.com/s/U7F5LyzZ07KVOFYJF74LtQ

最后修改 April 15, 2023: upgrade (dbd415b4)