這篇文章是我在公司 TechDay 上分享的內容的文字實錄版,原本不想寫這麼一篇冗長的文章,由於有很多的同窗問是否能寫一篇相關的文字版,原本沒有的也就有了。java
提及來這是我第二次在 TechDay 上作的分享,四年前第一屆 TechDay 不知天高地厚,上去講了一個《MySQL 最佳實踐》,如今想起來那些最佳實踐貌似不怎麼佳了。不扯遠了,接下來看看具體的內容。mysql
此次分享的主題是《內存問題探微》,會分爲下面幾個方面來聊一聊。linux
- Linux 內存知識的底層原理
- malloc、free 的底層實現原理
- ptmalloc2 的實現原理
- Arena、Heap、Chunk、Bin 的內部結構
- java 開發相關的內存問題說明
爲何要分享這個主題
由於這是我被問的最頻繁的問題,哎呀個人程序 OOM 了怎麼辦,個人程序內存超過配額被 k8s 殺掉了怎麼辦,個人程序看起來內存佔用很高正常嗎?git
下面這個圖就是咱們以前作壓測的時候,Nginx 內存佔用太高,被操做系統殺掉的一個圖。github
當壓測流量進來時,Nginx 內存蹭蹭蹭往上漲,達到 130G 左右時被系統 OOM-killer 殺掉,流量入口都有瓶頸,壓測就無法繼續進行下去了。那一個成熟的組件出現問題,要從哪些思路排查呢?golang
第二個緣由就是內存管理的知識很是龐大,好比 linux 三駕馬車,CPU、IO、內存,內存能夠說是這裏面最複雜的,與 CPU 和 IO 的性能有着千絲萬縷的關係,搞懂了內存問題,才能夠真正的搞清楚不少 Linux 性能相關的問題。算法
咱們最近在作一個 8 點早讀會,天天 8 點到 9 點分享一個小時,我花了將近 18 個小時講內存相關的知識,可是仍是有不少東西沒能覆蓋到。因此我想借這個機會再次把咱們以前分享的一些東西拿出來再講一講,儘量把咱們開發過程當中最經常使用的一些東西講清楚。sql
理解內存能夠幫助咱們更深刻的理解一些問題,好比:shell
- 爲何 golang 原生支持函數多返回值
- golang 逃逸分析是怎麼作的
- Java 堆外內存泄露如何分析
- C++ 智能指針是如何實現的
第二部分:Linux 內存管理的原理
接下來咱們來開始本次分享的主要內容:Linux 內存管理的原理,與人類的三個終極問題同樣,內存也有三個相似的問題,內存是什麼,內存從哪裏申請來,釋放之後去了哪裏。
編程
虛擬內存與物理內存
首先咱們來先看看虛擬內存與物理內存,虛擬內存和物理內存的關係印證了一句名言,「操做系統中的任何問題均可以經過一個抽象的中間層來解決」,虛擬內存正是如此。
沒有虛擬內存,進程直接就可能修改其它進程的內存數據,虛擬內存的出現對內存使用作好了隔離,每一個進程擁有獨立的、連續的、統一的虛擬地址空間(好一個錯覺)。像極了一個戀愛中的男人,擁有了她,彷彿擁有了全世界。
應用程序看到的都是虛擬內存,經過 MMU 進行虛擬內存到物理內存的映射,咱們知道 linux 內存是按 4k 對齊,4k = 2^12 ,虛擬地址中的低 12 位實際上是一個偏移量。
如今咱們把頁表想象爲一個一維數組,對於虛擬地址中的每一頁,都分配數組的一個槽位,這個槽位指向物理地址中的真正地址。那麼有這麼一個虛擬內存地址 0x1234010,那 0x010 就是頁內偏移量,0x1234 是虛擬頁號,CPU 經過 MMU 找到 0x1234 映射的物理內存頁地址,假定爲 0x2b601000,而後加上頁內偏移 0x010,就找到了真正的物理內存地址 0x2b601010。以下圖所示。
Linux 四級頁表
可是這種方式有一個很明顯的問題,虛擬地址空間可能會很是大,就算拿 32 位的系統爲例,虛擬地址空間爲 4GB,用戶空間內存大小爲 3GB,每頁大小爲 4kB,數組的大小爲 786432(1024 * 1024)。每一個頁表項用 4 個字節來存儲,這樣 4GB 的空間映射就須要 3MB 的內存來存儲映射表。(備註:這裏不少資料說的是 4M,也沒有太大的問題,我這裏的考慮是內核空間是共用的,不用太過於糾結。)
對於單個進程來講,佔用 3M 看起來沒有什麼,可是頁表是進程獨佔的,每一個進程都須要本身的頁表,若是有一百個進程,就會佔用 300MB 的內存,這還僅僅是作地址映射所花的內存。若是考慮 64 位系統超大虛擬地址空間的狀況,這種一維的數組實現的方式更加不切實際。
爲了解決這個問題,人們使用了 level 的概念,頁表的結構分爲多級,頁表項的大小隻與虛擬內存空間中真正使用的多少有關。以前一維數組表示的方式頁表項的多少與虛擬地址空間的大小成正比,這種多級結構的方式使得沒有使用的內存不用分配頁表項。
因而人們想出了多級頁表的形式,這種方式很是適合,由於大部分區域的虛擬地址空間其實是沒有使用的,使用多級頁表能夠顯著的減小頁表自己的內存佔用。在 64 位系統上,Linux 採用了四級頁表,
- PGD:Page Global Directory,頁全局目錄,是頂級頁表。
- PUD:Page Upper Directory,頁上級目錄,是第二級頁表
- PMD:Page Middle Derectory,頁中間目錄,是第三級頁表。
- PTE:Page Table Entry,頁面表,最後一級頁表,指向物理頁面。
以下圖所示。
應用程序看到的只有虛擬內存,是看不到物理地址的。固然是有辦法能夠經過一些手段經過虛擬地址拿到物理地址。好比這個例子,咱們 malloc 一個 1M 的空間,返回了一個虛擬地址 0x7ffff7eec010,怎麼知道這個虛擬地址對應的物理內存地址呢?
#include <stdlib.h> #include <stdio.h> #include <unistd.h> int main() { char *p = NULL; p = malloc(1024 * 1024); *p = 0; printf("ptr: %p, pid: %d\n", p, getpid()); getchar(); return 0; } ptr: 0x7ffff7eec010, pid: 2621
在應用層來作這個事情沒有辦法,可是難不倒咱們,咱們來寫一個內核擴展模塊來實現這個功能。
寫一個內核模塊也很是簡單,分爲下面幾個步驟:
- 定義好兩個回調鉤子,module_init, module_exit
- 經過傳入的 pid 獲取到這個進程的 task_struct經過 task_struct 中的 mm 變量和傳入的虛擬內存地址 va,就能夠拿到 pgd
- 經過 pgd 就能夠拿到 pud,而後再拿到 pmd,最好獲取到 pte,這個 pte 已經存儲的是物理內存的頁幀
- 經過低 12 位的頁內偏移就能夠獲得最終的物理內存的地址。
精簡之後的代碼以下所示。
#include <linux/module.h> ... int my_module_init(void) { unsigned long pa = 0; pgd_t *pgd = NULL; pud_t *pud = NULL; pmd_t *pmd = NULL; pte_t *pte = NULL; struct pid *p = find_vpid(pid); struct task_struct *task_struct = pid_task(p, PIDTYPE_PID); pgd = pgd_offset(task_struct->mm, va); // 獲取第一級 pgd pud = pud_offset(pgd, va); // 獲取第二級 pud pmd = pmd_offset(pud, va); // 獲取第三級 pmd pte = pte_offset_kernel(pmd, va); // 獲取第四級 pte unsigned long page_addr = pte_val(*pte) & PAGE_MASK; unsigned long page_addr &= 0x7fffffffffffffULL; page_offset = va & ~PAGE_MASK; pa = page_addr | page_offset; // 加上偏移量 printk("virtual address 0x%lx in RAM Page is 0x%lx\n", va, pa); return 0; } void my_module_exit(void) { printk("module exit!\n"); } module_init(my_module_init); // 註冊回調鉤子 module_exit(my_module_exit); // 註冊回調鉤子 MODULE_LICENSE("GPL"); MODULE_AUTHOR("Arthur.Zhang"); MODULE_DESCRIPTION("A simple virtual memory inspect");
咱們編譯這個內核模塊會生成一個 .ko 文件,而後加載這個 .ko 文件,傳入進程號、虛擬內存地址。
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules insmod my_mem.ko pid=2621 va=0x7ffff7eec010
而後執行 dmesg -T 就能夠看到真正的物理地址的值了。
[Sat Oct 10 05:11:12 2020] virtual address 0x7ffff7eec010 in RAM Page is 0x2358a4010
能夠看到在這個例子中,虛擬地址 0x7ffff7eec010 對應的物理地址是 0x2358a4010。
完整的代碼見:https://github.com/arthur-zhang/virtualmem2physical
進程的內存佈局
前面提到了虛擬內核和物理內存的關係,咱們知道 linux 上的可執行文件的格式是 elf,elf 是一個靜態文件,這個靜態文件由不一樣的分節組成,咱們這裏叫它 section,在運行時,部分跟運行時相關的 Section 會被映射到進程的虛擬地址空間中,好比圖中的代碼段和數據段。除了這部分靜態的區域,進程啓動之後還有大量動態內存消耗區,好比棧、堆、mmap 區。
下面這個圖是咱們線上 java 服務使用 pmap 輸出的內存佈局的一部分,以下圖所示。
那怎麼來看這些部分呢?這就須要咱們深刻去理解 Linux 中進程的內存是如何被瓜分的。
libc 內存管理原理探究
Linux 內存管理有三個層面,第一層是咱們的用戶管理層,好比咱們本身程序的內存池,mysql 的 bufferpool,第二層是 C 的運行時庫,這部分代碼是對內核的一個包裝,方便上層應用更方便的開發,再下一層就是咱們的內核層了。
咱們今天要重點介紹的就是中間那一層,這一層是由一個 libc 的庫來實現的,接下來詳細看看看 libc 內存管理是如何作的。
Linux 內存管理的核心思想就是分層管理、批發零售、隱藏內部細節。咱們還須要銘記在心的是 libc 中堆的管理是針對小內存分配釋放來設計的,爲了編程接口上的統一,大內存也是支持的。
咱們先來看內存申請釋放的兩個函數,malloc 和 free,這兩個函數的定義以下。
#include <stdlib.h> void *malloc(size_t size); void free(void *ptr);
這兩個函數壓根不是系統調用,它們只是對 brk、mmap、munmap 系統調用的封裝,那爲何有了這些系統調用,還須要 libc 再封裝一層呢?
一個主要緣由是由於系統調用很昂貴,而內存的申請釋放又特別頻繁,因此 libc 採起的的方式就是批量申請,而後做爲內存的黃牛二道販子,慢慢零售給後面的應用程序。
第二個緣由是爲了編程上的統一,好比有些時候用 brk,有些時候用 mmap,不太友好,brk 在多線程下還須要進行加鎖,用一個 malloc 就很香。
Linux 內存分配器
Linux 的內存分配器有不少種,一開始是 Doug Lea 大神開發的 dlmalloc,這個分配器對多線程支持不友好,多線程下會競爭全局鎖,隨後有人基於 dmalloc 開發了 ptmalloc,增長了多線程的支持,除了 linux 官方的 ptmalloc,各個大廠有開發不一樣的 malloc 算法,好比 facebook 出品的 jemalloc,google 出品的 tcmalloc。
這些內存分配器致力於解決兩個問題:多線程下鎖的粒度問題,是全局鎖,仍是局部鎖仍是無鎖。第二個問題是小內存回收和內存碎片問題,好比 jemalloc 在內存碎片上有顯著的優點。
ptmalloc 的核心概念
接下來咱們來看 Linux 默認的內存分配器 ptmalloc,我總結了一下它有關的四個核心概念:Arena、Heap、Chunk、Bins。
Arena
先來看 Arena,Arena 的中文翻譯的意思是主戰場、舞臺,對應在內存分配這裏,指的是內存分配的主戰場。
Arena 的出現首先用來解決多線程下全局鎖的問題,它的思路是儘量的讓一個線程獨佔一個 Arena,同時一個線程會申請一個或多個堆,釋放的內存又會進入回收站,Arena 就是用來管理這些堆和回收站的。
Arena 的數據結構長啥樣?它是一個結構體,能夠用下面的圖來表示。
它是一個單向循環鏈表,使用 mutex 鎖來處理多線程競爭,釋放的小塊內存會放在 bins 的結構中。
前面提到,Arena 會盡可能讓一個線程獨佔一個鎖,那若是我有幾千個線程,會生成幾千個 Arena 嗎?顯然是不會的,全部跟線程有關的瓶頸問題,最後都會走到 CPU 核數的限制這裏來,分配區的個數也是有上限的,64 位系統下,分配區的個數大小是 cpu 核數的八倍,多個 Arena 組成單向循環鏈表。
咱們能夠寫個代碼來打印 Arena 的信息。它的原理是對於一個肯定的程序,main_arena 的地址是一個位於 glibc 庫的肯定的地址,咱們在 gdb 調試工具中能夠打印這個地址。也可使用 ptype 命令來查看這個地址對應的結構信息,以下圖所示。
有了這個基礎,咱們就能夠寫一個 do while 來遍歷這個循環鏈表了。咱們把 main_arena 的地址轉爲 malloc_state 的指針,而後 do while 遍歷,直到遍歷到鏈表頭。
struct malloc_state { int mutex; int flags; void *fastbinsY[NFASTBINS]; struct malloc_chunk *top; struct malloc_chunk *last_remainder; struct malloc_chunk *bins[NBINS * 2 - 2]; unsigned int binmap[4]; struct malloc_state *next; struct malloc_state *next_free; size_t system_mem; size_t max_system_mem; }; void print_arenas(struct malloc_state *main_arena) { struct malloc_state *ar_ptr = main_arena; int i = 0; do { printf("arena[%02d] %p\n", i++, ar_ptr); ar_ptr = ar_ptr->next; } while (ar_ptr != main_arena); } #define MAIN_ARENA_ADDR 0x7ffff7bb8760 int main() { ... print_arenas((void*)MAIN_ARENA_ADDR); return 0; }
輸出結果以下。
那爲何還要區分一個主分配,一個非主分配區呢?
這有點像皇上和王爺的關係, 主分配區只有一個,它還有一個特權,可使用靠近 DATA 段的 Heap 區,它經過調整 brk 指針來申請釋放內存。
從某種意義上來說,Heap 區不過是 DATA 段的擴展而已。
非主分配區呢?它更像是一個分封在外地,自主創業的王爺,它想要內存時就使用 mmap 批發大塊內存(64M)做爲子堆(Sub Heap),而後在慢慢零售給上層應用。
一個 64M 用完,再開闢一個新的,多個子堆之間也是使用鏈表相連,一個 Arena 能夠有多個子堆。在接下的內容中,咱們還會繼續詳細介紹。
Heap
接下來咱們來看 ptmalloc2 的第二個核心概念 ,heap 用來表示大塊連續的內存區域。
主分配區的 heap 沒有什麼好講的,咱們這裏重點看「非主分配」的子堆(也稱爲模擬堆),前面提到過,非主分配批發大塊內存進行切割零售的。
那如何理解切割零售這句話呢?它的實現也很是簡單,先申請一塊 64M 大小的不可讀不可寫不可執行(PROT_NONE)的內存區域,須要內存時使用 mprotect 把一塊內存區域的權限改成可讀可寫(R+W)便可,這塊內存區域就能夠分配給上層應用了。
以咱們前面 java 進程的內存佈局爲例。
這中間的兩塊內存區域是屬於一個子堆,它們加起來的大小是 64M,而後其中有一塊 1.3M 大小的內存區域就是使用 mprotrect 分配出去的,剩下的 63M 左右的區域,是不可讀不可寫不可執行的待分配區域。
知道這個有什麼用呢?太有用了,你在 google 裏全部 Java 堆外內存等問題,有很大可能性會搜到 Linux 神奇的 64M 內存問題。有了這裏的知識,你就比較清楚到底這 64M 內存問題是什麼了。
與前面的 Arena 同樣,咱們一樣能夠在代碼中,遍歷全部 Arena 的全部的 heap 列表,代碼以下所示。
struct heap_info { struct malloc_state *ar_ptr; struct heap_info *prev; size_t size; size_t mprotect_size; char pad[0]; }; void dump_non_main_subheaps(struct malloc_state *main_arena) { struct malloc_state *ar_ptr = main_arena->next; int i = 0; while (ar_ptr != main_arena) { printf("arena[%d]\n", ++i); struct heap_info *heap = heap_for_ptr(ar_ptr->top); do { printf("arena:%p, heap: %p, size: %d\n", heap->ar_ptr, heap, heap->size); heap = heap->prev; } while (heap != NULL); ar_ptr = ar_ptr->next; } } #define MAIN_ARENA_ADDR 0x7ffff7bb8760 dump_non_main_subheaps((void*)MAIN_ARENA_ADDR);
Chunk
接下來咱們來看分配的基本單元 chunk,chunk 的字面意思是「厚塊; 厚片」,chunk 是 glibc 中內存分配的基礎單元。以一個簡單的例子來開頭。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { void *p; p = malloc(1024); printf("%p\n", p); p = malloc(1024); printf("%p\n", p); p = malloc(1024); printf("%p\n", p); getchar(); return (EXIT_SUCCESS); }
這段代碼邏輯是連續調用三次 malloc,每次分配 1k 內存,而後咱們來觀察它的內存地址。
./malloc_test 0x602010 0x602420 0x602830
能夠看到內存地址之間差了 0x410,1024 是等於 0x400,那多出來的 0x10 字節是什麼?咱們先按下不表。
再來回看 malloc 和 free,那咱們不由問本身一個問題,free 函數的參數只有一個指針,它是怎麼知道要釋放多少內存的呢?
#include <stdlib.h> void *malloc(size_t size); void free(void *ptr);
香港做家張小嫺說過,「凡事皆有代價,快樂的代價即是痛苦」。爲了存儲 1k 的數據,實際上還須要一些數據來記錄這塊內存的元數據。這塊額外的數據被稱爲 chunk header,長度爲 16 字節。這就是咱們前面看到的多出來 0x10 字節。
這種經過在實際數據前面添加 head 方式使用的很是廣泛,好比 java 中 new Integer(1024),實際存儲的數據大小遠不止 4 字節,它有一個巨大無比的對象頭,裏面存儲了對象的 hashcode,通過了幾回 GC,有沒有被當作鎖同步。
害,說 java 臃腫並非沒有道理。
在咱們繼續來看這個 16 字節的 header 裏面到底存儲了什麼,它的結構示意圖以下所示。
它分爲兩部分,前 8 字節表示前一個 chunk 塊的大小,接下來的 8 字節表示當前 chunk 塊的大小,由於 chunk 塊要按 16 字節對齊,因此低 4 字節都是沒用的,其中三個被用來當作標記位來使用,這三個分別是 AMP,其中 A 表示是不是主分配區,M 表示是不是 mmap 分配的大 chunk 塊,P 表示前一個 chunk 是否在使用中。
之前面的例子爲例,咱們能夠用 gdb 來查看這部分的內存。
能夠看到對應 size 的 8 個字節是 0x0411,這個值是怎麼來的呢?實際上是按 size + 8 對齊到 16B 再加上低三位的 B001。
0x0400 + 0x10 + 0x01 = 0x0411
由於當一個 chunk 正在被使用時,它的下一個 chunk 的 prev_size 是沒有意義的,這 8 個字節能夠被這個當前 chunk 使用。別奇怪,就是這麼摳。接下來咱們來看看 chunk 中 prev_size 的複用。測試的代碼以下。
#include <stdlib.h> #include <string.h> void main() { char *p1, *p2; p1 = (char *)malloc(sizeof(char) * 18); // 0x602010 p2 = (char *)malloc(sizeof(char) * 1); // 0x602030 memcpy(p1, "111111111111111111", 18); }
編譯這個源文件,而後使用 gdb 調試單步運行,查看 p一、p2 的地址。
p/x p1 $2 = 0x602010 (gdb) p/x p2 $3 = 0x602030
而後輸出 p一、p2 附近的內存區域。
(gdb) x/64bx p1-0x10 0x602000: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x602008: 0x21 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x602010: 0x31 0x31 0x31 0x31 0x31 0x31 0x31 0x31 0x602018: 0x31 0x31 0x31 0x31 0x31 0x31 0x31 0x31 0x602020: 0x31 0x31 0x00 0x00 0x00 0x00 0x00 0x00 0x602028: 0x21 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x602030: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x602038: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
佈局以下圖所示。
篇幅有限,我這裏只展現了 malloc chunk 的結構,還有 Free chunk、Top chunk、Last Remainder chunk 沒有展開,能夠參考其它的資料。
Bins
咱們接下來看最後一個概念,小塊內存的回收站 Bins。
內存的回收站分爲兩大類,第一類是普通的 bin,一類是 fastbin。
- fastbin 採用單向鏈表,每條鏈表的中空閒 chunk 大小是肯定的,插入刪除都在隊尾進行。
- 普通 bin 根據回收的內存大小又分爲了 small、large 和 unsorted 三種,採用雙向鏈表存儲,它們之間最大的區別就是它們存儲的 chunk 塊的大小範圍不同。
接下來咱們看這兩種 bin 的細節。
普通 bin 採用雙向鏈表存儲,以數組形式定義,共 254 個元素,兩個數組元素組成一個 bin,經過 fd、bk 組成雙向循環鏈表,結構有點像九連環玩具。
因此普通 bin 的總個數是 254/2 = 127 個。其中 unsorted bin 只有 1 個,small 有 62 個,large bin 有 63 個,還有一個暫未使用,以下圖所示。
smallbin
其中 smallbin 用於維護 <= 1024B 的 chunk 內存塊。同一條 small bin 鏈中的 chunk 具備相同的大小,都爲 index * 16,結構以下圖所示。
largebin
largebin 中同一條鏈中的 chunk 具備「不一樣」的大小
- 分爲 6 組
- 每組的 bin 數量依次爲 3三、1五、八、四、二、1,每條鏈表中的最大 chunk 大小公差依次爲 64B、 512B、4096B、32768B、262144B 等
結構以下圖所示。
unsorted bin
unsorted bin 只有一條雙向鏈表,它的特色以下。
- 空閒 chunk 不排序
- 大於 128B 的內存 chunk 回收時先放到 unsorted bin
它的結構以下圖所示。
下面是全部普通 bin 的概覽圖。
FastBin
說完了普通 bin,咱們來詳細看看 FastBin,FastBin 專門用來提升小內存的分配效率,它的結構以下。
它有下面這些特性。
- 小於 128B 的內存分配會先在 Fast Bin 中查找
- 單向鏈表,每條鏈表中的 chunk 大小相同,有 7 個 chunk 空閒鏈表,每一個 bin 的 chunk 大小依次爲 32B,48B,64B,80B,96B,112B,128B
- 由於是單向鏈表,fastbin 中的 bk 指針沒有用到,第一個 chunk 的 fd 指針指向特殊的 0 地址
- P 標記始終爲 1,通常狀況下不合並
- FIFO,添加和刪除都從隊尾進行
Fast bins 能夠看着是 small bins 的一小部分 cache。
內存的申請與釋放
有了前面的知識,咱們就能夠來回答分享一開頭的問題,內存從哪裏來。大塊內存申請沒有特別多能夠講的,直接 mmap 系統調用申請一塊,釋放的時候也直接還給操做系統。
小塊內存的申請就複雜不少了,原則就是先在 chunk 回收站中找,找到了是最好,就直接返回了,不用再去向內核申請。它是怎麼作的呢?
首先會根據傳入的大小計算真正 chunk 的大小,根據這個大小看看在不在 fastbin 的區間裏,若是有的話,從 fastbin 直接返回,若是不在則嘗試 smallbin,而後若是 smallbin 裏沒有則會觸發一次合併,而後從 unsorted bin 裏查找,尚未則會從 Large Bin 查找,若是沒有再去切割 top 塊,top 塊也沒有了,則會從新申請 heap 或者調整 heap 的大小,以下圖所示。
接下來咱們來回答最後一個問題,內存 free 之後去了哪裏,根據不一樣的大小,有不一樣的處理策略。
- 符合 fastbin 的超小塊內存直接放入 fastbin 單鏈表,快速釋放,畫外音就是這麼點空間,值得我處理半天嗎?
- 超大塊內存,直接還給內核不進入 bin 的管理邏輯。畫外音就是大客戶要特殊處理,畢竟大客戶是少數狀況。
- 大部分是介於中間的,釋放的時候首先會被放入 unsorted bin。根據狀況合併、遷移空閒塊,靠近 top 則更新 top chunk。這纔是人生常態啊。
棧內存
前面咱們介紹的大部分都是堆內存,其實還有一個很是重要的東西是棧內存,LInux 中默認的棧內存大小是 8M,而後外加 4K 的保護區,這 4k 的保護區不可讀不可寫不可執行,當真有棧越界時能夠更早的發現,儘快 fast fail。
這個圖就是一個典型的 linux 原生線程的棧內存佈局,能夠看到 8M 的棧空間和 4k 的 guard 區域的狀況。
對於 Java 來講,它作了一些細微的調整,默認的棧大小空間爲 1M,而後有 4k 的 RED 區域和 8K 的 yellow 區域,以便作更細粒度的棧溢出控制,這裏的 yellow 區域和 red 區域到底有什麼做用,以前我有寫一篇文章線程與棧的文章專門介紹,這裏就不展開了。
第三部分:開發相關的內存問題說明
接下來進入咱們的最後一個部分,開發相關的內存問題。
Xmx 與內存消耗
首先要說的是一個問的比較多的問題,爲何我 Java 應用的內存消耗遠大於 Xmx,這也是 Stack Overflow 上問的很是多的一個問題。
其實咱們要搞清楚,一個進程除了堆消耗內存,還有大量的其餘的開銷,以下所示。
- Heap
- Code Cache
- GC 開銷
- Metaspace
- Thread Stack
- Direct Buffers
- Mapped files
- C/C++ Native 內存消耗
- malloc 自己的開銷
- 。。。
內存大戶不是開玩笑的,根據多年實踐 Xmx 設置爲容器內存的 65% 左右比較合理
RES 佔用
第二個問題是 top 命令中 RES 佔用很高,是否是表明程序真正有大量消耗呢?
其實不是的,咱們以一個最簡單的 java 程序爲例,在使用 -Xms1G -Xmx1G 來運行程序時。
java -Xms1G -Xmx1G MyTest
它的內存佔用以下。
咱們把啓動命令稍做改動,加上 AlwaysPreTouch,以下所示。
java -XX:+AlwaysPreTouch -Xms1G -Xmx1G MyTest
這個時候 RES 佔用以下所示。
這裏的 1G 業務程序其實沒有使用,只是 JVM 把內存作了寫入,以便後面真正使用時,不用發起缺頁中斷去真正申請物理內存。
內存佔用不是越少越好,還要兼顧 GC 次數、GC 停頓時間。
替換默認的內存分配器
默認的 Linux 內存分配器在性能和內存碎片方面表現不是很好,能夠嘗試替換默認的內存分配器爲 jemalloc 或者 tcmalloc,只用新增一個 LD_PRELOAD 環境變量便可。
LD_PRELOAD=/usr/local/lib/libjemalloc.so
在實際的服務中,有一個服務內存佔用從 7G 變爲了 3G,效果仍是很是明顯的。
native 內存分析
Java 的堆內存分析很是容易,jmap 命令 dump 出內存,而後使用 jprofile、mat、perfma 等平臺均可以很快的進行分析了。然而對於 native 的內存佔用過大,仍是比較麻煩的。這裏可使用 jemalloc 和 tcmalloc 強大的 profile 功能。以 jemalloc 爲例,能夠將內存的申請關係生成 svg。
export MALLOC_CONF=prof:true,lg_prof_sample:1,lg_prof_interval:30,prof_prefix:jeprof.out jeprof –svg /path/to/svg jeprof.out.* > out.svg
生成的 svg 示意圖以下所示。
小結
此次介紹的只是內存問題的冰山一角,不少細節的東西沒能在此次分享裏詳細展開,有問題能夠來交流。
講完這個 PPT,有人跟我說,場子有點冷。這個結局還不錯,我覺得中間要走一半。
看完三件事❤️
若是你以爲這篇內容對你還蠻有幫助,我想邀請你幫我三個小忙:
-
點贊,轉發,有大家的 『點贊和評論』,纔是我創造的動力。
-
關注公衆號 『 java爛豬皮 』,不按期分享原創知識。
-
同時能夠期待後續文章ing🚀