当前位置: 首页 > java >正文

内存 “舞台” 上,进程如何 “翩翩起舞”?(转)

在数字世界里,计算机的每一次高效运转都离不开内存与进程的默契配合。内存,恰似一座宏大且有序的舞台,为进程提供了施展拳脚的空间。而进程,则如同舞台上的舞者,它们在内存的舞台上,遵循着一套复杂而精妙的规则,灵动地 “翩翩起舞”。

从启动程序的那一刻起,进程便踏上了这片舞台,开始了它与内存的互动之旅。这一过程,关乎计算机系统的高效稳定运行,也与我们日常使用的各类软件、应用的流畅体验紧密相连。接下来,就让我们一同揭开进程在内存舞台上的精彩 “舞步”,探寻它们背后的神秘机制 。

一、内存相关概述

在深入了解进程与内存的关系之前,我们先来认识一下内存这个计算机的关键部件。内存,也被称为内存储器或主存储器,它就像是计算机的 “临时仓库”,在计算机运行程序时扮演着至关重要的角色。

内存是计算机中重要的部件之一,它是与CPU进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。内存(Memory)也被称为内存储器,其作用是用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。只要计算机在运行中,CPU就会把需要运算的数据调到内存中进行运算,当运算完成后CPU再将结果传送出来,内存的运行也决定了计算机的稳定运行。

内存又称主存,是CPU能直接寻址的存储空间,由半导体器件制成。内存的特点是存取速率快。内存是电脑中的主要部件,它是相对于外存而言的。我们平常使用的程序,如Windows操作系统、打字软件、游戏软件等,一般都是安装在硬盘等外存上的,但仅此是不能使用其功能的,必须把它们调入内存中运行,才能真正使用其功能,我们平时输入一段文字,或玩一个游戏,其实都是在内存中进行的。就好比在一个书房里,存放书籍的书架和书柜相当于电脑的外存,而我们工作的办公桌就是内存。通常我们把要永久保存的、大量的数据存储在外存上,而把一些临时的或少量的数据和程序放在内存上,当然内存的好坏会直接影响电脑的运行速度。

内存就是暂时存储程序以及数据的地方,比如当我们在使用WPS处理文稿时,当你在键盘上敲入字符时,它就被存入内存中,当你选择存盘时,内存中的数据才会被存入硬(磁)盘。

内存一般采用半导体存储单元,包括随机存储器(RAM),只读存储器(ROM),以及高速缓存(CACHE)。只不过因为RAM是其中最重要的存储器。(synchronous)SDRAM同步动态随机存取存储器:SDRAM为168脚,这是目前PENTIUM及以上机型使用的内存。SDRAM将CPU与RAM通过一个相同的时钟锁在一起,使CPU和RAM能够共享一个时钟周期,以相同的速度同步工作,每一个时钟脉冲的上升沿便开始传递数据,速度比EDO内存提高50%。DDR(DOUBLE DATA RATE)RAM :SDRAM的更新换代产品,他允许在时钟脉冲的上升沿和下降沿传输数据,这样不需要提高时钟的频率就能加倍提高SDRAM的速度。

当我们打开电脑上的某个程序,比如一款图像处理软件,这个程序的代码和运行时所需的数据并不会直接从硬盘读取然后被 CPU 处理。因为硬盘的读取速度相对较慢,如果 CPU 直接从硬盘读取数据,计算机的运行效率会极其低下。这时,内存就发挥了关键的 “中介” 作用。在程序启动时,操作系统会将程序的代码和初始数据从硬盘加载到内存中。内存的读取速度比硬盘快得多,通常是硬盘的几十倍甚至上百倍 ,这使得 CPU 能够快速地从内存中读取指令和数据进行处理,大大提高了计算机的运行速度。在图像处理过程中,当我们对图片进行裁剪、调色等操作时,相关的数据会在内存中被快速地读取和修改,处理结果也会暂时存储在内存中,直到我们保存图像时,数据才会被写入硬盘进行长期存储。内存就像是一个高速运转的中转站,协调着 CPU 与硬盘之间的数据传输,让计算机能够高效地完成各种复杂的任务。

内存的内部是由各种IC电路组成的,它的种类很庞大,但是其主要分为三种存储器:

  • 只读存储器(ROM):ROM表示只读存储器(Read Only Memory),在制造ROM的时候,信息(数据或程序)就被存入并永久保存。这些信息只能读出,一般不能写入,即使机器停电,这些数据也不会丢失。ROM一般用于存放计算机的基本程序和数据,如BIOS ROM。其物理外形一般是双列直插式(DIP)的集成块。

  • 随机存储器(RAM):随机存储器(Random Access Memory)表示既可以从中读取数据,也可以写入数据。当机器电源关闭时,存于其中的数据就会丢失。我们通常购买或升级的内存条就是用作电脑的内存,内存条(SIMM)就是将RAM集成块集中在一起的一小块电路板,它插在计算机中的内存插槽上,以减少RAM集成块占用的空间。目前市场上常见的内存条有1G/条,2G/条,4G/条等。

  • 高速缓冲存储器(Cache):Cache也是我们经常遇到的概念,也就是平常看到的一级缓存(L1 Cache)、二级缓存(L2 Cache)、三级缓存(L3 Cache)这些数据,它位于CPU与内存之间,是一个读写速度比内存更快的存储器。当CPU向内存中写入或读出数据时,这个数据也被存储进高速缓冲存储器中。当CPU再次需要这些数据时,CPU就从高速缓冲存储器读取数据,而不是访问较慢的内存,当然,如需要的数据在Cache中没有,CPU会再去读取内存中的数据。

二、虚拟内存技术

当进程在内存这个 “临时仓库” 中运行时,虚拟内存则是背后的 “魔法”,让进程能够高效地使用内存资源。

2.1为什么需要使用虚拟内存

进程需要使用的代码和数据都放在内存中,比放在外存中要快很多。问题是内存空间太小了,不能满足进程的需求,而且现在都是多进程,情况更加糟糕。所以提出了虚拟内存,使得每个进程用于3G的独立用户内存空间和共享的1G内核内存空间。(每个进程都有自己的页表,才使得3G用户空间的独立)这样进程运行的速度必然很快了。而且虚拟内存机制还解决了内存碎片和内存不连续的问题。为什么可以在有限的物理内存上达到这样的效果呢?

2.2虚拟内存详解

虚拟内存是计算机系统内存管理的一种技术,它就像是给应用程序戴上了一副 “魔法眼镜”,让应用程序以为自己拥有连续可用的内存,即一个连续完整的地址空间 。但实际上,这些内存通常被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上。当计算机缺少运行某些程序所需的物理内存时,操作系统就会使用硬盘上的虚拟内存进行替代。这就好比你有一个小书架(物理内存),放不下所有的书(程序数据),于是你在旁边放了一个大箱子(虚拟内存),把暂时不看的书放在箱子里,等需要的时候再拿出来。在 32 位的操作系统中,每个进程可以拥有 4GB 的虚拟内存空间,但实际的物理内存可能远远小于这个数字。

例如:对于程序计数器位数为32位的处理器来说,他的地址发生器所能发出的地址数目为2^32=4G个,于是这个处理器所能访问的最大内存空间就是4G。在计算机技术中,这个值就叫做处理器的寻址空间或寻址能力。

照理说,为了充分利用处理器的寻址空间,就应按照处理器的最大寻址来为其分配系统的内存。如果处理器具有32位程序计数器,那么就应该按照下图的方式,为其配备4G的内存:

图片

这样,处理器所发出的每一个地址都会有一个真实的物理存储单元与之对应;同时,每一个物理存储单元都有唯一的地址与之对应。这显然是一种最理想的情况。

但遗憾的是,实际上计算机所配置内存的实际空间常常小于处理器的寻址范围,这是就会因处理器的一部分寻址空间没有对应的物理存储单元,从而导致处理器寻址能力的浪费。例如:如下图的系统中,具有32位寻址能力的处理器只配置了256M的内存储器,这就会造成大量的浪费:

图片

另外,还有一些处理器因外部地址线的根数小于处理器程序计数器的位数,而使地址总线的根数不满足处理器的寻址范围,从而处理器的其余寻址能力也就被浪费了。例如:Intel8086处理器的程序计数器位32位,而处理器芯片的外部地址总线只有20根,所以它所能配置的最大内存为1MB:

图片

在实际的应用中,如果需要运行的应用程序比较小,所需内存容量小于计算机实际所配置的内存空间,自然不会出什么问题。但是,目前很多的应用程序都比较大,计算机实际所配置的内存空间无法满足。

实践和研究都证明:一个应用程序总是逐段被运行的,而且在一段时间内会稳定运行在某一段程序里。

这也就出现了一个方法:如下图所示,把要运行的那一段程序自辅存复制到内存中来运行,而其他暂时不运行的程序段就让它仍然留在辅存。

图片

当需要执行另一端尚未在内存的程序段(如程序段2),如下图所示,就可以把内存中程序段1的副本复制回辅存,在内存腾出必要的空间后,再把辅存中的程序段2复制到内存空间来执行即可:

图片

在计算机技术中,把内存中的程序段复制回辅存的做法叫做“换出”,而把辅存中程序段映射到内存的做法叫做“换入”。经过不断有目的的换入和换出,处理器就可以运行一个大于实际物理内存的应用程序了。或者说,处理器似乎是拥有了一个大于实际物理内存的内存空间。于是,这个存储空间叫做虚拟内存空间,而把真正的内存叫做实际物理内存,或简称为物理内存。

那么对于一台真实的计算机来说,它的虚拟内存空间又有多大呢?计算机虚拟内存空间的大小是由程序计数器的寻址能力来决定的。例如:在程序计数器的位数为32的处理器中,它的虚拟内存空间就为4GB。

可见,如果一个系统采用了虚拟内存技术,那么它就存在着两个内存空间:虚拟内存空间和物理内存空间。虚拟内存空间中的地址叫做“虚拟地址”;而实际物理内存空间中的地址叫做“实际物理地址”或“物理地址”。处理器运算器和应用程序设计人员看到的只是虚拟内存空间和虚拟地址,而处理器片外的地址总线看到的只是物理地址空间和物理地址。

由于存在两个内存地址,因此一个应用程序从编写到被执行,需要进行两次映射。第一次是映射到虚拟内存空间,第二次时映射到物理内存空间。在计算机系统中,第两次映射的工作是由硬件和软件共同来完成的。承担这个任务的硬件部分叫做存储管理单元MMU,软件部分就是操作系统的内存管理模块了。

在映射工作中,为了记录程序段占用物理内存的情况,操作系统的内存管理模块需要建立一个表格,该表格以虚拟地址为索引,记录了程序段所占用的物理内存的物理地址。这个虚拟地址/物理地址记录表便是存储管理单元MMU把虚拟地址转化为实际物理地址的依据,记录表与存储管理单元MMU的作用如下图所示:

图片

综上所述,虚拟内存技术的实现,是建立在应用程序可以分成段,并且具有“在任何时候正在使用的信息总是所有存储信息的一小部分”的局部特性基础上的。它是通过用辅存空间模拟RAM来实现的一种使机器的作业地址空间大于实际内存的技术。

从处理器运算装置和程序设计人员的角度来看,它面对的是一个用MMU、映射记录表和物理内存封装起来的一个虚拟内存空间,这个存储空间的大小取决于处理器程序计数器的寻址空间。

可见,程序映射表是实现虚拟内存的技术关键,它可给系统带来如下特点:

  • 系统中每一个程序各自都有一个大小与处理器寻址空间相等的虚拟内存空间;

  • 在一个具体时刻,处理器只能使用其中一个程序的映射记录表,因此它只看到多个程序虚存空间中的一个,这样就保证了各个程序的虚存空间时互不相扰、各自独立的;

  • 使用程序映射表可方便地实现物理内存的共享。

2.3虚拟地址空间布局

Linux 内核给每个进程都提供了一个独立的虚拟地址空间,并且这个地址空间是连续的。这样,进程就可以很方便地访问内存,更确切地说是访问虚拟内存。

虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同字长(也就是单个CPU指令可以处理数据的最大长度)的处理器,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,它们的虚拟地址空间,如下所示:

图片

通过这里可以看出,32位系统的内核空间占用 1G,位于最高处,剩下的3G是用户空间。而 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

进程在用户态时,只能访问用户空间内存;只有进入内核态后,才可以访问内核空间内存。虽然每个进程的地址空间都包含了内核空间,但这些内核空间,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

既然每个进程都有一个这么大的地址空间,那么所有进程的虚拟内存加起来,自然要比实际的物理内存大得多。所以,并不是所有的虚拟内存都会分配物理内存,只有那些实际使用的虚拟内存才分配物理内存,并且分配后的物理内存,是通过内存映射来管理的;内存映射,其实就是将虚拟内存地址映射到物理内存地址。为了完成内存映射,内核为每个进程都维护了一张页表,记录虚拟地址与物理地址的映射关系,如下图所示:

图片

页表实际上存储在 CPU 的内存管理单元 MMU中,这样,正常情况下,处理器就可以直接通过硬件,找出要访问的内存;而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

另外,TLB(Translation Lookaside Buffer,转译后备缓冲器)会影响 CPU 的内存访问性能,TLB 其实就是 MMU 中页表的高速缓存。由于进程的虚拟地址空间是独立的,而 TLB 的访问速度又比 MMU 快得多,所以,通过减少进程的上下文切换,减少TLB的刷新次数,就可以提高TLB 缓存的使用率,进而提高CPU的内存访问性能;不过要注意,MMU 并不以字节为单位来管理内存,而是规定了一个内存映射的最小单位,也就是页,通常是 4 KB大小。这样,每一次内存映射,都需要关联 4 KB 或者 4KB 整数倍的内存空间。

页的大小只有4 KB ,导致的另一个问题就是,整个页表会变得非常大。比方说,仅 32 位系统就需要 100 多万个页表项(4GB/4KB),才可以实现整个地址空间的映射。为了解决页表项过多的问题,Linux 提供了两种机制,也就是多级页表和大页(HugePage)。

多级页表就是把内存分成区块来管理,将原来的映射关系改成区块索引和区块内的偏移。由于虚拟内存空间通常只用了很少一部分,那么,多级页表就只保存这些使用中的区块,这样就可以大大地减少页表的项数。

Linux用的正是四级页表来管理内存页,如下图所示,虚拟地址被分为5个部分,前4个表项用于选择页,而最后一个索引表示页内偏移。

图片

大页,就是比普通页更大的内存块,常见的大小有 2MB 和 1GB。大页通常用在使用大量内存的进程上,比如Oracle、DPDK等。

通过这些机制,在页表的映射下,进程就可以通过虚拟地址来访问物理内存了。

以 Linux 23位系统为例,进程的虚拟地址空间布局从低到高主要包含以下几个部分:

  • LOAD Segments:这部分包含了代码段(.text)、数据段(.data)和 BSS 段等。代码段存储着 CPU 执行的机器指令,它是只读的,防止指令被其他程序修改 。数据段用于存储初始化的全局变量和静态变量,BSS 段则用来存放未初始化的全局变量和静态变量。

  • 堆(Heap):堆是用于保存程序运行时动态申请的内存空间的区域,比如使用malloc或new申请的内存空间就来自堆 。堆的地址空间是 “向上增加” 的,即当堆上保存的数据越多,堆的地址就越高。

  • 共享库数据:很多进程会共享一些库文件,这些共享库的数据就存储在这里。通过共享库,多个进程可以共享相同的代码和数据,节省内存空间。

  • 栈(Stack):栈主要用于保存函数的局部变量(不包括static声明的静态变量,静态变量存放在数据段或 BSS 段)、参数、返回值、函数返回地址以及调用者环境信息(如寄存器值)等 。栈的内存由系统进行管理,在函数完成执行后,系统会自行释放栈区内存,不需要用户手动管理。整个程序的栈区大小可以由用户自行设定,Windows 默认的栈区大小为 1M ,64 位的 Linux 默认栈大小为 10MB。

  • 内核数据:这是操作系统内核使用的内存区域,用户进程一般不能直接访问。它包含了内核代码、内核数据结构以及一些系统调用的相关信息。

图片

通过这张图可以看到,用户空间内存,从低到高分别是五种不同的内存段:

  1. 只读段,包括代码和常量等。

  2. 数据段,包括全局变量等。

  3. 堆,包括动态分配的内存,从低地址开始向上增长。

  4. 文件映射段,包括动态库、共享内存等,从高地址开始向下增长。

  5. 栈,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。

在这五个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存;其实64位系统的内存分布也类似,只不过内存空间要大得多。

2.3虚拟内存使用方式

进程在启动时,操作系统会为其分配虚拟内存空间,并建立虚拟地址到物理地址的映射关系。在进程运行过程中,当需要访问内存时,CPU 会生成虚拟地址,这个虚拟地址会经过内存管理单元(MMU)的转换,找到对应的物理地址,然后访问物理内存。如果所需的内存页不在物理内存中,就会发生缺页中断,操作系统会从磁盘的虚拟内存中读取相应的内存页到物理内存中,并更新映射关系。

在 Linux 系统中,进程可以通过mmap、sbrk和brk等函数来操作虚拟内存。mmap函数用于将一个文件或者其它对象映射进内存 ,比如将共享库映射到进程的虚拟地址空间中。sbrk和brk函数则用于改变进程数据段的大小,从而实现内存的动态分配和释放。当malloc分配小于 128k 的内存时,会使用brk分配内存,将数据段的最高地址指针往高地址推;当malloc分配大于 128k 的内存时,会使用mmap在堆和栈之间找一块空闲内存分配 。

malloc() 是 C 标准库提供的内存分配函数,对应到系统调用上,有两种实现方式,即 brk() 和 mmap()。

  • 对小块内存(小于128K),C 标准库使用 brk() 来分配,也就是通过移动堆顶的位置来分配内存。这些内存释放后并不会立刻归还系统,而是被缓存起来,这样就可以重复使用。

  • 而大块内存(大于 128K),则直接使用内存映射 mmap() 来分配,也就是在文件映射段找一块空闲内存分配出去。

这两种方式,自然各有优缺点:

  • brk() 方式的缓存,可以减少缺页异常的发生,提高内存访问效率。不过,由于这些内存没有归还系统,在内存工作繁忙时,频繁的内存分配和释放会造成内存碎片。

  • mmap() 方式分配的内存,会在释放时直接归还系统,所以每次 mmap 都会发生缺页异常。在内存工作繁忙时,频繁的内存分配会导致大量的缺页异常,使内核的管理负担增大。这也是malloc 只对大块内存使用 mmap 的原因。

了解这两种调用方式后,还需要清楚一点,那就是,当这两种调用发生后,其实并没有真正分配内存。这些内存,都只在首次访问时才分配,也就是通过缺页异常进入内核中,再由内核来分配内存。

整体来说,Linux 使用伙伴系统来管理内存分配。这些内存在MMU中以页为单位进行管理,伙伴系统也一样,以页为单位来管理内存,并且会通过相邻页的合并,减少内存碎片化(比如brk方式造成的内存碎片);在用户空间,malloc 通过 brk() 分配的内存,在释放时并不立即归还系统,而是缓存起来重复利用;在内核空间,Linux 则通过 slab 分配器来管理小内存。可以把slab 看成构建在伙伴系统上的一个缓存,主要作用就是分配并释放内核中的小对象。

对内存来说,如果只分配而不释放,就会造成内存泄漏,甚至会耗尽系统内存。所以,在应用程序用完内存后,还需要调用 free() 或 unmap(),来释放这些不用的内存。

当然,系统也不会任由某个进程用完所有内存。在发现内存紧张时,系统就会通过一系列机制来回收内存,比如下面这三种方式:

  • 回收缓存,比如使用 LRU(Least Recently Used)算法,回收最近使用最少的内存页面;

  • 回收不常访问的内存,把不常用的内存通过交换分区直接写到磁盘中;

  • 杀死进程,内存紧张时系统还会通过 OOM(Out of Memory),直接杀掉占用大量内存的进程。

其中,第二种方式回收不常访问的内存时,会用到交换分区(以下简称 Swap)。Swap 其实就是把一块磁盘空间当成内存来用。它可以把进程暂时不用的数据存储到磁盘中(这个过程称为换出),当进程访问这些内存时,再从磁盘读取这些数据到内存中(这个过程称为换入)。

所以,可以发现,Swap 把系统的可用内存变大了。不过要注意,通常只在内存不足时,才会发生 Swap 交换。并且由于磁盘读写的速度远比内存慢,Swap 会导致严重的内存性能问题。

第三种方式提到的 OOM(Out of Memory),其实是内核的一种保护机制。它监控进程的内存使用情况,并且使用 oom_score 为每个进程的内存使用情况进行评分:

  • 一个进程消耗的内存越大,oom_score 就越大;

  • 一个进程运行占用的 CPU 越多,oom_score 就越小。

这样,进程的 oom_score 越大,代表消耗的内存越多,也就越容易被 OOM 杀死,从而可以更好保护系统。

当然,为了实际工作的需要,管理员可以通过 /proc 文件系统,手动设置进程的 oom_adj ,从而调整进程的 oom_score;oom_adj 的范围是 [-17, 15],数值越大,表示进程越容易被 OOM 杀死;数值越小,表示进程越不容易被 OOM 杀死,其中 -17 表示禁止OOM。

比如用下面的命令,就可以把 sshd 进程的 oom_adj 调小为 -16,这样, sshd 进程就不容易被 OOM 杀死。

1 echo -16 > /proc/$(pidof sshd)/oom_adj

三、进程与内存的交互舞步

3.1进程启动时的内存加载

当我们启动一个进程时,操作系统就像是一个忙碌的 “搬运工”,开始了一系列复杂而有序的内存加载工作。以 Windows系统为例,当我们双击一个.exe 可执行文件时,操作系统首先会读取该文件的头部信息,这个头部信息就像是一个 “导航图”,包含了程序运行所需的各种关键信息,如程序的入口点、依赖的动态链接库(DLL)等。

操作系统会为进程分配虚拟内存空间,这个空间就像是一个 “虚拟舞台”,进程将在上面进行各种操作 。然后,操作系统会根据可执行文件头部的信息,将程序的主要代码段和初始化数据加载到虚拟内存的相应位置。这些代码段和数据是程序启动和运行的基础,就像是一场演出的核心演员和基本道具。在加载代码段时,CPU 会读取其中的指令,开始执行程序的初始化工作,比如初始化全局变量、设置程序的运行环境等。

对于依赖的动态链接库,操作系统会在内存中查找是否已经加载了这些库。如果已经加载,就直接将库的地址映射到进程的虚拟地址空间中,让进程可以共享这些库的代码和数据 ,就像多个进程可以共用同一个舞台道具;如果没有加载,操作系统会从磁盘中读取相应的动态链接库文件,并将其加载到内存中,然后再进行地址映射。动态链接库的使用可以节省内存空间,提高程序的可维护性和可扩展性 。许多应用程序都会依赖于系统提供的一些通用的动态链接库,如 Windows 系统中的 Kernel32.dll,它提供了许多基本的操作系统功能调用。

3.2运行时内存分配

在进程运行过程中,常常需要动态分配内存来存储一些临时数据。以 C 语言中的malloc函数为例,它是进程运行时动态内存分配的一个典型工具。当我们调用malloc函数时,它会向操作系统申请一定大小的内存空间。

在 32 位的 Linux 系统中,malloc的内存分配机制如下:当请求的内存小于 128KB 时,malloc会使用brk系统调用,通过移动堆顶指针来分配内存 。假设堆顶指针初始指向地址 0x1000,我们调用malloc(100)申请 100 字节的内存,malloc会将堆顶指针移动到 0x1064(假设系统内存对齐为 8 字节,100 字节向上取整为 104 字节,加上一些元数据,假设为 8 字节,共 112 字节,即 0x70,所以堆顶指针移动到 0x1000 + 0x70 = 0x1070),并返回 0x1008 这个地址给用户程序,用户程序就可以使用这块内存来存储数据。

当请求的内存大于等于 128KB 时,malloc会使用mmap系统调用,在堆和栈之间的内存区域中找一块合适的空闲内存进行分配 。mmap会在虚拟地址空间中创建一个新的映射,将磁盘上的文件或者匿名内存区域映射到进程的虚拟地址空间中。这样,进程就可以像访问普通内存一样访问这个映射区域。

在动态内存分配过程中,虚拟内存的写时复制(Copy - on - Write,COW)策略发挥了重要作用。当一个进程通过fork系统调用创建子进程时,子进程会共享父进程的内存页面。在子进程或父进程没有对这些共享页面进行写操作之前,它们实际上共享的是相同的物理内存页面 ,只有当其中一个进程试图对共享页面进行写操作时,操作系统才会为写操作的进程复制一份物理内存页面,使得父子进程拥有各自独立的物理内存页面,这样可以节省内存资源,提高系统的效率。

如果进程访问的内存页面不在物理内存中,就会发生缺页中断。操作系统会根据页表信息,从磁盘的虚拟内存中找到对应的页面,并将其加载到物理内存中 。然后,操作系统会更新页表,将虚拟地址与新加载的物理内存页面建立映射关系,使得进程能够继续访问该内存页面。

3.3内存回收与管理

当进程结束运行或者内存不足时,操作系统就会进行内存回收工作,以释放内存空间供其他进程使用。当一个进程结束时,操作系统会回收该进程所占用的所有虚拟内存空间,并将这些空间标记为空闲 。操作系统会检查进程使用的堆内存、栈内存以及其他动态分配的内存区域,将这些内存归还给内存管理系统,就像是一场演出结束后,工作人员会将舞台上的道具和设备清理干净,为下一场演出做准备。

在内存不足的情况下,操作系统会采用内存置换算法来决定哪些内存页面可以被暂时置换到磁盘上,以腾出物理内存空间。常见的内存置换算法有最近最少使用(LRU,Least Recently Used)算法 。LRU 算法的核心思想是,如果一个内存页面在最近一段时间内没有被访问过,那么它在未来被访问的可能性也较小,因此可以将其置换出去。操作系统会维护一个内存页面的访问时间记录,当需要置换页面时,选择访问时间最早的页面进行置换。假设内存中有三个页面 A、B、C,它们的访问时间依次为 10:00、10:10、10:20,当内存不足需要置换页面时,LRU 算法会选择页面 A 进行置换,因为它是最久没有被访问的页面。

除了 LRU 算法,还有先进先出(FIFO,First In First Out)算法,它是将最早进入内存的页面置换出去;时钟(Clock)算法,它是 LRU 算法的一种近似实现,通过一个循环链表和一个访问位来模拟 LRU 算法的行为 。这些算法各有优缺点,操作系统会根据具体的应用场景和系统需求选择合适的算法,以确保系统的内存管理高效、稳定。

四、内存管理的底层奥秘

4.1MMU与地址映射

在进程使用内存的过程中,内存管理单元(MMU,Memory Management Unit)扮演着至关重要的角色,它就像是一个精准的 “翻译官”,负责将进程的虚拟地址动态翻译为物理地址 。

当 CPU 需要访问内存中的数据时,它会首先产生一个虚拟地址。这个虚拟地址会被发送到 MMU。MMU 内部包含了一个高速缓存,即转换后备缓冲器(TLB,Translation Lookaside Buffer),以及与进程相关的页表 。TLB 中存储了近期使用过的虚拟地址到物理地址的映射关系,就像是一个常用词汇的快速翻译手册。当 MMU 接收到虚拟地址后,会首先在 TLB 中查找对应的映射关系。如果在 TLB 中命中,MMU 可以快速地获取到对应的物理地址,从而大大提高了地址转换的速度 。

如果在TLB中没有命中,MMU就需要通过进程的页表来查找映射关系。页表是一个存储虚拟地址与物理地址映射关系的数据结构,它就像是一本完整的翻译词典 。MMU会根据虚拟地址中的页号,在页表中查找对应的物理页框号。找到物理页框号后,再结合虚拟地址中的页内偏移量,就可以计算出最终的物理地址。在 32 位的系统中,如果页面大小为4KB,那么虚拟地址可以被划分为20位的页号和12位的页内偏移量 。假设虚拟地址为 0x00401000,其中 0x0040 是页号,0x1000 是页内偏移量。MMU 通过页号 0x0040 在页表中查找对应的物理页框号,假设找到的物理页框号为 0x0080,那么最终的物理地址就是 0x00801000(0x0080 << 12 | 0x1000)。

4.2页表与多级页表

进程的页表是管理虚拟地址与物理地址映射关系的关键数据结构。它就像是一个精心编排的 “映射目录”,每个表项都记录了一个虚拟页到物理页框的映射关系 。在简单的分页系统中,页表可能是一个线性的数组,数组的索引是虚拟页号,数组的值是对应的物理页框号 。

然而,对于32位甚至64 位的地址空间来说,如果采用简单的线性页表,会占用大量的内存空间。在 32 位系统中,若页面大小为4KB,进程的虚拟地址空间为4GB,那么页表将包含 1M 个页表项(4GB / 4KB = 1M) 。假设每个页表项占用 4 个字节,那么仅页表就会占用4MB 的连续内存空间,这对于内存资源来说是一种巨大的浪费。

为了解决这个问题,现代操作系统通常采用多级页表。以二级页表为例,它将虚拟地址空间进一步划分。在 32 位系统中,可能将 20 位的页号再划分为 10 位的外层页号和 10 位的内层页号 。外层页表的每个表项指向一个内层页表,内层页表的表项才真正记录虚拟页到物理页框的映射关系 。这样,只有当外层页表中对应的表项被访问时,才会加载相应的内层页表,大大减少了内存的占用 。假设外层页号为 0x001,内层页号为 0x002,通过外层页表找到对应的内层页表,再在内层页表中通过内层页号 0x002 找到物理页框号,从而实现虚拟地址到物理地址的转换。

多级页表不仅减少了内存占用,还支持离散存储。由于页表可以离散地存储在物理内存中,不再需要连续的内存空间来存放整个页表,提高了内存的使用效率和灵活性 。

4.3缓存机制与局部性原理

为了进一步提高内存访问的速度,计算机系统引入了多种缓存机制,其中 CPU 缓存、Cache 和 TLB 表起着关键作用,它们的工作原理都基于局部性原理。

局部性原理包括时间局部性和空间局部性。时间局部性是指如果一个数据项被访问,那么在不久的将来它很可能再次被访问 。在一个循环结构中,循环变量和循环体内频繁使用的数据会被多次访问,这就体现了时间局部性。空间局部性是指如果一个数据项被访问,那么与它相邻的数据项很可能也会被访问 。当我们访问一个数组时,通常会按照顺序依次访问数组中的元素,这就利用了空间局部性。

CPU 缓存(Cache)是位于 CPU 和主存之间的高速存储部件,它利用了局部性原理来提高内存访问命中率。当 CPU 需要访问内存数据时,首先会在 Cache 中查找 。如果在 Cache 中命中,CPU 可以快速地获取数据,因为 Cache 的访问速度比主存快得多,通常可以达到主存访问速度的几十倍甚至上百倍 。如果在 Cache 中没有命中,才会访问主存,并将主存中的数据块加载到 Cache 中,以便后续访问。

TLB 表作为 MMU 中的高速缓存,同样利用了局部性原理。它缓存了近期使用过的虚拟地址到物理地址的映射关系 。当 MMU 接收到虚拟地址时,先在 TLB 中查找映射关系,如果命中,就可以快速完成地址转换,避免了通过页表进行查找的开销 。由于TLB的访问速度极快,几乎与 CPU 的速度同步,所以 TLB 的命中率对于地址转换的效率至关重要 。

http://www.xdnf.cn/news/3382.html

相关文章:

  • idea安装
  • 【Unity】 组件库分类详解
  • RAGFlow报错:ESConnection.sql got exception
  • 【基础算法】插值查找算法 - JAVA
  • (即插即用模块-Attention部分) 六十一、(2024 ACCV) LIA 基于局部重要性的注意力
  • 【数据分享】2020年中国高精度森林覆盖数据集(免费获取)
  • VBA数据库解决方案第二十讲:Select From Where条件表达式
  • 「面白い」日本 课文详解
  • 【MySQL数据库】视图
  • Flutter PIP 插件 ---- 新增PipActivity,Android 11以下支持自动进入PIP Mode
  • ARM ASM
  • 【云原生】基于Centos7 搭建Redis 6.2 操作实战详解
  • 【五一培训】Day1
  • Redis 挂掉后高并发系统的应对策略:使用 Sentinel 实现限流降级与 SkyWalking 监控优化
  • PostgreSQL 数据库下载和安装
  • Stm32 烧录 Micropython
  • 基于机器学习的舆情分析算法研究
  • 连接linux虚拟机并运行C++【从0开始】
  • 机器学习实战,天猫双十一销量与中国人寿保费预测,使用多项式回归,梯度下降,EDA数据探索,弹性网络等技术
  • vue中$set原理
  • Meta公司于2025年4月29日正式推出了全新Meta AI应用程序的首个版本
  • 正则表达式:精准匹配,高效处理文本
  • 《软件设计师》复习笔记(11.1)——生命周期、CMM、开发模型
  • 结构模式识别理论与方法
  • JWT Access Token 被窃取的风险与解决方案
  • spring-boot-maven-plugin 将spring打包成单个jar的工作原理
  • 企业经营系统分类及功能详解
  • 华为eNSP:IS-IS认证
  • 机器人--主机--控制系统
  • Python 常用内置函数详解(九):type()函数——获取对象类型或获取一个新的类型对象