操作系统(三)内存管理
为什么操作系统会有虚拟内存?
- 虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。(如写时复制的权限)
什么是内存分段?
分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)。
分段内存管理当中,地址是二维的,一维是段号,二维是段内地址;其中每个段的长度是不一样的,而且每个段内部都是从0开始编址的。
由于分段管理中,每个段内部是连续内存分配,但是段和段之间是离散分配的,因此也存在一个逻辑地址到物理地址的映射关系,相应的就是段表机制。
什么是内存分页?
把内存空间划分为大小相等且固定的块,作为主存的基本单位。
因为程序数据存储在不同的页面中,而页面又离散的分布在内存中,因此需要一个页表来记录映射关系,以实现从页号到物理块号的映射。
访问分页系统中内存数据需要两次的内存访问 (一次是从内存中访问页表,从中找到指定的物理块号,加上页内偏移得到实际物理地址;第二次就是根据第一次得到的物理地址访问内存取出数据)。
段式管理和页式管理会出现内存碎片吗?
段式管理可以做到根据段根据实际需求分配空间,所以有多少需求就分配多大的段,那么在段的内部就不会产生空间浪费,也就是没有碎片,但是由于每个段的长度不是固定的,所以段与段之间会产生碎片,这种称为外部碎片。
而页则是固定大小的,通常是 4k,假设我们要为代码准备空间,即使这一段机器码不足4k,我们也要为它分配 4k,因为一个页的大小就是 4k,我们最少只能分配一个页,所以页式管理,页与页之间紧密排列,但是页内出现内存浪费,也就是内存碎片,这种称为内部碎片。
页面置换算法
最佳置换算法(OPT):置换掉未来永远不会被访问,理论上这种算法效果最好,但是在实际中无法得知未来进程的行为,所以无法实现。
先进先出算法(FIFO):置换掉最早进入内存的页面。这个算法简单,易于实现,但可能导致"先进入"的页面是常用页面而被频繁置换,造成性能下降。
最近最久未使用算法(LRU):置换掉最近一段时间内最少被访问的页面,算法的实现是给每个页面设置一个时间戳,记录最近一次访问的时间,如果发生缺页错误,则从所有页面中淘汰时间戳最久远的一个。
最少使用算法(LFU):置换掉访问频率最少的内存页面,实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。然而,当一个页面在进程的初始阶段大量使用但是随后不再使用时,会出现问题。由于被大量使用,它有一个大的计数,即使不再需要却仍保留在内存中。一种解决方案是,定期地将计数右移 1 位,以形成指数衰减的平均使用计数。
时钟算法(Clock):每页设置一个访问位,再将内存中的所有页面都通过链接指针链接成一个循环队列。当某个页面被访问时,其访问位置1。淘汰时,检查其访问位,如果是0,就换出;若为1,则重新将它置0,然后检查下一个页面,如果到队列中的最后一个页面时,若其访问位仍为1,则再返回到队首再去检查第一个页面。
数组的物理空间连续吗?
不是的,数组的虚拟地址是连续的,但是虚拟地址映射到物理地址是由操作系统负责的,操作系统并不会保证映射的物理地址也是连续的。
栈的增长趋势是什么?
栈的增长趋势是向内存地址减小的方向增长。也就是说,栈的底部在较高的内存地址,而栈的顶部在较低的内存地址。当新的元素被添加到栈中时,栈的顶部会向较低的内存地址方向移动,栈的大小会增加。当元素从栈中被移除时,栈的顶部会向较高的内存地址方向移动,栈的大小会减小。因此,栈的增长趋势是由内存布局决定的,向内存地址减小的方向增长。
堆区和栈区有什么区别?
- 栈区的内存用于函数调用栈,主要保存函数的入参、局部变量、返回值等,栈区内存不可被其他线程共享,栈区的内存管理是由编译器自动完成的,编译时就确定了变量的生命周期,不需程序员来分配内存和释放内存,当函数执行结束时,栈区中的数据会自动被释放。
- 堆区内存需要通过动态内存分配函数来申请,堆区的内存可被其他线程共享,堆区内存的生命周期是由程序员控制的,需要进行显示分配和释放内存,如果反复向操作系统申请堆内存而不释放,会导致内存泄露。在 C / C++ 中,必须由程序员手动释放堆内存。而 Java / Golang 中有垃圾回收器,会定期主动回收内存。但是即使有垃圾回收器,也有内存泄漏的风险,比如长期持有某个大对象的引用。
- 堆区可分配的大小会比栈大很多,因为栈的内存是限的,通常是 8MB 大小,而堆的空间较大,受限于系统中有效的虚拟内存,比如 32 位系统,虚拟内存最大 4GB,64 位操作系统就 128TB。
- 栈区的内存分配效率会比堆区分配内存效率高,因为分配栈区内存,只需要移动栈指针即可,而分配堆区内存需要在堆中找到可用的内存块。
在栈上的数据操作比堆上快很多的原因?
访问栈上的数据是可以直接访问,因为 CPU 有栈相关的寄存器直接对栈进行访问,而堆上的数据需要通过指针进行访问,是一个间接寻址的过程,访问速度相对较慢。
还有栈上的数据是连续存在,这样可以更好的利用 CPU 的 CPU Cache,访问栈上的数据缓存命中率比较高,而堆上的数据分布是散乱的,无法充分利用 CPU 缓存,导致缓存命中率较低,访问速度相对较慢。
32 位操作系统,4G物理内存,程序可以申请 8GB内存吗?
32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
程序申请的内存实际上是虚拟内存,32 位操作系统中用户空间的虚拟内存大小是 3 GB,进程最多只能申请 3 GB 大小的虚拟内存空间,所以进程申请 8GB 内存的话,在申请虚拟内存阶段就会失败。
64 位操作系统,4G物理内存,程序可以申请 8GB内存吗?
在 64位 位操作系统,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区:
- 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);
- 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;
fork 的写时复制是如何实现的?
fork 是创建进程的系统调用, 执行 fork 的时候,子进程会复制父进程的 PCB 复制一份,这里包含页表、文件资源等,并且将页表的权限设置为只读,但是并不会复制物理内存,所以这时候父子进程的页表指向的物理内存的是同一片区域,此时父子进程的内存数据都是共享的。
当父进程或者子进程对这片共享的内存数据进行了写操作,发现页表是只读的属性,这时候就会发生写保护中断,内核就会为发生中断的进程分配新的物理内存,把老的物理页的数据拷贝进这个新的物理页,然后将最新的数据写入到这个新的物理页,最后把发生中断的虚拟地址映射到新的物理页,同时该进程的页表的权限设置为可读写,这就完成了一次写时复制。
malloc会陷入内核态吗?malloc的底层原理是什么?
malloc 不是系统调用函数,不会陷入到内核态,malloc 申请内存的时候,会通过 brk 或者 mmap 这两个系统调用来申请内存。为了避免频繁向操作系统申请内存,malloc 还实现了内存池化技术,先申请一大块内存,然后将内存分成不同大小的内存块,然后用户申请内存时,直接从内存池中选择一块相近的内存块即可,这样就减少系统调用的开销。