歡迎關注微信公衆號:BaronTalk,獲取更多精彩好文!git
書籍真的是常讀常新,古人說「書讀百遍其義自見」仍是蠻有道理的。周志明老師的這本《深刻理解 Java 虛擬機》我細讀了不下三遍,每一次閱讀都有新的收穫,每一次閱讀對 Java 虛擬機的理解就更進一步。於是萌生了將讀書筆記整理成文的想法,一是想檢驗下本身的學習成果,對學習內容進行一次系統性的覆盤;二是給還沒接觸過這部好做品的同窗推薦下,在閱讀這部佳做以前能經過個人文章一窺書中的精華。程序員
原想着一篇文章就夠了,但寫着寫着就發現篇幅大大超出了預期。看來仍是功力不夠,索性拆成了六篇文章,分別從自動內存管理機制、類文件結構、類加載機制、字節碼執行引擎、程序編譯與代碼優化、高效併發六個方面來作更加細緻的介紹。本文先說說 Java 虛擬機的自動內存管理機制。github
Java 虛擬機在執行 Java 程序的過程當中會把它所管理的內存區域劃分爲若干個不一樣的數據區域。這些區域都有各自的用途,以及建立和銷燬的時間,有些區域隨着虛擬機進程的啓動而存在,有些區域則是依賴線程的啓動和結束而創建和銷燬。Java 虛擬機所管理的內存被劃分爲以下幾個區域:算法
程序計數器是一塊較小的內存區域,能夠看作是當前線程所執行的字節碼的行號指示器。在虛擬機的概念模型裏,字節碼解釋器工做時就是經過改變這個計數器的值來選取下一條須要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都須要依賴這個計數器來完成。「屬於線程私有的內存區域」數組
就是咱們平時所說的棧,每一個方法被執行時,都會建立一個棧幀(Stack Frame)用於存儲局部變量表、操做棧、動態連接、方法出口等信息。每一個方法從被調用到執行完成的過程,就對應着一個棧幀在虛擬機棧中從出棧到入棧的過程。「屬於線程私有的內存區域」安全
局部變量表:局部變量表是 Java 虛擬機棧的一部分,存放了編譯器可知的基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference 類型,不等同於對象自己,根據不一樣的虛擬機實現,它多是一個指向對象起始地址的引用指針,也多是指向一個表明對象的句柄或者其餘與此對象相關的位置)和 returnAddress 類型(指向了一條字節碼指令的地址)。微信
和虛擬機棧相似,只不過虛擬機棧爲虛擬機執行的 Java 方法服務,本地方法棧爲虛擬機使用的 Native 方法服務。「屬於線程私有的內存區域」數據結構
對大多數應用而言,Java 堆是虛擬機所管理的內存中最大的一塊,是被全部線程共享的一塊內存區域,在虛擬機啓動時建立。此內存區域的惟一做用就是存放對象實例,幾乎全部的對象實例都是在這裏分配的(不絕對,在虛擬機的優化策略下,也會存在棧上分配、標量替換的狀況,後面的章節會詳細介紹)。Java 堆是 GC 回收的主要區域,所以不少時候也被稱爲 GC 堆。從內存回收的角度看,Java 堆還能夠被細分爲新生代和老年代;再細一點新生代還能夠被劃分爲 Eden Space、From Survivor Space、To Survivor Space。從內存回收的角度看,線程共享的 Java 堆可能劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。「屬於線程共享的內存區域」併發
用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。「屬於線程共享的內存區域」函數
運行時常量池: 運行時常量池是方法區的一部分,Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息就是常量池(Constant Pool Table),用於存放在編譯期生成的各類字面量和符號引用。
直接內存:直接內存(Direct Memory)並非虛擬機運行時數據區的一部分,也不是 Java 虛擬機規範中定義的內存區域。Java 中的 NIO 可使用 Native 函數直接分配堆外內存,而後經過一個存儲在 Java 堆中的 DiectByteBuffer 對象做爲這塊內存的引用進行操做。這樣能在一些場景顯著提升性能,由於避免了在 Java 堆和 Native 堆中來回複製數據。直接內存不受 Java 堆大小的限制。
前面介紹了 Java 虛擬機的運行時數據區,瞭解了虛擬機內存的狀況。接下來咱們看看對象是如何建立的、對象的內存佈局是怎樣的以及對象在內存中是如何定位的。
要建立一個對象首先得在 Java 堆中(不絕對,後面介紹虛擬機優化策略的時候會作詳細介紹)爲這個要建立的對象分配內存,分配內存的過程要保證併發安全,最後再對內存進行相應的初始化,這一系列的過程完成後,一個真正的對象就被建立了。
先說說內存分配,當虛擬機遇到一條 new 指令時,首先將去檢查這個指令的參數是否可以在常量池中定位到一個類的符號引用,而且檢查這個符號引用表明的類是否已被加載、解析和初始化。若是沒有,那必須先執行相應的類加載過程。在類加載檢查經過後,虛擬機將爲新生對象分配內存。對象所需的內存大小在類加載完成後即可徹底肯定,爲對象分配內存空間的任務等同於把一塊肯定大小的內存從 Java 堆中劃分出來。
在 Java 堆中劃份內存涉及到兩個概念:指針碰撞(Bump the Pointer)、空閒列表(Free List)。
若是 Java 堆中的內存絕對規整,全部用過的內存都放在一邊,空閒的內存放在另外一邊,中間放着一個指針做爲分界點的指示器,那所分配的內存就牢牢是把指針往空閒空間那邊挪動一段與對象大小相等的距離,這種分配方式稱爲**「指針碰撞」**。
若是 Java 堆中的內存並非規整的,已使用的內存和空閒的內存相互交錯,那就沒辦法簡單的進行指針碰撞了。虛擬機必須維護一個列表來記錄哪些內存是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式稱爲**「空閒列表」**。
選擇哪一種分配方式是由 Java 堆是否規整來決定的,而 Java 堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。
對象的建立在虛擬機中是一個很是頻繁的行爲,哪怕只是修改一個指針所指向的位置,在併發狀況下也是不安全的,可能出現正在給對象 A 分配內存,指針還沒來得及修改,對象 B 又同時使用了原來的指針來分配內存的狀況。解決這個問題有兩種方案:
對分配內存空間的動做進行同步處理(採用 CAS + 失敗重試來保障更新操做的原子性);
把內存分配的動做按照線程劃分在不一樣的空間之中進行,即每一個線程在 Java 堆中預先分配一小塊內存,稱爲本地線程分配緩衝(Thread Local Allocation Buffer, TLAB)。哪一個線程要分配內存,就在哪一個線程的 TLAB 上分配。只有 TLAB 用完並分配新的 TLAB 時,才須要同步鎖。
內存分配完後,虛擬機要將分配到的內存空間初始化爲零值(不包括對象頭),若是使用了 TLAB,這一步會提早到 TLAB 分配時進行。這一步保證了對象的實例字段在 Java 代碼中能夠不賦初始值就直接使用。
接下來設置對象頭(Object Header)信息,包括對象是哪一個類的實例、如何找到類的元數據、對象的 Hash、對象的 GC 分代年齡等。
這一系列動做完成以後,緊接着會執行 方法,把對象按照程序員的意圖進行初始化,這樣一個真正意義上的對象就產生了。
JVM 中對象的建立過程大體以下圖:
在 HotSpot 虛擬機中,對象在內存中的佈局能夠分爲 3 塊:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。
對象頭包含兩部分信息,第一部分用於存儲對象自身的運行時數據,好比哈希碼(HashCode)、GC 分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等。這部分數據稱之爲 Mark Word。對象頭的另外一部分是類型指針,即對象指向它的類元數據指針,虛擬機經過它來肯定對象是哪一個類的實例;若是是數組,對象頭中還必須有一塊用於記錄數組長度的數據。(並非全部全部虛擬機的實現都必須在對象數據上保留類型指針,在下一小節介紹「對象的訪問定位」的時候再作詳細說明)。
對象真正存儲的有效數據,也是在程序代碼中所定義的各類字段內容。
無特殊含義,不是必須存在的,僅做爲佔位符。
Java 程序須要經過棧上的 reference 信息來操做堆上的具體對象。根據不一樣的虛擬機實現,主流的訪問對象的方式主要有句柄訪問和直接指針兩種。
Java 堆中劃分出一塊內存來做爲句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。
使用句柄訪問的好處就是 reference 中存儲的是穩定的句柄地址,在對象被移動時只須要改變句柄中實例數據的指針,而 reference 自己不須要修改。
在對象頭中存儲類型數據相關信息,reference 中存儲的對象地址。
使用直接指針訪問的好處是速度更快,它節省了一次指針定位的開銷。因爲對象訪問在 Java 中很是頻繁,所以這類開銷聚沙成塔也是一項很是可觀的執行成本。HotSpot 中採用的就是這種方式。
在前面咱們介紹 JVM 運行時數據區的時候說過,程序計數器、虛擬機棧、本地方法棧 3 個區域隨線程而生,隨線程而死;棧中的棧幀隨着方法的進入和退出而有條不紊的執行着入棧和出棧的操做。每個棧幀中分配多少內存基本上在數據結構肯定下來的時候就已經知道了,所以這幾個區域內存的分配和回收是具備肯定性的,因此不用過分考慮內存回收的問題,由於在方法結束或者線程結束時,內存就跟着回收了。
而 Java 堆和方法區則不同,一個接口中的多個實現類須要的內存可能不同,一個方法中的多個分支須要的內存也可能不同,咱們只有在程序運行期才能知道會建立哪些對象,這部份內存的分配和回收是動態的,垃圾收集器要關注的就是這部份內存。
垃圾收集器在作垃圾回收的時候,首先須要斷定的就是哪些內存是須要被回收的,哪些對象是「存活」的,是不能夠被回收的;哪些對象已經「死掉」了,須要被回收。
判斷對象存活與否的一種方式是「引用計數」,即對象被引用一次,計數器就加 1,若是計數器爲 0 則判斷這個對象能夠被回收。可是引用計數法有一個很致命的缺陷就是它沒法解決循環依賴的問題,所以如今主流的虛擬機基本不會採用這種方式。
可達性分析算法又叫根搜索算法,該算法的基本思想就是經過一系列稱爲「GC Roots」的對象做爲起始點,從這些起始點開始往下搜索,搜索所走過的路徑稱爲引用鏈,當一個對象到 GC Roots 對象之間沒有任何引用鏈的時候(不可達),證實該對象是不可用的,因而就會被斷定爲可回收對象。
在 Java 中可做爲 GC Roots 的對象包含如下幾種:
不管是經過引用計數器仍是經過可達性分析來判斷對象是否能夠被回收都設計到「引用」的概念。在 Java 中,根據引用關係的強弱不同,將引用類型劃爲強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference)。
強引用:Object obj = new Object()
這種方式就是強引用,只要這種強引用存在,垃圾收集器就永遠不會回收被引用的對象。
軟引用:用來描述一些有用但非必須的對象。在 OOM 以前垃圾收集器會把這些被軟引用的對象列入回收範圍進行二次回收。若是本次回收以後仍是內存不足纔會觸發 OOM。在 Java 中使用 SoftReference 類來實現軟引用。
弱引用:同軟引用同樣也是用來描述非必須對象的,可是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生以前。當垃圾收集器工做時,不管當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在 Java 中使用 WeakReference 類來實現。
虛引用:是最弱的一種引用關係,一個對象是否有虛引用的存在徹底不影響對象的生存時間,也沒法經過虛引用來獲取一個對象的實例。一個對象使用虛引用的惟一目的是爲了在被垃圾收集器回收時收到一個系統通知。在 Java 中使用 PhantomReference 類來實現。
在可達性分析中斷定爲不可達的對象,也不必定就是「非死不可的」。這時它們處於「緩刑」階段,真正要宣告一個對象死亡,至少須要經歷兩次標記過程:
第一次標記:若是對象在進行可達性分析後被斷定爲不可達對象,那麼它將被第一次標記而且進行一次篩選。篩選的條件是此對象是否有必要執行 finalize() 方法。對象沒有覆蓋 finalize() 方法或者該對象的 finalize() 方法曾經被虛擬機調用過,則斷定爲不必執行。
第二次標記:若是被斷定爲有必要執行 finalize() 方法,那麼這個對象會被放置到一個 F-Queue 隊列中,並在稍後由虛擬機自動建立的、低優先級的 Finalizer 線程去執行該對象的 finalize() 方法。可是虛擬機並不承諾會等待該方法結束,這樣作是由於,若是一個對象的 finalize() 方法比較耗時或者發生了死循環,就可能致使 F-Queue 隊列中的其餘對象永遠處於等待狀態,甚至致使整個內存回收系統崩潰。finalize() 方法是對象逃脫死亡命運的最後一次機會,若是對象要在 finalize() 中挽救本身,只要從新與 GC Roots 引用鏈關聯上就能夠了。這樣在第二次標記時它將被移除「即將回收」的集合,若是對象在這個時候尚未逃脫,那麼它基本上就真的被回收了。
前面介紹過,方法區在 HotSpot 虛擬機中被劃分爲永久代。在 Java 虛擬機規範中沒有要求方法區實現垃圾收集,並且方法區垃圾收集的性價比也很低。
方法區(永久代)的垃圾收集主要回收兩部份內容:廢棄常量和無用的類。
廢棄常量的回收和 Java 堆中對象的回收很是相似,這裏就不作過多的解釋了。
類的回收條件就比較苛刻了。要斷定一個類是否能夠被回收,要知足如下三個條件:
正如標記-清除的算法名同樣,該算法分爲「標記」和「清除」兩個階段:
首先標記出全部須要回收的對象,在標記完成後回收全部被標記的對象。標記-清除算法是一種最基礎的算法,後續其它算法都是在它的基礎上基於不足之處改進而來的。它的不足體如今兩方面:一是效率問題,標記和清除的效率都不高;二是空間問題,標記清除以後會產生大量不連續的內存碎片,空間碎片太多可能會致使之後程序的運行過程當中又要分配較大對象是,沒法找打足夠的連續內存而不得不提早出發下一次 GC。
爲了解決效率問題,因而就有了複製算法,它將內存一分爲二劃分爲大小相等的兩塊內存區域。每次只使用其中的一塊。當這一塊用完時,就將還存活的對象複製到另外一塊上面,而後再把已使用過的內存空間一次清理掉。這樣作的好處是不用考慮內存碎片問題了,簡單高效。只不過這種算法代價也很高,內存所以縮小了一半。
如今的商業虛擬機都採用這種算法來回收新生代,在 IBM 的研究中新生代中的對象 98% 都是「朝生夕死」,因此並不須要按照 1:1 的比例來劃分空間,而是將內存分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor。當回收時,將 Eden 和 Survivor 中還存活的對象一次性複製到另外一塊 Survivor 空間上,最後清理掉 Eden 和剛纔用過的 Survivor 空間。 HotSpot 默認 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用的內存爲整個新生代容量的 90%(80%+10%),只有 10% 會被浪費。固然,98% 的對象可回收只是通常場景下的數據,咱們沒辦法保證每次回收後都只有很少於 10% 的對象存活,當 Survivor 空間不夠用時,須要依賴其它內存(這裏指老年代)進行分配擔保。若是另一塊 Survivor 空間沒有足夠空間存放上一次新生代收集下來存活的對象時,這些對象將直接經過分配擔保機制進入老年代。
經過前面對複製-收集算法的介紹咱們知道,其對老年代這種對象存活時間長的內存區域就不適用了,而標記整理的算法就比較適用這一場景。
標記-整理算法的標記過程與「標記-清除」算法同樣,可是後續步驟不是直接對可回收對象進行清理,而是讓全部存活的對象都向一端移動,而後直接清理掉端邊界之外的內存。
當前商業虛擬機的垃圾蒐集都採用「分代回收」算法,這種算法並無什麼新的思想,只是根據對象存活週期的不一樣將內存劃分爲幾塊。通常是將 Java 堆分爲新生代和老年代,這樣能夠根據各個年代的特色採用最合適的蒐集算法。
在新生代中,每次垃圾收集時都發現有大批對象死去,只有少許存活,那就選用複製算法,只須要付出少許存活對象的複製成本就能夠完成收集。
而老年代中由於對象存活率高、沒有額外空間對它進行分配擔保,就必須使用「標記-清除」或者「標記-整理」算法來進行回收。
所謂自動內存管理,最終要解決的也就是內存分配和內存回收兩個問題。前面咱們介紹了內存回收,這裏咱們再來聊聊內存分配。
對象的內存分配一般是在 Java 堆上分配(隨着虛擬機優化技術的誕生,某些場景下也會在棧上分配,後面會詳細介紹),對象主要分配在新生代的 Eden 區,若是啓動了本地線程緩衝,將按照線程優先在 TLAB 上分配。少數狀況下也會直接在老年代上分配。總的來講分配規則不是百分百固定的,其細節取決於哪種垃圾收集器組合以及虛擬機相關參數有關,可是虛擬機對於內存的分配仍是會遵循如下幾種「普世」規則:
多數狀況,對象都在新生代 Eden 區分配。當 Eden 區分配沒有足夠的空間進行分配時,虛擬機將會發起一次 Minor GC。若是本次 GC 後仍是沒有足夠的空間,則將啓用分配擔保機制在老年代中分配內存。
這裏咱們提到 Minor GC,若是你仔細觀察過 GC 平常,一般咱們還能從日誌中發現 Major GC/Full GC。
Minor GC 是指發生在新生代的 GC,由於 Java 對象大多都是朝生夕死,全部 Minor GC 很是頻繁,通常回收速度也很是快;
Major GC/Full GC 是指發生在老年代的 GC,出現了 Major GC 一般會伴隨至少一次 Minor GC。Major GC 的速度一般會比 Minor GC 慢 10 倍以上。
所謂大對象是指須要大量連續內存空間的對象,頻繁出現大對象是致命的,會致使在內存還有很多空間的狀況下提早觸發 GC 以獲取足夠的連續空間來安置新對象。
前面咱們介紹過新生代使用的是標記-清除算法來處理垃圾回收的,若是大對象直接在新生代分配就會致使 Eden 區和兩個 Survivor 區之間發生大量的內存複製。所以對於大對象都會直接在老年代進行分配。
虛擬機採用分代收集的思想來管理內存,那麼內存回收時就必須判斷哪些對象應該放在新生代,哪些對象應該放在老年代。所以虛擬機給每一個對象定義了一個對象年齡的計數器,若是對象在 Eden 區出生,而且可以被 Survivor 容納,將被移動到 Survivor 空間中,這時設置對象年齡爲 1。對象在 Survivor 區中每「熬過」一次 Minor GC 年齡就加 1,當年齡達到必定程度(默認 15) 就會被晉升到老年代。
爲了更好的適應不一樣程序的內存狀況,虛擬機並非永遠要求對象的年齡必需達到某個固定的值(好比前面說的 15)纔會被晉升到老年代,而是會去動態的判斷對象年齡。若是在 Survivor 區中相同年齡全部對象大小的總和大於 Survivor 空間的一半,年齡大於等於該年齡的對象就能夠直接進入老年代。
在新生代觸發 Minor GC 後,若是 Survivor 中任然有大量的對象存活就須要老年隊來進行分配擔保,讓 Survivor 區中沒法容納的對象直接進入到老年代。
對於咱們 Java 程序員來講,虛擬機的自動內存管理機制爲咱們在編碼過程當中帶來了極大的便利,不用像 C/C++ 等語言的開發者同樣當心翼翼的去管理每個對象的生命週期。但同時咱們也喪失了內存控制的管理權限,一旦發生內存泄漏若是不瞭解虛擬機的內存管理原理,就很排查問題。但願這篇文章能對你們理解 Java 虛擬機的內存管理機制有所幫助。若是想對 Java 虛擬機有更進一步的瞭解,推薦你們去讀周志明老師的《深刻理解 Java 虛擬機:JVM 高級特性與最佳實踐》這本書。
好了,關於 Java 虛擬機的自動內存管理機制就介紹到這裏,下一篇咱們來聊聊「類文件結構」。
參考資料:
若是你喜歡個人文章,就關注下個人公衆號 BaronTalk 、 知乎專欄 或者在 GitHub 上添個 Star 吧!
- 微信公衆號:BaronTalk
- 知乎專欄:zhuanlan.zhihu.com/baron
- GitHub:github.com/BaronZ88
- 我的博客:baronzhang.com