内存区域划分

我们经常说内存分配、内存不足、内存溢出。内存到底是什么?只是刻板印象中的那块由 PCB 板、芯片和颗粒构成的长条吗?而对计算机而言内存又分为几种呢?


内存是什么?由什么构成?

内存条主要是由 PCB 板、芯片和颗粒构成,而内存是由编址块组成的。内存和外存(硬盘)统称为计算机的存储器。

虽然通常外存的大小是内存大小的几十倍到上百倍不止,但是我们仍然将内存称为“主存储器”。因为内存存放的都是“活跃数据”,CPU 要取数进行运算也只能从内存里取,而不能直接访问外存数据,即中间不能省去先从外存加载数据到内存中的这一步。也就是说相比于外存,内存里存放的数据跟我们当前计算机的状态具有更高的相关性。


对计算机而言内存分为几种?有何不同?

根据内存所在区域不同大致分为内核空间、栈区、堆区、可读写区和只读区。后面四个属于用户空间,下面来它们逐一讨论。

先大概看一眼各类内存的分布情况:

内存大致分布

  1. 栈区

    栈区内存是由编译器负责分配和释放,是一段连续的内存空间,由栈区分配的地址都是从高位向低位延伸。

    函数中的参数值,局部变量等都是存放在栈区。只要栈的剩余的内存空间大于申请空间,就会成功分配栈空间,否则就会报错栈溢出。

    Linux 可以使用命令 ulimit -a 查看栈空间大小:

    1
    2
    >>> ulimit -a | grep stack
    -s: stack size (kbytes) 8192

    栈空间默认是 8192 / 1024 = 8M ,也就是说如果申请超过 8M 的栈空间会报栈溢出。

    我们来用 C 来模拟一下栈溢出,以下代码将会申请一个大小为 8192 * 1024 的字符串数组,系统会报栈溢出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    #include <stdio.h>

    int main() {
    printf("start\n");
    char a[8192*1024];
    printf("end\n");

    return 0;
    }

    如果要修改默认栈空间的大小,Linux 下可以使用命令 ulimit -s 进行临时设置,重新连接后恢复默认值。注意是重新连接,不是重启设备。

    通过 ulimit -s 命令的修改仅对当前连接有效,以下命令将栈空间临时修改为原来的两倍:

    1
    >>> ulimit -s 16384

    如果希望永久性的修改栈空间大小,有两种思路:

    1. 在 rc 文件下写入命令 ulimit -s XXXX ,这样每次连接都会先执行一遍 rc 文件,修改栈大小。
    2. 修改 /etc/security/limits.conf 配置文件

    关于修改默认栈空间,无论是临时性的修改还是永久性的修改,都建议”改大不改小“,以免影响某些系统函数的正常执行

    到这里不免产生一个疑问,能否无限增大栈空间的大小呢?答案是系统不同,情况不同。

    在我的 mbp 上可以通过命令 unlimit # 修改为允许设置的最大限度:65532(约 64M ),如果再通过 ulimit -s 修改为比 65532 大的数字会报错: ulimit: value exceeds hard limit

    1
    2
    3
    4
    5
    >>> unlimit #
    >>> ulimit -a | grep stack
    -s: stack size (kbytes) 65532
    >>> ulimit -s 65533
    ulimit: value exceeds hard limit

    在 Centos 上面,没有对应的 unlimit # ,在 root 用户下使用 ulimit -s 命令设置一个比我实际内存还大的值都是被允许。

    1
    2
    3
    4
    5
    >>> grep MemTotal /proc/meminfo
    MemTotal: 3514440 kB
    >>> ulimit -s 6553200000000
    >>> ulimit -a | grep stack
    stack size (kbytes, -s) 6553200000000

    所以关于修改默认栈空间,最后再多说一点:虽然系统允许我们自己修改栈空间大小,甚至有些系统(Centos)允许我们无脑增大栈空间,但是我们在遵循”改大不改小“原则的前提,也不要过分的去增大栈空间,改成默认的大小的几倍,最多改到 100M ,我觉得已经完全足够了。因为从系统内存分布图可以看到,栈空间大小从上是紧接着内核空间,内核空间大小是固定,也就是说我们改大栈空间,只能让栈区空间往下延伸,而栈区空间往下是接着堆区空间,堆区空间才是我们自己可以申请分配的内存空间。所以改大栈空间其实就是意味着将我们可以自己分配的内存分一部分给编译器自己管理,而编译器用到的内存只是用作函数参数值、局部变量等,只是很小一部分的非常驻内存。

    总结一下:栈区内存特点是由编译器负责分配和释放;一般比较小,是一段连续内存,地址从高位往低位延伸;一般存放函数的参数值和局部变量,函数结束后编译器自动释放;对于栈空间大小的修改建议是”改大不改小“,设定范围为默认大小的几倍,不超过 100M 。


  2. 堆区

    堆区的内存一般是由程序动态申请,由 malloc、new 所分配的内存都在堆上。堆内存的地址是从低位往高位延伸。

    堆区空间的使用与在使用过程中始终保持连续的栈区相比,碎片化分布比较严重,这也是堆区内存的存储效率不如栈区的原因。为什么堆区内存的使用过程中会产生碎片化内存,而栈区不会?举一个简单的例子就能很好的理解:假如我们有以下一段 C 程序:

    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
    #include <stdlib.h>

    int add3(int a) {
    int r = a + 3; // 栈区
    return r;
    }

    int add2(int a) {
    int r = a + 2; // 栈区
    r = add3(r);
    return r;
    }

    int add1(int a) {
    int r = a + 1; // 栈区
    r = add2(r);
    return r;
    }

    int main() {
    int i = 0;
    int result = add1(i);

    char* a = (char* )malloc(32); // 堆区
    char* b = (char* )malloc(32);
    char* c = (char* )malloc(32);

    free(b);
    free(c);
    free(a);

    return 0;
    }

    看一遍执行过程不难发现:

    栈区内存的使用场景主要是内存随着函数的执行而分配,函数的结束而释放,同时多层函数调用过程中遵循后进先出原则(函数调用栈:最后被调用的函数最先执行结束,返回结果给上一级函数,上一级函数接受到结果接着往下执行直到完成,将结果再返回给上一级函数..),所以这段程序中的栈空间分配情况和释放情况如下:

    程序中栈区的内存分配与释放

    而堆区内存的使用场景是:我们无法按照我们分配内存的顺序去释放内存。完全可以按 a -> b -> c 的顺序分配,然后按 b -> c -> a 的顺序 free 。

    正是由于栈区和堆区的使用场景不同,系统采用了不同的数据结构来管理栈区和堆区的内存,栈区采用管理函数调用关系的栈结构,而堆区使用了链表进行内存管理。

    所以网上其他博客说:由于栈区是使用栈结构来管理内存,所以能保证内存顺序存储,而不会产生内存碎片;堆区使用链表来管理内存,所以内存不会被物理顺序存储,会产生内存碎片。

    从事实来说,这些表述都没问题。但是在我看来,他们都犯了用结果去解释原因的错误。我觉得更加合理的解释是:由于栈区内存的使用场景是可以保证顺序执行,所以使用栈结构来管理,在满足需求的前提下保证高效;而堆区的内存使用场景无法保证释放顺序和分配顺序一致,所以使用链表来灵活管理。


  3. 可读写区

    可读写区包括 BSS 段(Block Started by Symbol)和 DATA 段,两者都属于可读写类型。

    BSS 段存放的是未初始化或初始值为 0 / 空指针 的全局变量和静态变量。

    DATA 段存放的是经过初始化且初始化值不为 0 / 空指针 的全局变量和静态变量。

    BSS 段由于存放都是未经初始化或者初始化为 0 / 空指针 的数据,所以不占用二进制文件的空间。只需要在可执行文件被执行时,读出 start_bss 和 end_bss 地就可以确定 BSS 段内存大小总和,然后由操作系统负责将该段内存清零。


  4. 只读区

    只读区通常存放的是可执行文件代码、字符串常量和某些只读变量等。

    可执行文件代码在只读区一方面是出于对安全性的考虑( 程序不重打包的 Hook 一般只能 Hook 堆区变量,修改堆区变量里面的值,而不能通过动态修改汇编或者机器指令去改掉程序的逻辑,因为本身代码是被加载进只读区 )。

    另一方面是为了节省内存,如果限定了可执行文件代码只能在只读区,那么配合虚拟内存机制,同一可执行文件被执行多次所创建出来的多个进程,它们的只读区内存可以在物理内存中只占用一份内存。


在理解了内存分布之后,怎么看进程中的真实的内存分布呢?

其实对于 C 这种静态语言,内存分布在编译阶段就已经确定了。我们可以通过如下几个命令来窥探 C 进程中的内存分布:

  1. 将 C 语言编译成目标文件,使用到的命令是 gcc -c ,得到编译后的目标文件 xxx.o 。

    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
    >>> cat testing.c
    #include <stdio.h>

    int add_20(int i) {
    printf("%d\n", i);
    int a;
    a = i + 20;
    return a;
    }

    int global_var = 84;
    char* global_pointer_var;
    int main(){
    static int static_var1 = 19;
    static int static_var2;

    int a = 1;
    int b;
    b = add_20(static_var1 + static_var2 + a);

    return 0;
    }

    >>> gcc -c testing.c
    >>> ll | grep testing
    -rw-r--r--@ 1 Gra staff 322B 2 3 20:43 testing.c
    -rw-r--r-- 1 Gra staff 1.2K 2 3 20:43 testing.o

  2. 使用 objdump 进行查看各个区域的内存分布。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    >>> objdump -h testing.o
    testing.o: file format Mach-O 64-bit x86-64

    Sections:
    Idx Name Size Address Type
    0 __text 00000079 0000000000000000 TEXT
    1 __cstring 00000004 0000000000000079 DATA
    2 __data 00000008 0000000000000080 DATA
    3 __bss 00000004 0000000000000130 BSS
    4 __compact_unwind 00000040 0000000000000088 DATA
    5 __eh_frame 00000068 00000000000000c8 DATA


进程内部分段,段内部分页

根据前面的分析可以知道对进程而言是分段的,而段内部是分页的(标准页大小为 4K)。

为什么需要对段进行分页呢?想象一下,如果每个运行的应用程序都完全从硬盘中加载到内存中,很快内存就会被填满,计算机的多任务能力就会变得很弱。这就引入“分页加载”的概念。

有了“分页加载”,解决了内容容易填满的问题。又产生了一个疑问:不同进程的内存是否能安全的独立开来?想象一下,假如某个进程分配的物理内存是 0000H-FFFFH ,这时候不够用了内存怎么办,该如何延伸?以及确保该进程无法访问 FFFFH 的下一位地址(其他进程的内存)。这就引入了“虚拟内存”概念。

  1. 分页加载

当一个应用程序启动,会马上创建进程,但并不会一下子将硬盘的内容全部加载到内存。而是先对硬盘的内容进行分页(默认为标准页 4K),创建页表。然后用到哪一页加载哪一页,当内存满了之后使用 LRU 算法将不常用页置换到 SWAP 分区中。

  1. 虚拟内存

对于每个进程而言,它使用的都是“全部内存”,但这并不对应真实的物理内存,只是虚拟内存,进程使用到的所有内存地址也都是虚拟地址。

虚拟空间的大小等于寻址空间大小。这取决于操作系统,如果是 64 位的操作系统,寻址空间的大小为 2 ^ 64 (单位字节)。也就是虚拟空间通常比物理空间大很多。

虚拟内存通过操作系统 + MMU(Memory Management Unit)将虚拟内存地址映射为物理内存地址。

实现虚拟内存的目的是防止不同进程之间的内存能够有效的独立开来,同时突破物理内存限制,让每个程序都认为自己有连续可用的内存,无须考虑实际物理内存是否够用的底层问题。

进程内部是分段,段内部分页。而页是按需加载到叶框(内存)里。当内存满了之后还会使用 LRU 算法将不常用页置换到 SWAP 分区中。

  1. 缺页中断

缺页中断又称为硬中断,缺页故障。

是指当试图访问已映射在虚拟地址空间中,但是目前尚未被加载在物理内存中的一个分页时,由 CPU 的内存管理单元所发出的中断。

通常情况下,用于处理此中断的程序是 OS 的一部分。如果操作系统判断此次访问是有效的,那么操作系统会通过页表尝试将相关的分页从硬盘上的虚拟内存文件中调入内存。而如果访问是不被允许的,那么操作系统通常会结束相关的进程。


总结

以上就是对计算机中的几类内存的大致说明,为了阅读体验,当中选择性的忽略了一些真实情况中还存在的机制,如堆栈大小动态调整、堆内存字节对齐、只读区往下的保留区等等。我尝试过将我知道的与内存相关的知识都结合在一起来写,但因为自身写作水平有限,揉合到一起之后可读性大大降低,所以最终只好退而求其次,简明扼要的说明一下几类内存的主要区别。

最后贴一份能够很好地说明程序中各种变量分别在内存哪个区域的 C 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdlib.h>

int a1 = 0; // 全局(静态)初始化区
char* a2; // 全局(静态)未初始化区

int main() {
static int a3 =0; // 全局(静态)初始化区

int b1; //栈
char b2[] = "abc"; // abc 是在常量区,b2 在栈上
char* b3; //栈
char* b4 = "123456"; // 123456 在常量区,b4 在栈上

char* c1 = (char* )malloc(10); // 堆区
char* c2 = (char* )malloc(20); // 堆区

return 0;
}

几个挺有意思的Java疑问

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×