淺析java記憶體管理機制

演算法與數學之美發表於2018-09-14

記憶體管理是計算機程式設計中的一個重要問題,一般來說,記憶體管理主要包括記憶體分配記憶體回收兩個部分。不同的程式語言有不同的記憶體管理機制,本文在對比C++和Java語言記憶體管理機制的不同的基礎上,淺析java中的記憶體分配和記憶體回收機制,包括java物件初始化及其記憶體分配,記憶體回收方法及其注意事項等……


java與C++記憶體管理機制對比


在C++中,所有的物件都會被銷燬,區域性物件的銷燬發生在以右花括號為界的物件作用域的末尾處,而程式猿new出來的物件則應該主動呼叫delete操作符從而呼叫解構函式去回收物件佔用的記憶體。但是C++這種直接操作記憶體的方式存在很大記憶體洩露風險,而且人為管理記憶體複雜且困難。

在java中,記憶體管理由JVM完全負責,java中的“垃圾回收器”負責自動回收無用物件佔據的記憶體資源,這樣可以大大減少程式猿在記憶體管理上花費的時間,可以更集中於業務邏輯和具體功能實現;但這並不是說java有了垃圾回收器程式猿就可以高枕無憂,將記憶體管理拋之腦外了!一方面,實際上java中還存在垃圾回收器沒法回收以某種“特殊方式”分配的記憶體的情況(這種特殊方式我們將在下文中進行詳細描述);另一方面,java的垃圾回收是不能保證一定發生的,除非JVM面臨記憶體耗盡的情況。所以java中部分物件記憶體還是需要程式猿手動進行釋放,合理地對部分物件進行管理可以減少記憶體佔用與資源消耗。

java記憶體分配

 java程式執行過程 

640?wx_fmt=jpeg

640?wx_fmt=png

1、首先Java原始碼檔案(.java字尾)會被Java編譯器編譯為位元組碼檔案(.class字尾),然後由JVM中的類載入器載入各個類的位元組碼檔案,載入完畢之後,交由JVM執行引擎執行(執行過程還包括將位元組碼編譯成機器碼),JVM執行引擎在執行位元組碼時首先會掃描四趟class檔案來保證定義的型別的安全性,再檢查空引用,資料越界,自動垃圾收集等。在整個程式執行過程中,JVM會用一段空間來儲存程式執行期間需要用到的資料和相關資訊,這段空間一般被稱作為Runtime Data Area(執行時資料區),也就是我們常說的JVM記憶體


2、類載入器分為啟動類載入器(不繼承classLoader,屬於虛擬機器的一部分;負責載入原生程式碼實現的Java核心庫,包括載入JAVA_HOME中jre/lib/rt.jar裡所有的 class);擴充套件類載入器(負責在JVM中擴充套件庫目錄中去尋找載入Java擴充套件庫,包括JAVA_HOME中jre/lib/ext/xx.jar或-Djava.ext.dirs指定目錄下的 jar 包);應用程式類載入器(ClassLoader.getSystemClassLoader()負責載入Java類路徑classpath中的類)

640?wx_fmt=png

1、類載入機制的流程:包括了載入、連線(驗證、準備、解析)、初始化五個階段


載入:查詢裝載二進位制檔案,通過一個類的全限定名獲取類的二進位制位元組流,並將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;在 Java 堆中生成一個代表這個類的 java.lang.Class 物件,作為對方法區中這些資料的訪問入口。

驗證:為了確保Class檔案中的位元組流包含的資訊符合當前虛擬機器的要求,完成以下四個階段的驗證:檔案格式的驗證、後設資料的驗證、位元組碼驗證和符號引用驗證。

準備:準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中分配

解析:解析階段是虛擬機器將常量池中的符號引用轉化為直接引用的過程

初始化:初始化階段是根據程式設計師通過程式指定的主觀計劃去初始化類變數和其他資源,也就是執行類構造器()方法的過程


現代硬體記憶體架構


640?wx_fmt=png

1、一個有兩個或者多個 CPU 的現代計算機上同時執行多個執行緒是可能的,如果你的 Java 程式是多執行緒的,在你的 Java 程式中每個 CPU 上一個執行緒可能同時(併發)執行

2、CPU在暫存器上的執行操作速度稍微大於CPU快取層的執行速度,遠大於在主存上的執行速度

3、Java記憶體模型中的堆疊分佈在硬體記憶體結構中的CPU暫存器,CPU快取層,CPU主存中,大部分分佈在主存中


java記憶體模型劃分


一般來講,我們將java記憶體劃分為以下幾個區域, 如圖:

640?wx_fmt=png

640?wx_fmt=png

 GC備註:

1、年輕物件存放在年輕代,採用Minor GC(指從年輕代空間(包括 Eden 和 Survivor 區域)回收記憶體); 長期存活的年老物件以及大物件直接存放在年老代,採用Full GC(Full GC == Major GC指的是對老年代/永久代的stop the world的GC),回收速度慢;JVM維護一個物件的年齡來進行物件的記憶體區域轉移,從Eden-Survivor-老年代

2、新生代包括一個Eden區,兩個survivor的from和to區(8:1:1),負責年輕小物件的回收;Eden區存放新建立的大量物件,回收頻繁,所以區域大;Survivor存放每次垃圾回收後存活的物件

3、一個物件的成員變數可能隨著這個物件自身存放在堆上

4、一個Object的大小計算方法:一個引用4byte+空Object本身佔據8byte+其它資料型別佔據自身大小byte(例如char佔用2byte);然而由於系統分配以8byte為單位,所以每個Object佔據的大小必須為8的倍數,比如一個空的Object應該佔據4+8=12,也就是說需要佔據16byte

下文中將要提到的記憶體分配與回收主要是指物件所佔據的堆記憶體的釋放與回收。


java物件建立及初始化


java物件建立之後,就會在堆記憶體擁有自己的一塊區域,接著就是物件的初始化過程。物件一般通過構造器來進行初始化,構造器是一種與類名相同的沒有返回值的特殊方法;如果一個類中沒有定義建構函式,則系統會自動生成一個不接受任何引數的預設構造器;但是如果已經定義一個構造器(無論是否有引數),編譯器就不會再自動建立預設構造器了;我們可以對建構函式進行多次過載(即傳遞不同數目或不同順序的引數列表),也可以在一個構造器中呼叫另一個構造器,但是隻能呼叫一次,並且必須將構造器放在最起始處,否則編譯器會報錯。

那麼類成員初始化又是怎麼做的呢?順序是怎樣的呢?java中所有變數在使用前都應該得到恰當的初始化,即使是方法的區域性變數,如果不進行初始化就會發生編譯錯誤;而如果是類的成員變數,即使你不進行初始化賦值,系統也是會給與其一個初始值的,例如char、int型別的初始值都是0,物件引用不進行初始化則預設為null。

類成員初始化順序總結:先靜態後普通再構造, 先父類後子類,同級看書寫順序

1.先執行父類靜態變數和靜態程式碼塊,再執行子類靜態變數和靜態程式碼塊

2.先執行父類普通變數和程式碼塊,再執行父類構造器(static方法)

3.先執行子類普通變數和程式碼塊,再執行子類構造器(static方法)

4.static方法初始化先於普通方法,靜態初始化只有在必要時刻才進行且只初始化一次。


注意:子類的構造方法,不管這個構造方法帶不帶引數,預設的它都會先去尋找父類的不帶引數的構造方法。如果父類沒有不帶引數的構造方法,那麼子類必須用supper關鍵子來呼叫父類帶引數的構造方法,否則編譯不能通過。

java記憶體回收

 垃圾回收器(4種收集器)和finalize()方法 

java中垃圾回收器可以幫助程式猿自動回收無用物件佔據的記憶體,但它只負責釋放java中建立的物件所佔據的所有記憶體,通過某種建立物件之外的方式為物件分配的記憶體空間則無法被垃圾回收器回收;而且垃圾回收本身也有開銷,GC的優先順序比較低,所以如果JVM沒有面臨記憶體耗盡,它是不會去浪費資源進行垃圾回收以恢復記憶體的。最後我們會發現,只要程式沒有瀕臨儲存空間用完那一刻,物件佔用的空間就總也得不到釋放。我們可以通過程式碼System.gc()來主動啟動一個垃圾回收器(雖然JVM不會立刻去回收),在釋放new分配記憶體空間之前,將會通過finalize()釋放用其他方法分配的記憶體空間。

1、Serial收集器:一個單執行緒的新生代收集器,它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束。簡單高效

2、Parallel(並行)收集器:JVM預設收集器,其最大的優點是使用多個執行緒來通過掃描並壓縮堆。序列收集器在GC時會停止其他所有工作執行緒(stop-the-world),CPU利用率是最高的,所以適用於要求高吞吐量(throughput)的應用,但停頓時間(pause time)會比較長,所以對web應用來說就不適合,因為這意味著使用者等待時間會加長。而並行收集器可以理解是多執行緒序列收集,在序列收集基礎上採用多執行緒方式進行GC,很好的彌補了序列收集的不足,可以大幅縮短停頓時間,因此對於空間不大的區域(如young generation),採用並行收集器停頓時間很短,回收效率高,適合高頻率執行。

3、CMS收集器:基於“標記-清除”演算法實現的,它使用多執行緒的演算法去掃描老生代堆(標記)並對發現的待回收物件進行回收(清除),容易產生大量記憶體碎片使得大物件無法建立然後不得不提前觸發full GC。CPU資源佔用過大,標記之後容易產生浮動垃圾只能留到下一次GC處理

4、G1收集器:G1收集器是基於“標記-整理”演算法實現的收集器,也就是說它不會產生空間碎片。G1是一個針對多處理器大容量記憶體的伺服器端的垃圾收集器,其目標是在實現高吞吐量的同時,儘可能的滿足垃圾收集暫停時間的要求。它可以非常精確地控制停頓,既能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,具備了一些實時Java(RTSJ)的垃圾收集器的特徵。垃圾收集器


finalize()方法的工作原理是:一旦垃圾回收器準備好釋放物件佔用的儲存空間,將首先呼叫並且只能呼叫一次該物件的finalize()方法(通過程式碼System.gc()實現),並且在下一次垃圾回收動作發生時,才會真正回收物件佔用的記憶體。所以如果我們過載finalize()方法就能在垃圾回收時刻做一些重要的清理工作或者自救該物件一次(只要在finalize()方法中讓該物件重新和引用鏈上的任何一個物件建立關聯即可)。finalize()方法用於釋放用特殊方式分配的記憶體空間,這是因為我們可能在java中呼叫非java程式碼來分配記憶體,比如Android開發中呼叫NDK。那麼,當我們呼叫C中的malloc()函式分配了儲存空間,我們就只能用free()函式來釋放這些記憶體,這樣就需要我們在finalize()函式中用本地方法呼叫它。

物件記憶體狀態&&引用形式及回收時機

java物件記憶體狀態轉換圖

640?wx_fmt=png

 如何判斷java物件需要被回收?GC判斷方法 


1、引用計數,引用計數法記錄著每一個物件被其它物件所持有的引用數,被引用一次就加一,引用失效就減一;引用計數器為0則說明該物件不再可用;當一個物件被回收後,被該物件所引用的其它物件的引用計數都應該相應減少,它很難解決物件之間的相互迴圈引用問題迴圈引用例項

2、可達性分析演算法:從GC Root物件向下搜尋其所走過的路徑稱為引用鏈,當一個物件不再被任何的GC root物件引用鏈相連時說明該物件不再可用,GC root物件包括四種:方法區中常量和靜態變數引用的物件,虛擬機器棧中變數引用的物件,本地方法棧中引用的物件; 解決迴圈引用是因為GC Root通常是一組特別管理的指標,這些指標是tracing GC的trace的起點。它們不是物件圖裡的物件,物件也不可能引用到這些“外部”的指標。

3、採用引用計數演算法的系統只需在每個例項物件建立之初,通過計數器來記錄所有的引用次數即可。而可達性演算法,則需要再次GC時,遍歷整個GC根節點來判斷是否回收


 java物件的四種引用 
1.強引用 :建立一個物件並把這個物件直接賦給一個變數,eg :Person person = new Person(“sunny”); 不管系統資源有麼的緊張,強引用的物件都絕對不會被回收,即使他以後不會再用到。
2.軟引用 :通過SoftReference類實現,eg : SoftReference p = new SoftReference(new Person(“Rain”));記憶體非常緊張的時候會被回收,其他時候不會被回收,所以在使用之前要判斷是否為null從而判斷他是否已經被回收了。
3.弱引用 :通過WeakReference類實現,eg : WeakReference p = new WeakReference(new Person(“Rain”));不管記憶體是否足夠,系統垃圾回收時必定會回收
4.虛引用 :不能單獨使用,主要是用於追蹤物件被垃圾回收的狀態,為一個物件設定虛引用關聯的唯一目的是希望能在這個物件被收集器回收時收到一個系統通知。通過PhantomReference類和引用佇列ReferenceQueue類聯合使用實現


常見垃圾回收演算法參考圖

(https://yq.aliyun.com/articles/14411)


 停止-複製演算法 
這是一種非後臺回收演算法,將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊,記憶體浪費嚴重.它先暫停程式的執行,然後將所有存活的物件從當前堆複製到另外一個堆,沒被複制的死物件則全部是垃圾,存活物件被複制到新堆之後全部緊密排列,就可以直接分配新空間了。此方法耗費空間且效率低,適用於存活物件少。


 標記-清掃演算法 
同樣是非後臺回收演算法,該演算法從堆疊區和靜態域出發,遍歷每一個引用去尋找所有需要回收的物件,對每個找到需要回收物件都進行標記。標記結束之後,開始清理工作,被標記的物件都會被釋放掉,如果需要連續堆空間,則還需要對剩下的存貨物件進行整理;否則會產生大量記憶體碎片


 標記-整理演算法 
先標記需要回收的物件,但是不會直接清理那些可回收的物件,而是將存活物件向記憶體區域的一端移動,然後清理掉端以外的記憶體。適用於存活物件多。

 分代演算法 
在新生代中,每次垃圾收集時都會發現有大量物件死去,只有少量存活,因此可選用停止複製演算法來完成收集,而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用標記—清除演算法或標記—整理演算法來進行回收。


JVM效能調優


1、JVM分配超大堆(前提是物理機的記憶體足夠大)來提升伺服器的響應速度,但分配超大堆的前提是有把握把應用程式的 Full GC 頻率控制得足夠低,因為一次 Full GC 的時間造成比較長時間的停頓。控制 Full GC 頻率的關鍵是保證應用中絕大多數物件的生存週期不應太長,尤其不能產生批量的、生命週期長的大物件,這樣才能保證老年代的穩定

2、分配超大堆時,如果用到了 NIO 機制分配使用了很多的 Direct Memory,則有可能導致 Direct Memory 的 OutOfMemoryError 異常,這時可以通過-XX:MaxDirectMemorySize 引數調整 Direct Memory 的大小

3、調整執行緒堆疊,socket緩衝區,JNI佔用的記憶體以及虛擬機器、GC消耗的記憶體

4、“-Xms and -Xmx (or: -XX:InitialHeapSize and -XX:MaxHeapSize)”引數:分別指定初始堆和最大堆大小,Xms一般代表著堆記憶體的最小值,JVM在執行時可以動態調整堆記憶體大小,如果我們 設定Xms=Xmx就相當於設定了一個固定大小的堆記憶體;例如:“java -Xms128m -Xmx2g MyApp”啟動一個初始化堆記憶體為 128M,最大堆記憶體為 2G,名叫 “MyApp” 的 Java 應用程式;當我們設定Xmx最大堆記憶體不恰當時就很容易發生記憶體溢位,這樣我們可以通過設定 - XX:+HeapDumpOnOutOfMemoryError 讓 JVM 在發生記憶體溢位時自動生成堆記憶體快照,預設儲存在JVM的啟動目錄下名為 java_pid.hprof 的檔案裡,分析它可以很好地定位到溢位位置


Linux下面檢視Jvm效能資訊的命令


jstat: 用於檢視Jvm的堆疊資訊,能夠檢視eden,survivor,old,perm等堆區的的容量,利用率資訊,對於檢視系統是不是有記憶體洩漏以及引數設定是否合理有不錯的意義。例如’’’ jstat -gc 12538 5000 —- 即會每5秒一次顯示程式號為12538的java進成的GC情況 ‘’’

jstack:用來檢視Jvm當前的執行緒dump的,可以看到當前Jvm裡面的執行緒狀況,對於查詢blocked執行緒比較有意義

jmap:用來檢視Jvm當前的heap dump的,可以看出當前Jvm中各種物件的數量,所佔空間等等;尤其值得一提的是這個命令可以匯出一份binary heap dump的bin檔案,這個檔案能夠直接用Eclipse Memory Anayliser來分析,並找出潛在的記憶體洩漏的地方。

非jvm命令—netstat:通過這個命令可以看到Linux系統當前在各個埠的連結狀態,比如檢視資料庫連線數等

記憶體相關問題


記憶體洩露是指分配出去的記憶體沒有被回收回來,由於失去了對該記憶體區域的控制(例如你把它的地址給弄丟了),因而造成了資源的浪費。Java 中一般不會產生記憶體洩露,因為有垃圾回收器自動回收垃圾,但這也不絕對,Java堆內也可能發生記憶體洩露(Memory Leak; 當我們 new 了物件,並儲存了其引用,但是後面一直沒用它,而垃圾回收器又不會去回收它,這邊會造成記憶體洩露


記憶體溢位是指程式所需要的記憶體超出了系統所能分配的記憶體(包括動態擴充套件)的上限


符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到了記憶體中。


直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用是與虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那說明引用的目標必定已經存在於記憶體之中了。


雙親委派模型:表示類載入器之間的載入順序從頂至下的層次關係,載入器之間的父子關係一般都是通過組合來實現,而不是繼承。可以防止記憶體中出現多份同樣的位元組碼,並確保載入順序


雙親委派模型的工作過程是:在loadClass函式中,首先會判斷該類是否被載入過,載入過則進行下一步—-解析,否則進行載入;如果一個類載入器收到了類載入器的請求,先不會自己嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父類載入器反饋自己無法完成這個載入請求(它的搜說範圍中沒有找到所需的類時,子載入類才會嘗試自己去載入)


靜態分派和動態分派:靜態分派發生在編譯階段,是指依據靜態型別(變數宣告時定義的變數型別)來決定方法的執行版本,例如方法過載中依據引數的定義型別來定位具體應該執行的方法;動態分派發生在執行期,根據變數例項化時的實際型別來決定方法的執行版本,例如方法重寫;目前的 Java 語言(JDK1.6)是一門靜態多分派、動態單分派的語言。


動態分派具體實現Java虛擬機器是通過在方法區中建立一個虛方法表,通過使用方法表的索引來代替後設資料查詢以提高效能。虛方法表中存放著各個方法的實際入口地址,如果子類沒有覆蓋父類的方法,那麼子類的虛方法表裡面的地址入口與父類是一致的;如果重寫父類的方法,那麼子類的方法表的地址將會替換為子類實現版本的地址。方法表是在類載入的連線階段(驗證、準備、解析)進行初始化,準備了子類的初始化值後,虛擬機器會把該類的虛方法表也進行初始化。


JDK7和8中記憶體模型變化:JDK7中把String常量池從永久代移到了堆中,並通過intern方法來保證不在堆中重複建立一個物件;JDK7開始使用G1收集器替代CMS收集器。JDK8使用元空間來替代原來的方法區,並且提供了字串去重功能,也就是G1收集器可以識別出堆中那些重複出現的字串並讓他們指向同一個內部char[]陣列,而不是在堆中存在多份拷貝

∑編輯 | Gemini

來源 | candyguy242部落格

640?wx_fmt=png

演算法數學之美微信公眾號歡迎賜稿

稿件涉及數學、物理、演算法、計算機、程式設計等相關領域,經採用我們將奉上稿酬。

投稿郵箱:math_alg@163.com

相關文章