虛擬存儲器又叫做虛擬內存,我們現在的操作系統普遍都支持了虛擬內存,這樣做是因為我們同時運行著太多的程序了,就目前我電腦的狀態來看,我既要打開瀏覽器,又要聽歌,可能同時還登陸的有QQ,如果不使用虛擬內存4G的內存空間很快就會被耗盡,而一旦沒有了內存空間,其他程序就無法加載了。虛擬內存的出現就是為了解決這個問題,當一個程序開始運行的時候,其實是為每個程序單獨創建了一個頁表(這個以后講),只將一部分放入內存中,以后根據實際的需要隨時從硬盤中調入內容。當然虛擬內存不僅僅只有這個功能,我們的操作系統也是在內存中運行著的,虛擬內存同時還提供了一種保護,這樣做其他進程就不會損壞掉系統的內存空間。那么虛擬內存是如何實現的呢?
1.1 物理尋址和虛擬尋址
虛擬內存主要是一種地址擴展技術,主要是建立和管理兩套地址系統:物理地址和虛擬地址。由虛擬地址空間(硬盤上)裝入進程,其實際執行是在物理地址空間(內存上)承載進程的執行。虛擬地址空間比物理地址空間要大的多,操作系統同時承擔著管理者兩套地址空間的轉換。我們來看看什么是物理尋址:
主存的每個地址都是唯一的,第一個字節地址為0,接下來為2,以此類推。CPU使用這種訪問方式就是物理尋址。上圖所示就是CPU通過地址總線傳遞讀取主存中4號地址開始處的內容并通過數據總線傳送到CPU的寄存器中。
當然地址總線也不是無限大的,我們通常所說的32位系統,其尋址能力是2^32 = 4 294 967 296B(4GB)也就是說內存條插的再多也沒有用,地址總線只能最多訪問到4GB的地址內容。我們前面說過4GB的物理內存空間其實并不大(如果是獨占的話)。這時候科學家們想到了一個很好的方法,建立虛擬尋址方式,使用一個成為MMU的地址翻譯工具將虛擬地址翻譯成物理地址在提供訪問,如下圖:
使用虛擬尋址的時候,cpu先是生成一個虛擬地址:4100再經過地址翻譯器,將4100翻譯成物理地址。
我們說過虛擬地址要比物理地址大的多,為啥還要麻煩的將物理地址轉成虛擬地址呢?虛擬地址的發明究竟是為了什么,我們知道對內存的訪問要比硬盤的訪問快10000倍,如果我們在內存中沒有找到相應的內容(不命中),而需要到硬盤上找的話,我們必須要提供相對來說高效率的訪問方式。這時候就創建了一個虛擬存儲器,管理著磁盤,以每頁的方式進行整合,每個頁面的大小4kb-2mb不等,加上偏移量就成為了一個虛擬地址。比如4100,說明的就是頁4編號,偏移100處的位置。這就比挨個挨個單獨尋址要快的多。
1.2 地址空間
地址空間是一個非負整數的集合{0,1,2,……},一個32位的系統中有:2^32 = 4 294 967 296B(4GB)個有效地址。地址空間的概念很重要,我們必須要清楚數據對象(字節)和它的屬性(地址)的區別, 舉個例子:我和我老婆住在蒼溪縣xx小區7棟1單元,這個就是我的屬性:地址。另外,住在家的我和我老婆就是數據對象(字節)。虛擬存儲器的基本思想是:主存中的每個字節都有一個選自虛擬地址空間的虛擬地址和一個選自物理地址空間的物理地址。
1.3 虛擬存儲器的工作原理
我們先來看看虛擬內存,就windows系統而言是保存?在磁盤上的一個文件,存放于C盤的pagefile.sys點擊屬性可以看到其大小為3.96G,這相當于一個倉庫,保存著臨時需要又還沒用到的數據。
這個倉庫的的數據被分割成塊,稱為虛擬頁。虛擬存儲器的主要思想就是:在主存中緩存硬盤上的虛擬頁(pagefile.sys),虛擬頁有三個狀態:未分配、緩存的、未緩存的。
上圖所示的是一個有8個虛擬頁的小虛擬存儲器(建立在硬盤上),虛擬頁0和3還未分配,因此在磁盤上還不存在。虛擬頁1、4和6被緩存在右邊的主存中。
(內存訪問速度要比硬盤快10000倍,因此不命中的話代價要昂貴的多。我們前面說過是以虛擬頁來緩存的,也就是分成塊,每個塊(虛擬頁)的大小4kb-2mb不等。)
我們現在來看看地址翻譯MMU是如何完成虛擬地址到物理地址的轉換的,學習這個知識是幫助我們理解虛擬存儲器是如何將虛擬也緩存到主存(內存)中去的。
① 頁表
頁表是一個存放在內存中的數據結構,MMU就是通過頁表來完成虛擬地址到物理地址的轉換。這個數據結構每一個條目稱為PTE(Page Table Entry),由兩部分組成:有效位和n位地址段。有效位如果是1,那么n位地址就指向已經在內存中緩存好了的地址;如果為0,地址為null的話表示為分配,地址指向磁盤上的虛擬內存(pagefile.sys)的話就是未緩存。我們來看一個典型的頁表圖:
虛擬頁vp1,2,7,4當前被緩存在內存中,頁表上有效位設置成1,分別用PTE1,2,4,7表示。VP0和VP5(PTE0、5)未被分配,VP3和VP6被分配并指向虛擬內存,但未被緩存。
② 頁命中
當我們使用2100虛擬地址來訪問虛擬頁2的內容的時候,就是一個頁命中。地址翻譯將指向PTE2上,由于有效位1,地址翻譯器MMU就知道VP2已經緩存在內存中了。就使用頁表中保存的物理地址進行訪問。
③ 缺頁
我們再來看看不命中,也就是缺頁的情況,當CPU需要VP3的一個字時,初始化是這樣的:
PTE3有效位是0,同時地址位指向了虛擬內存(pagefile.sys),就會觸發缺頁異常。異常處理程序會選擇犧牲一個內存(DRAM)中的頁,本例中選擇的是內存中的PP3頁的VP4,接下來內核就從虛擬內存中拷貝VP3到內存中的PP3,并使得PTE3指向內存中的PP3,形成如下:
(注:虛擬存儲器出現早于高速緩存,按照習慣的說法塊被叫做頁。從虛擬內存到物理內存傳送頁的活動就叫做頁面交換。)
1.4 虛擬存儲器的作用
虛擬存儲器有諸多的好處,操作系統其實為每個進程提供了一個獨立的頁表,使用不同的頁表也就創建了獨立的虛擬地址空間,下圖展示了基本思想:
進程i將VP1映射到了內存的PP2處,VP2映射到了內存的PP7處。進程j將VP1映射到了內存的PP7,將VP2映射到了PP10處。
簡化鏈接:每個進程一個頁表后,這個進程就會覺得全世界都是它的(頁表模擬出一個虛擬存儲器),那什么符號鏈接的時候(也就是符號映射到地址的時候),不再會受到內存中還有其他應用程序的干擾,因為我們面向的是虛擬存儲器,我們的進程的地址空間是獨立的,我這個符號放到離0偏移100的地方,那個放到離0偏移200的地方很容易就搞定了。
簡化加載:在硬盤中雙擊一個圖標,啟動一個應用程序時,實際上你都不需要將這個程序從硬盤給加載到內存,只需要建個頁表,然后頁表里的編號指向的是硬盤,然后CPU訪問到具體代碼的時候,再按照上一節的尋址的方式,按需的將硬盤上的東東加載到內存。加載過程及其簡單了。
簡化共享:我們有很多的進程在系統中運行,但是有些代碼,比如調用操作系統的API,這些API可能許多進程都要使用比如printf,這就要共享一部分內存,我們不需要將這部分內存在每個進程空間都拷貝一份,實際上每個進程都有一個頁表,而不是全局只有一個,頁表把共享內存映射到同一個地方。
簡化存儲器分配:當一個進程使用malloc要求額外的空間時,操作系統只需要保證形成了一個連續的虛擬頁面,但可以映射到物理內存中任意的位置,可以隨機分散在內存的不同位置。
簡化保護:我們可以通過為PTE添加額外的標識位提供對存儲器的保護。
通過新添加的三個標識位:SUP:內核or用戶;READ:讀;WRITE:寫。運行在用戶模式下的進程只允許訪問SUP為否的頁面,如果一個指令違法了訪問的設置條件,就會轉到保護故障,引起一個段錯誤。
1.5 虛擬存儲器工作原理詳解:地址翻譯
地址翻譯從形式上來說就是建立一個虛擬地址空間到物理地址空間的映射關系,我們前面說過MMU使用的是頁表來實現這種映射。CPU中有一個專門的頁表基址寄存器(PTBR)指向當前頁表,使用頁表進行翻譯的時候方法如下:
每個虛擬地址由兩部分組成:虛擬頁號(VPN)+虛擬頁偏移量(VPO),當CPU生成一個虛擬地址并傳遞給MMU開始翻譯的時候,MMU利用虛擬地址的VPN來選擇相應的PTE,同時將頁表中的物理頁號(PPN)+虛擬地址的VPO就生成了相應的物理地址。(物理地址是由頁表中的物理頁號+虛擬地址中的偏移量構成)
頁面命中是一個簡單的過程,我們就不做詳解,這里來跟蹤看一下缺頁的情況:
說明:①CPU生成虛擬地址;②MMU生成PTE地址從內存的頁表中請求內容;③ 內存中的頁表返回相應的PTE值;④ PTE的有效位是0,轉到異常處理程序;⑤ 異常處理程序確定內存中的犧牲頁,并將其寫會到磁盤上(pagefile.sys);⑥從pagefile.sys中調入新的文件并更新PTE。⑦ 由于PTE已經被更新好了,從新發送虛擬地址到MMU(后面就和命中的過程一樣了)
我們講了大致的地址翻譯原理,有什么辦法能夠提高翻譯的速度嗎?
① 加入高速緩存
高速緩存被發明出來的一個重要原因就是提高對內存的訪問速度,我們來看看加入高速緩存后的訪問示意圖:
高速緩存被放在存儲器和MMU之間,可以緩存頁表條路。當MMU發送一個PTEA請求的時候,優先從高速緩存中尋找相應的PTE值,如果命中直接返回給MMU,如果不命中從內存中獲得并發送到高速緩存,再由高速緩存返回到MMU。(高速緩存使用的是物理尋址,不涉及地址保護問題,因為MMU已經加入了保護標識位)
② 加入翻譯后備緩沖器TLB
TLB是一個小的、虛擬尋址的緩存,其中每一行都保存一個PTE塊,高度相連。主要是提供虛擬地址到物理地址的翻譯速度。大致范圍示意圖如下:
說明:①CPU生成一個虛擬地址并發送到MMU;② ③MMU從TLB中獲取相應的PTE④翻譯成相應的物理地址后從內存中請求內容;⑤ 數據從內存返回給CPU
③ 加入多級頁表
我們來分析一下單級頁表的弱勢之處,然后指出改進的方法。我們雙擊圖標運行一個程序的時候,在單級頁表模式下,其實是在內存中為這個程序創建了一個頁表,使得程序有了獨立的地址空間。我們以32位系統4GB地址空間為例,我們將物理內存分割為虛擬的頁面,每個頁面保存4KB大小的內容,這樣我們總共需要1048576個頁面,才能瓜分所有的4GB空間。那么我們的頁表要能夠完成所有物理內存的映射,就必須要1048576個頁表項,由于每個頁表項占用4B的空間,那么我們這個頁表就需要占用4194304B(4M)的內存空間,每個進程都有這樣的一個4M的頁表占用著內存空間,才能完成映射。
我們來看看有什么方法優化一下,下面我們加入分級頁表(二級):
我們加入分級的思想以后,每一級的頁表就都只有4KB的大小,數量也有原來的1048576變成了1024個,兩級相乘其實表示的數量還是原來那么多。上圖所示,一級頁表每條PTE負責映射二級頁表1024個PTE項,二級頁表的每個PTE在映射虛擬存儲器中4KB大小的位置。也就是說一級頁表每條PTE負責映射一塊4M大小的空間,而一級頁表總共有1024個頁表項,也就能用來映射完成所有物理內存空間。這樣做的好處是,如果一級頁表中有未被分配的項目,那么這條PTE直接設置成null,不指向任何二級列表,也就不再占用空間。還有一個好處是不是所有的二級列表都需要常駐內存,每個進程只需要在內存中建立一級頁表(4kb)大小,二級列表按需要的時候創建調入,這樣就更省了。
④ 綜合:一個從虛擬地址到物理地址并獲取數據的模擬
為了方便討論,我們以一個小的存儲系統作如下假設:
1> 虛擬地址大小14位:結構如下
2> 物理地址大小12位:結構如下
3> 內存大小為4KB,物理頁號為64個,每個頁面大小為64B,頁表如下:
4> TLB 翻譯后備緩存器分成4組,每組4條,一共16個條目:
5> 高速緩存64B大小,使用物理尋址、直接映射的方式,每行4B,共計16個組:
好了,有了這些假設以后我們來看一下,當CPU讀取0x03d4處內容會發生些什么:
此處是虛擬地址,0x03d4二進制表示就為:(0000 1111 0101 00)14位,由于虛擬地址的低6位用來表示偏移量(每個頁面64B大?。?^6=64),剩下的高8位用來表示虛擬頁號,一共有128個虛擬頁號(2^8)。
我們從虛擬地址中:
1> 抽取出虛擬頁號為:0x0f;
2> 將虛擬頁號與TLB進行對比,為了方便,我們形成TLBT標記位,TLBI組索引;
組索引在0x03號位置,標記也為0x03,這時候回到我們的假設“4>”處進行檢查,發現0x03組,標記位0x03處的有效位是1,所以命中。取出物理頁號(PPN)0D用于構造物理地址用。物理地址就為:PPN-VPO = 0x354:
3 > 根據物理地址:0x354,我們在高速緩存中去碰碰運氣,前面假設的時候我們說過大小為64B,我們將其分成16個條目,由:標記位+有效位+塊0-3組成。其實際存放數據的塊每個條目只有4個(0-3)所以總大小為64B,我們的物理地址要到高速緩存中去尋找數據,就得有某種對應方式。其中物理地址的低2位用作偏移量(CO)因為每個條目只有4個數據塊,緊接著的4位表示組索引,因為一共是16個組,最后的高7位作為標記位。我們形成如下的:CO=0x0,偏移量為0也就是塊0的內容;CI = 0x05也就是第0x05組和CT:0x0d標志位。有了這些內容以后我們返回到假設5中去尋找,發現高速緩存中的5號索引,標記位為0x0d,并且有效,讀取塊0處的內容為36。這就是我們要返回給CPU的內容。至此完成了一個端到端地址翻譯并返回數據的手工模擬,當然我們還可能遇到很多不同的情況。如在高速緩存中不命中,TLB不命中等等,但大致原理幾乎類似,請自行腦補。
1.6 案例研究:Intel CoreI7/ Linux存儲系統
① Core i7 地址翻譯:
Core i7采用4級頁表層次結構,CPU產生的虛擬地址,如果命中由TLB生成物理地址,如果不命中后通過4級頁表生成物理地址。物理地址如果命中優先從L1高速緩存中獲取數據,如果不命中再從主存中獲取結果,最后傳遞給CPU
四級頁表將虛擬地址翻譯成物理地址的過程也相當簡單,36位的虛擬地址被分割成4個9位的片。VPN1有一個到L1 PTE的偏移量,找到這個PTE以后又會包含到L2頁表的基礎地址;VPN2包含一個到L2PTE的偏移量,找到這個PTE以后又會包含到L3頁表的基礎地址;VPN3包含一個到L3PTE的偏移量,找到這個PTE以后又會包含到L4頁表的基礎地址;VPN4包含一個到L4PTE的偏移量,找到這個PTE以后就是相應的PPN(物理頁號)。
頁表條目格式說明:
② Linux虛擬存儲系統
一個單獨的Linux系統進程虛擬存儲主要分為:內核虛擬存儲器和進程虛擬存儲器。
我們主要來講一下內核虛擬存儲器:由下往上是內核的代碼和數據結構,是每個進程共享的數據結構和代碼;再往上是一組連續的虛擬頁面映射到相應的物理頁面的物理存儲器,大小同主存一樣大,提供很方便訪問物理頁面的任何位置。最后是每個進程不同的是頁表、task(mm)、內核棧等。
虛擬存儲器區域:
區域就是我們通常說的段,text、data、bss都是不同的區域,這些區域是被分為連續的片。每個虛擬頁面都在不同的段中,不屬于某個段的虛擬頁面是不存在的,且不能被使用。我們來看看內核中的一個task數據結構(mm):
task_struct是位于內核虛擬存儲器中對于每個進程的都不同的內核數據結構,包含運行該進程所需要的基本信息(PID、可執行文件名稱、程序計數器等)。這個結構中有一個mm字段,指向的是mm_struct中的pgd和mmap,其中pgd是一級頁表的基地址,mmap指向的是一個vm_area_structs的鏈表,每個該鏈表中的一個元素描述的是當前虛擬地址空間的一個段(text、data、bss等),當內核運行該進程的時候CR3寄存器就被放入了pgd。
Linux缺頁異常處理:
我們將了一些存儲器區域劃分的基礎知識,并且介紹說mmap指向的是一個鏈表,這個鏈表中的每個元素都指向該進程的相應的段,其中vm_strat是段開始的地方,vm_end是段結束的地方。
1> 訪問地址是否合法:缺頁處理程序只需要將這個地址A與vm_area_struct鏈表中的每個元素的start和end數據比較,如果都沒有的話,表示該地址不在相應的段中。就是一個段錯誤。
2> 保護異常:vm_area_struct中的vm_prot結構是包含了所有頁面的讀寫權限,所以當對只有讀權限的文本內容寫入數據的時候,就會引發保護異常。
3> 最后,正常缺頁。也就是相應的頁面不在物理內存的時候,缺頁程序就會鎖定一個犧牲頁面,將它的內容與實際需要的內容交換過來,當缺頁程序返回的時候就可以正常的訪問了。
1.7 存儲器映射
存儲器映射是通過將磁盤上的一個文件與虛擬存儲器中的一個區域關聯起來的過程。
① 理解共享對象
一個對象被映射到虛擬存儲器的一個區域,這個區域要么是共享對象,要么是私有對象。如果一個進程A將一個共享對象映射X到了它的虛擬存儲器中,那么對于也把這個共享對象X映射了的其他進程而言,進程A對共享對象X的任何讀寫操作都是可見的。下圖是進程1和進程2映射了共享區域的圖例:
私有區域:即使是私有區域在物理存儲器上也是同一個區域,如下圖進程1和進程2所映射的私有對象在物理存儲器上只是一份拷貝。
每個對象都有唯一的一個文件名,在進程1的虛擬存儲器中已經完成了私有對象到存儲器的映射,進程2如果要映射這個區域只需要將頁表條目指向已經映射好的物理存儲器位置就行了。如上圖所示,進程1和2將一個私有對象映射到了物理存儲器的一個區域并共享這個私有對象。這個對象會被標記為只讀,當其中一個進程2確實需要寫這個區域的時候,就會引發一個保護故障,內核會在物理存儲器中創建這個私有對象的一個拷貝,稱為寫時拷貝,更新頁面條目使得進程1指向這個新的條目。然后把老對象修改為可寫權限。這樣當保護故障程序返回的時候,CPU從新執行寫的操作就不會出錯了。
② 理解fork函數如何創建獨立的虛擬地址空間
當當前進程調用fork函數的時候,內核為新進程創建各種數據結構,并分配PID。為了給新進程創建一個虛擬存儲器,它創建的當前進程的mm_struct、區域結構和頁表的一個拷貝,內核為兩個進程的每個頁表標記為只讀,并將誒個區域標記為私有的寫時拷貝。這樣當fork函數返回的時候,新進程的虛擬存儲器和當前進程的虛擬存儲器剛好相同。任何一個進程進行寫操作的時候,才會創建新的頁面。
③ 理解execve函數實際上如何加載和執行程序
1> 刪除已存在的用戶區域;
2> 映射私有區域
所有的.text、.data、.bss區域都是新創建的,這些區域是私有的、寫時拷貝。.bss是匿名文件區域,初始化為二進制0,棧、堆也都是初始化為0.
3> 映射共享區域;
這些共享區域是動態鏈接到程序然后映射到虛擬地址空間的共享區域。
4> 設置程序計數器。
最后一步就是設置當前進程的上下文計數器,并指向.text入口
④ 使用mmap函數創建新的存儲器映射
函數原型如下:
說明:
start:從地址start開始處創建,通常為NULL;length:連續對象的大小;
port:訪問權限(PROT_EXEC\PROT_READ\PROT_WRITE\PROT_NONE);
flags:被映射對象的位(MAP_ANOE\MAP_PRIVATE\MAP_SHARED);
fd: 指定的磁盤文件;offset:距離磁盤文件偏移的位置處開始;
返回值:調用成功,返回新區域的地址。
(注:可以使用munmap刪除相應的虛擬存儲器區域)
1.8 動態存儲分配
動態存儲器分配指的是在程序運行的時候分配額外的存儲空間,分配器維護著虛擬存儲器中的堆實現這種分配。
堆是緊跟著.bss段,并向上增長,內核維護著一個brk指針,指向堆的頂部。任何一個堆中的塊要么是已分配的要么是空閑的。分配的方式分為兩種:顯式和隱式,我們接下來主要講一下顯示分配和實現一個分配器的基礎知識,隱式分配指的其實是分配器回收空間,這個在分配器基礎知識中有所講解,就不再另外提出了:
① 顯式分配:程序調用malloc和free函數
經常直到我們的程序運行的時候,我們才知道某些數據結構的大小。這時候就必須顯式的分配相應的存儲空間。如下圖所示:
使用malloc函數以具體的輸入內容分配相應大小的存儲空間,函數原型如下:
如果想要初始化存儲器為0,可以使用calloc函數。想要改變已分配的大小可以使用realloc函數
釋放是通過調用free函數來實現的:
ptr是指向一個已分配空間的起始位置
我們來看一個分配實例:
(a)請求一個4字大小的塊,malloc將分配好的空間的首地址返回給p1;
(b)請求一個5字大小的塊,由于使用的雙字對其,所以填充了一個空閑塊;
(c)請求一個6字大小的塊,返回給p3;
(d)釋放p2,調用后p2仍然指向原來的位置;
(e)請求一個2字大小的塊,在已經釋放的p2處優先分配,然后返回指針p4
② 分配器基礎知識
分配器的目標主要是找到吞吐量和利用率的契合點,那么為什么需要隱式的分配,因為碎片的產生會降低存儲空間的利用率
碎片:內部和外部
1>內部碎片:我們上面講到的(b)的情況,分配了一個額外的空閑塊,實現雙字對其;
2>外部碎片:(e)中如果請求7字大小的塊,即使存儲空間有這么大,還是不行
當然,還有許多問題要思考,諸如:空閑塊如何組織、如何分配新的塊、怎么分割和合并塊,這些技術都要求我們提供一種新的數據結構
隱式空閑鏈表:
這樣的一種結構,主要是由三部分組成:頭部、有效載荷、填充(可選);
頭部:是由塊大小+標志位(a已分配/f空閑);有效載荷:實際的數據
應用舉例:
這個鏈表(大小/是否分配)是通過頭部中的大小字段 隱含連接著的(頭部+大小=下一塊位置),分配器可以遍歷所有的塊,在遇到結束位(0/1)處停止。即使是要求分配一個數據塊,也要有(8/0)一個頭部,兩個字來完成。
簡單的放置策略:
1> 首次適配:從頭搜索,遇到第一個合適的塊就停止;
2> 下次適配:從頭搜索,遇到下一個合適的塊停止;
3> 最佳適配:全部搜索,選擇合適的塊停止。
分割空閑塊:
適配到合適的空閑塊,分配器將空閑塊分割成兩個部分,一個是分配塊,一個是新的空閑塊:
增加堆的空間:通過調用sbrk函數,申請額外的存儲器空間,插入到空閑鏈表中
合并:
1> 為什么要合并:處理假碎片現象
如上所示,雖然釋放了兩個3字節大小的數據空間,而且空閑的空間相鄰,但是就是無法再分配4字節的空間了,這時候就需要進行一般合并:合并的策略是立即合并和推遲合并,我們可能不立即推遲合并,如果有空間直接合并不好嗎?有時候的確還真不好,如果我們馬上合并上圖的空間后又申請3字節的塊,那么就會開始分割,釋放以后立即合并的話,又將是一個合并分割的過程,這樣的話推遲合并就有好處了。需要的時候再合并,就不會產生抖動了。
2> 怎樣合并:帶邊界標記
我們需要從新審視一下我們的隱式鏈表數據結構,加入新的邊界標記形成如下結構:
在鏈表的底部加入頭部同樣的格式,用a表示已分配、f表示空閑
我們列舉一下可能的所有情況:
說明:
(a):在合并的時候,由于前后都是已分配不執行合并,只是把當前塊標記位空閑:
(b):后面的塊是空閑的,當前塊與后面的塊合并,用新的塊的大?。ó斍皦K大小+后面塊大?。?,更新當前塊的頭部和后面塊的腳部
(c):前面塊是空閑,前面塊與當前塊合并,用新的塊的大?。ó斍皦K的大小+前面塊的大?。?,更新前面塊的頭部和當前塊的腳部
(d):三個塊都是空閑,3個塊的大小來更新前面塊的頭部和后面塊的腳部
注意:當(c)和(d)兩種情況,前面的塊是空閑的,才需要用到當前塊的腳部。(a)不需要更新,(b)更新的是后面塊的腳部+塊大小。如果我們把前面的塊的位存放在當前塊頭部未使用多出來的低位中,那么已分配的塊就不需要腳部了。(當然空閑塊仍然需要腳部)
③ 綜合:實現一個簡單的分配器
我們將實現的是一個基于隱式空閑鏈表,立即邊界標記合并方式的簡單分配器。數據的結構如下:
最小的塊大小為16字節,必須包含:頭部(8字節)+腳部(8字節);
隱式的空閑鏈表具有如下恒定的形式:
其中首字(對齊)是不使用的填充塊,緊跟著的是一個特殊的序言塊,由一個頭部和腳部組成,序言塊在初始化的時候創建,永不釋放。中間就是由malloc創建的普通塊,最后是一個特殊的結尾塊,序言和結尾塊都是為了消除合并邊界條件的技巧。(heap_listp總是指向序言塊)
接下來的內容,我們直接上與malloc和free有關的函數源碼,用到什么知識的時候再做補充:為了和系統的malloc和free函數區分,我們起名為(mm_malloc和mm_free):
這里我們要對extend_heap函數進行說明,在擴展堆的時候必須要調用mm_init函數進行初始化,創建一個空的鏈表。初始化mm_init函數如下:
具體的擴展函數如下,當堆初始化或者mm_malloc函數不能匹配的時候就會進行擴展:
分配我們講完了,下面來看看mm_free函數和合并空閑塊函數coalesce函數,合并的4種情況:
④ 分離的空閑鏈表
我們使用單向的空閑鏈表分配時間并沒有改善,我們現在來看看比較流行的分離存儲方法,在一個分離存儲的系統中,分配器維護著一個空閑鏈表數組,每個空閑鏈表是一些大小不同的類。我們可以按照2的冪來劃分,比如1,2,4,8,16,32.......等等。
像上圖那樣,每個大小類都是2的冪,按照升序排列就是伙伴系統。我們要分配一個9字節的空間,就需要從前往后依次尋找,找到了第5個空閑鏈表中的空間足夠就分割它,將不需要的插入到空閑鏈表中去。如果找不到合適的,比如需要17個字的空間,就向操作系統申請額外的堆存儲器。
申請:如果我們要在16字節的空間中分配一個4字節大小的空間,就會首先將這個16字節的總空間分割成兩個8字節大小的空間,其中空閑的那部分(左邊)叫做伙伴,被放置到空閑鏈表中。我們發現8字節的空間依然大于我們要分配的空間,就再一次將8字節的空間分割成兩部分,每個4字節,剛好完成分割,這時候8字節中的左邊部分也就是伙伴,被放置到空閑鏈表中。
釋放:需要釋放4字節空間的時候,會與其伙伴進行空閑合并,形成一個8字節大小的空閑空間,繼續發現另外的8字節伙伴也是空閑的,繼續合并。直到遇到的伙伴已經被分配了才停止。
⑤ 垃圾收集
垃圾收集是一種很有用的方法,當使用了malloc分配了空間卻忘記了釋放,就會造成內存的極大浪費。垃圾收集就是使用特殊的方法,定期回收這部分不使用或者無效的空間。
當然收集的方法分為很多種,我們只講一下【標記清除算法】:
垃圾收集器將存儲器視為一張有向的圖,根節點保存在寄存器、棧變量或者虛擬存儲器的全局變量中,子節點在堆中,每個子節點對于一個已分配的塊。白色為可到達,藍色為不可到達應該被回收的區域。
垃圾回收期就是維護著這種可達圖,釋放其中不可達的節點,返回給空閑鏈表。
像c和c++這類語音不能維持可達圖的精確表示,有些不可達的節點可能會被錯誤的標識為可達,它的垃圾收集器就是一個保守的垃圾收集器。加入到malloc包中就形成這樣的形式:
當我們需要空間的時候,調用了malloc函數,如果找不到合適的空間就會啟用垃圾收集器,希望回收一部分可利用的空間,垃圾收集器將代替程序執行free函數,釋放的空間返回以后重新調用malloc,如果還是失敗才從操作系統申請額外的存儲空間。
如何標記:
標記前的狀態是這樣的:
淡藍色的塊是已分配的頭部,6個塊都是未標記的。其中根節點指向塊4,4的又分別指向了3和6.其中3又指向了1,這就形成了一個有向的鏈表,其中只有2和5不能到達。
這時候我們使用標記函數,循環遍歷進行標記:
標記完成以后,形成如下所示:
由于塊1、3、4、6是可達的,所以都被標記了。2和5無法標記是垃圾
sweep函數釋放所有的未標記的塊:
清除塊2和塊5以后是這樣的:
我們之所以說c和c++是保守的垃圾收集器,是因為在標記階段的isPtr函數識別并不準確,c不知道輸入的參數是否是一個指針,也不知道指向的是否是一個有效載荷的位置。(這里需要多讀讀,還有點兒不懂)
1.9 c程序中常見的10大存儲器相關錯誤
存儲器的錯誤總是令人沮喪的,特別是在運行了一段時間之后才顯示出來,就特別特別的煩人了,我們列舉一些常見的錯誤,僅供參考:
① 間接引用壞指針:將本來的地址引用寫成了內容scanf("%d", &val)寫成scanf("%d", val)
② 讀未初始化的存儲器:
在堆中申請了一塊空間:int *y = (int *)Malloc(n * sizeof(int));由于堆中的空間是未被初始化的,下面的使用就會出錯:y[i] += A[i][j] * x[j];推薦使用calloc函數
③ 允許棧緩沖溢出:
推薦使用fgets函數
④ 假設指針和它指向的對象是相同大小的
使用int **A = (int **)Malloc(n * sizeof(int));本來是想創建的一個int *的數組,但是sizeof上面用到的確實int
⑤ 錯位
申請了n個空間,卻要訪問n+1處位置
⑥ 引用指針而不是它指向的對象
*size--; /* This should be (*size)-- */
其中,--和*有相同的優先級,由于這是右結合。所以先--再*,就出錯了。
⑦ 誤解指針運算
p += sizeof(int); /* Should be p++ */
指針p++就會指向下一個位置,+= int的大小的話,就跳了幾個數據了
⑧ 引用不存在的變量
本地變量在棧中創建,函數結束以后就已經不存在了。
⑨ 引用已經釋放了的塊中的數據
在行10的時候已經將塊釋放了,在行14的時候又在使用
?? ?不釋放引起內存泄漏
如果經常調用leak,又不釋放的話,內存就會充滿垃圾。
2017年04月28日23:59:58 完