內存管理主要做了什麼?#
- 內存的分配與回收:對進程所需的內存進行分配和釋放,malloc 函數:申請內存,free 函數:釋放內存。
- 地址轉換:將程序中的虛擬地址轉換成內存中的物理地址。
- 內存擴充:當系統沒有足夠的內存時,利用虛擬內存技術或自動覆蓋技術,從邏輯上擴充內存。
- 內存映射:將一個文件直接映射到進程的進程空間中,這樣可以通過內存指針用讀寫內存的辦法直接存取文件內容,速度更快。
- 內存優化:通過調整內存分配策略和回收算法來優化內存使用效率。
- 內存安全:保證進程之間使用內存互不干擾,避免一些惡意程序通過修改內存來破壞系統的安全性。
什麼是內存碎片?#
內存碎片是由內存的申請和釋放產生的,通常分為下面兩種:
- 內部內存碎片 (Internal Memory Fragmentation,簡稱為內存碎片):已經分配給進程使用但未被使用的內存。導致內部內存碎片的主要原因是,當採用固定比例比如 2 的幂次方進行內存分配時,進程所分配的內存可能會比其實際所需要的大。舉個例子,一個進程只需要 65 字節的內存,但為其分配了 128(2^7) 大小的內存,那 63 字節的內存就成為了內部內存碎片。
- 外部內存碎片 (External Memory Fragmentation,簡稱為外部碎片):由於未分配的連續內存區域太小,以至於不能滿足任意進程所需要的內存分配請求,這些小片段且不連續的內存空間被稱為外部碎片。也就是說,外部內存碎片指的是那些並為分配給進程但又不能使用的內存。我們後面介紹的分段機制就會導致外部內存碎片。
常見的內存管理方式有哪些?#
連續內存管理#
塊式管理
塊式管理會將內存分為幾個固定大小的塊,每個塊中只包含一個進程。如果程序運行需要內存的話,操作系統就分配給它一塊,如果程序運行只需要很小的空間的話,分配的這塊內存很大一部分幾乎被浪費了。這些在每個塊中未被利用的空間,我們稱之為內部內存碎片。除了內部內存碎片之外,由於兩個內存塊之間可能還會有外部內存碎片,這些不連續的外部內存碎片由於太小了無法再進行分配。
在 Linux 系統中,連續內存管理採用了 夥伴系統(Buddy System)算法 來實現,這是一種經典的連續內存分配算法,可以有效解決外部內存碎片的問題。夥伴系統的主要思想是將內存按 2 的幂次劃分(每一塊內存大小都是 2 的幂次比如 2^6=64 KB),並將相鄰的內存塊組合成一對夥伴(注意:必須是相鄰的才是夥伴)。
當進行內存分配時,夥伴系統會嘗試找到大小最合適的內存塊。如果找到的內存塊過大,就將其一分為二,分成兩個大小相等的夥伴塊。如果還是大的話,就繼續切分,直到到達合適的大小為止。
假設兩塊相鄰的內存塊都被釋放,系統會將這兩個內存塊合併,進而形成一個更大的內存塊,以便後續的內存分配。這樣就可以減少內存碎片的問題,提高內存利用率。
非連續內存管理#
非連續內存管理存在下面 3 種方式:
- 段式管理:以段 (— 段連續的物理內存) 的形式管理 / 分配物理內存。應用程序的虛擬地址空間被分為大小不等的段,段是有實際意義的,每個段定義了一組邏輯信息,例如有主程序段 MAIN、子程序段 X、數據段 D 及棧段 S 等。
- 頁式管理:把物理內存分為連續等長的物理頁,應用程序的虛擬地址空間劃也被分為連續等長的虛擬頁,現代操作系統廣泛使用的一種內存管理方式。
- 段頁式管理機制:結合了段式管理和頁式管理的一種內存管理機制,把物理內存先分成若干段,每個段又繼續分成若干大小相等的頁。
虛擬內存#
什麼是虛擬內存?有什麼用?#
虛擬內存 (Virtual Memory) 是計算機系統內存管理非常重要的一個技術,本質上來說它只是邏輯存在的,是一個假想出來的內存空間,主要作用是作為進程訪問主存(物理內存)的橋梁並簡化內存管理。
總結來說,虛擬內存主要提供了下面這些能力:
- 隔離進程:物理內存通過虛擬地址空間訪問,虛擬地址空間與進程一一對應。每個進程都認為自己擁有了整個物理內存,進程之間彼此隔離,一個進程中的代碼無法更改正在由另一進程或操作系統使用的物理內存。
- 提升物理內存利用率:有了虛擬地址空間後,操作系統只需要將進程當前正在使用的部分數據或指令加載入物理內存。
- 簡化內存管理:進程都有一個一致且私有的虛擬地址空間,程序員不用和真正的物理內存打交道,而是借助虛擬地址空間訪問物理內存,從而簡化了內存管理。
- 多個進程共享物理內存:進程在運行過程中,會加載許多操作系統的動態庫。這些庫對於每個進程而言都是公用的,它們在內存中實際只會加載一份,這部分稱為共享內存。
- 提高內存使用安全性:控制進程對物理內存的訪問,隔離不同進程的訪問權限,提高系統的安全性。
- 提供更大的可使用內存空間:可以讓程序擁有超過系統物理內存大小的可用內存空間。這是因為當物理內存不夠用時,可以利用磁碟充當,將物理內存頁(通常大小為 4 KB)保存到磁碟文件(會影響讀寫速度),數據或代碼頁會根據需要在物理內存與磁碟之間移動。
什麼是虛擬地址和物理地址?#
物理地址(Physical Address) 是真正的物理內存中地址,更具體點來說是內存地址寄存器中的地址。程序中訪問的內存地址不是物理地址,而是 虛擬地址(Virtual Address) 。
也就是說,我們編程開發的時候實際就是在和虛擬地址打交道。比如在 C 語言中,指針裡面存儲的數值就可以理解成為內存裡的一個地址,這個地址也就是我們說的虛擬地址。
操作系統一般通過 CPU 芯片中的一個重要組件 MMU (Memory Management Unit,內存管理單元) 將虛擬地址轉換為物理地址,這個過程被稱為 地址翻譯 / 地址轉換(Address Translation) 。
虛擬地址與物理內存地址是如何映射的?#
MMU 將虛擬地址翻譯為物理地址的主要機制有 3 種:
- 分段機制
- 分頁機制
- 段頁機制
分段機制#
分段機制(Segmentation) 以段 (— 段 連續 的物理內存) 的形式管理 / 分配物理內存。應用程序的虛擬地址空間被分為大小不等的段,段是有實際意義的,每個段定義了一組邏輯信息,例如有主程序段 MAIN、子程序段 X、數據段 D 及棧段 S 等。
段表有什麼用?地址翻譯過程是怎樣的?#
分段機制下的虛擬地址由兩部分組成,段選擇因子和段內偏移量。
段選擇因子和段內偏移量:
- 段選擇子就保存在段寄存器裡面。段選擇子裡面最重要的是段號,用作段表的索引。段表裡面保存的是這個段的基地址、段的界限和特權級別等。
- 虛擬地址中的段內偏移量應該位於 0 和段界限之間,如果段內偏移量是合法的,就將段基地址加上段內偏移量得到物理內存地址。
在上面,知道了虛擬地址是通過段表與物理地址進行映射的,分段機制會把程序的虛擬地址分成 4 個段,每個段在段表中有一個項,在這一項找到段的基地址,再加上偏移量,於是就能找到物理內存中的地址,如下圖:
分段的辦法很好,解決了程序本身不需要關心具體的物理內存地址的問題,但它也有一些不足之處:
- 第一个就是內存碎片的問題。
- 第二個就是內存交換的效率低的問題。
內存分段會出現內存碎片嗎?#
內存分段管理可以做到段根據實際需求分配內存,所以有多少需求就分配多大的段,所以不會出現內部內存碎片。
但是由於每個段的長度不固定,所以多個段未必能恰好使用所有的內存空間,會產生了多個不連續的小物理內存,導致新的程序無法被裝載,所以會出現外部內存碎片的問題。
解決「外部內存碎片」的問題就是內存交換。
可以把音樂程序佔用的那 256MB 內存寫到硬碟上,然後再從硬碟上讀回到內存裡。不過再讀回的時候,我們不能裝載回原來的位置,而是緊緊跟著那已經被佔用了的 512MB 內存後面。這樣就能空缺出連續的 256MB 空間,於是新的 200MB 程序就可以裝載進來。
這個內存交換空間,在 Linux 系統裡,也就是我們常看到的 Swap 空間,這塊空間是從硬碟劃分出來的,用於內存與硬碟的空間交換。
分段為什麼會導致內存交換效率低的問題?#
對於多進程的系統來說,用分段的方式,外部內存碎片是很容易產生的,產生了外部內存碎片,那不得不重新 Swap
內存區域,這個過程會產生性能瓶頸。
因為硬碟的訪問速度要比內存慢太多了,每一次內存交換,我們都需要把一大段連續的內存數據寫到硬碟上。
所以,如果內存交換的時候,交換的是一個佔內存空間很大的程序,這樣整個機器都會顯得卡頓。
為了解決內存分段的「外部內存碎片和內存交換效率低」的問題,就出現了內存分頁
分頁機制#
分頁是把整個虛擬和物理內存空間切成一段段固定尺寸的大小。這樣一個連續並且尺寸固定的內存空間,我們叫頁(Page)。在 Linux 下,每一頁的大小為 4KB
。
虛擬地址與物理地址之間通過頁表來映射,如下圖:
頁表是存儲在內存裡的,內存管理單元 (MMU)就做將虛擬內存地址轉換成物理地址的工作。
而當進程訪問的虛擬地址在頁表中查不到時,系統會產生一個缺頁異常,進入系統內核空間分配物理內存、更新進程頁表,最後再返回用戶空間,恢復進程的運行。
頁表有什麼用?地址翻譯過程是怎樣的?#
在分頁機制下,每個應用程序都會有一個對應的頁表。
分頁機制下的虛擬地址由兩部分組成:
- 頁號:通過虛擬頁號可以從頁表中取出對應的物理頁號;
- 頁內偏移量:物理頁起始地址 + 頁內偏移量 = 物理內存地址。
具體的地址翻譯過程如下:
- MMU 首先解析得到虛擬地址中的虛擬頁號;
- 通過虛擬頁號去該應用程序的頁表中取出對應的物理頁號(找到對應的頁表項);
- 用該物理頁號對應的物理頁起始地址(物理地址)加上虛擬地址中的頁內偏移量得到最終的物理地址。
分頁是怎麼解決分段的「外部內存碎片和內存交換效率低」的問題?#
內存分頁由於內存空間都是預先劃分好的,也就不會像內存分段一樣,在段與段之間會產生間隙非常小的內存,這正是分段會產生外部內存碎片的原因。而採用了分頁,頁與頁之間是緊密排列的,所以不會有外部碎片。
但是,由於內存分頁機制分配內存的最小單位是一頁,即使程序不足一頁大小,我們最少只能分配一個頁,所以頁內會出現內存浪費,所以針對內存分頁機制會有內部內存碎片的現象。
如果內存空間不夠,操作系統會把其他正在運行的進程中的「最近沒被使用」的內存頁給釋放掉,也就是暫時寫在硬碟上,稱為換出(Swap Out)。一旦需要的時候,再加載進來,稱為換入(Swap In)。所以,一次性寫入磁碟的也只有少數的一個頁或者幾個頁,不會花太多時間,內存交換的效率就相對比較高。
更進一步地,分頁的方式使得我們在加載程序的時候,不再需要一次性都把程序加載到物理內存中。我們完全可以在進行虛擬內存和物理內存的頁之間的映射之後,並不真的把頁加載到物理內存裡,而是只有在程序運行中,需要用到對應虛擬內存頁裡面的指令和數據時,再加載到物理內存裡面去。
單級頁表有什麼問題?為什麼需要多級頁表?#
因為操作系統是可以同時運行非常多的進程的,那這不就意味著頁表會非常的龐大。
在 32 位的環境下,虛擬地址空間共有 4GB,假設一個頁的大小是 4KB(2^12),那麼就需要大約 100 萬 (2^20) 個頁,每個「頁表項」需要 4 個字節大小來存儲,那麼整個 4GB 空間的映射就需要有 4MB
的內存來存儲頁表。
這 4MB 大小的頁表,看起來也不是很大。但是要知道每個進程都是有自己的虛擬地址空間的,也就說都有自己的頁表。
那麼,100
個進程的話,就需要 400MB
的內存來存儲頁表,這是非常大的內存了,更別說 64 位的環境了。
多級頁表#
要解決上面的问题,就需要採用一種叫作多級頁表(Multi-Level Page Table)的解決方案。
我們把這個 100 多萬個「頁表項」的單級頁表再分頁,將頁表(一级頁表)分為 1024
個頁表(二級頁表),每個表(二級頁表)中包含 1024
個「頁表項」,形成二級分頁。如下圖所示:
二級列表分為一级頁表和二級頁表。一级頁表共有 1024 個頁表項,一级頁表又關聯二級頁表,二級頁表同樣共有 1024 個頁表項。二級頁表中的一级頁表項是一對多的關係,二級頁表按需加載(只會用到很少一部分二級頁表),進而節省空間佔用。
假設只需要 2 個二級頁表,那兩級頁表的內存佔用情況為: 4KB(一级頁表佔用) + 4KB * 2(二級頁表佔用) = 12 KB。
多級頁表屬於時間換空間的典型場景,利用增加頁表查詢的次數減少頁表佔用的空間。
你可能會問,分了二級表,映射 4GB 地址空間就需要 4KB(一级頁表)+ 4MB(二級頁表)的內存,這樣佔用空間不是更大了?#
當然如果 4GB 的虛擬地址全部都映射到了物理內存上的話,二級分頁佔用空間確實是更大了,但是,我們往往不會為一個進程分配那麼多內存。
其實我們應該換個角度來看問題,還記得計算機組成原理裡面無處不在的局部性原理麼?
每個進程都有 4GB 的虛擬地址空間,而顯然對於大多數程序來說,其使用到的空間遠未達到 4GB,因為會存在部分對應的頁表項都是空的,根本沒有分配,對於已分配的頁表項,如果存在最近一定時間未訪問的頁表,在物理內存緊張的情況下,操作系統會將頁面換出到硬碟,也就是說不會佔用物理內存。
如果使用了二級分頁,一级頁表就可以覆蓋整個 4GB 虛擬地址空間,但如果某個一级頁表的頁表項沒有被用到,也就不需要創建這個頁表項對應的二級頁表了,即可以在需要時才創建二級頁表。做個簡單的計算,假設只有 20% 的一级頁表項被用到了,那麼頁表佔用的內存空間就只有 4KB(一级頁表) + 20% * 4MB(二級頁表)= 0.804MB
,這對比單級頁表的 4MB
是不是一個巨大的節約?
TLB#
多級頁表雖然解決了空間上的問題,但是虛擬地址到物理地址的轉換就多了幾道轉換的工序,這顯然就降低了這兩個地址轉換的速度,也就是帶來了時間上的開銷。
程序是有局部性的,即在一段時間內,整個程序的執行僅限於程序中的某一部分。相應地,執行所訪問的存儲空間也局限於某個內存區域。
我們就可以利用這一特性,把最常訪問的幾個頁表項存儲到訪問速度更快的硬件,於是計算機科學家們,就在 CPU 芯片中,加入了一個專門存放程序最常訪問的頁表項的 Cache,這個 Cache 就是 TLB(Translation Lookaside Buffer) ,通常稱為頁表快取、轉址旁路快取、快表等。
有了 TLB 後,那麼 CPU 在尋址時,會先查 TLB,如果沒找到,才會繼續查常規的頁表。
段頁式內存管理#
內存分段和內存分頁並不是對立的,它們是可以組合起來在同一個系統中使用的,那麼組合起來後,通常稱為段頁式內存管理。
段頁式內存管理實現的方式:
- 先將程序劃分為多個有邏輯意義的段,也就是前面提到的分段機制;
- 接著再把每個段劃分為多個頁,也就是對分段劃分出來的連續空間,再劃分固定大小的頁;
這樣,地址結構就由段號、段內頁號和頁內位移三部分組成。
用於段頁式地址變換的數據結構是每一個程序一張段表,每個段又建立一張頁表,段表中的地址是頁表的起始地址,而頁表中的地址則為某頁的物理頁號,如圖所示:
段頁式地址變換中要得到物理地址須經過三次內存訪問:
- 第一次訪問段表,得到頁表起始地址;
- 第二次訪問頁表,得到物理頁號;
- 第三次將物理頁號與頁內位移組合,得到物理地址。
可用軟、硬件相結合的方法實現段頁式地址變換,這樣雖然增加了硬件成本和系統開銷,但提高了內存的利用率。
malloc 是如何分配內存的?#
Linux 進程的內存分布長什麼樣?#
在 Linux 操作系統中,虛擬地址空間的內部又被分為內核空間和用戶空間兩部分,不同位數的系統,地址空間的範圍也不同。比如最常見的 32 位和 64 位系統,如下所示:
通過這裡可以看出:
32
位系統的內核空間占用1G
,位於最高處,剩下的3G
是用戶空間;64
位系統的內核空間和用戶空間都是128T
,分別占據整個內存空間的最高和最低處,剩下的中間部分是未定義的。
再來說說,內核空間與用戶空間的區別:
- 進程在用戶態時,只能訪問用戶空間內存;
- 只有進入內核態後,才可以訪問內核空間的內存;
雖然每個進程都各自有獨立的虛擬內存,但是每個虛擬內存中的內核地址,其實關聯的都是相同的物理內存。這樣,進程切換到內核態後,就可以很方便地訪問內核空間內存。
接下來,進一步了解虛擬空間的劃分情況,用戶空間和內核空間劃分的方式是不同的,內核空間的分布情況就不多說了。
我們看看用戶空間分布的情況,以 32 位系統為例,我畫了一張圖來表示它們的關係:
通過這張圖你可以看到,用戶空間內存從低到高分別是 6 種不同的內存段:
- 代碼段,包括二進制可執行代碼;
- 數據段,包括已初始化的靜態常量和全局變量;
- BSS 段,包括未初始化的靜態變量和全局變量;
- 堆段,包括動態分配的內存,從低地址開始向上增長;
- 文件映射段,包括動態庫、共享內存等,從低地址開始向上增長(跟硬件和內核版本有關);
- 棧段,包括局部變量和函數調用的上下文等。棧的大小是固定的,一般是
8 MB
。當然系統也提供了參數,以便我們自定義大小;
在這 6 個內存段中,堆和文件映射段的內存是動態分配的。比如說,使用 C 標準庫的 malloc()
或者 mmap()
,就可以分別在堆和文件映射段動態分配內存。
malloc 是如何分配內存的?#
malloc 申請內存的時候,會有兩種方式向操作系統申請堆內存。
- 方式一:通過 brk () 系統調用從堆分配內存
- 方式二:通過 mmap () 系統調用在文件映射區域分配內存;
方式一實現的方式很簡單,就是通過 brk () 函數將「堆頂」指針向高地址移動,獲得新的內存空間。如下圖:
方式二通過 mmap () 系統調用中「私有匿名映射」的方式,在文件映射區分配一塊內存,也就是從文件映射區 “偷” 了一塊內存。如下圖:
malloc () 源碼裡默認定義了一個閾值:
- 如果用戶分配的內存小於 128 KB,則通過 brk () 申請內存;
- 如果用戶分配的內存大於 128 KB,則通過 mmap () 申請內存;
malloc () 分配的是物理內存嗎?#
不是的,malloc () 分配的是虛擬內存。
malloc (1) 會分配多大的虛擬內存?#
malloc () 在分配內存的時候,并不是老老實實按用戶預期申請的字節數來分配內存空間大小,而是會預分配更大的空間作為內存池。
具體會預分配多大的空間,跟 malloc 使用的內存管理器有關
這個例子分配的內存小於 128 KB,所以是通過 brk () 系統調用向堆空間申請的內存,因此可以看到最右邊有 [heap] 的標識。
可以看到,堆空間的內存地址範圍是 00d73000-00d94000,這個範圍大小是 132KB,也就說明了 malloc (1) 實際上預分配 132K 字節的內存。
free 釋放內存,會歸還給操作系統嗎?#
- malloc 通過 brk() 方式申請的內存,free 釋放內存的時候,並不會把內存歸還給操作系統,而是緩存在 malloc 的內存池中,待下次使用;
- malloc 通過 mmap() 方式申請的內存,free 釋放內存的時候,會把內存歸還給操作系統,內存得到真正的釋放。
為什麼不全部使用 mmap 來分配內存?#
如果都用 mmap 來分配內存,等於每次都要執行系統調用。
另外,因為 mmap 分配的內存每次釋放的時候,都会归还给操作系统,于是每次 mmap 分配的虛擬地址都是缺頁狀態的,然後在第一次訪問該虛擬地址的時候,就會觸發缺頁中斷。
頻繁通過 mmap 分配的內存話,不僅每次都會發生運行態的切換,還會發生缺頁中斷(在第一次訪問虛擬地址後),這樣會導致 CPU 消耗較大。
為了改進這兩個問題,malloc 通過 brk () 系統調用在堆空間申請內存的時候,由於堆空間是連續的,所以直接預分配更大的內存來作為內存池,當內存釋放的時候,就緩存在內存池中。
等下次在申請內存的時候,就直接從內存池取出對應的內存塊就行了,而且可能這個內存塊的虛擬地址與物理地址的映射關係還存在,這樣不僅減少了系統調用的次數,也減少了缺頁中斷的次數,這將大大降低 CPU 的消耗。
為什麼不全部使用 brk 來分配?#
過 brk 從堆空間分配的內存,并不會歸還給操作系統,如果我們連續申請了 10k,20k,30k 這三片內存,如果 10k 和 20k 這兩片釋放了,變為了空閒內存空間,如果下次申請的內存小於 30k,那麼就可以重用這個空閒內存空間。
但是如果下次申請的內存大於 30k,沒有可用的空閒內存空間,必須向 OS 申請,實際使用內存繼續增大。
因此,隨著系統頻繁地 malloc 和 free ,尤其對於小塊內存,堆內將產生越來越多不可用的碎片,導致 “內存泄露”。而這種 “泄露” 現象使用 valgrind 是無法檢測出來的。
所以,malloc 實現中,充分考慮了 brk 和 mmap 行為上的差異及優缺點,默認分配大塊內存 (128KB) 才使用 mmap 分配內存空間。
free () 函數只傳入一個內存地址,為什麼能知道要釋放多大的內存?#
malloc 返回給用戶態的內存起始地址比進程的堆空間起始地址多了 16 字節
這個多出來的 16 字節就是保存了該內存塊的描述信息,比如有該內存塊的大小。
這樣當執行 free () 函數時,free 會對傳入進來的內存地址向左偏移 16 字節,然後從這個 16 字節的分析出當前的內存塊的大小,自然就知道要釋放多大的內存了。
內存滿了,會發生什麼?#
內存分配和回收的過程是怎樣的?#
當應用程序讀寫了這塊虛擬內存,CPU 就會去訪問這個虛擬內存, 這時會發現這個虛擬內存沒有映射到物理內存, CPU 就會產生缺頁中斷,進程會從用戶態切換到內核態,並將缺頁中斷交給內核的 Page Fault Handler (缺頁中斷函數)處理。
缺頁中斷處理函數會看是否有空閒的物理內存,如果有,就直接分配物理內存,並建立虛擬內存與物理內存之間的映射關係。
如果沒有空閒的物理內存,那麼內核就會開始進行回收內存的工作,回收的方式主要是兩種:直接內存回收和後台內存回收。
- 後台內存回收(kswapd):在物理內存緊張的時候,會喚醒 kswapd 內核線程來回收內存,這個回收內存的過程異步的,不會阻塞進程的執行。
- 直接內存回收(direct reclaim):如果後台異步回收跟不上進程內存申請的速度,就會開始直接回收,這個回收內存的過程是同步的,會阻塞進程的執行。
- 如果直接內存回收後,空閒的物理內存仍然無法滿足此次物理內存的申請,那麼內核就會放最後的大招了 ——觸發 OOM (Out of Memory)機制。
OOM Killer 機制會根據算法選擇一個佔用物理內存較高的進程,然後將其殺死,以便釋放內存資源,如果物理內存依然不足,OOM Killer 會繼續殺死佔用物理內存較高的進程,直到釋放足夠的內存位置。
哪些內存可以被回收?#
主要有兩類內存可以被回收,而且它們的回收方式也不同。
- 文件頁(File-backed Page):內核緩存的磁碟數據(Buffer)和內核緩存的文件數據(Cache)都叫作文件頁。大部分文件頁,都可以直接釋放內存,以後有需要時,再從磁碟重新讀取就可以了。而那些被應用程序修改過,並且暫時還沒寫入磁碟的數據(也就是髒頁),就得先寫入磁碟,然後才能進行內存釋放。所以,回收乾淨頁的方式是直接釋放內存,回收髒頁的方式是先寫回磁碟後再釋放內存。
- 匿名頁(Anonymous Page):這部分內存沒有實際載體,不像文件緩存有硬碟文件這樣一個載體,比如堆、棧數據等。這部分內存很可能還要再次被訪問,所以不能直接釋放內存,它們回收的方式是通過 Linux 的 Swap 機制,Swap 會把不常訪問的內存先寫到磁碟中,然後釋放這些內存,給其他更需要的進程使用。再次訪問這些內存時,重新從磁碟讀入內存就可以了。
文件頁和匿名頁的回收都是基於 LRU 算法,也就是優先回收不常訪問的內存。LRU 回收算法,實際上維護著 active 和 inactive 兩個雙向鏈表,其中:
- active_list 活躍內存頁鏈表,這裡存放的是最近被訪問過(活躍)的內存頁;
- inactive_list 不活躍內存頁鏈表,這裡存放的是很少被訪問(非活躍)的內存頁;
越接近鏈表尾部,就表示內存頁越不常訪問。這樣,在回收內存時,系統就可以根據活躍程度,優先回收不活躍的內存。
回收內存帶來的性能影響#
回收內存的操作基本都會發生磁碟 I/O 的,如果回收內存的操作很頻繁,意味著磁碟 I/O 次數會很多,這個過程勢必會影響系統的性能,整個系統給人的感覺就是很卡。
解決方式。
調整文件頁和匿名頁的回收傾向#
從文件頁和匿名頁的回收操作來看,文件頁的回收操作對系統的影響相比匿名頁的回收操作會少一點,因為文件頁對於乾淨頁回收是無法發生磁碟 I/O 的,而匿名頁的 Swap 換入換出這兩個操作都會發生磁碟 I/O。
Linux 提供了一個 /proc/sys/vm/swappiness
選項,用來調整文件頁和匿名頁的回收傾向。
swappiness 的範圍是 0-100,數值越大,越積極使用 Swap,也就是更傾向於回收匿名頁;數值越小,越消極使用 Swap,也就是更傾向於回收文件頁。
一般建議 swappiness 設置為 0(默認值是 60),這樣在回收內存的時候,會更傾向於文件頁的回收,但是並不代表不會回收匿名頁。
儘早觸發 kswapd 內核線程異步回收內存#
內核定義了三個內存閾值(watermark,也稱為水位),用來衡量當前剩餘內存(pages_free)是否充裕或者緊張,分別是:
- 頁最小閾值(pages_min);
- 頁低閾值(pages_low);
- 頁高閾值(pages_high);
這三個內存閾值會劃分為四種內存使用情況
- 圖中橙色部分:如果剩餘內存(pages_free)在頁低閾值(pages_low)和頁最小閾值(pages_min)之間,說明內存壓力比較大,剩餘內存不多了。這時 kswapd0 會執行內存回收,直到剩餘內存大於高閾值(pages_high)為止。雖然會觸發內存回收,但是不會阻塞應用程序,因為兩者關係是異步的。
- 圖中紅色部分:如果剩餘內存(pages_free)小於頁最小閾值(pages_min),說明用戶可用內存都耗尽了,此時就會觸發直接內存回收,這時應用程序就會被阻塞,因為兩者關係是同步的。
頁低閾值(pages_low)可以通過內核選項 /proc/sys/vm/min_free_kbytes
(該參數代表系統所保留空閒內存的最低限)來間接設置。
min_free_kbytes 雖然設置的是頁最小閾值(pages_min),但是頁高閾值(pages_high)和頁低閾值(pages_low)都是根據頁最小閾值(pages_min)計算生成的,它們之間的計算關係如下:
pages_min = min_free_kbytes
pages_low = pages_min*5/4
pages_high = pages_min*3/2
4. 如何保護一個進程不被 OOM 殺掉呢?#
Linux 到底是根據什麼標準來選擇被殺的進程呢?這就要提到一個在 Linux 內核裡有一個 oom_badness()
函數,它會把系統中可以被殺掉的進程掃描一遍,並對每個進程打分,得分最高的進程就會被首先殺掉。
用「系統總的可用頁面數」乘以 「OOM 校準值 oom_score_adj」再除以 1000,最後再加上進程已經使用的物理頁面數,計算出來的值越大,那麼這個進程被 OOM Kill 的幾率也就越大。
- 如果你不想某個進程被首先殺掉,那你可以調整該進程的 oom_score_adj,從而改變這個進程的得分結果,降低該進程被 OOM 殺死的概率。
- 如果你想某個進程無論如何都不能被殺掉,那你可以將 oom_score_adj 配置為 -1000。
7. 如何避免預讀失效和快取污染的問題?#
1.Linux 和 MySQL 的快取#
Linux 操作系統的快取#
在應用程序讀取文件的數據的時候,Linux 操作系統是會對讀取的文件數據進行快取的,會緩存在文件系統中的 Page Cache
Page Cache 屬於內存空間裡的數據,由於內存訪問比磁碟訪問快很多,在下一次訪問相同的數據就不需要通過磁碟 I/O 了,命中快取就直接返回數據即可。
因此,Page Cache 起到了加速訪問數據的作用。
MySQL 的快取#
MySQL 的數據是存儲在磁碟裡的,為了提升數據庫的讀寫性能,Innodb 存儲引擎設計了一個緩衝池(Buffer Pool),Buffer Pool 屬於內存空間裡的數據。
有了緩衝池後:
- 當讀取數據時,如果數據存在於 Buffer Pool 中,客戶端就會直接讀取 Buffer Pool 中的數據,否則再去磁碟中讀取。
- 當修改數據時,首先是修改 Buffer Pool 中數據所在的頁,然後將其頁設置為髒頁,最後由後台線程將髒頁寫入到磁碟。
2. 傳統 LRU 是如何管理內存數據的?#
傳統的 LRU 算法並沒有被 Linux 和 MySQL 使用,因為傳統的 LRU 算法無法避免下面這兩個問題:
- 預讀失效導致快取命中率下降;
- 快取污染導致快取命中率下降;
2. 預讀失效,怎麼辦?#
什麼是預讀機制?#
Linux 操作系統為基於 Page Cache 的讀快取機制提供預讀機制,一個例子是:
- 應用程序只想讀取磁碟上文件 A 的 offset 為 0-3KB 範圍內的數據,由於磁碟的基本讀寫單位為 block(4KB),於是操作系統至少會讀 0-4KB 的內容,這恰好可以在一個 page 中裝下。
- 但是操作系統出於空間局部性原理(靠近當前被訪問數據的數據,在未來很大概率會被訪問到),會選擇將磁碟塊 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加載到內存,於是額外在內存中申請了 3 個 page;
下圖代表了操作系統的預讀機制:
因此,預讀機制帶來的好處就是減少了 磁碟 I/O 次數,提高系統磁碟 I/O 吞吐量。
MySQL Innodb 存儲引擎的 Buffer Pool 也有類似的預讀機制,MySQL 從磁碟加載頁時,會提前把它相鄰的頁一並加載進來,目的是為了減少磁碟 IO。
預讀失效會帶來什麼問題?#
如果這些被提前加載進來的頁,並沒有被訪問,相當於這個預讀工作是白做了,這個就是預讀失效。
如果使用傳統的 LRU 算法,就會把「預讀頁」放到 LRU 鏈表頭部,而當內存空間不夠的時候,還需要把末尾的頁淘汰掉。
如果這些「預讀頁」如果一直不會被訪問到,就會出現一個很奇怪的問題,不會被訪問的預讀頁卻佔用了 LRU 鏈表前排的位置,而末尾淘汰的頁,可能是熱點數據,這樣就大大降低了快取命中率 。
如何避免預讀失效造成的影響?#
inux 操作系統和 MySQL Innodb 通過改進傳統 LRU 鏈表來避免預讀失效帶來的影響,具體的改進分別如下:
- Linux 操作系統實現兩個了 LRU 鏈表:活躍 LRU 鏈表(active_list)和非活躍 LRU 鏈表(inactive_list);
- MySQL 的 Innodb 存儲引擎是在一個 LRU 鏈表上劃分來 2 個區域:young 區域 和 old 區域。
這兩個改進方式,設計思想都是類似的,都是將數據分為了冷數據和熱數據,然後分別進行 LRU 算法。不再像傳統的 LRU 算法那樣,所有數據都只用一個 LRU 算法管理。
給大家舉個例子。
假設有一個長度為 10 的 LRU 鏈表,其中 young 區域占比 70 %,old 區域占比 30 %。
現在有個編號為 20 的頁被預讀了,這個頁只會被插入到 old 區域頭部,而 old 區域末尾的頁(10 號)會被淘汰掉。
如果 20 号頁一直不會被訪問,它也沒有佔用到 young 區域的位置,而且還會比 young 區域的數據更早被淘汰出去。
如果 20 号頁被預讀後,立刻被訪問了,那麼就會將它插入到 young 區域的頭部,young 區域末尾的頁(7 號),會被擠到 old 區域,作為 old 區域的頭部,這個過程並沒有頁被淘汰。
3. 快取污染,怎麼辦?#
什麼是快取污染?#
當我們在批量讀取數據的時候,由於數據被訪問了一次,這些大量數據都會被加入到「活躍 LRU 鏈表」裡,然後之前緩存在活躍 LRU 鏈表(或者 young 區域)裡的熱點數據全部都被淘汰了,如果這些大量的數據在很長一段時間都不會被訪問的話,那麼整個活躍 LRU 鏈表(或者 young 區域)就被污染了。
怎麼避免快取污染造成的影響?#
前面的 LRU 算法只要數據被訪問一次,就將數據加入活躍 LRU 鏈表(或者 young 區域),這種 LRU 算法進入活躍 LRU 鏈表的門檻太低了!正式因為門檻太低,才導致在發生快取污染的時候,很容就將原本在活躍 LRU 鏈表裡的熱點數據淘汰了。
所以,只要我們提高進入到活躍 LRU 鏈表(或者 young 區域)的門檻,就能有效地保證活躍 LRU 鏈表(或者 young 區域)裡的熱點數據不會被輕易替換掉。
Linux 操作系統和 MySQL Innodb 存儲引擎分別是這樣提高門檻的:
- Linux 操作系統:在內存頁被訪問第二次的時候,才將頁從 inactive list 升級到 active list 裡。
- MySQL Innodb:在內存頁被訪問第二次的時候,並不馬上將該頁從 old 區域升級到 young 區域,因為還要進行停留在 old 區域的時間判斷:
- 如果第二次的訪問時間與第一次訪問的時間在 1 秒內(默認值),那麼該頁就不會被從 old 區域升級到 young 區域;
- 如果第二次的訪問時間與第一次訪問的時間超過 1 秒,那麼該頁就會從 old 區域升級到 young 區域;
提高了進入活躍 LRU 鏈表(或者 young 區域)的門檻後,就很好了避免快取污染帶來的影響。
在批量讀取數據時,如果這些大量數據只會被訪問一次,那麼它們就不會進入到活躍 LRU 鏈表(或者 young 區域),也就不會把熱點數據淘汰,只會待在非活躍 LRU 鏈表(或者 old 區域)中,後續很快也會被淘汰。