你好,我是海纳。

随着课程的不断更新,同学们的留言也越来越精彩。我也经常会到这门课的留言区转一转,大部分的疑问,我都已经在留言区做了回复,但我知道你可能在一些地方还存在着疑问。所以,我在这里将同学们问得比较多的问题提炼出来,统一地进行解答,同时,我还整理了软件篇中难度比较大的课后思考题答案,希望能给你带来帮助。

那我们先从高频问题的解答开始吧。

高频留言答疑

问题一:JVM的内存布局和Linux进程的内存布局有什么关系?它们是一样的吗?

这个问题看起来很难,但你可以从JVM的原理入手来找到突破点。典型的JVM,比如Hotspot,它们运行起来以后,也是操作系统上一个普通进程而已。所以,我们可以推论,hotspot进程也有自己的代码段、数据段、堆和栈。我们按照从简单到复杂的顺序来展开Hotspot进程中的内存布局。

首先,我们先看代码段。Hotspot运行以后,它的代码段里存放的不是Java代码,而是虚拟机自己的代码。这些代码都是由C++编写的,它实现了虚拟机的逻辑。Hotspot会新开辟一段区域,叫做Metaspace,用于加载Java的class文件,并且Java的类信息、方法定义的字节码都存储在这里。所以,从Linux进程的角度看,这一块内存其实是操作系统的堆内存,只不过被用来存储Java字节码了而已。

然后,我们再看栈。Java原生支持多线程,它的线程创建,本质上是使用pthread编程接口实现的。所以Java的线程栈是直接复用了系统线程栈的。但是,你一定要注意,hotspot栈的操作方式和gcc编译C代码的操作方式不一样。Hotspot将栈帧分成了变量表和操作数栈两部分。字节码在执行过程中是在操作栈帧里的操作数栈。这一点你要引起重视,因为我看到很多同学都没能正确区分函数栈和栈帧里的操作数栈。操作数栈就是在栈里还嵌套了一个栈,这是基于栈的虚拟机常见的结构。

接下来,我们来讨论即时编译(JIT)所使用的代码段。在第11节课我们已经介绍了,JIT使用的代码段是通过mmap得到的、可写可执行的内存区域。严格地说,JIT代码段位于mmap使用的文件映射区,但是由于它被用来当做代码段使用,所以,我们还是把这段内存区域称为代码段。我曾经在课程里讲过,有一些情况下,代码段的地址完全可以比堆的地址更高,这里就是一个实际的例子。

最后,我们再来讨论堆内存。Hotspot中所说的堆,相比Linux进程中的堆,范围更小。Hotspot首先通过malloc/mmap申请内存区域,然后,Java中的所有对象都会在这块内存区域中创建。这块内存区域就是hotspot虚拟机中的堆,也就是Java程序员日常工作中称呼为“Java堆”的区域。

经过这些讨论以后,我们发现,Java语言虚拟机在实现的过程中,自由度非常大。它可以任意地将自己的申请来的内存转变为metaspace,用来存储Java类信息和方法的字节码,也可以将它转变为JIT所使用的代码段。

如果你想深入学习虚拟机的更多知识,可以参考我写的《自己动手写Python虚拟机》,这本书可以帮助你了解虚拟机使用内存的更多细节。

问题二:专栏里说mmap可以修改堆大小,那映射的区域为啥不属于堆呢?

这个问题非常典型,很多同学问了好几个相似的问题。核心就是纠结到底哪一块内存是堆,哪一块内存是栈。那么,mmap出来的内存到底是文件映射区呢,还是堆呢?

其实,我们只要把握住一条原则就可以了:决定一块内存区域的性质的,不是它的地址,而是它的作用。

我们知道mmap的作用有很多。比如,人们会使用私有文件映射来加载动态库,这是文件映射区的核心作用。但是在glibc中,malloc函数的实现依赖于mmap使用私有匿名映射分配内存,这样分配的内存,会被glibc用于堆内存。所以这块内存从地址上说,是位于文件映射区的,但它起的是堆的作用,所以它被认为是堆内存。

你可以回忆一下,第5节课,我们在实现协程的时候,使用malloc申请了一块内存区域,用作协程的栈。然后,在第9节课中,我们讲malloc的实现在向操作系统申请内存时,可能会有两种操作:它有可能使用sbrk,这就是从传统的堆区域里分配一块内存;它也有可能使用mmap,这就是从文件映射区分配内存。所以我们在实现协程的时候,就可能会在堆内存区域(brk指针下面),或者文件映射区域里“偷”了一段内存用于程序栈。

由此可见,内存区域的划分是非常灵活的。我们前面第3节课的示意图只是描述了典型的Linux进程的内存布局,但这并不意味着所有内存区域都只能按照这种方式,进行严格地划分,永远一成不变。要牢记,内存区域的性质不是由它的地址决定的,而是由它的作用来决定的。

问题三:Java程序中的堆外内存是指什么?

这个问题的产生,根源还是Java中堆的概念和Linux进程中堆的概念范围不同。我们知道,Java对象都是在Java堆中创建,Java中的堆是由JVM托管,并且其中的对象会自动分配,自动回收。

堆外内存是指不在Java堆中管理的用户数据。常见的堆外内存是使用Java原生接口(Java Native Interface, JNI)的场景,JNI中可以使用malloc申请内存,然后把确定性的数据都放到这部分内存中。所谓确定性的数据,是指用户明确地知道这些数据的生命周期,可以确定性地手动申请和释放。我们往往把大规模确定性数据放在堆外内存中,这样做的好处是避免了垃圾回收器经常扫描搬移这部分数据,从而带来性能的下降

然后我们再回到Linux进程的视角看看,不管是Java堆还是堆外内存,其本质还是在进程的堆空间分配的。所以显然,堆外内存的概念只能是指不在Java堆中的那部分内存,而不能是进程堆空间之外的内存。

软件篇思考题解析

第2节课的思考题:段寄存器还起作用吗?

解析:其实在32位Linux操作系统上,数据段和代码段都是从地址0开始且段大小为4G。也就是说,所有的段都被映射到了0~4G这段空间,并且从软件的层面废弃了分段机制。虽然Linux也设置了GDT和LDT为分段机制提供了相应的数据结构,但显然,Linux的内存管理只是为了敷衍CPU,并没有真的使用这种机制。

有同样待遇的还有TSS这个结构。我们在第4节课用了很小的篇幅提了一下TSS结构。实际上,在i386的设计中TSS结构扮演了重要的角色。CPU希望每个进程都有一个自己专属的TSS,并通过’'jmp tss"指令切换进程。但是从内核版本2.2开始,Linux也不再为每个进程创建一个TSS了,而是每个CPU都只有一个,切换进程时,只要将进程的上下文恢复进TSS结构就相当于完成了进程切换。

从这个角度看,x86 CPU其实有一些设计是属于过度设计。在现代CPU中,这些设计都被简化了。

第3节课的思考题:堆应该被授予怎样的权限?

解析:其实这个问题和上面问题二是一样的。堆内存区域可能会被用作JIT代码段,也可以被用作协程栈,文件映射区也有同样的情况。所以堆被授予怎样的权限,关键还是要看堆内存被用来做什么。如果是被用于JIT代码段,那显然,它的权限就是可读、可写和可执行了。

第5节课的思考题:请你思考,线程的栈切换,是更类似协程那种提前创建好的方式,还是更类似于进程那种按需写时复制?为什么?

解析:分析这个问题的关键在于明确线程与进程之间是怎样的关系。我们在课程中反复强调,一个进程中的所有线程共享进程的资源,包括页表,文件等。所以线程是可以访问进程的内存空间的,这就导致按需写时复制机制不能实现。所以线程一定要采用类似协程那种,提前把栈准备好的方式。

第8节课的思考题:在生成一个动态库文件的时候,我们一定要加shared选项,但-fPIC选项是必然要加的吗?有没有不需要用这个选项的情况呢?如果没有,为什么?如果有的话,又是什么情况呢?

解析:要回答这个问题,我们就要从地址无关代码产生的原因去推导。我们知道对于函数foo,如果它的调用者和它不在同一个动态库中,那它们之间的相对位移在不同的进程中就不是固定的。为了解决这个问题,只能在生成调用者的机器码时,将它生成为地址无关的。

如果相对位移不存在,那么这个问题也就不存在了,自然就不需要再产生地址无关代码啦。也就是说,如果一个动态库没有调用其他动态库的函数,这个动态库就不必生成地址无关代码。只不过,通常情况下,一个功能完善的动态库往往会依赖各种其他的动态库,例如最常见的glibc等。所以,在实际工作中,大多数的动态库在编译时还是要带上-fPIC选项的。

第12节课的思考题:在HVA到HPA的转换过程中,当前的实现是主动调用get_user_pages来分配物理页。我们又知道VMM运行在内核态,实际上,它是有能力直接为GPA分配物理内存,而不必再借助HVA的,那为什么KVM要选择保留HVA呢?

解析:这么做的主要是原因是,如果直接将GPA映射到HPA的话,那么显然,虚拟机能使用的最大物理内存是受主机物理内存限制的。而我们在第1节课就已经知道,通常,主机的虚拟内存空间远超物理内存。

如果把GPA映射到HVA,那么我们就可以充分使用主机的虚拟内存机制,为虚拟机提供比较大的客户物理地址空间。而且由于我们可以使用缺页中断来管理主机虚拟内存的映射,这样也就不必再为客户虚拟机提前分配主机物理内存了,也就进一步节约了主机的物理内存。

今天的答疑就到这里了,不知道你消化得如何?如果还有疑问,请大胆地在留言区提出来,也欢迎你把你的课后思考和心得都记录下来,与我和其他同学一起讨论。我是海纳,我们下节课再见!

评论