09.计算机内存设计和原理
目录介绍
- 01.程序装载的挑战
- 1.1 看一个实际场景
- 1.2 程序装载到内存
- 1.3 虚拟和物理内存
- 1.4 什么是物理内存
- 1.5 什么是虚拟内存
- 1.6 为何设计虚拟内存
- 1.7 程序装载的挑战
- 02.内存的设计技术
- 2.1 内存的特点
- 2.2 物理内存设计
- 2.3 虚拟内存设计
- 2.4 内存分段设计
- 2.5 内存分页设计
- 2.6 解决内存装载挑战
- 03.程序内部共享内存
- 3.1 思考一个问题
- 3.2 节省运行内存思路
- 3.3 静态链接
- 3.4 动态链接
- 3.5 共享内存地址设计
- 3.6 PLT 和 GOT方案
01.程序装载的挑战
1.1 看一个实际场景
- 在 Java 这样使用虚拟机的编程语言里面,我们写的程序是怎么装载到内存里面来的呢?
- 加载程序是通过内存分页和内存交换的方式加载到内存里面来的么?
- 程序中某个类创建了对象,对象存储在内存中,这里的内存有何特点?
1.2 程序装载到内存
- 多个文件合并成一个最终可执行文件。
- 在运行这些可执行文件的时候,我们其实是通过一个装载器,解析 ELF 或者 PE 格式的可执行文件。装载器会把对应的指令和数据加载到内存里面来,让 CPU 去执行。
- 说起来只是装载到内存里面这一句话的事儿,实际上装载器需要满足两个要求。
- 第一,可执行程序加载后占用的内存空间应该是连续的。执行指令的时候,程序计数器是顺序地一条一条指令执行下去。这也就意味着,这一条条指令需要连续地存储在一起。
- 第二,同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。虽然编译出来的指令里已经有了对应的各种各样的内存地址,但是实际加载的时候,我们其实没有办法确保,这个程序一定加载在哪一段内存地址上。因为我们现在的计算机通常会同时运行很多个程序,可能你想要的内存地址已经被其他加载了的程序占用了。
- 如何满足这两个基本的要求
- 那就是我们可以在内存里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。
1.3 虚拟和物理内存
- 把指令里用到的内存地址叫作虚拟内存地址(Virtual Memory Address),实际在内存硬件里面的空间地址,我们叫物理内存地址(Physical Memory Address)。
- 重点看虚拟内存:程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了。对于任何一个程序来说,它看到的都是同样的内存地址。
- 虚拟指向物理映射表:我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。
- 内存连续:因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
- 看到这里思考几个问题?
- 为什么程序,不可以设计成直接操作物理内存?
- 创建对象,对象放到堆内存中,引用放到栈里,调用对象是通过引用地址找到具体的值。这个如何用虚拟内存和物理内存来解释?
1.4 什么是物理内存
- 物理内存是计算机系统中实际存在的内存,也称为主存储器或随机存取存储器(RAM)。
- 它是计算机用于存储正在运行的程序和数据的物理硬件。物理内存的容量决定了计算机可以同时存储的数据量。
- 物理内存由一组存储单元组成,每个存储单元都有一个唯一的地址。这些存储单元以字节为单位进行编址,可以存储和读取数据。
- 计算机系统中的所有程序和数据都必须加载到物理内存中才能被CPU访问和处理。
- 当程序执行时,CPU从物理内存中读取指令和数据,并将计算结果写回到物理内存中。
1.5 什么是虚拟内存
- 虚拟内存是一种扩展物理内存的技术,它允许计算机系统在物理内存不足时使用硬盘空间作为辅助存储。
- 虚拟内存将物理内存和硬盘空间结合起来,形成一个更大的地址空间供程序使用。虚拟内存的容量可以远远大于物理内存的容量。
- 虚拟内存的工作原理如下
- 分页:虚拟内存将程序和数据划分为固定大小的页面(Page),通常是4KB或8KB。这些页面被映射到物理内存或硬盘上的页面框(Page Frame)。
- 页面置换:当物理内存不足时,操作系统会将一部分不常用的页面从物理内存中换出到硬盘上的页面文件(Page File)。这样,物理内存中就有空间来加载新的页面。
- 页面调度:当程序需要访问一个不在物理内存中的页面时,操作系统会将该页面从硬盘加载到物理内存中,并更新页面表(Page Table)以反映页面的新位置。
- 虚拟内存(Virtual Memory)是计算机系统中的一种技术,它允许操作系统将物理内存(RAM)和磁盘空间结合起来,为运行的程序提供一个抽象的、虚拟的内存空间。
- 虚拟内存的主要目的是扩展可用的内存容量,使得运行的程序可以使用比物理内存更大的内存空间。
- 它通过将不常用的数据从物理内存转移到磁盘上的虚拟内存空间,从而释放物理内存供其他程序使用。
- 虚拟内存的工作原理
- 地址映射:每个运行的程序都有自己的虚拟地址空间,它是连续的、从0开始的地址范围。操作系统负责将程序的虚拟地址映射到物理内存或磁盘上的虚拟内存页(Virtual Memory Page)。
- 页面访问权限:操作系统可以为每个内存页设置访问权限,如只读、读写、执行等。这样可以提供内存保护和安全性,防止程序越界访问或恶意代码执行。
1.6 为何设计虚拟内存
- 通过常见的案例来说明虚拟内存和物理内存的作用是多任务操作系统中的进程管理。
- 在一个多任务操作系统中,可以同时运行多个程序(进程)。每个进程都有自己的地址空间,包括代码、数据和堆栈等部分。虚拟内存和物理内存在进程管理中起着重要的作用。
- 以下是一个简化的案例:
- 进程加载:当一个进程被启动时,操作系统将为其分配一个虚拟地址空间。这个虚拟地址空间是由连续的虚拟内存页面组成的,每个页面对应着一段固定大小的内存。
- 虚拟内存映射:进程的虚拟地址空间中的页面被映射到物理内存中的页面框。这个映射关系由操作系统维护的页面表来管理。页面表记录了虚拟页面和物理页面之间的对应关系。
- 页面置换:当物理内存不足时,操作系统会将一部分不常用的页面从物理内存中换出到硬盘上的页面文件。这样,物理内存中就有空间来加载新的页面。
- 页面调度:当进程需要访问一个不在物理内存中的页面时,操作系统会将该页面从硬盘加载到物理内存中,并更新页面表以反映页面的新位置。
- 为何设计虚拟内存?为何设计虚拟内存?
- 通过虚拟内存和物理内存的组合,多任务操作系统可以同时运行多个进程,并为每个进程提供独立的地址空间。
- 每个进程都认为自己独占整个内存空间,而实际上它们共享物理内存。操作系统通过虚拟内存的映射和页面置换等技术,实现了进程之间的内存隔离和保护。
1.7 程序装载的挑战
- 程序装载(Program Loading)是将程序从存储介质(如硬盘)加载到计算机的物理内存中的过程。可能遇到的挑战是:
- 内存限制:计算机的物理内存是有限的,而程序的大小可能超过可用的内存容量。这意味着在装载程序时,需要确保程序可以适应可用的内存空间。如果程序太大而无法完全加载到内存中,可能需要采取分页、分段或虚拟内存等技术来管理内存。
- 冲突解决:当多个程序同时加载到内存中时,可能会发生内存冲突。这种冲突可能是由于程序之间的地址空间重叠或资源竞争引起的。操作系统需要解决这些冲突,确保每个程序都能够正确地访问和使用内存。
- 思考一下几个问题
- 程序太大,可以采用分页,分段或者虚拟内存来管理内存。这些设计思想是什么?
02.内存的设计技术
2.1 内存的特点
- 内存是计算机系统中用于存储数据和指令的地方。它是一个临时存储器,用于存储正在运行的程序和数据。内存以字节为单位进行存储,每个字节都有一个唯一的地址。内存的主要特点包括:
- 容量:内存的容量决定了计算机可以同时存储的数据量。较大的内存容量可以支持更多的程序和数据同时运行。
- 访问速度:内存的访问速度非常快,可以迅速读取和写入数据。CPU可以直接从内存中读取指令和数据,而不需要像硬盘那样进行机械操作。
- 临时存储:内存中存储的数据是临时的,当计算机关闭或断电时,内存中的数据会丢失。因此,内存主要用于存储正在运行的程序和临时数据。
2.2 物理内存设计
2.3 虚拟内存设计
2.4 内存分段设计
- 找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫分段(Segmentation)。
- 这里的段,就是指系统分配出来的那个连续的内存空间。
- 分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处,第一个就是内存碎片(Memory Fragmentation)的问题。
- 举一个例子来看内存碎片
- 来看这样一个例子。我现在手头的这台电脑,有 1GB 的内存。我们先启动一个图形渲染程序,占用了 512MB 的内存,接着启动一个 Chrome 浏览器,占用了 128MB 内存,再启动一个 Python 程序,占用了 256MB 内存。
- 这个时候,我们关掉 Chrome,于是空闲内存还有 1024 - 512 - 256 = 256MB。
- 按理来说,我们有足够的空间再去装载一个 200MB 的程序。但是,这 256MB 的内存空间不是连续的,而是被分成了两段 128MB 的内存。
- 最终的结果,实际情况是,我们的程序没办法加载进来。
- 如何解决内存碎片的问题
- 解决的办法叫内存交换(Memory Swapping)。
- 可以把 Python 程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里面。不过读回来的时候,我们不再把它加载到原来的位置,而是紧紧跟在那已经被占用了的 512MB 内存后面。
- 这样,我们就有了连续的 256MB 内存空间,就可以去加载一个新的 200MB 的程序。
- 如果你自己安装过 Linux 操作系统,你应该遇到过分配一个 swap 硬盘分区的问题。这块分出来的磁盘空间,其实就是专门给 Linux 操作系统进行内存交换用的。
- 内存交换会导致性能的问题
- 虚拟内存、分段,再加上内存交换,看起来似乎已经解决了计算机同时装载运行很多个程序的问题。这三者的组合仍然会遇到一个性能瓶颈。
- 硬盘的访问速度要比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
- 所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。
2.5 内存分页设计
- 为什么计算机要引入内存分页技术
- 既然问题出在内存碎片和内存交换的空间太大上,那么解决问题的办法就是,少出现一些内存碎片。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决这个问题。
- 这个办法,在现在计算机的内存管理里面,就叫作内存分页(Paging)。
- 和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。
- 而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。
- 从虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个一个页来的。页的尺寸一般远远小于整个程序的大小。
- 在 Linux 下,我们通常只设置成 4KB。可以通过命令看看你手头的 Linux 系统设置的页的大小:$ getconf PAGE_SIZE
- 设计内存分页的优势是什么,会有内存分段的问题吗
- 由于内存空间都是预先划分好的,也就没有了不能使用的碎片,而只有被释放出来的很多 4KB 的页。
- 即使内存空间不够,需要让现有的、正在运行的其他程序,通过内存交换释放出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换的过程给卡住。
- 利用内存分页加载程序会怎么样
- 分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
- 操作系统是如何使用内存分页技术的
- 当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)。
- 我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。
- 这种方式,使得我们可以运行那些远大于我们实际物理内存的程序。同时,这样一来,任何程序都不需要一次性加载完所有指令和数据,只需要加载当前需要用到就行了。
2.6 解决内存装载挑战
- 任何一个程序,如何做到把内存当成是一块完整而连续的空间来直接使用
- 通过虚拟内存、内存交换和内存分页这三个技术的组合,我们最终得到了一个让程序不需要考虑实际的物理内存地址、大小和当前分配空间的解决方案。
- 这些技术和方法,对于我们程序的编写、编译和链接过程都是透明的。这也是我们在计算机的软硬件开发中常用的一种方法,就是加入一个间接层。
- 通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。
- 在虚拟内存、内存交换和内存分页这三者结合之下,你会发现,其实要运行一个程序,“必需”的内存是很少的。
- CPU 只需要执行当前的指令,极限情况下,内存也只需要加载一页就好了。再大的程序,也可以分成一页。
- 每次,只在需要用到对应的数据和指令的时候,从硬盘上交换到内存里面来就好了。
03.程序内部共享内存
3.1 思考一个问题
- 程序的链接,是把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件。
- 这个链接的方式,让我们在写代码的时候做到了“复用”。同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了。
- 举一个例子来理解
- 现在有5个App应用都用到了图片编解码so库,那么当多个程序加载到内存中,这个图片编解码库是占用1份,还是5份呢?
- 多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间吗?
3.2 节省运行内存思路
- 如果我们能够让同样功能的代码,在不同的程序里面,不需要各自占一份内存空间,是复用一份内存空间,那该有多好啊!
- 这个思路就引入一种新的链接方法,叫作动态链接(Dynamic Link)。相应的,我们之前说的合并代码段的方法,就是静态链接(Static Link)。
3.3 静态链接
3.4 动态链接
- 在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)。顾名思义,这里的共享库重在“共享“这两个字。
- 这个加载到内存中的共享库会被很多个程序的指令调用到。
- 在 Windows 下,这些共享库文件就是.dll 文件,也就是 Dynamic-Link Libary(DLL,动态链接库)。
- 在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)。
- 这两大操作系统下的文件名后缀,一个用了“动态链接”的意思,另一个用了“共享”的意思,正好覆盖了两方面的含义。
- 思考一下,这个共享库,如何在多个应用内存中运行呢?
- 注意:不同进程间的应用内存是相互隔离的,调用到共享库后,是如何通过地址找到对应的对象?
3.5 共享内存地址设计
- 要想要在程序运行的时候共享代码,也有一定的要求,就是这些机器码必须是“地址无关”的。
- 编译出来的共享库文件的指令代码,是地址无关码(Position-Independent Code)。
- 换句话说就是,这段代码,无论加载在哪个内存地址,都能够正常执行。如果不是这样的代码,就是地址相关的代码。
- 大部分函数库其实都可以做到地址无关,因为它们都接受特定的输入,进行确定的操作,然后给出返回结果就好了。
- 无论是实现一个向量加法,还是实现一个打印的函数,这些代码逻辑和输入的数据在内存里面的位置并不重要。
- 对于所有动态链接共享库来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。
- 为什么不把共享库用到的虚拟内存地址设计成通用的,让每个程序调用都是一样的地址值
- 我们没办法、也不应该要求动态链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致。如果这样的话,我们写的程序就必须明确地知道内部的内存地址分配。
- 要怎么样才能做到,动态共享库编译出来的代码指令,都是地址无关码呢?
- 动态代码库内部的变量和函数调用都很容易解决,我们只需要使用相对地址(Relative Address)就好了。
- 各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址。
- 因为整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的。
3.6 PLT 和 GOT方案
- 要实现动态链接共享库,也并不困难,和前面的静态链接里的符号表和重定向表类似,还是和前面一样,我们还是拿出一小段代码来看一看。
更多内容推荐
- GitHub:https://github.com/yangchong211
- 博客:https://juejin.cn/user/1978776659695784
- 博客汇总:https://github.com/yangchong211/YCBlogs
- 设计模式专栏:https://github.com/yangchong211/YCDesignBlog
- Java高级进阶专栏:https://github.com/yangchong211/YCJavaBlog
- 网络协议专栏:https://github.com/yangchong211/YCNetwork
- 计算机基础原理专栏:https://github.com/yangchong211/YCComputerBlog
- 系统性学习C编程:https://github.com/yangchong211/YCStudyC
- C++学习案例:https://github.com/yangchong211/YCStudyCpp
- Leetcode算法专栏:https://github.com/yangchong211/YCLeetcode
- Android技术专栏:https://github.com/yangchong211/YCAndroidBlog