用最直接的大白話來聊一聊Java對象的GC垃圾回收以及Java對象在內存中的那些事

前言:

今天閒來無事,有空閒的時間,所以想坐下來聊一聊Java的GC以及Java對象在內存中的分配。

和標題一樣,本篇絕對是用最直接最通俗易懂的大白話來聊

文章中基本不會有聽起來很高大上專業術語,也不會有太多概念性的描述,本着一看就懂的原則來寫。

因爲之前看很多文章都是概念性的東西太多,讓人越看越迷糊,越看越覺得有距離感,不接地氣。看完之後甚至會覺得自己完了,沒救了,好多東西怎麼學都學不會;我最不希望的就是這個,所以我寫東西都是儘量用最通俗易懂、最接地氣的大白話來描述,我寫所有博客的願景都是讓看文章的人看完後覺得:“哦!原來這東西不就是個這嗎?也沒啥難的啊”!

總之,希望對看到文章的小可愛們有所幫助。當然,寫的如有不對或不妥之處,還請海涵,歡迎下方留言指正,共同進步

一、Java的垃圾回收GC

先來聊GC,是因爲這個過程中會涉及到JVM對堆內存的分代管理,以及eden區等等這些概念,有了這些概念後,再去聊Java對象在內存中的分配會好理解很多,所以先來聊一聊GC。

1、確定垃圾對象

GC(Garbage Collection)顧名思義就是垃圾回收的意思

那既然要回收垃圾對象,首先得知道哪些對象是垃圾吧,怎麼判定哪些是垃圾對象呢?

有兩個辦法:

(1)第一種是引用標記法,簡單來說就是這個對象被一個地方引用了,這個計數的標記就加一,有地方釋放了這個對象的引用,這個標記就減一。這個算法認爲當一個對象的引用數量爲零,那就意味着沒有地方引用這個對象了,那此時這個對象就是垃圾對象。但是引用標記法有個致命的問題,那就是解決不了循環引用的問題,所以Java並沒有採用引用標記法。具體什麼是循環引用,並不是這裏的重點,可以自行查一下

(2)第二種是可達性分析法,簡單說,就是從一個根對象出發,到某個對象如果有可達的路徑,就認爲它不是垃圾對象,否則認爲是垃圾對象。根對象也被稱爲GC Root,那有個關鍵的問題:哪些對象可以被視爲根對象呢?

在《深入理解JVM虛擬機》中有這樣的描述,以下三種可以作爲根對象

1.虛擬機棧:棧幀中的局部變量表引用的對象

2.方法區中的靜態變量和常量引用的對象

3.native方法引用的對象

確定了根對象後,通過這個算法就可以定位到哪些對象是垃圾對象了

2、回收垃圾對象

通過上邊的方法知道了哪些對象是垃圾對象後,就可以回收垃圾了,回收垃圾同樣也有幾種不同的方法

常見的有三種

(1)標記-清除法:簡單的說就是把那些已經標記爲垃圾的對象進行清除,這種算法有一個缺點就是清理完成後會產生大量內存碎片(因爲這些垃圾對象在內存中很多都是分散分佈的,不可能總是連續的在一起的,所以清理完會導致內存不連續)

(2)標記-壓縮法:簡單來說就是把那些已經標記爲垃圾的對象進行清除後,再把分散開的內存碎片進行整理

(3)複製法:簡單說就是在要回收的內存區域之外,再另準備一塊空白的內存,把不是垃圾的對象直接複製到這個空白內存區域裏,然後就可以簡單粗暴的把要回收的那個內存區域全部清空。

不同的算法有不同的特點,不同的算法適用於不同的場景:

少量對象存活,適合使用複製算法

大量對象存活,適合使用標記清理或者標記壓縮法

所以JVM把堆內存進行了分代來管理,分爲年輕代、老年代和永久代

由於年輕代的對象大部分是朝生夕死,只有少部分對象存活,所以很適合用複製算法
但是複製算法有一個很大的缺點,就是需要兩塊一樣大小的內存來進行輪換,這就導致了會浪費一半的空間。但是經過研究統計發現,年輕代每次只有大概10%的對象存活,所以就又把年輕代分爲了eden區、servivor from區、servivor to區,他們的比例是8:1:1,也就是eden區佔80%,servivor from和servivor to 它倆作爲輪換區域,分別佔10%(也就是用盡量少的內存資源來實現複製算法)。這個8:1:1面試的時候可能會被問到,需要注意一下

但是每次大概只有10%對象存活,這是個統計的概率事件,實際中並不一定每次都只有10%或者少於10%的對象存活
所以那要萬一有時候存活的對象大於10%呢,那準備的servivor區的空間不就不夠複製算法運行了嗎?

這怎麼辦?所以這時候需要擔保,具體的說就是用老年代來作爲擔保,每次年輕代GC(minor GC)的時候都會去檢查老年代最大連續可用空間是否大於年輕代中所有對象總和的大小,如果大於則認爲這個擔保是沒有風險的,就進行正常的minor GC;

但是如果發現小於,那就會繼續判斷你是否設置了允許擔保失敗,如果你設置的是不允許擔保失敗,那這次minor GC就要改爲一次Full GC;如果你設置的是允許擔保失敗,那它會繼續去判斷老年代最大連續可用空間是否大於歷次晉升到老年代的對象大小,如果大於,就嘗試着去minor GC一次,儘管這次GC是有風險的,如果嘗試後失敗了,那就Full GC;如果小於的話,那這次minor GC也要改爲一次Full GC。

可以發現如果開啓了允許擔保失敗的話,有可能會出現繞了一大圈最後發現還是失敗了的情況,最後還是得去Full GC

雖然會出現這種情況,但是還是建議設置開啓這個允許擔保失敗,因爲開啓了允許擔保失敗後,會在一定程度上減少Full GC的次數,要知道一次Full GC的時間是minor GC的幾倍甚至幾十倍,所以要儘量避免Full GC

以上只是對垃圾回收的大體總結,並不涉及具體的細節

有了以上大體瞭解後,建議拜讀《深入理解JVM虛擬機》一書,寫的確實很好的一本書,相信讀後定會收穫頗豐。

講垃圾回收一定繞不開那七種垃圾回收器:Serial(年輕代)、ParNew(年輕代)、Paralle Scavenge(年輕代)、Serial Old(年老代)、Parallel Old(年老代)、CMS(Concurrent Mark Sweep年老代)、G1

不同垃圾回收器使用的算法不同(但都是基於標記清除或標記整理或複製算法這三種的),用的場景也不同

關於這七種垃圾回收器,本篇並不展開詳述,只是拋磚引玉,如有需要,也可自行參考《深入理解JVM虛擬機》

二、Java對象在內存中的那些事

下面來聊一聊Java對象在內存中的那些事。

我總結了大概這幾方面:對象在內存中的創建、對象在內存中的佈局、對象在內存中的訪問定位

下面分別展開詳述

1、對象在內存中的創建

對象在內存中創建到底是怎麼樣一個過程呢?大致有以下幾個步驟:

(1)虛擬機遇到一條new指令時,首先檢查這個對應的類能否在常量池中定位到一個類的符號引用

(2)判斷這個類是否已被加載、解析和初始化

     如果沒有,則必須進行相應的加載過程

(3)爲新生對象在Java堆中分配內存空間

     分配內存有兩種方式:指針碰撞和空閒列表

     指針碰撞是指:假設Java堆中內存絕對規整,所有已使用的內存都放在一邊,空閒的內存都放在另一邊,中間放着一個指針作爲分界點,那這時候分配內存就僅僅是把這個指針向空閒的那邊挪動一段(挪動的大小就是需要分配的對象的大小),這種分配方式就稱爲“指針碰撞”。

     空閒列表是指:如果Java堆內存並不是規整的,已經使用的內存和空閒的內存相互交錯,那肯定就沒辦法使用指針碰撞的方式進行內存分配了,這時候虛擬機就必須維護一個列表來記錄哪些內存塊兒是可用的,然後在需要進行內存分配的時候就從列表中找到一塊兒足夠大的內存劃分給對象,並且更新列表上的記錄,這種分配方式稱之爲“空閒列表”。

     到底用哪種方式進行分配是由堆內存是否規整決定的,而堆內存是否規整又是由你具體使用的哪種垃圾回收器決定的,

如果你使用的是“標記-清除”這種類型的垃圾回收器,那麼會導致堆內存不規則產生內存碎片,適合使用空閒列表的方式;

如果你使用的是“標記-整理(壓縮)”這種類型的垃圾回收器,適合使用指針碰撞的方式。

除了討論使用哪種方式進行內存分配外,還有一個問題需要考慮:對象創建在虛擬機中是非常頻繁的行爲,即使是僅僅修改一個指針指向的位置(指針碰撞的方式)或者找到空閒空間給對象分配,並更新列表(空閒列表的方式),在併發情況下也並不是安全的,因爲上述的操作並不能保證其原子性,很可能出現不同的對象申請到同一塊內存的情況

解決這個問題有幾種方案:基於硬件指令的CAS方式來保證操作的原子性或者使用TLAB的方式,再或者可以使用棧上分配

棧上分配和TLAB的具體內容本篇不展開討論,具體內容可參考我之前寫的另一篇:用大白話來聊一聊Java對象的棧上分配和TLAB

(4)內存分配完之後,虛擬機需要將分配到的內存空間都初始化爲零值

         比如int 型的零值是0,布爾的零值是false,引用數據類型零值是null等等

(5)設置對象頭相關數據:GC分代年齡、對象的哈希碼 hashCode、元數據信息等等

(6)執行<init>方法

        執行<init>方法包括但不僅限於:構造代碼塊兒、構造函數,具體步驟如下

   1)父類靜態變量,靜態代碼塊執行初始化(靜態變量,靜態代碼按順序執行,屬於同一優先級) 
   2)子類靜態變量,靜態代碼塊執行初始化 
   3)父類全局變量,代碼塊被初始化(全局變量,代碼按順序執行,屬於同一優先級) 
   4)父類構造函數執行 
   5)子類全局變量,代碼塊被初始化 
   6)子類構造函數被執行 

上述就是針對對象創建底層步驟的一些總結

對象在內存裏創建出來後,那創建完的對象在內存中是什麼樣的?對象裏都包括哪些內容?

2、對象在內存中的佈局

在虛擬機中,對象在內存中的存儲佈局可分爲三塊:對象頭、實例數據和對齊填充

(1)對象頭:對象頭用於存儲對象的元數據信息

對象頭又可以分爲兩塊內容:第一部分用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分數據的長度在32位和64位的虛擬機中分別位32bit和64bit,官方稱它爲 Mark Word。對象頭的另一部分是類型指針,指向它的類元數據的指針,用於判斷對象屬於哪個類的實例,另外,如果對像是一個數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因爲虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中卻無法確定數組的大小。

!!!注意:對象頭這一塊很重要,它是實現synchronized鎖的基礎,也是後續偏向鎖,輕量級鎖,自旋鎖等鎖優化,鎖升級的基礎,關於鎖的介紹,可以參考我寫的另一篇文章:Synchronized、偏向鎖、自旋鎖、輕量級鎖以及鎖的升級過程

(2)實例數據

實例數據部分是對象真正存儲的有效信息,也是在程序代碼中所定義各種類型的字段內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄下來。父類定義的變量會出現在子類定義的變量的前面。各字段的分配策略爲longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同寬度的字段總是被分配到一起,便於之後取數據。

(3)對齊填充

對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起着佔位符的作用。爲什麼需要有對齊填充呢?由於hotspot VM的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話,就是對象的大小必須是8字節的整數倍。而對象頭正好是8字節的倍數。因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。

再給個圖幫助理解記憶

以上就是對象在內存中佈局的一些總結

對象創建好了,也知道創建的對象在內存中存儲的佈局結構了

咱們創建對象肯定是爲了訪問它,使用它,那怎麼訪問它呢?

3、對象在內存中的訪問定位

建立對象是爲了使用對象,我們的Java程序需要通過棧上的reference數據來操作堆上的具體對象。由於reference類型在Java虛擬機規範中只規定了一個指向對象的引用,並沒有定義這個引用應該通過何種方式去定位訪問堆中的對象的具體位置,所以對象的訪問方式取決於具體的虛擬機實現而定。目前主流的訪問方式有使用句柄和直接指針兩種。

(1)句柄的訪問方式

如圖所示,如果使用句柄訪問的話,那麼Java堆中將會劃分出一塊內存來作爲句柄池,句柄池中放的是一個一個的句柄,句柄中存的是對象實例數據與對象類型數據的指針引用。棧中局部變量裏reference中存儲的是對象句柄的地址,而句柄中包含了對象實例數據與類型數據的具體地址信息,相當於二級指針

(2)直接指針的訪問方式

如圖所示,直接指針訪問對象,棧中局部變量裏reference中存儲的就是對象地址,相當於一級指針

(3)對比

這兩種對象訪問方式各有利弊,使用句柄訪問的最大好處就是在移動對象時(如垃圾回收的標記整理算法在回收完垃圾對象後需要把剩下存活的對象進行整理移動,以減少內存碎片),reference中存儲的地址是穩定的地址,不需要修改,僅需要修改對象句柄的地址;但是如果使用直接指針方式的話,在對象被移動的時候需要修改reference中存儲的地址。從效率方面比較的話,直接指針的效率要高於句柄,因爲直接指針的方式只進行了一次指針定位,節省了時間開銷,HotSpot採用的直接指針的實現方式。

上述就是對Java對象的訪問定位的理解與總結

咱們說完了對象在內存中分配的以及訪問的具體細節後,下面從宏觀上來說一下對象分配的幾個特點

4、對象分配的幾個特點

對象的分配有以下幾個特點

(1)對象優先分配在eden區

(2)大對象直接進去老年代,這個閾值可以自定義

(3)年長的對象進去老年代,默認的年長值是15

(4)動態對象年齡判斷

以上幾點的具體內容也可參考《深入理解JVM虛擬機》一書

OK,今天就先聊到這裏,如果覺得有幫助的話,可以點個贊,關注一下哈~

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