常工作中,發現有蠻多日常細節與內存管理有關,一直想要停下來總結總結,未果。這兩天和一朋友溝通時,虛擬地址與物理地址的mapping方式這個問題,讓平常一直考慮的關于top、mmap、ringbuffer、DirectByteBuffer等細節點在腦海中翻騰,竟然一時語塞。所以今天在家寫了點測試代碼,讓自己把思路理順,整理出來,希望這些基礎知識對大家有用。
1.硬件層面和物理內存
物理內存概要
大家都知道,物理內存就是RAM。處理器通過內存總線連接到物理內存,總線位數(比如32位或者48位)決定了可尋址的物理內存大小。這里提到48位這個值,是提醒不要與CPU的寄存器帶寬混淆。X86_64的寄存器帶寬是64,但是物理地址位數可能是48。(物理地址擴展后,物理地址位數也可能大于寄存器帶寬)。
物理內存分頁尋址,每頁4K。對于32位的地址,第0頁從0x00000000到0x00001000。可以看到,只需要前20位用來尋址物理頁,而后12位用來標示頁內地址。
思考一:這樣內存分頁使用有什么優點呢?在本文最后講到ringbuffer時會分析這個問題。
物理內存和磁盤的互惠交易
在linux協調下,物理內存和磁盤間有“最惠國待遇條約”:
1) 物理內存充裕時:
linux會把一些物理內存用于io的buffer及cache,提升系統運行效率。
Linux下的sar -r命令結果中,總體可用的物理內存應該為:kbmemfree+kbbuffers+kbcached。
思考二:這里使用的物理內存,它所mapping到的虛存會歸屬什么進程呢?后面會有討論。
2) 物理內存不夠時:
linux會把物理內存的一部分數據放入磁盤swap區存儲,以騰出內存給程序使用。
Vmstat命令結果中,swap下的si是每秒從磁盤讀到內存的數據量,so是從內存寫到磁盤的數據量。
創建Swap時(mkswap命令)可以用swap分區,也可以用普通文件。我實驗了一下,用swap分區方式,在swapon /dev/hdc7后,used、free、buffer、cache內存都有增長;而用文件方式,這四個量都不變。
思考三:這里說的把文件映射到物理內存,與下文提到的MappedByteBuffer映射到虛擬內存場景是不一樣的。
2.操作系統和虛擬內存
虛擬內存一對多映射
進程的虛擬地址空間中的區域可被映射到物理內存、文件或任何其他可尋址存儲。這里的區域也使用分頁機制,當一個程序嘗試使用虛擬地址訪問內存時,操作系統連同硬件會將該分頁的虛擬地址映射到物理位置,這個位置可以是物理RAM、一個文件或頁面文件(交換分區)。
思考四:我們開發中用的MappedByteBuffer其實說的通俗一點就是Map把文件的內容映像到計算機虛擬內存的一塊區域,這樣就可以直接操作內存當中的數據,而無需每次都通過I/O去物理硬盤讀取文件,所以效率上有很大的提升。MappedByteBuffer主要使用場景有:需要用文件共享來實現進程間通信(使用同一個文件inode);需要寫內存同時自動持久化到文件。
虛擬內存多對一映射
思考五:在圖一中,虛擬內存地址可以指向同一個物理內存地址嗎?一些嵌入式OS中,程序的確直接使用全部的物理內存;但是windows和linux是具有虛擬內存的操作系統,虛擬內存允許多個進程共享物理內存。
盡管每個進程都有其自己的地址空間,但程序通常無法使用所有這些空間。地址空間被劃分為內核空間和用戶空間。大部分操作系統將每個進程地址空間的一部分映射到一個通用的內核內存區域。被映射來供內核使用的地址空間部分稱為內核空間,其余部分稱為用戶空間,可供用戶應用程序使用。
在思考二中,物理內存被用于buffer與cache,大都是內核空間的行為。默認情況下,32 位 Windows 擁有 2GB 用戶空間和 2GB 內核空間;而linux分別是3G和1G。
內核是主要的操作系統程序,包含用于連接計算機硬件、調度程序以及提供聯網和虛擬內存等服務的邏輯。作為計算機啟動序列的一部分,操作系統內核運行并初始化硬件。
如果用戶程序需要來自操作系統的服務,它可以執行一種稱為系統調用的操作與內核程序交互。系統調用通常是讀取和寫入文件、聯網和啟動新進程等操作所必需的。Mmap系統調用實現時
3 Java進程使用的內存
在 Linux 和 Windows 上,進程是一個由受操作系統控制的資源(比如文件和套接字信息)、一個典型的虛擬地址空間(在某些架構上不止一個)和至少一個執行線程構成的集合。
Java是單進程應用,和普通進程沒有本質區別。
有了上面的分析,可以很容易明白為什么我們用top看到的java進程消耗的內存有時候會大于-Xmx 與-XX:MaxPermSize的和。java進程消費的內存包括JVM內存和java應用消費的JVM之外的物理內存,我們一般情況下不能用top來判斷多少是JVM消耗的、多少是JVM外的內存。
Java進程使用的內存一般有如下這些:
1) java堆和永久代。
2) 線程堆棧。-Xss可以調整,棧深度不夠時拋出StackOverflowException,無內存可以分配于新線程創建時拋出OutOfMemoryError。
3) JIT編譯、JNI代碼、GC。
4) Socket緩沖區。每個Socket連接的Receive緩存區約37KB,Send緩存區約25KB,在連接數多的情況下也是很可觀的。
5) DirectMemory。平常開發用的一些框架(比如CometD),會有大量的NIO操作使用到DirectByteBuffer,它通過native庫直接分配堆外內存,這里使用的空間也在虛擬內存地址范圍內,受進程可訪問空間的限制,也可能導致OutofMemoryError。
寫了一個簡單的程序來測試DirectByteBuffer對top和jstat的結果的影響:
1) DirectByteBuffer在堆中的引用清空后,gc,也可以釋放堆外物理內存。
2) 程序啟動時,-Xms的空間只是被分配地址空間,top不計入使用的內存。
3) 使用過內存后,即使堆內存GC,還是會計入使用的內存。這個可能與新生代使用“復制算法”而不是“標記-整理算法”來實現GC有關系。
4 分頁機制與ringbuffer
Ringbuffer也使用了mmap技術,但是這里我們要討論的是思考一中的問題:它借鑒內存分頁技術,可以用來做什么?
前面的4K分頁技術,可以前20位用來尋址物理頁,而后12位用來標示頁內地址;其實可以再演化,比如用前10位表示第幾個4M的文件,后22位表示是4M文件中的第幾個。這樣可以在Ringbuff中設置不同的slot大小,用來解決內存碎片、快取、文件轉內存等問題。
1:注意不要返回指向棧內存的指針或引用,因為在函數返回時改內存已經被銷毀了
2:C/C++沒有辦法知道指針所指的內存容量大小 當數組作為參數傳遞時,數組將退化成相同類型的指針 不要指望要指針參數去申請動態內存,因為函數會為產生一個臨時變量指向參數的內存,當函數內分配內存時,將內存的地址賦給了臨時參數,而沒有給實參賦值,所有實參沒有發生任何變化,應該修改的是指針所指的內容,而不是修改指針的指向,所有可以用指向指針的指針
3:重載和內聯機制既可用于全局函數也可用于類的成員函數,const和virtual機制即用于類的成員函數
4:在繼承關系中,非虛方法:調用指針類型的方法;虛方法:調用指針所指的對象類型的方法 非虛方法和默認參數都是靜態綁定,在繼承關系中只跟指針類型有關,跟指針所指的對象的實際類型無關
5:互相引用的兩個類,兩個類最好聲明在同一個頭文件中,定義可以放在同一個或兩個的文件中;這樣即解決了互相引用的問題,同時解決了在一個類中不能正確delete另一個類
6:處理#include預編譯指令,將被包含的文件插入到預編譯指令的位置,這個過程是遞歸進行的
7:C語言的編譯后執行語句都編譯成機器代碼,保存在.text段
.data:已初始化的全局變量和局部靜態變量
.bss:為未初始化的全局變量和局部靜態變量預留位置而已(不占磁盤空間,運行時當然是占空間的)
.rodata:存放只讀數據,const修飾的變量,常量字符串;(傳說中的字符串池,暈,別說得這么高級,只不過是在編譯的時候就分配好了內存)
8:程序源代碼被編譯后主要分成兩種段:程序指令和程序數據 分開的好處:
1:數據和指令分別被映射到兩個虛存區域,數據可讀寫,指令只讀;
2:CPU的高級緩存分為數據緩存和指令緩存
3:當程序有多個副本時,可以共享指令,有各自的數據,資源(圖片)共享
9:可執行文件中的代碼段和數據段都是由輸入的目標文件中相應的段合并而來的
10:作用域:全局變量不管定義在哪里(.h或.cpp)整個解決方案都可見,定義在頭文件中的靜態全局變量整個解決方案都可以見,定義在實現文件(.cpp)中的靜態變量只有這個文件可見,類中的public靜態變量作用域是整個解決方案,可以通過類名使用這個靜態變量,而private靜態變量作用域則是這個類,方法中的靜態變量作用域就是這個方法
11:.text和.data在文件和虛擬地址都要分配空間 .bss在文件中不分配空間,而要分配虛擬地址空間,因為在文件中它根本沒有內容
12:鏈接過程:1空間與地址分配;2符號解析與重定位 VMA:virtual Memory Address虛擬地址 鏈接前虛擬地址都是空,鏈接后虛擬地址都分配好了
13:目標文件代碼段的起始地址以0x00000000開始,等到空間分配完成以后,各個函數才會情定自己在虛擬地址空間中的位置
14:在編譯的時候每個目標文件都會有一個符號表,如果A文件引用了B文件中的變量或方法,那么在符號表中就會標記這些變量或方法是沒有定義了,在鏈接的時候如果沒有找到這些變量或方法的定義,在鏈接的時候就會報符號未定義錯誤
15:靜態裝入:程序執行是所需要的指令和數據必須在內存中才能正常運行,最簡單的辦法就是將程序運行所需要的指令和數據全部裝入內存中
動態裝入的基本原理:程序運行有局部性,將程序最常用的部分駐留在內存中,不太常用的數據存放在磁盤中
16:創建一個獨立的虛擬地址空間:將虛擬空間和物理空間映射 讀取可執行文件頭,并建立虛擬空間與可執行文件的映射關系:虛擬空間和執行文件映射 將CPU的指令寄存器設置成可執行文件的入口地址,啟動運行
17:Windows平臺下用C++編寫動態鏈接庫要盡量遵循以下幾個指導意見:
1:所有的接口函數都應該是抽象的,所有的方法都應該是純虛的
2:所有的全局函數都應該使用extern C來防止名字修飾的不兼容,并且導出函數都應是_stdcall調用規范的, 這樣即使用戶本身的程序是默認以_cdecl方式編譯的,對于 DLL的調用也能夠正確
3:不要使用C++標準庫STL
4:不要使用異常
5:不要使用虛析構函數,可以創建一個destory方法并且充值delete操作符并調用destory()方法
6:不要再DLL里面申請內存,而且在DLL外釋放(或者相反),不同的DLL和可執行文件可能使用不同的堆,在一個堆里面申請的內存而在另一個堆里面釋放會導致錯誤,對于內存分配相關的函數不應該是inline的,以防止它在編譯時被展開到不同的DLL和可執行文件中
7:不要再接口中使用重載方法,因為不同的編譯器對于vtable的安排可能不同
18:棧一般保存的內容:
1:函數的返回地址和參數
2:臨時變量:包括函數的非靜態局部變量以及編譯器自動生成的其他臨時變量
3:保存上下文:包括函數調用前后需要保持不變的寄存器
19:多態的實現原理:
1:含有虛方法的類都有一個虛函數表
2:子類的虛方法會覆蓋父類對應的虛方法
3:含有虛方法的類的每個實例都有一個指向虛方法表的指針,如果虛繼承的話可能會有多個
4:根據3中的指針調用虛方法表中對應的虛方法
20:全局構造與析構:
編譯器將兩個段.init和.finit這兩個段拼成兩個函數_init()和_finit(),這兩個函數先后于main函數執行,當然main函數并不是程序的入口,_start才是入口函數,.init段里面有個數組,數組中存放所有全局構造函數的指針,在執行函數_init()時會執行全局變量的構造函數,也就是說在調用main函數前,全局變量已經初始化好了,main函數執行完成之后,在執行_finit(),即全局變量的析構。
對每個編譯單元(.cpp),編譯器會遍歷其中所有的全局變量,生成一個特殊的函數_GLOBAL_I_Hw,這個函數的作用就是初始化當前編譯單元中的所有全局變量,如果這個特殊函數存在(即有全局變量),那么編譯器會在目標文件的.ctors段中存放這個函數的一個指針,連接器在鏈接所有的目標文件的時候,會將同名的段合并在一起,每個目標文件的.ctors段也就合并在一起了,這樣.ctors段中存放的就是每個目標文件中全局構造函數的指針,執行這些全局構造函數,全局變量就初始化好了。
全局析構的過程我想聰明的你不看也應該知道個大概,
21:程序員的自我修養-讀書筆記,未完.....待續......