在《初步了解JVM第一篇》和《初步了解JVM第二篇》中,分別介紹了:
- 類加載器:負責加載*.class文件,將字節碼內容加載到內存中。其中類加載器的類型有如下:執行引擎:負責解釋命令,提交給操作系統執行。
- 啟動類加載器(Bootstrap)
- 擴展類加載器(Extension)
- 應用程序類加載器(AppClassLoader)
- 用戶自定義加載器(User-Defined)
- 執行引擎:負責解釋命令,提交給操作系統執行。
- 本地接口:目的是為了融合不同的編程語言提供給Java所用,但是企業中已經很少會用到了。
- 本地方法棧:將本地接口的方法在本地方法棧中登記,在執行引擎執行的時候加載本地方法庫
- PC寄存器:是線程私有的,記錄方法的執行順序,用以完成分支、循環、跳轉、異常處理、線程恢復等基礎功能。
- 方法區:存放類的架構信息,ClassLoader加載的class文件內容存放在方法區中。
- 棧:線程私有,用來管理Java程序的運行。
進行簡單的回顧后,接下來為大家介紹Java中的堆。
堆(Heap)
大家可會分不清棧和堆,其實可以簡單記住一句話:棧管運行,堆管存儲。堆是線程共享的,而棧是線程私有的。那么什么是堆呢?
在一個JVM實例中,堆內存只存在一個。對內存的大小是可以進行調節的,類加載器讀取了類文件之后需要把類、方法、常變量放到堆內存中,保存所有引用類型的真實信息,以便執行器執行。
首先拋給一個大的概念給大家先,為大家介紹堆內存的三大部分(這里我們講的以JDK8的版本為准,也就是將永久代變改為元空間):
- 新生區:我們new出來的對象的存放地址,而新生區又分為三部分:
- Eden(伊甸區)
- Survivor 0 Space(幸存者0區)
- Survivor 1 Space(幸存者1區)
- 養老區:新生區的對象經過15次的GC回收(垃圾回收)之后存活下來的對象就放在這里,養老區如果滿了也會進行GC回收,只不過發生的頻率小於新生區
- 元空間:元空間我們上一篇已經講過了,主要是用來存放類的結構信息,類似一個模板。
上以就是堆內存的大三部分:伊甸區、養老區、元空間。上圖是邏輯上的結構,但是在物理上只有新生區和養老區,而且我們需要區分新生代和養老代用的是JVM的內存,但是元空間用的是系統內存。如果看得有點懵,不要緊,先來我們來一個一個介紹,首先第一部分新生區。
新生區
新生區就是類的誕生、成長、消亡的區域。一個類在這里產生、然后應用,最后被垃圾回收器回收,結束了的生命的過程釋放出內存。那么我們來簡單說一下,一個類被new出來之后從開始到消亡的一個過程:
- 1)假設有一個程序是一直不斷在new對象,那么new出來的對象首先就是存放在新生區的伊甸區,(注意:一般new的對象是放在新生區的伊甸區的,大的對象會特殊處理)。
- 2)伊甸區的內存也是有限,程序一直在不斷的new對象,終於!!!在某一個時刻,伊甸園的空間快沒有地方可以存放新的對象了。也就是達到伊甸區存放對象的閾值。這時候,注意!!!伊甸區就開始進行垃圾回收,也就是我們常說的輕GC,將大部分不再使用的對象Kill掉!!留下還在使用的對象。因為堆內存里面的對象絕大多數都是臨時對象,所以一次垃圾回收會Kill掉90%以上的對象,能存活下來的數量非常少。
- 3)存活下來的對象就從伊甸區移到了幸存者0區,注意幸存者0區還有一個別名就做From。
- 4)雖然垃圾回收會Kill掉大部分的對象,但是我們還是不能排除有個別現象存在伊甸區和幸存者0區再一次滿了的情況,因為程序new的速度肯定是比Kill的速度快的,終於又在某一時刻!!!伊甸區又達到了一定的閾值,再次進行垃圾回收,這時候就會將伊甸區和幸存者0區(注意:遷移的對象包括幸存者0區)存活下來的對象遷移到幸存者1區(幸存者1區的另外一個別名為To)。
- 5)一直如此反復,等到幸存者1區也滿了,就將存活的對象移到養老區進行養老,能到養老區的一般都一些長期使用的對象。那養老區怎么確定哪些才是長期使用的對象呢?在新生區中,一個對象經過每次垃圾回收之后幸存下來的,都會進行計數,經過了15次垃圾回收之后依然存在的,就會進入到養老區。
(注意:講到這里,是大部分對象消亡了,但是還是有經過15次垃圾回收之后存活下來的對象進入了養老區)
養老區
在新生區中,我們已經描述了一個類從開始到消亡或者進入養老區的過程,要么就是被kill了,要么就是進入了養老區。進入養老區之后就可以舒舒服服的摸魚了嗎?你想得太簡單了,接下來看看,養老區又有怎么樣的一番搏斗呢:
- 1)從新生區幸存下來的幸運兒來到了養老區養老,養老區就相當一個養老院,但是一個養老院也會滿員。這時候,沒辦法了,只能清出一部分老人,讓新的一批從新生區來的老人入住,這時候就發生了垃圾回收,也就是我們說的重GC。
- 2)雖然在養老區也會發生垃圾回收機制,但是還是會有一天,這個養老院實在是騰不出空位了,即使是進行重GC也騰不出幾個空間,這時候沒辦法了!!!代表已經沒有內存了,玩不轉了,所以系統就會報錯,也就是我們常看到的OOM(“OutOfMemoryError”):對內存溢出。
- 3)於是乎,程序就異常停止了,所有對象都消亡了,這個就是程序中一個對象從開始到消亡的整個過程。
堆的內存大小分配:
注:
- From就是上面說的幸存者0區的別名
- To就是上面說的幸存者1區的別名
這個比例我們一定要記住,非常重要,這是在GC時選取何種算法的一個依據之一,新生代跟老年代是1:2,而新生代中的三個分區中分別是8:1:1。
看完了堆內存的結構,接下來我們就要講講GC垃圾回收算法了。在上面我們描述了一個對象從開始到結束的過程,中間會發生GC回收,其中:
- 新生代:發生的GC叫做輕GC也叫MinorGC,所用的算法叫做復制算法。
- 老年代:發生的GC叫做重GC也叫Full GC,所用的算法叫做標記清除算法和標記壓縮算法
這里過個眼熟,下面我們在GC垃圾回收算法的時候會講到。
垃圾回收算法
在進行垃圾回收的時候,JVM需要根據不同的堆內存和結構去選取適合的算法來提高垃圾回收的效率,而垃圾回收算法主要有:
- 引用計數法
- 復制算法
- 標記清除算法
- 標記壓縮算法
1)引用計數算法
原理:給對象中每一個對象分配一個引用計數器,每當有地方引用該對象時,引用計數器的值加一,當引用失效時,引用計數器的值減一,不管什么時候,只要引用計數器的值等於0了,說明該對象不可能再被使用了。
優點:
- 實現原理簡單,而且判定效率很高。大部分情況下都是一個不錯的算法。
缺點:
- 每次對對象復制時均要維護引用計數器,且計數器本身也有一定的消耗。
- 較難處理循環引用。
在JVM中一般不采用這種方式實現,所以就不展開來講了。
2)復制算法(Copying)——新生代使用
在新生代中的GC,用的主要算法就是復制算法,而且發生GC的過程中From區和To區會發生一次交換(請記住這句話)。在堆的內存分配圖中JVM把年輕代分為了三部分:1個Eden區和2個Survivor區(別名叫From和To)。默認比例為8:1:1,一般情況下,新創建的對象都會被分配到Eden區(一些大對象特殊處理),當Eden區進行了GC還存留下來的就會被移到Survivor區。對象在Survivor區每經過一輪GC存留下來年齡就會加1。直到它存活到了一定歲數的是時候就會被移到養老區。由於新生區中的絕大部分對象都是臨時對象,不會存活太久,所以經過每一輪的GC之后存活下來的對像都不多,所以新生區所用的GC算法就是復制算法。
復制算法原理:
首先先給大家介紹一個名詞叫做根集合(GC Root):
- 通過System Class Loader或者Boot Class Loader加載的class對象,通過自定義類加載器加載的class不一定是GC Root
- 處於激活狀態的線程
- 棧中的對象
- JNI棧中的對象
- JNI中的全局對象
- 正在被用於同步的各種鎖對象
- JVM自身持有的對象,比如系統類加載器等
有了上面的了解我們就可以來學學復制算法:
- 復制算法從根集合(GC Root)開始,從From區中找到經過GC存活下來的對象(注意:雖然說是From區,但是這里的From區是包括了伊甸區和幸存者1區(別名From),所以大家不要認為From區就是單單包括From區而已)。拷貝到To中;
- 上面我們說過From和To會發生一次交換就是發生在這里,From將幸存下來的對象拷貝到To之后,這時From區就沒有對象,空出來了,而To現在不是空的,存放了From的幸存的對象(默認狀態是From有對象,To是空的)。這時候From和To就會發生身份的互換,下次內存分配從To開始。也就是說發生一次GC之后From就會變成To,To就會變成From(當誰是空的,誰就是To)
- 一直這樣反復GC,一直再一次發生GC的時候,From存活的對象拷貝到To時,To會被填滿,這時候就會把這些對象(滿足年齡為15的對象,這個值可以通過-XX:MaxTenuringThreshold來設置,默認是15)移動到養老區。
下面我們用一張圖來描述一下復制算法發生的過程:
我們一直都在反復強調,Eden區的對象存活率是比較低的,所以一般就是拿兩塊10%的內存作為空閑區(To)和活動區(From),拿80%的內存來存儲新建的對象。一但GC過后,就會將這10%的活動區和80%的Eden區存留下來的對象移到空閑區(To)中。然后之前的內存就得到了釋放,依次類推。
復制算法的缺點:
- 復制的時候需要耗費一般的內存,內存消耗大(但是效率的快的,而且新生區的存活效率低,並不需要復制太多的對象,所以新生區用這種算法效率是比我們下面要講的算法效率高的)。
- 如果對象的存活率很高,需要復制的對象太多,這時候效率就大大降低了。
復制算法的優點:
- 沒有標記和清除的過程,效率高。
- 因為是直接對對象進行復制的,所以不會產生內存碎片。
3)標記清除算法(Mark-Sweep)
老年代主要由標記清除算法和標記壓縮算法混合使用。
標記算法的步驟從名字其實就可以看出來是怎么回事了:
- 標記需要清除的對象
- 清除標記的對象
在復制算法中我們就說了它的缺點是浪費空間,所以為了解決這個問題,就不將對象進行復制了,因為復制一份需要同等大小的內存。標記清除算法采用標記的方式,將要清除的對象進行標記然后直接清除掉,這樣就就大大節省了空間了。同上,繼續來通過一張圖來理解:
上圖就是標記清除算法的過程,從過程中可以看出一些問題:
由於回收的對象是進行標記后直接刪除的,所以就像上圖回收后所展示的一樣,內存空間是不連續的,也就是會有內存碎片的產生。第二個問題是復制算法是直接復制的,但是標記清除算法是需要掃描兩次,耗時嚴重。
標記清除算法的優點:
- 對需要回收的對象進行標記清除,不需要額外的空間。
標記清除算法的缺點:
- 效率低,在進行GC時,需要停止整個程序。
- 清理出來的內存空間是不連續的,存在內存碎片。由於空間不連續,查找的效率也會降低
但是由於養老區存活下來的對象會比新生區的對象多,所以用標記清除是比復制算法好的。
4)標記壓縮算法(Mark-Compact)
理解了標記清除算法后,其實這一個算法就比較簡單理解了。就是多了一步整理的階段,清除內存碎片使空間變得連續。過程如下圖:
標記壓縮算法的優點:
- 可以看到,標記的存活對象將會被整理,按照內存地址依次排列,而未被標記的內存會被清理掉。如此一來,當我們需要給新對象分配內存時,JVM只需要持有一個內存的起始地址即可,這比維護一個空閑列表顯然少了許多開銷。
- 標記/整理算法不僅可以彌補標記清除算法當中,內存區域分散的缺點,也消除了復制算法當中,內存減半的高額代價。
標記壓縮算法的缺點:
- 雖然這個算法解決了上兩個算法的一些缺點,但是這個算法卻是耗時最長的。從效率來看是低於標記清除算法和復制算法的。
以上就是GC的四大算法,當然出了這四大算法還有標記清除壓縮算法(Mark-Sweep-Compact),這個也很好理解就是在整理階段不再是GC一次就整理一次,而是每隔一段時間整理一次,減少移動對象的成本。
分代收集算法:
當有人問你哪個算法是最好的時候,你的回答應該是:無,沒有最好的算法,只有最合適的算法。使用哪個算法應該看GC發生在什么地方:
- 新生代:復制算法
- 原因:存活率低,需要復制的對象很少,所需要用到的空間不是很多。另外一方面,新生代發生的頻率是非常高的,而復制算法的效率在新生代是最高的,所以新生代用復制算法是最合適的。
- 老年代:標記清除和標記壓縮算法混合使用
- 原因:存在大量存活率高的對像,復制算法明顯變得不合適。一般是由標記清除或者是標記清除與標記整理的混合實現。
- Mark階段的開銷與存活對像的數量成正比,這點上說來,對於老年代,標記清除或者標記整理有一些不符,但可以通過多線程利用,對並發、並行的形式提高標記效率。
- Sweep階段的開銷與所管理區域的大小成正相關,但是清除“就地處決”的特點,回收的過程沒有移動對象。使其相對其它有移動對像步驟的回收算法,仍然是效率最好的。但是需要解決內存碎片問題。
- Compact階段的開銷與存活對像的數據成開比,如上一條所描述,對於大量對像的移動是很大開銷的,做為老年代的第一選擇並不合適。
- 基於上面的考慮,老年代一般是由標記清除或者是標記清除與標記整理的混合實現。以hotspot中的CMS回收器為例,CMS是基於Mark-Sweep實現的,對於對像的回收效率很高,而對於碎片問題,CMS采用基於Mark-Compact算法的Serial Old回收器做為補償措施:當內存回收不佳(碎片導致的Concurrent Mode Failure時),將采用Serial Old執行Full GC以達到對老年代內存的整理。
終於寫完了,以上便是本人對JVM的理解,如有不足歡迎提出,謝謝!!!