概述
我們都知道一個程式是與其他程式共享CPU和記憶體資源的。正因如此,作業系統需要有一套完善的記憶體管理機制才能防止程式之間記憶體洩漏的問題。
為了更加有效地管理記憶體並減少出錯,現代作業系統提供了一種對主存的抽象概念,即是虛擬記憶體(Virtual Memory)。虛擬記憶體為每個程式提供了一個一致的、私有的地址空間,它讓每個程式產生了一種自己在獨享主存的錯覺(每個程式擁有一片連續完整的記憶體空間)。
理解不深刻的人會認為虛擬記憶體只是“使用硬碟空間來擴充套件記憶體“的技術,這是不對的。虛擬記憶體的重要意義是它定義了一個連續的虛擬地址空間,使得程式的編寫難度降低。並且,把記憶體擴充套件到硬碟空間只是使用虛擬記憶體的必然結果,虛擬記憶體空間會存在硬碟中,並且會被記憶體快取(按需),有的作業系統還會在記憶體不夠的情況下,將某一程式的記憶體全部放入硬碟空間中,並在切換到該程式時再從硬碟讀取(這也是為什麼Windows會經常假死的原因...)。
虛擬記憶體主要提供瞭如下三個重要的能力:
它把主存看作為一個儲存在硬碟上的虛擬地址空間的快取記憶體,並且只在主存中快取活動區域(按需快取)。
它為每個程式提供了一個一致的地址空間,從而降低了程式設計師對記憶體管理的複雜性。
它還保護了每個程式的地址空間不會被其他程式破壞。
介紹了虛擬記憶體的基本概念之後,接下來的內容將會從虛擬記憶體在硬體中如何運作逐漸過渡到虛擬記憶體在作業系統(Linux)中的實現。
本文作者為SylvanasSun(sylvanas.sun@gmail.com),首發於SylvanasSun’s Blog。
原文連結:sylvanassun.github.io/2017/10/29/…
(轉載請務必保留本段宣告,並且保留超連結。)
CPU定址
記憶體通常被組織為一個由M個連續的位元組大小的單元組成的陣列,每個位元組都有一個唯一的實體地址(Physical Address PA),作為到陣列的索引。CPU訪問記憶體最簡單直接的方法就是使用實體地址,這種定址方式被稱為物理定址。
現代處理器使用的是一種稱為虛擬定址(Virtual Addressing)的定址方式。使用虛擬定址,CPU需要將虛擬地址翻譯成實體地址,這樣才能訪問到真實的實體記憶體。
虛擬定址需要硬體與作業系統之間互相合作。CPU中含有一個被稱為記憶體管理單元(Memory Management Unit, MMU)的硬體,它的功能是將虛擬地址轉換為實體地址。MMU需要藉助存放在記憶體中的頁表來動態翻譯虛擬地址,該頁表由作業系統管理。
頁表
虛擬記憶體空間被組織為一個存放在硬碟上的M個連續的位元組大小的單元組成的陣列,每個位元組都有一個唯一的虛擬地址,作為到陣列的索引(這點其實與實體記憶體是一樣的)。
作業系統通過將虛擬記憶體分割為大小固定的塊來作為硬碟和記憶體之間的傳輸單位,這個塊被稱為虛擬頁(Virtual Page, VP),每個虛擬頁的大小為P=2^p
位元組。實體記憶體也會按照這種方法分割為物理頁(Physical Page, PP),大小也為P
位元組。
CPU在獲得虛擬地址之後,需要通過MMU將虛擬地址翻譯為實體地址。而在翻譯的過程中還需要藉助頁表,所謂頁表就是一個存放在實體記憶體中的資料結構,它記錄了虛擬頁與物理頁的對映關係。
頁表是一個元素為頁表條目(Page Table Entry, PTE)的集合,每個虛擬頁在頁表中一個固定偏移量的位置上都有一個PTE。下面是PTE僅含有一個有效位標記的頁表結構,該有效位代表這個虛擬頁是否被快取在實體記憶體中。
虛擬頁VP 0
、VP 4
、VP 6
、VP 7
被快取在實體記憶體中,虛擬頁VP 2
和VP 5
被分配在頁表中,但並沒有快取在實體記憶體,虛擬頁VP 1
和VP 3
還沒有被分配。
在進行動態記憶體分配時,例如malloc()
函式或者其他高階語言中的new
關鍵字,作業系統會在硬碟中建立或申請一段虛擬記憶體空間,並更新到頁表(分配一個PTE,使該PTE指向硬碟上這個新建立的虛擬頁)。
由於CPU每次進行地址翻譯的時候都需要經過PTE,所以如果想控制記憶體系統的訪問,可以在PTE上新增一些額外的許可位(例如讀寫許可權、核心許可權等),這樣只要有指令違反了這些許可條件,CPU就會觸發一個一般保護故障,將控制權傳遞給核心中的異常處理程式。一般這種異常被稱為“段錯誤(Segmentation Fault)”。
頁命中
如上圖所示,MMU根據虛擬地址在頁表中定址到了PTE 4
,該PTE的有效位為1,代表該虛擬頁已經被快取在實體記憶體中了,最終MMU得到了PTE中的實體記憶體地址(指向PP 1
)。
缺頁
如上圖所示,MMU根據虛擬地址在頁表中定址到了PTE 2
,該PTE的有效位為0,代表該虛擬頁並沒有被快取在實體記憶體中。虛擬頁沒有被快取在實體記憶體中(快取未命中)被稱為缺頁。
當CPU遇見缺頁時會觸發一個缺頁異常,缺頁異常將控制權轉向作業系統核心,然後呼叫核心中的缺頁異常處理程式,該程式會選擇一個犧牲頁,如果犧牲頁已被修改過,核心會先將它複製回硬碟(採用寫回機制而不是直寫也是為了儘量減少對硬碟的訪問次數),然後再把該虛擬頁覆蓋到犧牲頁的位置,並且更新PTE。
當缺頁異常處理程式返回時,它會重新啟動導致缺頁的指令,該指令會把導致缺頁的虛擬地址重新傳送給MMU。由於現在已經成功處理了缺頁異常,所以最終結果是頁命中,並得到實體地址。
這種在硬碟和記憶體之間傳送頁的行為稱為頁面排程(paging):頁從硬碟換入記憶體和從記憶體換出到硬碟。當缺頁異常發生時,才將頁面換入到記憶體的策略稱為按需頁面排程(demand paging),所有現代作業系統基本都使用的是按需頁面排程的策略。
虛擬記憶體跟CPU快取記憶體(或其他使用快取的技術)一樣依賴於區域性性原則。雖然處理缺頁消耗的效能很多(畢竟還是要從硬碟中讀取),而且程式在執行過程中引用的不同虛擬頁的總數可能會超出實體記憶體的大小,但是區域性性原則保證了在任意時刻,程式將趨向於在一個較小的活動頁面(active page)集合上工作,這個集合被稱為工作集(working set)。根據空間區域性性原則(一個被訪問過的記憶體地址以及其周邊的記憶體地址都會有很大機率被再次訪問)與時間區域性性原則(一個被訪問過的記憶體地址在之後會有很大機率被再次訪問),只要將工作集快取在實體記憶體中,接下來的地址翻譯請求很大機率都在其中,從而減少了額外的硬碟流量。
如果一個程式沒有良好的區域性性,將會使工作集的大小不斷膨脹,直至超過實體記憶體的大小,這時程式會產生一種叫做抖動(thrashing)的狀態,頁面會不斷地換入換出,如此多次的讀寫硬碟開銷,效能自然會十分“恐怖”。所以,想要編寫出效能高效的程式,首先要保證程式的時間區域性性與空間區域性性。
多級頁表
我們目前為止討論的只是單頁表,但在實際的環境中虛擬空間地址都是很大的(一個32位系統的地址空間有2^32 = 4GB
,更別說64位系統了)。在這種情況下,使用一個單頁表明顯是效率低下的。
常用方法是使用層次結構的頁表。假設我們的環境為一個32位的虛擬地址空間,它有如下形式:
虛擬地址空間被分為4KB的頁,每個PTE都是4位元組。
記憶體的前2K個頁面分配給了程式碼和資料。
之後的6K個頁面還未被分配。
再接下來的1023個頁面也未分配,其後的1個頁面分配給了使用者棧。
下圖是為該虛擬地址空間構造的二級頁表層次結構(真實情況中多為四級或更多),一級頁表(1024個PTE正好覆蓋4GB的虛擬地址空間,同時每個PTE只有4位元組,這樣一級頁表與二級頁表的大小也正好與一個頁面的大小一致都為4KB)的每個PTE負責對映虛擬地址空間中一個4MB的片(chunk),每一片都由1024個連續的頁面組成。二級頁表中的每個PTE負責對映一個4KB的虛擬記憶體頁面。
這個結構看起來很像是一個B-Tree
,這種層次結構有效的減緩了記憶體要求:
如果一個一級頁表的一個PTE是空的,那麼相應的二級頁表也不會存在。這代表一種巨大的潛在節約(對於一個普通的程式來說,虛擬地址空間的大部分都會是未分配的)。
只有一級頁表才總是需要快取在記憶體中的,這樣虛擬記憶體系統就可以在需要時建立、頁面調入或調出二級頁表(只有經常使用的二級頁表才會被快取在記憶體中),這就減少了記憶體的壓力。
地址翻譯的過程
從形式上來說,地址翻譯是一個N元素的虛擬地址空間中的元素和一個M元素的實體地址空間中元素之間的對映。
下圖為MMU利用頁表進行定址的過程:
頁表基址暫存器(PTBR)指向當前頁表。一個n位的虛擬地址包含兩個部分,一個p位的虛擬頁面偏移量(Virtual Page Offset, VPO)和一個(n - p)位的虛擬頁號(Virtual Page Number, VPN)。
MMU根據VPN來選擇對應的PTE,例如VPN 0
代表PTE 0
、VPN 1
代表PTE 1
....因為物理頁與虛擬頁的大小是一致的,所以物理頁面偏移量(Physical Page Offset, PPO)與VPO是相同的。那麼之後只要將PTE中的物理頁號(Physical Page Number, PPN)與虛擬地址中的VPO串聯起來,就能得到相應的實體地址。
多級頁表的地址翻譯也是如此,只不過因為有多個層次,所以VPN需要分成多段。假設有一個k級頁表,虛擬地址會被分割成k個VPN和1個VPO,每個VPN i
都是一個到第i級頁表的索引。為了構造實體地址,MMU需要訪問k個PTE才能拿到對應的PPN。
TLB
頁表是被快取在記憶體中的,儘管記憶體的速度相對於硬碟來說已經非常快了,但與CPU還是有所差距。為了防止每次地址翻譯操作都需要去訪問記憶體,CPU使用了快取記憶體與TLB來快取PTE。
在最糟糕的情況下(不包括缺頁),MMU需要訪問記憶體取得相應的PTE,這個代價大約為幾十到幾百個週期,如果PTE湊巧快取在L1快取記憶體中(如果L1沒有還會從L2中查詢,不過我們忽略多級緩衝區的細節),那麼效能開銷就會下降到1個或2個週期。然而,許多系統甚至需要消除即使這樣微小的開銷,TLB由此而生。
TLB(Translation Lookaside Buffer, TLB)被稱為翻譯後備緩衝器或翻譯旁路緩衝器,它是MMU中的一個緩衝區,其中每一行都儲存著一個由單個PTE組成的塊。用於組選擇和行匹配的索引與標記欄位是從VPN中提取出來的,如果TLB中有T = 2^t
個組,那麼TLB索引(TLBI)是由VPN的t個最低位組成的,而TLB標記(TLBT)是由VPN中剩餘的位組成的。
下圖為地址翻譯的流程(TLB命中的情況下):
第一步,CPU將一個虛擬地址交給MMU進行地址翻譯。
第二步和第三步,MMU通過TLB取得相應的PTE。
第四步,MMU通過PTE翻譯出實體地址並將它傳送給快取記憶體/記憶體。
第五步,快取記憶體返回資料到CPU(如果快取命中的話,否則還需要訪問記憶體)。
當TLB未命中時,MMU必須從快取記憶體/記憶體中取出相應的PTE,並將新取得的PTE存放到TLB(如果TLB已滿會覆蓋一個已經存在的PTE)。
Linux中的虛擬記憶體系統
Linux為每個程式維護了一個單獨的虛擬地址空間。虛擬地址空間分為核心空間與使用者空間,使用者空間包括程式碼、資料、堆、共享庫以及棧,核心空間包括核心中的程式碼和資料結構,核心空間的某些區域被對映到所有程式共享的物理頁面。Linux也將一組連續的虛擬頁面(大小等於記憶體總量)對映到相應的一組連續的物理頁面,這種做法為核心提供了一種便利的方法來訪問實體記憶體中任何特定的位置。
Linux將虛擬記憶體組織成一些區域(也稱為段)的集合,區域的概念允許虛擬地址空間有間隙。一個區域就是已經存在著的已分配的虛擬記憶體的連續片(chunk)。例如,程式碼段、資料段、堆、共享庫段,以及使用者棧都屬於不同的區域,每個存在的虛擬頁都儲存在某個區域中,而不屬於任何區域的虛擬頁是不存在的,也不能被程式所引用。
核心為系統中的每個程式維護一個單獨的任務結構(task_struct)。任務結構中的元素包含或者指向核心執行該程式所需的所有資訊(PID、指向使用者棧的指標、可執行目標檔案的名字、程式計數器等)。
mm_struct:描述了虛擬記憶體的當前狀態。pgd指向一級頁表的基址(當核心執行這個程式時,pgd會被存放在CR3控制暫存器,也就是頁表基址暫存器中),mmap指向一個vm_area_structs的連結串列,其中每個vm_area_structs都描述了當前虛擬地址空間的一個區域。
vm_starts:指向這個區域的起始處。
vm_end:指向這個區域的結束處。
vm_prot:描述這個區域內包含的所有頁的讀寫許可許可權。
vm_flags:描述這個區域內的頁面是與其他程式共享的,還是這個程式私有的以及一些其他資訊。
vm_next:指向連結串列的下一個區域結構。
記憶體對映
Linux通過將一個虛擬記憶體區域與一個硬碟上的檔案關聯起來,以初始化這個虛擬記憶體區域的內容,這個過程稱為記憶體對映(memory mapping)。這種將虛擬記憶體系統整合到檔案系統的方法可以簡單而高效地把程式和資料載入到記憶體中。
一個區域可以對映到一個普通硬碟檔案的連續部分,例如一個可執行目標檔案。檔案區(section)被分成頁大小的片,每一片包含一個虛擬頁的初始內容。由於按需頁面排程的策略,這些虛擬頁面沒有實際交換進入實體記憶體,直到CPU引用的虛擬地址在該區域的範圍內。如果區域比檔案區要大,那麼就用零來填充這個區域的餘下部分。
一個區域也可以對映到一個匿名檔案,匿名檔案是由核心建立的,包含的全是二進位制零。當CPU第一次引用這樣一個區域內的虛擬頁面時,核心就在實體記憶體中找到一個合適的犧牲頁面,如果該頁面被修改過,就先將它寫回到硬碟,之後用二進位制零覆蓋犧牲頁並更新頁表,將這個頁面標記為已快取在記憶體中的。
簡單的來說:普通檔案對映就是將一個檔案與一塊記憶體建立起對映關係,對該檔案進行IO操作可以繞過核心直接在使用者態完成(使用者態在該虛擬地址區域讀寫就相當於讀寫這個檔案)。匿名檔案對映一般在使用者空間需要分配一段記憶體來存放資料時,由核心建立匿名檔案並與記憶體進行對映,之後使用者態就可以通過操作這段虛擬地址來操作記憶體了。匿名檔案對映最熟悉的應用場景就是動態記憶體分配(malloc()函式)。
Linux很多地方都採用了“懶載入”機制,自然也包括記憶體對映。不管是普通檔案對映還是匿名對映,Linux只會先劃分虛擬記憶體地址。只有當CPU第一次訪問該區域內的虛擬地址時,才會真正的與實體記憶體建立對映關係。
只要虛擬頁被初始化了,它就在一個由核心維護的交換檔案(swap file)之間換來換去。交換檔案又稱為交換空間(swap space)或交換區域(swap area)。swap區域不止用於頁交換,在實體記憶體不夠的情況下,還會將部分記憶體資料交換到swap區域(使用硬碟來擴充套件記憶體)。
共享物件
虛擬記憶體系統為每個程式提供了私有的虛擬地址空間,這樣可以保證程式之間不會發生錯誤的讀寫。但多個程式之間也含有相同的部分,例如每個C程式都使用到了C標準庫,如果每個程式都在實體記憶體中保持這些程式碼的副本,那會造成很大的記憶體資源浪費。
記憶體對映提供了共享物件的機制,來避免記憶體資源的浪費。一個物件被對映到虛擬記憶體的一個區域,要麼是作為共享物件,要麼是作為私有物件的。
如果一個程式將一個共享物件對映到它的虛擬地址空間的一個區域內,那麼這個程式對這個區域的任何寫操作,對於那些也把這個共享物件對映到它們虛擬記憶體的其他程式而言,也是可見的。相對的,對一個對映到私有物件的區域的任何寫操作,對於其他程式來說是不可見的。一個對映到共享物件的虛擬記憶體區域叫做共享區域,類似地,也有私有區域。
為了節約記憶體,私有物件開始的生命週期與共享物件基本上是一致的(在實體記憶體中只儲存私有物件的一份副本),並使用寫時複製的技術來應對多個程式的寫衝突。
只要沒有程式試圖寫它自己的私有區域,那麼多個程式就可以繼續共享實體記憶體中私有物件的一個單獨副本。然而,只要有一個程式試圖對私有區域的某一頁面進行寫操作,就會觸發一個保護異常。在上圖中,程式B試圖對私有區域的一個頁面進行寫操作,該操作觸發了保護異常。異常處理程式會在實體記憶體中建立這個頁面的一個新副本,並更新PTE指向這個新的副本,然後恢復這個頁的可寫許可權。
還有一個典型的例子就是fork()
函式,該函式用於建立子程式。當fork()
函式被當前程式呼叫時,核心會為新程式建立各種必要的資料結構,並分配給它一個唯一的PID。為了給新程式建立虛擬記憶體,它複製了當前程式的mm_struct
、vm_area_struct
和頁表的原樣副本。並將兩個程式的每個頁面都標為只讀,兩個程式中的每個區域都標記為私有區域(寫時複製)。
這樣,父程式和子程式的虛擬記憶體空間完全一致,只有當這兩個程式中的任一個進行寫操作時,再使用寫時複製來保證每個程式的虛擬地址空間私有的抽象概念。
動態記憶體分配
雖然可以使用記憶體對映(mmap()
函式)來建立和刪除虛擬記憶體區域來滿足執行時動態記憶體分配的問題。然而,為了更好的移植性與便利性,還需要一個更高層面的抽象,也就是動態記憶體分配器(dynamic memory allocator)。
動態記憶體分配器維護著一個程式的虛擬記憶體區域,也就是我們所熟悉的“堆(heap)”,核心中還維護著一個指向堆頂的指標brk(break)。動態記憶體分配器將堆視為一個連續的虛擬記憶體塊(chunk)的集合,每個塊有兩種狀態,已分配和空閒。已分配的塊顯式地保留為供應用程式使用,空閒塊則可以用來進行分配,它的空閒狀態直到它顯式地被應用程式分配為止。已分配的塊要麼被應用程式顯式釋放,要麼被垃圾回收器所釋放。
本文只講解動態記憶體分配的一些概念,關於動態記憶體分配器的實現已經超出了本文的討論範圍。如果有對它感興趣的同學,可以去參考dlmalloc的原始碼,它是由Doug Lea(就是寫Java併發包的那位)實現的一個設計巧妙的記憶體分配器,而且原始碼中的註釋十分多。
記憶體碎片
造成堆的空間利用率很低的主要原因是一種被稱為碎片(fragmentation)的現象,當雖然有未使用的記憶體但這塊記憶體並不能滿足分配請求時,就會產生碎片。有以下兩種形式的碎片:
內部碎片:在一個已分配塊比有效載荷大時發生。例如,程式請求一個5字(這裡我們不糾結字的大小,假設一個字為4位元組,堆的大小為16字並且要保證邊界雙字對齊)的塊,記憶體分配器為了保證空閒塊是雙字邊界對齊的(具體實現中對齊的規定可能略有不同,但對齊是肯定會有的),只好分配一個6字的塊。在本例中,已分配塊為6字,有效載荷為5字,內部碎片為已分配塊減去有效載荷,為1字。
外部碎片:當空閒記憶體合計起來足夠滿足一個分配請求,但是沒有一個單獨的空閒塊足夠大到可以來處理這個請求時發生。外部碎片難以量化且不可預測,所以分配器通常採用啟發式策略來試圖維持少量的大空閒塊,而不是維持大量的小空閒塊。分配器也會根據策略與分配請求的匹配來分割空閒塊與合併空閒塊(必須相鄰)。
空閒連結串列
分配器將堆組織為一個連續的已分配塊和空閒塊的序列,該序列被稱為空閒連結串列。空閒連結串列分為隱式空閒連結串列與顯式空閒連結串列。
隱式空閒連結串列,是一個單向連結串列,並且每個空閒塊僅僅是通過頭部中的大小欄位隱含地連線著的。
顯式空閒連結串列,即是將空閒塊組織為某種形式的顯式資料結構(為了更加高效地合併與分割空閒塊)。例如,將堆組織為一個雙向空閒連結串列,在每個空閒塊中,都包含一個前驅節點的指標與後繼節點的指標。
查詢一個空閒塊一般有如下幾種策略:
首次適配:從頭開始搜尋空閒連結串列,選擇第一個遇見的合適的空閒塊。它的優點在於趨向於將大的空閒塊保留在連結串列的後面,缺點是它趨向於在靠近連結串列前部處留下碎片。
下一次適配:每次從上一次查詢結束的地方開始進行搜尋,直到遇見合適的空閒塊。這種策略通常比首次適配效率高,但是記憶體利用率則要低得多了。
最佳適配:檢查每個空閒塊,選擇適合所需請求大小的最小空閒塊。最佳適配的記憶體利用率是三種策略中最高的,但它需要對堆進行徹底的搜尋。
對一個連結串列進行查詢操作的效率是線性的,為了減少分配請求對空閒塊匹配的時間,分配器通常採用分離儲存(segregated storage)的策略,即是維護多個空閒連結串列,其中每個連結串列的塊有大致相等的大小。
一種簡單的分離儲存策略:分配器維護一個空閒連結串列陣列,然後將所有可能的塊分成一些等價類(也叫做大小類(size class)),每個大小類代表一個空閒連結串列,並且每個大小類的空閒連結串列包含大小相等的塊,每個塊的大小就是這個大小類中最大元素的大小(例如,某個大小類的範圍定義為(17~32),那麼這個空閒連結串列全由大小為32的塊組成)。
當有一個分配請求時,我們檢查相應的空閒連結串列。如果連結串列非空,那麼就分配其中第一塊的全部。如果連結串列為空,分配器就向作業系統請求一個固定大小的額外記憶體片,將這個片分成大小相等的塊,然後將這些塊連結起來形成新的空閒連結串列。
要釋放一個塊,分配器只需要簡單地將這個塊插入到相應的空閒連結串列的頭部。
垃圾回收
在編寫C程式時,一般只能顯式地分配與釋放堆中的記憶體(malloc()
與free()
),程式設計師不僅需要分配記憶體,還需要負責記憶體的釋放。
許多現代程式語言都內建了自動記憶體管理機制(通過引入自動記憶體管理庫也可以讓C/C++實現自動記憶體管理),所謂自動記憶體管理,就是自動判斷不再需要的堆記憶體(被稱為垃圾記憶體),然後自動釋放這些垃圾記憶體。
自動記憶體管理的實現是垃圾收集器(garbage collector),它是一種動態記憶體分配器,它會自動釋放應用程式不再需要的已分配塊。
垃圾收集器一般採用以下兩種(之一)的策略來判斷一塊堆記憶體是否為垃圾記憶體:
引用計數器:在資料的物理空間中新增一個計數器,當有其他資料與其相關時(引用),該計數器加一,反之則減一。通過定期檢查計數器的值,只要為0則認為是垃圾記憶體,可以釋放它所佔用的已分配塊。使用引用計數器,實現簡單直接,但缺點也很明顯,它無法回收迴圈引用的兩個物件(假設有物件A與物件B,它們2個互相引用,但實際上物件A與物件B都已經是沒用的物件了)。
可達性分析:垃圾收集器將堆記憶體視為一張有向圖,然後選出一組根節點(例如,在Java中一般為類載入器、全域性變數、執行時常量池中的引用型別變數等),根節點必須是足夠“活躍“的物件。然後計算從根節點集合出發的可達路徑,只要從根節點出發不可達的節點,都視為垃圾記憶體。
垃圾收集器進行回收的演算法有如下幾種:
標記-清除:該演算法分為標記(mark)和清除(sweep)兩個階段。首先標記出所有需要回收的物件,然後在標記完成後統一回收所有被標記的物件。標記-清除演算法實現簡單,但它的效率不高,而且會產生許多記憶體碎片。
標記-整理:標記-整理與標記-清除演算法基本一致,只不過後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體。
複製:將程式所擁有的記憶體空間劃分為大小相等的兩塊,每次都只使用其中的一塊。當這一塊的記憶體用完了,就把還存活著的物件複製到另一塊記憶體上,然後將已使用過的記憶體空間進行清理。這種方法不必考慮記憶體碎片問題,但記憶體利用率很低。這個比例不是絕對的,像HotSpot虛擬機器為了避免浪費,將記憶體劃分為Eden空間與兩個Survivor空間,每次都只使用Eden和其中一個Survivor。當回收時,將Eden和Survivor中還存活著的物件一次性地複製到另外一個Survivor空間上,然後清理掉Eden和剛才使用過的Survivor空間。HotSpot虛擬機器預設的Eden和Survivor的大小比例為8:1,只有10%的記憶體空間會被閒置浪費。
分代:分代演算法根據物件的存活週期的不同將記憶體劃分為多塊,這樣就可以對不同的年代採用不同的回收演算法。一般分為新生代與老年代,新生代存放的是存活率較低的物件,可以採用複製演算法;老年代存放的是存活率較高的物件,如果使用複製演算法,那麼記憶體空間會不夠用,所以必須使用標記-清除或標記-整理演算法。
總結
虛擬記憶體是對記憶體的一個抽象。支援虛擬記憶體的CPU需要通過虛擬定址的方式來引用記憶體中的資料。CPU載入一個虛擬地址,然後傳送給MMU進行地址翻譯。地址翻譯需要硬體與作業系統之間緊密合作,MMU藉助頁表來獲得實體地址。
首先,MMU先將虛擬地址傳送給TLB以獲得PTE(根據VPN定址)。
如果恰好TLB中快取了該PTE,那麼就返回給MMU,否則MMU需要從快取記憶體/記憶體中獲得PTE,然後更新快取到TLB。
MMU獲得了PTE,就可以從PTE中獲得對應的PPN,然後結合VPO構造出實體地址。
如果在PTE中發現該虛擬頁沒有快取在記憶體,那麼會觸發一個缺頁異常。缺頁異常處理程式會把虛擬頁快取進實體記憶體,並更新PTE。異常處理程式返回後,CPU會重新載入這個虛擬地址,並進行翻譯。
虛擬記憶體系統簡化了記憶體管理、連結、載入、程式碼和資料的共享以及訪問許可權的保護:
簡化連結,獨立的地址空間允許每個程式的記憶體映像使用相同的基本格式,而不管程式碼和資料實際存放在實體記憶體的何處。
簡化載入,虛擬記憶體使向記憶體中載入可執行檔案和共享物件檔案變得更加容易。
簡化共享,獨立的地址空間為作業系統提供了一個管理使用者程式和核心之間共享的一致機制。
訪問許可權保護,每個虛擬地址都要經過查詢PTE的過程,在PTE中設定訪問許可權的標記位從而簡化記憶體的許可權保護。
作業系統通過將虛擬記憶體與檔案系統結合的方式,來初始化虛擬記憶體區域,這個過程稱為記憶體對映。應用程式顯式分配記憶體的區域叫做堆,通過動態記憶體分配器來直接操作堆記憶體。