09 | 程序装载:“640K内存”真的不够用么?
程序装载面临的挑战
通过链接器,把多个文件合并成一个最终可执行文件。在运行这些 可执行文件的时候,我们其实是通过一个装载器,解析 ELF 或者 PE 格式的可执行文件。装 载器会把对应的指令和数据加载到内存里面来,让 CPU 去执行。
装载器需要满足两个要求:
- 可执行程序加载后占用的内存空间应该是连续的。执行指令的 时候,程序计数器是顺序地一条一条指令执行下去。这也就意味着,这一条条指令需要连续 地存储在一起。
- 我们需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。
解决方法: 在内存里面,找到一 段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序 指令里指定的内存地址做一个映射。
指令里用到的内存地址叫作虚拟内存地址(Virtual Memory Address),实际在内 存硬件里面的空间地址,我们叫物理内存地址(Physical Memory Address)。
程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了。对于任何一个程序来 说,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际 程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是 连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
内存分段
找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫分段 (Segmentation)。这里的段,就是指系统分配出来的那个连续的内存空间。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处,第一个就是内存碎片(Memory Fragmentation)的问题。
内存交换:将程序占用的内存写到硬盘上,然后再从硬盘上读回来到内存里面。不过读回来的时候,我们不再把它加载到原来的位置,而是紧紧跟在那已经被占用了的其他内存后面。
虚拟内存、分段,再加上内存交换,看起来似乎已经解决了计算机同时装载运行很多个程序的问题。不过,这三者的组合仍然会遇到一个性能瓶颈。硬盘的访问速度要比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所 以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。
内存分页
既然问题出在内存碎片和内存交换的空间太大上,那么解决问题的办法就是,少出现一些内存碎片。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决这个问题。
和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。从虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个一个页来的。
由于内存空间都是预先划分好的,也就没有了不能使用的碎片,而只有被释放出来的很多 4KB 的页。即使内存空间不够,需要让现有的、正在运行的其他程序,通过内存交换释放 出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时 间,让整个机器被内存交换的过程给卡住。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理 内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载 到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加 载到物理内存里面去。
当要读取特定的页,却发现数据并没有加载到 物理内存里的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)。我们的操作系 统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物 理内存里。这种方式,使得我们可以运行那些远大于我们实际物理内存的程序。同时,这样 一来,任何程序都不需要一次性加载完所有指令和数据,只需要加载当前需要用到就行了。
通过虚拟内存、内存交换和内存分页这三个技术的组合,我们最终得到了一个让程序不需要 考虑实际的物理内存地址、大小和当前分配空间的解决方案。
缺页错误是访问内存数据,缺页异常是分配内存的时候触发的。
问题1:在 Java 这样使用虚拟机的编程语言里面,我们写的程序是怎么装载到内存里面来的呢?
jvm已经是上层应用,无需考虑物理分页,一般更直接是考虑对象本身的空间大小,物理硬件管理统一由承载jvm的操纵系统去解决。 思考题
首先,我们编写的Java程序,即源代码.java文件经过编译生成字节码文件.class;然后,创建JVM环境,即查找和装载libjvm.so文件;最后,通过创建JVM实例,加载主类的字节码文件到系统给该JVM实例分配的内存中;
Java代码的执行需要JVM环境,JVM环境的创建就是查找和装载libjvm.so文件:装载libjvm.so是通过内存分页和内存交换的方式加载到内存的。字节码文件是通过类加载器加载到主类文件对应的JVM实例的内存空间中的,这一部分不是使用内存分页和内存交换的方式来管理的,使用的是JVM的内存分配策略来管理的;
问题2:既然有了虚拟内存和物理内存作映射,为什么还要要求物理内存是连续的?如果不需要连续的物理内存,那么内存碎片的问题就不存在了。按页分配就不需要连续内存空间了吗?进而,既然不需要连续,为什么还要再交换,不是随便放就好了吗?
一页之内要连续,不同的页之间不需要。
分页存在之前,内存交换是为了去掉内存碎片。分页存在之后,内存交换只是为了把不活跃的内存占用交换到磁盘,来释放内存,以有效的利用内存。分页存在前内存交互最终结果是移动程序所占的内存空间,而分页存在之后内存交互的结果是移出程序所占的内存空间,也就是释放内存。
按页分配确实不需要连续内存空间,但是也是需要和硬盘交换的,因为内存空间毕竟是有大小的,有限的空间运行更多活跃的程序,提高了内存效率。
虚拟内存远大于物理内存,比如64位系统虚拟内存可以到256T。程序运行中物理内存是不可见的,他只负责使用虚拟内存,如果使用的内存大于真实的物理内存,就会把真实物理内存中长时间不使用的页换到硬盘,程序使用这一段空出来的内存。
问题3:分页是不连续的,那一个程序的多个页是怎么串联起来的?程序怎么做到顺序执行的?
内存分页使得映射的基本单元从段变成了规范的,容易处理的页。
在虚拟内存中是连续的,在物理内存中是分页的,虚拟和物理通过页表机制实现转换。
页表里面连续的就行了 只不过 通过映射到不连续的物理内存页罢了。
总结
用虚拟内存解决了进程之间的隔离、用分段技术解决了物理地址不连续的问题。 但是分段技术又会带来内存碎片问题,从而引入内存交换用来解决内存碎片问题。 但是分段技术的内存交换,是交换整个程序的内存,如果一个程序的内存比较大的话,由于内存交换需要和磁盘打交道,而磁盘的吞吐率比内存慢好几档,此时发生内存交换的空间较大的话会严重影响系统,严重的话会造成系统的卡顿。 要解决这个问题,我们首先得明白这个问题是怎么产生的 —— 内存交换的空间太大,所以引入了内存分页。 内存分页:将虚拟内存和物理内存分成了一个个页,在linux上,每个页的大小是4k,这样虚拟内存和物理内存上的页与页之间的对应,即使内存空间不够了,那也只是给页大小的物理内存进行置换,优化了交换内存的性能。 注:页虚拟内存和物理页内存的对应可以是不连续的(这点从老师提供的图即可看出)。
10 | 动态链接:程序内部的“共享单车”
链接可以分动、静,共享运行省内存
静态链接(Static Link): 合并代码段
动态链接(Dynamic Link): “链接” 加载到内存中的共享库(Shared Libraries)。
加载到内存中的共享库会被很多个程序的指令调用到。在 Windows 下,这些共享库 文件就是.dll 文件,也就是 Dynamic-Link Libary(DLL,动态链接库)。在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)。
地址无关很重要,相对地址解烦恼
对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但 是在不同的应用程序里,它所在的虚拟内存地址是不同的。我们没办法、也不应该要求动态 链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致。如果这 样的话,我们写的程序就必须明确地知道内部的内存地址分配。
PLT 和 GOT,动态链接的解决方案
需要从 PLT,也就是程序链接表(Procedure Link Table)里面找要调用的函数。
在共享库的 data section 里面,保存了一张全局偏移表 (GOT,Global Offset Table)。虽然共享库的代码部分的物理内存是共享的,但是数据 部分是各个动态链接它的应用程序里面各加载一份的。
11 | 二进制编码:“手持两把锟斤拷,口中疾呼烫烫烫”?
万事万物在计算机里都是 0 和 1
理解二进制的“逢二进一”
任何一个十进制的整数,都能通过二进制表示出来。把一个二进制数,对应到十进制,非常 简单,就是把从右到左的第 N 位,乘上一个 2 的 N 次方,然后加起来,就变成了一个十进 制数。当然,既然二进制是一个面向程序员的“语言”,这个从右到左的位置,自然是从 0 开始的。
想要把一个十进制的数,转化成二进制,使用短除法就可以了。
一个 4 位的二进制数, 0011 就表示为 +3。而 1011 最左侧的第一位是 1,所以它 就表示 -3。这个其实就是整数的原码表示法。原码表示法有一个很直观的缺点就是,0 可 以用两个不同的编码来表示,1000 代表 0, 0000 也代表 0。
补码表示法:
仍然通过最左侧第一位的 0 和 1,来判断这个数 的正负。但是,我们不再把这一位当成单独的符号位,在剩下几位计算出的十进制前加上正 负号,而是在计算整个二进制值的时候,在左侧最高位前面加个负号。
如果最高位是 1,这个数必然是负数;最高位是 0,必然是正数。并且,只有 0000 表示 0,1000 在这样的情况下表示 -8。一个 4 位的二进制数,可以表示从 -8 到 7 这 16 个整数,不会白白浪费一位。
字符串的表示,从编码到数字
ASCII 码就好比一个字典,用 8 位二进制中的 128 个不同的数,映射到 128 个不同的字符 里。比如,小写字母 a 在 ASCII 里面,就是第 97 个,也就是二进制的 0110 0001,对应 的十六进制表示就是 61。而大写字母 A,就是第 65 个,也就是二进制的 0100 0001,对 应的十六进制表示就是 41。
我们可以看到,最大的 32 位整数,就是 2147483647。如果用整数表示法,只需要 32 位 就能表示了。但是如果用字符串来表示,一共有 10 个字符,每个字符用 8 位的话,需要整 整 80 位。比起整数表示法,要多占很多空间。 这也是为什么,很多时候我们在存储数据的时候,要采用二进制序列化这样的方式,而不是 简单地把数据通过 CSV 或者 JSON,这样的文本格式存储来进行序列化。不管是整数也 好,浮点数也好,采用二进制序列化会比存储文本省下不少空间。
“锟斤拷”的来源是这样的。如果我们想要用 Unicode 编码记录一些文本,特别是 一些遗留的老字符集内的文本,但是这些字符在 Unicode 中可能并不存在。于是, Unicode 会统一把这些字符记录为 U+FFFD 这个编码。如果用 UTF-8 的格式存储下来, 就是\xef\xbf\xbd。如果连续两个这样的字符放在一起,\xef\xbf\xbd\xef\xbf\xbd,这个 时候,如果程序把这个字符,用 GB2312 的方式进行 decode,就会变成“锟斤拷”。这 就好比我们用 GB2312 这本密码本,去解密别人用 UTF-8 加密的信息,自然没办法读出有 用的信息。 而“烫烫烫”,则是因为如果你用了 Visual Studio 的调试器,默认使用 MBCS 字符 集。“烫”在里面是由 0xCCCC 来表示的,而 0xCC 又恰好是未初始化的内存的赋值。于 是,在读到没有赋值的内存地址或者变量的时候,电脑就开始大叫“烫烫烫”了。
12 | 理解电路:从电报机到门电路,我们如何做到“千里传信”?
从信使到电报,我们怎么做到“千里传书”?
灯塔和烽火台这样的设备,电报信号有两个明显的优势。第一,信号的传输距离迅速增 加。因为电报本质上是通过电信号来进行传播的,所以从输入信号到输出信号基本上没有延 时。第二,输入信号的速度加快了很多。电报机只有一个按钮,按下就是输入信号,按的时 间短一点,就是发出了一个“点”信号;按的时间长一些,就是一个“划”信号。只要一个手指,就能快速发送电报。
电报机本质上就是一个“蜂鸣器 + 长长的电线 + 按钮 开关”。蜂鸣器装在接收方手里,开关留在发送方手里。双方用长长的电线连在一起。当按 钮开关按下的时候,电线的电路接通了,蜂鸣器就会响。短促地按下,就是一个短促的点信 号;按的时间稍微长一些,就是一个稍长的划信号。
理解继电器,给跑不动的信号续一秒
随着电线的线路越长,电线的电阻就越大。当电阻很大,而电压不够的时 候,即使你按下开关,蜂鸣器也不会响。
总结
本文涉及计算机内存管理、动态链接、二进制编码和基本电路。计算机内存管理由拟内存、内存交换和内存分页三个技术组合,动态链接保证程序复用,计算机里所有内容都用二进制表示,基本电路的机构是组成计算机的基础。