操作系统(operating system,简称OS)是管理计算机硬件与软件资源的计算机程序。操作系统需要处理如管理与配置内存、决定系统资源供需的优先次序、控制输入设备与输出设备、操作网络与管理文件系统等基本事务。
操作系统也提供一个让用户与系统交互的操作界面。
1、进程管理,其工作主要是进程调度,在单用户单任务的情况下,处理器仅为一个用户的一个任务所独占, 进程管理的工作十分简单。但在多道程序或多用户的情况 下,组织多个作业或任务时,就要解决处理器的调度、 分配和回收等问题 。
2、存储管理分为几种功能:存储分配、存储共享、存储保护 、存储扩张。
3、设备管理分有以下功能:设备分配、设备传输控制 、设备独立性。
4、文件管理:文件存储空间的管理、目录管理 、文件操作管理、文件保护。
4、作业管理是负责处理用户提交的任何要求。
内存的分配方式在宏观上可以分为连续分配方式和非连续性分配方式。
连续性分配方式又分为单一连续分配方式、固定分区方式和动态分区方式;
非连续分配方式则就是我们今天重点需要讲解的内存分段式管理和分页式管理以及更高端的段页式内存管理方式。
整个内存只能有一个程序,这种分配方式已经过时了。
在这种分配方式下,内存被分为 系统区和用户区,系统区用于装操作系统的相关数据,用户区用于装程序。由于整个用户区只能有一个程序,因此内存利用率极差。
固定分区能够支持多道程序。
原理是:把用户区进一步分成多个分区,每一个分区存放一个进程。固定分区分配又分成两种:一种是各个分区大小相同,另一种是各个分区大小不相等。
分区大小不等有利于存放大小不同的进程。
当操作系统用到分区技术时,都会维护一个数据结构----分区说明表,来实现各个分区的分配与回收,每个表项对应一个分区,通常按分区大小排列,用数组或链表都可以表示这张表,表具体如下:
?优点:实现简单,无外部碎片。(外部碎片即用户区里有未被用到的地方,由于分区是刚好占满用户去的,故没有外部碎片)
缺点:存在内部碎片(即分区内因进程没占满分区而多出来未被使用的地方);而且遇到一个大进程,以致所有分区都放不下,那就只能使用覆盖技术,覆盖技术由于会把进程的某些段从外存和内存之间调出调入,因此会增加I/O开销。
亮点:这种分区方式不会预先划分内存分区,而是在进程装入内存时,再动态地根据进程的大小建立分区。使得分区的大小刚好适合进程使用。
存在的问题:
由于进程在运行完后,会退出内存,留下空余的碎片,如下面的6MB和10MB和4MB。此时若 有一个 20MB的进程进来,则虽然空闲空间刚刚好有20MB,但依然是放不下的。这就是存在的问题,为了解决这个问题,只能引入拼凑技术。
拼凑技术:拼接技术/紧凑技术:移动内存中所有已经分配区到内存的一端,将其余空闲分区合并为一个大的空闲分区
拼凑后就能放入20MB的进程了
优点:动态分区没有内部碎片
缺点:动态分区有外部碎片。必要时要引入拼凑技术解决存在的问题。但是拼凑技术的时间成本很大。
实际上,连续分配方式和非连续性内存分配方式的最大区别就是:连续性分配方式是一个分区一个进程;非连续分配方式是把一个进程分解成几块,分别放入几个不同的分区。
上面讲述了内存的连续性分配管理方式,而后面非连续性的分配管理方式,我们和虚拟内存一起合并起来,挨个重点讲述。
在了解非连续性内存分配方式之前,我们先简单了解一下虚拟内存。
上述内存的分配方法有个很难受的问题就是,进程必须要全部放进去才能开始运行,但是由于局部性原理我们可以知道,一个进程有时候只会频繁使用某些页(后续会说),因此其他不被使用到的页就会占用着内存空间。为了解决这个问题,提出了虚拟内存技术的,总体而言就是把用不到的进程页从内存调到外存中,当使用到的时候再调入内存。
而这之间的互相调用的时候,其实就是虚拟地址和真正物理地址之间的互相操作。
要知道,在很早以前的操作系统中,一个CPU只有一个核,也只能运行一个程序,要想在内存中同时运行两个程序是不可能的。如果第一个程序在某个的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。
在最早的单核系统中,CPU 是直接操作内存的「物理地址」。
?
如果将操作系统比喻为一个餐厅,CPU为厨师,进程为客人,最初的餐厅只能服务一名客人,第二个进店的客人回和第一个客人“抢饭吃”(资源)。
但是显然,我们现在的操作系统上能运行不止一个程序,那么操作系统是如何处理这个问题的呢?
这里关键的问题是这两个程序都引用了绝对物理地址,而这正是我们最需要避免的,就像是客人都能看见厨师一样。
我们可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套虚拟地址,人人都有,大家自己玩自己的地址就行,互不干涉。就像我们在餐厅设立包间(虚拟地址),不让客人和厨师面对面。这样每个进程就会自我认为自己占据了全部内存,静静等待资源分配就行了。
但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。
?
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
于是,这里就引出了两种地址的概念:
我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
?
那么看到这里,我们不禁有个问题:操作系统是如何管理虚拟地址与物理地址之间的关系?
这里便牵扯出我们内存管理的几个重要的方式,实际上主要有两种方式,分别是内存分段和内存分页。
程序是由若干个逻辑分段组成的,如可由程序分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。
而内存分段的定义就是:按照程序自身逻辑关系划分为若干个段。每个段在内存中占连续空间,但是各个段之间可以不相邻。
也就是说,分段内存可以根据进程的需求,也就是程序自身的逻辑关系来划分内存,所以分段的大小可以是不一样的,并且可以不相邻,这和我们后面要说的分页式内存不太一样。
而我们内存中的段信息,比如每个段在内存中的起始位置与大小,则存储在段表之中。
上述是我们内存的一种分配方式,而在虚拟内存中,段表应该如何映射虚拟内存和物理内存之间的关系呢?
传统的分段机制很简单,段表只需要记录每个段在内存中的起始位置与大小就行了,而分段机制下的虚拟地址则由两部分组成,段选择子和段内偏移量。
?
段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
?在上面了,知道了虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
第一个就是内存碎片的问题。
第二个就是内存交换的效率低的问题。
我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:
游戏占用了 512MB 内存
浏览器占用了 128MB 内存
音乐占用了 256 MB 内存。
这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。
如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。
这里的内存碎片的问题,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载。
?
针对上面两种内存碎片的问题,解决的方式会有所不同。
解决外部内存碎片的问题就是内存交换。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
那分段为什么会导致内存交换效率低的问题呢?
对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap
内存区域,这个过程会产生性能瓶颈。
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。
分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。
要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。
页框:把内存分为一个个相等的小分区,再按照分区大小把进程拆分成一个个小部分。这些小从部分就是所谓的“页框”或“页帧”或“内存块”。每个页框都有一个编号,叫页框号,编号从0开始。
页面:把进程分成多个部分,每个部分大小与页框相等,这些部分就叫页面。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB
。
既然有段表,那么就有页表,为了能知道进程的每个页面在内存中存放的位置,操作系统要为每个进程建立一张页表,进程的页表是存储在内存中的,而且是以连续的方式存;传统的分页机制的页表非常简单,只需要记录对应页表的内存块号(页框)就行了,但是虚拟内存的请求分页时可能会把某些页面放到外存,因此此页表不仅要记录,内存位置,还要记录外存的位置。
虚拟地址与物理地址之间也通过页表来映射,如下图:
页表实际上存储在 CPU 的内存管理单元 (MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。
而当进程访问的虚拟地址在页表中查不到,也就是页面不在内存中时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
快表与慢表:
快表:其实是一个访问速度比访问内存快很多的高速缓存器。
慢表:内存中的页表相对于快表而言,它就是慢表了。
快表的作用:
作用:快表用于存放当前访问的若干个页表项(页表项即页表中的一行数据)。操作系统会把最近经常访问的页表对应的页表项放入快表中。
背景:快表的设计是基于时间局部性原理的,意思就是当前使用的页面很可能在接下来的时间内被再次使用。
引入快表后的不同:
当没有引入快表时,CPU访问某个进程的页面需要两次访问内存:1次是访问内存中的慢表(页表),从里面找到所需页面在内存的哪个页框内。第2次是根据页表得到的内存页框号访问内存,找到所需进程页面。
当引入快表时,CPU访问某个进程的页面会现在快表中查找,若查不到,则会回到页表上找。若找到,就会根据快表中给出的页框号,直接访问内存对应的页框。差别就在,快表是高速缓存器,访问快表比访问内存快多了,所以可以节省时间,而且快表肯定比页表小,所以查询起来,查询成本也低。
在分页机制下,由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在外存上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
?
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。 ?
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
把虚拟内存地址,切分成页号和偏移量;
根据页号,从页表里面,查询对应的物理页号;
直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB
的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。
那么,100
个进程的话,就需要 400MB
的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB
的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024
个页表(二级页表),每个表(二级页表)中包含 1024
个「页表项」,形成二级分页。如下图所示:
你可能会问,分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?
其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?
每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。
所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
全局页目录项 PGD(Page Global Directory);
上层页目录项 PUD(Page Upper Directory);
中间页目录项 PMD(Page Middle Directory);
页表项 PTE(Page Table Entry);
?
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
?
在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
分段与分页的对比:
分段比分页更容易实现信息的共享和保护,因为,一个程序中,分为可修改代码和不可修改代码,可修改代码是不可共享的,比如一个代码段有很多变量,各进程并发访问会造成数据的不一致,如图:
?
所以,相比较于分页,分段很方便地对逻辑模块实现信息的共享和保护。但是也有一定的缺点,跟动态分区分配一样,会产生外部碎片。虽然说外部碎片可以使用紧凑技术处理,但是这样会付出很大的时间成本。而且如果段长过大,为其分配的连续存储空间会很不方便。
上述中,内存的分页分段都有一定的局限性,也都有自己的可取性。实际上,内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理。
段页式内存管理实现的方式:
先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
这样,地址结构就由段号、段内页号和页内位移三部分组成。
用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:
?
段页式管理中的段表、页表与内存的关系
段页式地址变换中要得到物理地址须经过三次内存访问:
第一次访问段表,得到页表起始地址;
第二次访问页表,得到物理页号;
第三次将物理页号与页内位移组合,得到物理地址。
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。
那么,Linux 操作系统采用了哪种方式来管理内存呢?
在回答这个问题前,我们得先看看 Intel 处理器的发展历史。
早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。
因此,在不久以后的 80386 中就实现了对页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。
但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的的地址上再加上一层地址映射。
由于此时段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。
Intel X86 逻辑地址解析过程
这里说明下逻辑地址和线性地址:
程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;
逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。
了解完 Intel 处理器的发展历史后,我们再来说说 Linux 采用了什么方式管理内存?
Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。
这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。
但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
我们再来瞧一瞧,Linux 的虚拟地址空间是如何分布的?
?
用户空间与内存空间
通过这里可以看出:
32
位系统的内核空间占用 1G
,位于最高处,剩下的 3G
是用户空间;
64
位系统的内核空间和用户空间都是 128T
,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
再来说说,内核空间与用户空间的区别:
进程在用户态时,只能访问用户空间内存;
只有进入内核态后,才可以访问内核空间的内存;
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
?
每个进程的内核空间都是一致的
接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。
我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:
?
通过这张图你可以看到,用户空间内存,从低到高分别是 7 种不同的内存段:
程序文件段,包括二进制可执行代码;
已初始化数据段,包括静态常量;
未初始化数据段,包括未初始化的静态变量;
堆段,包括动态分配的内存,从低地址开始向上增长;
文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB
。当然系统也提供了参数,以便我们自定义大小;
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()
或者 mmap()
,就可以分别在堆和文件映射段动态分配内存。
为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套的虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。
那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。
那么对于虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。
内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。
于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB
。由于分了页后,就不会产生细小的内存碎片。
同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。
再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。
于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。
Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0
,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。
另外,Linxu 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。
原文链接:https://mp.weixin.qq.com/s/oexktPKDULqcZQeplrFun
见我的单独博客:计算机操作系统之进程、线程、协程-CSDN博客
栈(操作系统):由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈,栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放 堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。堆则是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。
Java中的堆和栈
对象实例: 保存对象实例,实际上是保存对象实例的属性值,属性的类型和对象本身的类型标记等,并不保存对象的方法(方法是指令,保存在stack中)。 对象实例在heap中分配好以后,需要在stack中保存一个4字节heap内存地址,用来定位该对象实例在heap中的位置,便于找到该对象实例。如String,Integer,即类。 new创建的,都是放到堆Heap
基本数据类型包括byte、int、char、long、float、double、boolean和short。函数方法属于指令.
?1、内存分配方面:
- 堆:一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式是类似于链表。可能用到的关键字如下:new、malloc、delete、free等等。
- 栈:由编译器(Compiler)自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
2、申请方式方面:
- 堆:需要程序员自己申请,并指明大小。在c中malloc函数如p1 = (char *)malloc(10);在C++中用new运算符,但是注意p1、p2本身是在栈中的。因为他们还是可以认为是局部变量。
- 栈:由系统自动分配。 例如,声明在函数中一个局部变量 int b;系统自动在栈中为b开辟空间。
3、系统响应方面:
- 堆:操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样代码中的delete语句才能正确的释放本内存空间。另外由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
- 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
4、大小限制方面:
- 堆:是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
- 栈:在Windows下, 栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是固定的(是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
5、效率方面:
- 堆:是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便,另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈是直接在进程的地址空间中保留一快内存,虽然用起来最不方便。但是速度快,也最灵活。
- 栈:由系统自动分配,速度较快。但程序员是无法控制的。
6、存放内容方面:
- 堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。
- 栈:在函数调用时第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈,然后是函数中的局部变量。 注意: 静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
7、存取效率方面:
- 堆:char *s1 = "Hellow Word";是在编译时就确定的;
- 栈:char s1[] = "Hellow Word"; 是在运行时赋值的;用数组比用指针速度要快一些,因为指针在底层汇编中需要用edx寄存器中转一下,而数组在栈上直接读取。
堆和栈的区别可以用如下的比喻来看出:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。、
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
堆栈是一种存储部件,即数据的写入跟读出不需要提供地址,而是根据写入的顺序决定读出的顺序。
形象来说,栈就是一条流水线,而流水线中加工的就是方法的主要程序,在分配栈时,由于程序是自上而下顺序执行,就将程序指令一条一条压入栈中,就像流水线一样。而堆上站着的就是工作人员,他们加工流水线中的商品
由程序员分配:何时加工,如何加工。而我们通常使用new运算符为对象在堆上分配内存(java),堆上寻找对象的任务交给句柄,而栈中由栈指针管理
操作系统将整个程序的执行状态分为内核态和用户态,或者说是两种程序级别,不同级别拥有不同的操作等级,目的是不让我们的用户程序影响硬件 。
内核态:cpu内核中一些非常核心的,一般来说是和硬件打交道的操作,只有内核能执行的程序。也就是运行操作系统程序,操作硬件。当我们执行程序到了需要进行这些操作的时候,就需要求助内核帮我们执行,自己不能执行,这时候需要内核态来帮忙,比如,内存申请、进程/线程调度、锁的申请。(ring0级别)
用户态:现实中大多数应用程序,比如我们的java程序是跑在用户态上的,也就是用户态运行用户程序,当它需要进行一些特殊操作的时候,就需要内核来帮忙执行,向内核态申请,让内核执行这些程序。(ring3级别)
?