深入理解Java虛擬機之——內存管理與垃圾回收

聲明:原創作品,轉載請註明出處https://www.jianshu.com/p/feb01f5e94e5

最近在看周志明的《深入理解Java虛擬機》,所以打算寫幾篇關於Java虛擬機的文章,內容包括Java虛擬機的內存管理、垃圾回收、高併發及類加載幾部分,主要是對《深入理解Java虛擬機》一書的總結以及自己的一些理解。如果之前沒看過《深入理解Java虛擬機》的同學,可以先看看,寫的還是非常不錯的。今天這篇主要講下Java虛擬機的內存管理和垃圾回收策略。

內存劃分

內存管理中的內存指的就是Java虛擬機運行時存儲數據的地方,它被劃分成了多個區域,每個區域都有各自的用途,來看下Java虛擬機內存是怎麼劃分的,


從上圖可以看到,內存被劃分了五個區域:方法區、堆、虛擬機棧、本地方法棧以及程序計數器。其中方法區和堆是線程共享的,即各個線程都可以訪問,而虛擬機棧和本地方法棧以及程序計數器是線程隔離的,什麼是線程隔離呢?拿程序計數器舉例,每個線程都會有一個各自獨立的程序計數器。換句話說就是,一個虛擬機中不管有幾個線程都只有一個方法區和一個堆,但是會有多個虛擬機棧、本地方法棧和程序計數器。
接下來挨個看下各個區的作用:
程序計數器
我們知道Java虛擬機在執行程序的時候,其實就是在一條條的執行指令,這個程序計數器你可以理解就是存當前指令的地址,那爲什麼要存這個地址呢,因爲虛擬機或者說CPU在執行多線程或者多任務的時候,不是同時進行的(當然多核處理器除外),而是其中一個線程執行一段時間,再停下來轉去執行另一個線程,然後這個線程執行一段時間再轉入原來那個線程,這樣有個好處就是CPU的資源可以較平均的分配,那麼當CPU執行一個線程一段時間後暫停,然後又重新執行時,這時CPU就需要知道這個線程得從哪裏接着執行,不可能讓這個線程再從頭執行一遍,那麼程序計數器就起到了一個很好的作用,用來標誌某個線程的執行進度,也因此這個程序計數器是一個線程私有的存儲區域,因爲每個線程的執行進度都是不一樣的。
Java虛擬機棧
我們常說的堆棧,其中的棧指的就是這裏的Java虛擬機棧,爲什麼叫棧呢,因爲每個方法在執行的時候,都會創建一個棧幀,用來保存方法中的局部變量、操作數棧、動態鏈接什麼的,每一個方法從開始執行到結束,就對應這個棧幀的入棧和出棧。它也是線程私有的,生命週期和線程相同。如果線程請求的棧深度大於虛擬機所允許的深度,將會拋出StackOverflowError異常;如果虛擬機棧可以動態擴展,但是擴展時無法申請到足夠的內存,就會拋出OutOfMemoryError異常。
本地方法棧
本地方法棧和Java虛擬機棧很像,只不過Java虛擬機棧是服務於執行Java方法,而本地方法棧是服務於執行本地方法比如C/C++之類的方法。
Java堆
這個大家應該都很熟悉,就是用來存放對象和數組的地方,它是被所有線程共享,虛擬機一啓動就創建。堆是Java垃圾回收的重點區域,因此我們常常叫它爲GC堆。
方法區
方法區和堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯後的代碼等數據。雖然Java虛擬機規範把方法區描述爲堆的一個邏輯部分,但是它卻有一個別名 Non-Heap(非堆),主要是爲了和Java堆區分開來。方法區還有一片區域叫做運行時常量池,主要存放編譯器生成的各種字面量和符號引用。
直接內存
直接內存不是虛擬機運行時數據區的一部分,也不是Java虛擬機規範中定義的內存區域,但是這部分內存也被頻繁使用。在JDK1.4中新加入了NIO類,引入了基於通道與緩衝區的I/O方式,他可以使用Native函數庫直接分配堆外內存,然後通過一個存儲在Java堆中的DirectByteBuffer對象作爲這塊內存的作用進行操作,這樣可以顯著提高性能。雖然直接內存不會受到Java堆大小的限制,但是會受到本機總內存及處理器尋址空間的限制,所以還是會存在內存溢出的風險。

對象

好了,瞭解了Java虛擬機的一個大致內存情況後,我們再來看下Java虛擬機究竟是怎麼使用內存的。我們之前說過Java虛擬機內存中佔比最大的就是堆內存的使用,而堆是存儲對象的主要區域,所有接下來我們來看下對象的一些存儲細節,包括對象的創建、對象的分配、對象的內存佈局以及對象的訪問。

對象的創建

說起對象的創建很多人都會想到一個關鍵詞:new。沒錯只要new下一個對象就創建了,但是你有沒有想過這背後Java虛擬機到底做了什麼事。其實不要看它只有一句簡單的語句,其實對虛擬機來說做了很多工作。首先虛擬機會在方法區的常量池中檢測有無這個類的符號引用,並且檢查這個類有沒有被加載、解析、初始化過,如果沒有則會先執行類加載過程。類加載過程完成後,Java虛擬機就會爲這個對象分配內存。內存分配有兩種方式,一種爲指針碰撞,另一種爲空閒列表
指針碰撞


如上所示,指針碰撞這種存儲方式是非常規整的,已存儲的區域佔一邊,未存儲的佔一邊,中間分界用一個指針來指引,每次分配一個對象的時候,只用指針偏移這個對象所佔存儲空間就好了。
空閒列表
這種方式,其內存空間沒有這麼規整,而是不連續的。

如上圖所示,圓形狀的代表已存儲的空間。然後虛擬機會維護一個隊列,裏面記錄着哪些內存是可用的。然後需要分配內存的從這裏找就可以了。
那麼爲什麼會存在這兩種方式呢,這主要和後面要將的垃圾回收的機制有關,內存經過不同的回收機制回收後,其分佈會表現的不一樣。
另外,對象的分配在多線程中還會存在一定的問題,比如線程A正在給對象分配內存,指針還沒改過來,此時線程B分配內存時用的指針還是原來的指針。這樣就衝突了。要解決這個問題有兩種方式,一種是對分配內存空間的動作加同步處理,這裏虛擬機會用到CAS指令。另一種方式是每個線程在堆中分配一小塊屬於這個線程的內存區域,我們稱之爲本地線程分配緩衝(TLAB),這樣每個線程分配內存所用的空間都是獨立的。但是當TLAB用完的時候還是會採用同步的方式。

對象分配

對象的分配主要分配在新生代的Eden區上(一般虛擬機會把內存分爲新生代和老生代,新生代又分爲Eden區和Survivor區,這個後面將垃圾回收時會提到),如果啓動了本地線程分配緩衝,將按線程優先分配在TLAB上,少數情況下也可能直接分配在老年代中,具體分配規則還得取決於哪一種垃圾收集器,以及虛擬機和內存的參數設置。當Eden區沒有足夠的內存空間進行分配時,虛擬機會發起一次Minor GC(即在新生代進行一次垃圾回收)。不過虛擬機在Minor GC之前還會進行空間分配擔保,就是會先判斷下老年代的內存空間是否大於新生代所有對象的總內存,如果大於則執行Minor GC,如果這個條件不成立,則會判斷HandlePromotionFailure設置值是否允許擔保失敗,如果允許則會繼續判斷老年代剩餘空間是否大於歷次晉升到老年代的對象的平均大小,如果大於則進行Minor GC,不過有可能會失敗,如果失敗則會進行Full GC(即進行老年代的垃圾回收,Full GC一般都會伴有至少一次的Minor GC,Full GC要比Minor慢10倍以上)。如果老年代的內存空間小於新生代所有對象的總內存,或者HandlePromotionFailure爲false則直接進行Full GC。每次Minor GC之後,Eden存活下來的對象就會被移到新生代中的Survivor區中,如果Survivor區中放不下,就會進入老年代,當然前提是老年代有足夠空間,否則就會觸發Full GC。另外也有一些大對象爲直接進入老年代比如一些長字符串和數組,我們應儘量避免這些大對象。當對象從Eden區進入Survivor區後,每經歷一次Minor GC並且能順利活下來,那麼他的年齡就加一,如果到了15歲,就會進入老年代,默認是15歲你可以通過 -XX:MaxTenuringThreshold來設置。如果在Survivor空間中相同年齡所有對象大小總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無需等到MaxTenuringThreshold中要求的年齡。
內存分配完成後,虛擬機需要將分配到的內存都初始化爲零值(不包括對象頭),如果使用TLAB,這一工作過程也可以提前到TLAB分配時進行。這一步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。賦完零值,對象會初始化對象頭,對象頭在稍後的對象佈局中講解。
完成上面的工作後,從虛擬機層面來說一個新的對象已經產生了,但是從Java程序的角度來說對象的創建纔剛剛開始,因爲init方法也就是我們說的構造方法還沒執行。所以一般來說執行new指令後會接着執行init方法,這樣一個真正可用的對象纔算完全創建成功。

對象的佈局

通過上面的分析,我們瞭解了一個對象的創建過程。接下來我們來看下一個對象裏面到底存了哪些數據。有的童鞋可能有疑惑,對象還能存什麼數據,不就是我們聲明的類中的一些實例數據嗎。當然這是其中一部分也是最主要的一部分,除此之外還有對象頭和對齊填充。我們分別來看下:

對象頭

對象頭主要存兩部分數據,一部分爲對象自身的運行時數據,如HashCode、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳,當然這些東西是什麼現在暫時不用管,之後的幾篇文章中再細講。這部分數據的長度在32位和64位的虛擬機中分別爲32bit和64bit,官方稱它爲Mark Word。其實需要存儲的運行數據很多,已經超出了規定的長度,但是這部分數據有時和對象自身定義的數據無關,如果專門爲這部分再開闢空間的話,虛擬機的空間效率就比較低。因此MarkWord 被設計成不是固定的數據結構,會根據對象的狀態複用自己的存儲空間。例如在32位的HotSpot虛擬機中,如果對象處於未被鎖定的狀態下,那麼MarkWord的32位空間中的25位用於存儲對象的HashCode,4位用於存儲對象的分代年齡,2位用於存儲鎖標誌位,1位固定爲0,而其他狀態下對象的存儲內容如下

存儲內容 標誌位 狀態
對象HashCode、對象分代年齡 01 未鎖定
指向鎖記錄的指針 00 輕量級鎖定
指向重量級鎖的指針 10 膨脹
空,不需要記錄信息 11 GC標記
偏向線程,偏向時間戳、對象分代年齡 01 可偏向

類型指針

另一部分主要是存儲指向類元數據的指針,就是這個對象是哪個類的實例。當然對象中不一定都要存這個類元指針。另外如果這個對象是一個Java數組的話,那麼對象頭中還必須保存這個數組的長度,因爲普通對象的大小可以直接由類元數據確定,但是數組只能通過實例才知道。

對齊填充

說完對象頭,我們再來看下對齊填充。什麼是對齊填充呢?由於HotSpot 虛擬機的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說就是Java對象的大小必須爲8字節的整數倍,而剛纔講的對象頭正好是8字節的整數倍,只有對象的實例數據有可能不是8字節的整數倍,因此如果實例數據大小不是8字節的整數倍,虛擬機就會把剩下的填滿,這部分就是對齊填充,沒有具體的含義,僅僅起到一個佔位的作用。

對象的訪問

建立對象是爲了使用對象,換句話說我們得能訪問到我們剛創建的對象。對象的訪問目前有兩種主流方式,一種爲通過句柄訪問,一種爲直接指針訪問。
句柄訪問

上圖展示了句柄訪問的方式,從圖中可以看到,Java堆中劃出了一部分區域用作句柄池,這個句柄保存了對象的兩個地址,對象實際的實例數據存儲地址和這個對象的類元數據地址,而我們變量指向的就是這個對象的句柄地址。
指針訪問


上圖展示的是直接指針訪問,可以看到對象的實例數據中保存了類數據的指針,而本地變量直接指向這個對象地址。
那麼這兩種方式各有什麼優勢呢?使用句柄訪問最大的好處就是本地變量中存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中示例數據的指針,而變量中存儲的指向句柄的地址不會改變。而使用直接指針最大的好處就是速度更快,因爲它只用一次就指向了對象,而句柄訪問需要指針指向兩次才能訪問到示例數據。

垃圾回收

上面我們知道了內存分配以及對象創建以及訪問的一些細節,接下來我們來看下Java虛擬機是如何進行垃圾回收的。這裏的垃圾指的就是那些已經死去的對象,那麼什麼叫死去的對象?或者說Java虛擬機是如何判斷一個對象是死的?這裏介紹兩種方法,一種是引用計數算法,另一種是可達性分析算法,我們挨個來看下:

引用計數算法
引用計數算法就是在對象中添加一個引用計數器,每當有一個地方引用了這個對象,這個計數器的值加1,當這個引用失效時就減1,如果這個計數器的值爲0,則表示這個對象就已經死了,不可能再被使用。當Java虛擬機進行垃圾回收時遇到這個對象就會把它回收了。不過這個算法有一個問題:如果有兩個對象A、B相互引用(如下圖所示),但是這兩個對象都沒有被其他第三者引用,也就是說這兩個對象其實已經不可能再被使用了,但是由於他們相互引用,計數器的值都不爲0,導致無法被Java虛擬機回收。因此主流的Java虛擬機都已經不再採用這個算法,而是用另一個算法--可達性分析算法


可達性分析算法
這個算法是通過定義一系列的GC Root的對象作爲起點,從這些起點向下搜索,搜索所走過的路徑稱爲引用鏈,當一個對象到GC Root沒有任何引用鏈相連時,則證明此對象是不可用的。

如上圖所示,GC Root引用了 Object1,Object1引用了Object2,說明Object2到GC Root是走的通的,也就是說Object2是可用的不會被回收,同理Object3,Object4也是可用的。然而Object5和Object6、Object7有引用關係,但是他們到GC Root是不通的,所有即使他們有引用關係但是還是不可用的,這時Java虛擬機可以對他們進行回收。那麼這裏的GC Root是什麼呢,在Java中有四種對象可作爲GC Root:

  • 虛擬機棧中引用的對象
  • 方法區中類靜態屬性引用的對象
  • 方法區中常量引用的對象
  • 本地方法棧中JNI引用的對象

引用
上面講的兩種算法其實都和引用有關,在jdk1.2之前,一個對象只有兩種狀態,要麼有引用要麼沒用引用。Java虛擬機只會對沒有引用的對象進行回收,但是這樣有一個問題,就是假如有一個場景中存在一個對象,當內存充足的時候不會回收這個對象,但是當內存不足時就會回收這個對象。那麼上面的這個只用兩種狀態的引用就無法滿足這個場景了。於是在jdk1.2之後Java對引用的概念進行了擴充。將引用分爲強引用(String Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference),這四種引用強度依次逐漸減弱。分別看下這四種引用:

  • 強引用:強引用在程序代碼中普遍存在,類似“Object obj = new Object()”這個類的引用,只要強引用還存在,Java虛擬機就永遠不會回收這個對象。
  • 軟引用:用來描述一些還有用到時不是必須的對象。在系統將要發生內存溢出異常之前,Java虛擬機會回收這些對象,如果回收後還是內存不足纔會發生內存溢出異常。
  • 弱引用:也是用來描述一些非必要的對象,但是強度比軟引用更弱一點,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前,當垃圾收集器工作時,無論當前內存是否足夠都會回收掉這個對象
  • 虛引用:虛引用也叫幽靈引用,它是最弱的一種引用關係,他不會影響Java虛擬機回收機制,它的主要作用就是當被虛引用關聯得對象被回收時會向系統發送一個通知。
    最後的掙扎
    我們上面說過,當一個對象沒有引用時,這個對象將會被回收,其實這種說法是不嚴謹的,因爲對象在被回收前還是可以最後掙扎一下。什麼意思呢?其實對象中有一個叫做finalize的方法,你可以在這個方法中讓這個即將被回收的對象重新連接到引用鏈上,讓它到GC Root的鏈路是通的,這樣這個對象就被救活了。但是這個方法是不建議使用的,因爲這個方法只會被虛擬機執行一次,如果第一次執行後這個對象沒有救活,那麼虛擬機下次就不會再執行而是直接回收了。另外虛擬機在執行這個方法時並不會保證這個方法一定會執行到結束,可能執行到一半就結束了,這樣做的原因是爲了防止你在這個方法中加入一些比如死循環、耗時這樣的騷操作,這些操作可能會直接把虛擬機的整個垃圾回收系統給搞崩了,所以finalize這個方法還是少用,最好不用爲妙。

垃圾回收算法

上面分析了哪些對象會被回收,接下來我們來看下,找到這些對象後虛擬機是如何回收的。虛擬機回收有不同的算法實現,主要有標記清除算法複製算法標記整理算法分代算法。接來下分別來介紹下這幾種算法:

標記清除算法

標記清除顧名思義,這個算法分兩個步驟:首先需要標記哪些對象是需要被回收的,這個就是我們剛纔上面介紹的,標記出來後就可以對他們進行清除。這也是最基礎的回收算法,因爲後續的回收算法其實多多少少都是經過這個算法修改得到的。不過這個算法有兩個缺點:第一,標記和清除是比較耗時間的,效率不是很高。第二就是通過這個算法回收之後我們存儲區域中會出現一些不連續的存儲區域,如果你要存一個大的對象時,可能就找不到一塊連續的存儲區域,從而觸發另一次垃圾回收操作。


複製算法

複製算法是將現有存儲空間劃分成兩半,每次只用其中的一半,如果這半的空間用完了,就將還存活的對象都複製到另一半上去,然後把之前那一半的空間都清理掉,這樣的話就解決了空間連續性的問題,不過也帶來了新的問題,就是每次只能使用一半的內存,空間利用率不高。這種算法一般用在回收新生代的內存,在新生代的對象一般生命週期比較短,每次大概有百分之九十的對象會被回收,那麼這個存儲區域其實沒有必要設計成1:1的比例,可以是9:1,這樣空間利用率就大大提高了。


標記整理算法

標記整理算法有點類似前面的標記清除算法,不同的是這個標記之後不是直接清除對象,而是把還存活的對象都移動到一邊,然後把另一邊的都直接清除掉。

分代收集算法

其實這個算法是上面算法的組合,當前主流的商業虛擬機會根據對象的存活週期對內存進行分代,一般分爲新生代和老生代,新生代中的對象存活週期比較短,適合複製算法,而老生代中的對象存活週期長,可以用標記清除算法和標記整理算法。

HotSpot的算法實現

上面我們介紹了對象存活判定和垃圾回收算法,但是在具體的商業虛擬機中具體實現算法時爲了提高效率肯定需要做一些優化。

枚舉根節點

我們上面講到,判斷一個對象是否存活,只要判斷一個對象是否在引用鏈上,但是內存中會存在大量的GCRoot,如果一條條遍歷每個引用鏈,那麼將是非常耗時的。而且虛擬機在進行可達性分析時,會暫停Java所有線程,因爲如果你在分析對象的引用關係時,如果這邊線程還在繼續進行那麼這個引用鏈的關係勢必會打亂,虛擬機的回收工作也將無法進行,就好比你媽在家打掃衛生,而你卻在旁邊不停的扔垃圾,你媽可能會崩潰的。由於暫停了所有線程,在可達性分析的時候就會卡頓下,如果分析的時間越久那麼卡頓的時間也將會越久。因此爲了提高虛擬機可達性分析效率,引入一個OopMap的數據結構,你可以理解這個數據結構是用來存放對象的引用,每當需要可達性分析的時候,不用挨個遍歷引用鏈,只用掃描這個數據結構就可以了,效率將大大提升。

安全點

但是隨之而來一個問題,引起OopMap變化的指令會多,如果爲每一條指令都生成OopMap的話,那麼會需要額外的內存空間,導致GC的空間成本變高。爲解決這個問題,OopMap只會在某些特定位置發生更新,這個位置就是安全點。這個其實有點類似有些單機闖關遊戲中的記錄保存功能,在遊戲的某些位置比如通過一個小關卡,這時遊戲會自動保存記錄,而不是每時每刻的保存。同樣的在程序中這個安全點的設置也是有一定規律的,比如會在方法調用、循環跳轉、異常跳轉等位置會設置安全點。設置安全點,意味着虛擬機的垃圾回收也只能在安全點進行,如果不在安全點,這時的OopMap的引用關係就會有問題。但是當虛擬機進行垃圾回收時,有的程序或者說線程其實不在安全點,那麼這時就需要想辦法讓線程跑到安全點上去。虛擬機提供了兩種方法讓線程跑到安全點上去:搶先試中斷和主動式中斷。搶先試中斷是當虛擬機發生垃圾回收會停掉所有線程,如果某個線程不在安全點,則恢復再重新跑到安全點上,不過現在幾乎沒有虛擬機是採用這種方式。第二種主動式中斷是虛擬機在垃圾回收時在安全點設置標記,當線程執行到標記位置時就主動停止線程。

安全區域

其實上面還有一個問題,就是當虛擬機發生垃圾回收時,某個線程正好處於Sleep狀態,無法響應中斷進入安全點。這時就需要引入一個安全區域的概念。安全區域是指在一段代碼片段中,引用關係不會發生變化,在這個區域的任意地方開始垃圾回收都是安全的。當線程執行到安全區域時,會標記自己已經進入安全區域,這時如果虛擬機要垃圾回收就不用管那些標記自己進入安全區域的線程。當線程要離開安全區域時,它就要檢查系統是否已經完成了根節點枚舉。如果完成了那麼線程就繼續執行,否則就必須等到可以離開安全區域的信號爲止。

其實安全點和安全區域有點像你在家裏,你媽在拖地的情景。當你媽在客廳拖地的時候,你肯定不能亂走動,因爲你邊走你媽邊拖地,估計你媽要瘋了。於是當你媽要拖地的時候,會在客廳裏劃出幾個位置,比如凳子、沙發等,每次拖地你就在那不要動。這個位置就類似於安全點。那麼有時你媽拖地時你正好不在凳子上,那麼你媽就會把你趕過去,這個叫搶先式中斷。當然你媽也可以在凳子、沙發上插個小紅旗,當你走動遇到小紅旗時,你就呆那不要動,這個就叫主動式中斷。當然有的時候你媽拖地時,你躺地上睡着了。。你又是個200斤的大胖子,你媽不管怎麼搞,都不能把你弄到凳子上。於是想出了一個辦法,就是當你進到你自己的房間時就讓你告訴她一聲,這時你媽就可以放飛自我打掃客廳了,但是如果你要出來的時候就得看看你媽地拖好了沒有,如果沒有你就乖乖的等着吧,直到你媽說可以出來你才能出來。那麼這裏你的房間就好比安全區域。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章