MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

内存管理保障进程内存按需分配与释放

2023-08-077.6k 阅读

内存管理基础概念

进程与内存的关系

在操作系统中,进程是资源分配和调度的基本单位。每个进程都需要一定的内存空间来存储其代码、数据以及运行时产生的各种临时信息。例如,一个简单的C语言程序:

#include <stdio.h>

int main() {
    int num = 10;
    printf("The number is: %d\n", num);
    return 0;
}

在这个程序中,变量num需要内存来存储其值,printf函数的代码也需要内存空间,整个程序在运行时作为一个进程,操作系统必须为其分配足够的内存以保证其正常运行。

进程在内存中的布局通常包括代码段、数据段、堆和栈等区域。代码段存放程序的可执行指令,数据段用于存储已初始化的全局变量和静态变量,堆用于动态内存分配(如C语言中的malloc函数),栈则用于存储函数调用时的局部变量、参数和返回地址等。

内存管理的目标

内存管理的核心目标之一就是保障进程内存能够按需分配与释放。具体来说,它需要做到以下几点:

  1. 高效分配:当进程需要内存时,能够快速地为其分配到合适大小的内存块。例如,在一个图形渲染程序中,可能会频繁地创建和销毁各种图形对象,每个对象都需要一定的内存空间。内存管理系统必须能够迅速响应这些请求,将可用内存分配给相应的对象。
  2. 避免浪费:防止内存碎片化,提高内存利用率。想象一下,如果内存中存在大量不连续的小空闲块,而此时有一个进程需要一个较大的连续内存块,就可能因为无法找到合适的空闲空间而导致分配失败,尽管总体上可能有足够的空闲内存。
  3. 正确释放:当进程不再需要某些内存时,内存管理系统要能够正确地回收这些内存,以便重新分配给其他进程。例如,在C++中,如果使用new关键字分配了内存,就需要使用delete关键字释放,操作系统的内存管理机制需要确保这些释放的内存能够被有效回收。

内存分配策略

连续分配

  1. 单一连续分配:这是一种最简单的内存分配方式,早期的操作系统常采用。整个内存被分为系统区和用户区,系统区供操作系统使用,用户区分配给一个进程使用。例如,在一个简单的嵌入式系统中,可能只有一个主要的应用程序在运行,这种分配方式就比较适用。其优点是实现简单,缺点是内存利用率低,因为整个用户区只能供一个进程使用,即使进程实际需要的内存小于用户区大小,剩余部分也不能被其他进程利用。
  2. 固定分区分配:将内存划分为若干个固定大小的分区,每个分区可以分配给一个进程。在分区划分时,可以根据常见进程的大小设置不同大小的分区。例如,有100KB、200KB、300KB等不同大小的分区。当进程申请内存时,系统选择一个大小合适的空闲分区分配给它。这种方式解决了单一连续分配中内存利用率低的问题,但也存在一些不足,比如如果进程大小与分区大小不匹配,会造成内部碎片(分区内未被使用的空间)。例如,一个进程需要150KB内存,但只能分配到200KB的分区,就浪费了50KB空间。
  3. 动态分区分配:根据进程的实际需求动态地划分内存分区。当进程申请内存时,系统从空闲内存空间中找出一块大小足够的区域分配给它。常见的动态分区分配算法有首次适应算法、最佳适应算法和最坏适应算法。
    • 首次适应算法:从空闲分区链表的头部开始查找,找到第一个大小能满足进程需求的空闲分区,将其分配给进程。例如,假设内存中有3个空闲分区,大小分别为100KB、200KB、300KB,一个进程需要150KB内存,首次适应算法会将200KB的分区分配给该进程。这种算法的优点是简单快速,缺点是可能会在内存低地址部分留下许多小的空闲分区,导致后续大进程分配失败。
    • 最佳适应算法:从空闲分区链表中找到一个大小最接近进程需求且能满足需求的空闲分区。继续以上面的例子,对于需要150KB内存的进程,最佳适应算法会选择200KB的分区,而不是300KB的分区,以减少浪费。然而,这种算法会导致内存中留下很多难以利用的小空闲块,加剧了外部碎片问题。
    • 最坏适应算法:选择空闲分区链表中最大的空闲分区分配给进程。例如,对于需要150KB内存的进程,最坏适应算法会选择300KB的分区。它的优点是可以避免在内存低地址部分留下过多小空闲块,但缺点是可能会使大的空闲分区很快被耗尽,导致后续大进程无法分配到足够的内存。

非连续分配

  1. 分页存储管理:将进程的逻辑地址空间划分为大小相等的页,内存物理空间也划分为与页大小相等的块,称为页框。进程的页可以离散地存储在不同的页框中。例如,一个进程的逻辑地址空间为4MB,页大小为4KB,那么该进程将被划分为1024个页。操作系统通过页表来记录进程的页与物理页框的对应关系。当进程访问某个逻辑地址时,系统根据页表将逻辑地址转换为物理地址。例如,逻辑地址为1025(假设页大小为1024),它属于第1页(页号为1),偏移量为1,通过页表查到第1页对应的物理页框号,再结合偏移量1就可以得到物理地址。分页存储管理解决了连续分配中的外部碎片问题,提高了内存利用率。
  2. 分段存储管理:与分页不同,分段是根据进程的逻辑结构进行划分,如代码段、数据段、栈段等。每个段的大小可以不同,且段在内存中可以离散存储。操作系统通过段表来管理段的信息,包括段的起始地址、长度等。例如,一个进程的代码段可能从内存地址1000开始,长度为500,数据段从地址2000开始,长度为300。当进程访问某个逻辑地址时,系统先根据段号在段表中找到对应的段信息,再根据偏移量计算出物理地址。分段存储管理更符合程序的逻辑结构,便于实现共享和保护,但也可能产生外部碎片。
  3. 段页式存储管理:结合了分页和分段的优点。先将进程按逻辑结构划分为段,再将每个段划分为页。例如,一个进程有代码段、数据段和栈段,每个段再进一步划分为页。系统需要段表和页表来进行地址转换。首先根据段号在段表中找到对应的页表起始地址,再根据页号在页表中找到对应的物理页框号,最后结合偏移量得到物理地址。段页式存储管理既提高了内存利用率,又满足了程序逻辑结构的需求。

内存释放机制

基于引用计数的释放

在一些编程语言和内存管理系统中,会采用引用计数的方法来管理内存释放。每个对象都有一个引用计数,记录指向该对象的引用数量。当一个对象被创建时,其引用计数初始化为1。每当有一个新的引用指向该对象时,引用计数加1;当一个引用不再指向该对象时,引用计数减1。当引用计数为0时,说明该对象不再被任何地方引用,可以将其占用的内存释放。例如,在Python语言中,很多对象的内存管理就采用了引用计数的方式:

a = [1, 2, 3]  # 创建一个列表对象,其引用计数为1
b = a  # 引用计数加1
del a  # 引用计数减1
del b  # 引用计数减为0,列表对象占用的内存被释放

这种方式的优点是内存释放及时,不会造成内存泄漏。但缺点是对于循环引用的情况(如两个对象互相引用),会导致引用计数永远不为0,从而造成内存泄漏。例如:

class A:
    def __init__(self):
        self.b = None

class B:
    def __init__(self):
        self.a = None

a = A()
b = B()
a.b = b
b.a = a
del a
del b

在这个例子中,ab互相引用,即使del adel b执行后,它们的引用计数都不会变为0,从而导致内存泄漏。为了解决循环引用问题,Python还引入了垃圾回收机制。

垃圾回收机制

垃圾回收(Garbage Collection,GC)是一种自动内存管理机制,用于检测和回收不再被使用的内存。常见的垃圾回收算法有标记 - 清除算法、复制算法、标记 - 整理算法等。

  1. 标记 - 清除算法:首先从根对象(如全局变量、栈中的变量等)开始,标记所有可达的对象,然后清除所有未被标记的对象(即不可达对象)所占用的内存。例如,在一个有向图表示的对象关系中,从根节点出发,沿着边标记所有能到达的节点,未被标记的节点就是垃圾对象。这种算法的优点是实现简单,不需要额外的空间来复制对象。缺点是会产生内存碎片,并且标记和清除过程会暂停程序的运行,影响性能。
  2. 复制算法:将内存分为两个相等的区域,每次只使用其中一个区域。当该区域内存满时,将存活的对象复制到另一个区域,然后清除原来的区域。例如,在Java的新生代垃圾回收中,就采用了类似复制算法的策略。这种算法的优点是不会产生内存碎片,且回收速度快。缺点是需要额外的空间,并且如果存活对象较多,复制的开销会很大。
  3. 标记 - 整理算法:在标记 - 清除算法的基础上,增加了整理过程。在标记完成后,将存活的对象移动到内存的一端,然后清除边界以外的内存。这样可以避免内存碎片的产生。例如,在一些老年代垃圾回收中,会采用标记 - 整理算法。它的优点是解决了内存碎片问题,缺点是移动对象的开销较大。

手动释放机制

在一些编程语言中,如C和C++,提供了手动释放内存的方式。在C语言中,使用malloc函数分配内存,使用free函数释放内存:

#include <stdio.h>
#include <stdlib.h>

int main() {
    int *ptr = (int *)malloc(sizeof(int));
    if (ptr!= NULL) {
        *ptr = 10;
        printf("The value is: %d\n", *ptr);
        free(ptr);
    }
    return 0;
}

在C++中,使用new关键字分配内存,使用delete关键字释放内存:

#include <iostream>

int main() {
    int *ptr = new int;
    if (ptr!= nullptr) {
        *ptr = 10;
        std::cout << "The value is: " << *ptr << std::endl;
        delete ptr;
    }
    return 0;
}

手动释放机制的优点是程序员对内存管理有更精确的控制,可以根据程序的需求及时释放内存。缺点是如果程序员忘记释放内存,就会导致内存泄漏。例如,在C语言中,如果在free(ptr)语句前加入return 0;,就会导致ptr指向的内存无法释放。

内存管理与进程生命周期

进程创建时的内存分配

当一个新进程被创建时,操作系统需要为其分配内存。首先,要为进程的代码段分配内存,将可执行文件的代码加载到内存中。例如,在Linux系统中,exec系列函数会将新程序的代码和数据加载到进程的地址空间。对于数据段,要根据程序中已初始化的全局变量和静态变量的大小分配内存,并将初始值复制进去。对于堆和栈,也需要分配一定的初始空间。栈通常从高地址向低地址增长,而堆从低地址向高地址增长。例如,在一个多线程程序中,每个线程都有自己的栈空间,在进程创建时,会为每个线程的栈分配内存。

进程运行时的内存动态调整

在进程运行过程中,可能会根据需求动态调整内存。例如,一个数据库管理程序在处理大量数据时,可能需要动态分配更多的内存来存储临时数据。通过调用内存分配函数(如C语言中的malloc或C++中的new),进程可以向操作系统申请更多的内存。操作系统根据当前内存的使用情况,采用合适的分配策略为进程分配内存。如果内存不足,可能会触发内存交换(将暂时不用的内存数据交换到磁盘上)或内存紧缩(整理内存碎片以获得更大的连续空闲空间)等操作。

另一方面,当进程中的某些数据不再需要时,会通过内存释放函数(如C语言中的free或C++中的delete)释放内存。操作系统回收这些内存后,会将其重新加入空闲内存列表,以便分配给其他进程或本进程后续的内存请求。

进程终止时的内存释放

当进程终止时,操作系统需要释放该进程占用的所有内存。包括代码段、数据段、堆和栈等区域的内存。首先,操作系统会检查进程是否有未关闭的文件描述符等资源,如果有,会先关闭这些资源。然后,回收进程占用的内存空间,将其标记为空闲,供其他进程使用。例如,在Windows系统中,当一个应用程序关闭时,操作系统会清理该进程所占用的内存,确保系统资源的有效回收和再利用。如果进程在终止时没有正确释放内存(如存在内存泄漏),操作系统在进程终止后也会尽力回收这些内存,但这可能会导致系统资源的浪费和性能下降。

内存管理中的保护机制

内存访问权限控制

为了保证进程之间的内存隔离以及操作系统内核的安全,操作系统对内存访问设置了权限控制。一般来说,内存有读、写、执行等访问权限。例如,代码段通常设置为只读和可执行权限,防止进程意外修改自身的代码。数据段根据情况可以设置为可读可写权限。对于其他进程的内存区域,一般设置为不可访问权限,以防止进程之间互相干扰。在x86架构的CPU中,通过段描述符中的访问权限位来控制内存的访问权限。例如,一个段描述符可以设置该段为只读、可写、可执行等不同的权限组合。当进程试图访问内存时,CPU会检查当前进程的权限是否与内存的访问权限匹配,如果不匹配,就会触发一个异常,操作系统会捕获这个异常并进行相应的处理,如终止违规的进程。

地址空间隔离

每个进程都有自己独立的虚拟地址空间,这是内存管理中的一个重要保护机制。虚拟地址空间使得进程可以认为自己独占整个内存,而实际上多个进程的虚拟地址空间会映射到物理内存的不同位置。例如,进程A的虚拟地址0x1000可能映射到物理地址0x5000,而进程B的虚拟地址0x1000可能映射到物理地址0x6000。这种地址空间隔离防止了一个进程通过错误的指针访问到其他进程的内存数据,提高了系统的稳定性和安全性。操作系统通过页表等数据结构来实现虚拟地址到物理地址的映射。例如,在分页存储管理系统中,每个进程都有自己的页表,当进程访问虚拟地址时,操作系统根据其页表将虚拟地址转换为物理地址,确保每个进程只能访问自己地址空间内的内存。

内存保护与异常处理

当进程违反内存访问权限或访问了不存在的虚拟地址时,会触发内存保护异常。操作系统的异常处理机制会捕获这些异常并进行处理。例如,在Linux系统中,如果一个进程试图访问一个未映射的虚拟地址,会触发段错误(Segmentation Fault)。操作系统会终止该进程,并可能记录相关的错误信息,以便开发人员调试。异常处理机制还可以用于实现一些高级功能,如写时复制(Copy - on - Write,COW)技术。在COW中,当多个进程共享同一份物理内存时,如果其中一个进程试图对共享内存进行写操作,就会触发写时复制异常。操作系统会为该进程复制一份物理内存,使其可以在自己的副本上进行写操作,而不影响其他进程对原内存的访问,这样既节省了内存,又保证了数据的一致性和安全性。

内存管理在不同操作系统中的实现

Linux内存管理

  1. 虚拟内存管理:Linux采用了分页虚拟内存管理机制。每个进程都有自己的虚拟地址空间,通过页表将虚拟地址映射到物理地址。内核维护了一个全局的页表结构,用于管理系统中的所有物理页框。当进程访问一个虚拟地址时,如果该地址对应的物理页框不在内存中(即发生缺页异常),内核会根据页表中的信息将所需的页从磁盘交换到内存中。例如,当一个进程运行一个大型程序,其部分代码和数据可能被交换到磁盘上,当访问到这些部分时,就会触发缺页异常,内核会将相应的页从磁盘读入内存。
  2. 内存分配算法:在用户空间,Linux提供了malloc等函数用于动态内存分配。malloc函数底层通常使用brkmmap系统调用来实现。brk用于扩展或收缩进程的堆空间,mmap则可以将文件或设备映射到进程的地址空间,也可以用于分配匿名内存。在内存分配算法上,Linux采用了一种称为伙伴系统(Buddy System)的算法来管理物理内存。伙伴系统将物理内存划分为不同大小的块,每个块大小是2的幂次方。当需要分配内存时,从合适大小的块中选择一个空闲块分配给进程;当内存释放时,会将相邻的空闲块合并成更大的块,以减少内存碎片。
  3. 内存回收机制:Linux通过页面置换算法(如最近最少使用算法,LRU)来回收内存。当内存不足时,内核会选择一些最近最少使用的页面交换到磁盘上,以腾出物理内存供其他进程使用。同时,Linux还支持内存共享和写时复制技术,多个进程可以共享同一份物理内存,当某个进程试图修改共享内存时,采用写时复制机制为其创建一个副本,避免影响其他进程。

Windows内存管理

  1. 虚拟内存架构:Windows同样采用虚拟内存技术,每个进程都有自己独立的4GB虚拟地址空间(在32位系统下)。其中,低地址部分(通常为2GB)供用户模式进程使用,高地址部分供内核模式使用。Windows通过页表来实现虚拟地址到物理地址的映射,并且采用了多级页表结构来提高地址转换效率。例如,在一个32位Windows系统中,虚拟地址被分为多个部分,分别用于索引不同级别的页表,最终找到对应的物理页框。
  2. 内存分配函数:Windows提供了HeapAlloc等函数用于用户模式下的内存分配。HeapAlloc函数在进程的堆上分配内存,堆管理器采用了多种优化策略来提高内存分配和释放的效率,减少内存碎片。在系统层面,Windows内核采用了一种称为段页式的内存管理方式,结合了分段和分页的优点,既满足了程序逻辑结构的需求,又提高了内存利用率。
  3. 内存回收与优化:Windows通过内存压缩和页面置换等技术来回收内存。当内存不足时,系统会压缩一些不常用的页面,将其存储在内存中的压缩缓冲区中,以腾出更多的物理内存。同时,Windows也采用了类似LRU的算法来选择需要置换的页面。此外,Windows还提供了内存诊断工具,如Task Manager中的内存使用分析功能,帮助用户和开发人员了解系统内存的使用情况,优化内存性能。

其他操作系统的内存管理特点

  1. UNIX内存管理:UNIX系统的内存管理与Linux有一些相似之处,也采用分页虚拟内存管理机制。但在内存分配算法上,不同的UNIX版本可能有所差异。例如,Solaris操作系统采用了一种称为FastFit的内存分配算法,它试图在满足内存请求的同时,尽量减少内存碎片的产生。FastFit算法维护了多个空闲列表,每个列表对应不同大小范围的空闲块,当有内存请求时,优先从最合适的列表中选择空闲块进行分配。
  2. macOS内存管理:macOS基于UNIX内核,其内存管理也采用虚拟内存技术。它通过动态内存分配策略,根据进程的实际需求分配内存。在内存回收方面,macOS采用了一种称为自动释放池(Autorelease Pool)的机制,主要用于Objective - C对象的内存管理。当一个对象被发送autorelease消息时,它会被添加到最近的自动释放池中。当自动释放池被销毁时,池中的所有对象会被释放,这种机制有助于减少手动管理内存的负担,提高内存管理的效率。

通过深入了解不同操作系统的内存管理实现,可以更好地优化程序性能,提高系统的稳定性和安全性。无论是开发应用程序还是进行系统级编程,对内存管理的深入理解都是至关重要的。