在 Linux 作業系統中,虛擬位址空間的內部又被分成核心空間與使用者空間兩部分,不同位數的系統,位址空間的範圍也不同。例如最常見的 32 位元和 64 位元系統,如下所示:
透過這裡可以看出:
再來來說,核心空間與使用者空間的差別:
雖然每個進程都各自有獨立的虛擬內存,但是每個虛擬內存中的內核地址,其實關聯的都是相同的物理內存。這樣,進程切換到內核態後,就可以很方便地存取內核空間記憶體。
接下來,進一步了解虛擬空間的劃分情況,使用者空間和核心空間劃分的方式是不同的,核心空間的分佈就不多說了。
我們來看看使用者空間分佈的情況,以 32 位元系統為例,我畫了一張圖來表示它們的關係:
透過這張圖你可以看到,使用者空間記憶體從低到高分別是 6 種不同的記憶體段:
在這 6 個記憶體段中,堆和檔案映射段的記憶體是動態分配的。比方說,使用 C 標準函式庫的 malloc() 或 mmap() ,就可以分別在堆和檔案映射段動態分配記憶體。
實際上,malloc() 並不是系統調用,而是 C 庫裡的函數,用於動態分配記憶體。
malloc 申請記憶體的時候,會有兩種方式向作業系統申請堆記憶體。
#方式一實現的方式很簡單,就是透過 brk() 函數將「堆頂」指標往高位址移動,得到新的記憶體空間。如下圖:
方式二透過 mmap() 系統呼叫中「私有匿名映射」的方式,在檔案映射區分配一塊內存,也就是從檔案映射區「偷」了一塊記憶體。如下圖:
「
什麼場景下 malloc() 會透過 brk() 分配記憶體?又是什麼場景下透過 mmap() 分配記憶體?
」
#malloc() 原始碼裡預設定義了一個閾值:
注意,不同的 glibc 版本定義的閾值也是不同的。
malloc() 分配的是實體記憶體嗎?
不是的,malloc() 分配的是虛擬記憶體。
如果分配後的虛擬記憶體沒有被存取的話,虛擬記憶體是不會映射到實體記憶體的,這樣就不會佔用實體記憶體了。
只有在存取已分配的虛擬位址空間的時候,作業系統透過尋找頁表,發現虛擬記憶體對應的頁沒有在實體記憶體中,就會觸發缺頁中斷,然後作業系統會建立虛擬記憶體和物理記憶體之間的映射關係。
malloc() 在分配記憶體的時候,並不是老實按使用者預期申請的位元組數來分配記憶體空間大小,而是會預先分配更大的空間作為記憶體池。
具體會預先分配多大的空間,跟 malloc 使用的記憶體管理器有關係,我們就以 malloc 預設的記憶體管理器(Ptmalloc2)來分析。
接著裡,我們做個實驗,用下面這個程式碼,透過 malloc 申請 1 位元組的記憶體時,看看作業系統實際上分配了多大的記憶體空間。
#include #include int main() { printf("使用cat /proc/%d/maps查看内存分配\n",getpid()); //申请1字节的内存 void *addr = malloc(1); printf("此1字节的内存起始地址:%x\n", addr); printf("使用cat /proc/%d/maps查看内存分配\n",getpid()); //将程序阻塞,当输入任意字符时才往下执行 getchar(); //释放内存 free(addr); printf("释放了1字节的内存,但heap堆并不会释放\n"); getchar(); return 0; }
執行程式碼(先事先說明,我使用的 glibc 函式庫的版本是 2.17):
我們可以透過 /proc//maps 檔案查看進程的記憶體分佈。我在 maps 檔案透過此 1 位元組的記憶體起始位址過濾出了記憶體位址的範圍。
[root@xiaolin ~]# cat /proc/3191/maps | grep d730 00d73000-00d94000 rw-p 00000000 00:00 0 [heap]
這個範例分配的記憶體小於 128 KB,所以是透過 brk() 系統呼叫向堆空間申請的內存,因此可以看到最右邊有 [heap] 的標識。
可以看到,堆空間的記憶體位址範圍是 00d73000-00d94000,這個範圍大小是 132KB,也就說明了 malloc(1) 實際上預先分配 132K 位元組的記憶體。
可能有的同學注意到了,程式裡列印的記憶體起始位址是 d73010,而 maps 檔案顯示堆記憶體空間的起始位址是 d73000,為什麼會多出來 0x10 (16位元組)呢?這個問題,我們先放著,後面就會說。
#free 釋放內存,會歸還給作業系統嗎?
我們在上面的進程往下執行,看看透過 free() 函數釋放記憶體後,堆記憶體還在嗎?
從下圖可以看到,透過 free 釋放記憶體後,堆記憶體還是存在的,並沒有歸還給作業系統。
這是因為與其把這1 位元組釋放給作業系統,不如先緩存著放進malloc 的記憶體池裡,當進程再次申請1 位元組的記憶體時就可以直接復用,這樣速度快了很多。
當然,當行程退出後,作業系統就會回收所有行程的資源。
上面所說的 free 記憶體後堆記憶體還存在,是針對 malloc 透過 brk() 方式申請的記憶體的情況。
如果 malloc 透過 mmap 方式申請的內存,free 釋放內存後就會歸還給作業系統。
我們做個實驗驗證下, 透過 malloc 申請 128 KB 位元組的內存,來使得 malloc 透過 mmap 方式來分配記憶體。
#include #include int main() { //申请1字节的内存 void *addr = malloc(128*1024); printf("此128KB字节的内存起始地址:%x\n", addr); printf("使用cat /proc/%d/maps查看内存分配\n",getpid()); //将程序阻塞,当输入任意字符时才往下执行 getchar(); //释放内存 free(addr); printf("释放了128KB字节的内存,内存也归还给了操作系统\n"); getchar(); return 0; }
執行程式碼:
查看進程的記憶體的分佈情況,可以發現最右邊沒有 [head] 標誌,說明是透過 mmap 以匿名映射的方式從檔案映射區分配的匿名記憶體。
然後我們釋放掉這個記憶體看看:
再次查看該 128 KB 記憶體的起始位址,可以發現已經不存在了,說明歸還給了作業系統。
對於 “malloc 申請的內存,free 釋放內存會歸還給操作系統嗎?”這個問題,我們可以做個總結了:
因為向作業系統申請內存,是要透過系統呼叫的,執行系統呼叫是要進入內核態的,然後在回到用戶態,運行態的切換會耗費不少時間。
所以,申請記憶體的操作應該避免頻繁的系統調用,如果都用 mmap 來分配內存,等於每次都要執行系統調用。
另外,因為mmap 分配的記憶體每次釋放的時候,都會歸還給作業系統,於是每次mmap 分配的虛擬位址都是缺頁狀態的,然後在第一次存取該虛擬位址的時候,就會觸發缺頁中斷。
也就是說,經常透過mmap 分配的記憶體話,不僅每次都會發生運行態的切換,還會發生缺頁中斷(在第一次造訪虛擬位址後),這樣會導致CPU 消耗較大。
為了改進這兩個問題,malloc 透過brk() 系統呼叫在堆空間申請記憶體的時候,由於堆空間是連續的,所以直接預先分配更大的記憶體來作為記憶體池,當記憶體釋放的時候,就緩存在記憶體池中。
等下次在申請記憶體的時候,就直接從記憶體池取出對應的記憶體區塊就行了,而且可能這個記憶體區塊的虛擬位址與實體位址的映射關係還存在,這不僅減少了系統呼叫的次數,也減少了缺頁中斷的次數,這將大大降低CPU 的消耗。
前面我們提到透過 brk 從堆空間分配的內存,並不會歸還給作業系統,那麼我們那考慮這樣一個場景。
如果我們連續申請了10k,20k,30k 這三片內存,如果10k 和20k 這兩片釋放了,變為了空閒內存空間,如果下次申請的內存小於30k,那麼就可以重用這個空閒內存空間。
但是如果下次申請的記憶體大於 30k,沒有可用的空閒記憶體空間,必須向 OS 申請,實際使用記憶體繼續增大。
因此,隨著系統頻繁地 malloc 和 free ,尤其對於小塊內存,堆內將產生越來越多不可用的碎片,導致「記憶體外洩」。而這種「洩漏」現象使用 valgrind 是無法偵測出來的。
所以,malloc 實作中,充分考慮了 brk 和 mmap 行為上的差異及優缺點,預設分配大塊記憶體 (128KB) 才使用 mmap 分配記憶體空間。
還記得,我前面提到, malloc 回傳給用戶態的記憶體起始位址比進程的堆空間起始位址多了 16 位元組嗎?
這個多出來的 16 位元組就是保存了該記憶體區塊的描述訊息,例如有該記憶體區塊的大小。
這樣當執行free() 函數時,free 會對傳入進來的記憶體位址向左偏移16 位元組,然後從這個16 位元組的分析出目前的記憶體區塊的大小,自然就知道要釋放多大的記憶體了。
以上是一文讀懂 Linux 記憶體分配策略的詳細內容。更多資訊請關注PHP中文網其他相關文章!