一、對象建立的過程
我們先畫一個流程圖來看一下對象在建立的過程中,經曆了哪些步驟:
類加載檢查
虛拟機遇到一條new指令時,首先将去檢查這個指令的參數是否能在常量池中定位到一個類的符号引用,并且檢查這個符号引用代表的類是否已被加載、解析和初始化過。如果沒有,那就會先執行相應的類加載過程。
配置設定記憶體
在類加載檢查通過後,接下來虛拟機将為新生對象配置設定記憶體。對象所需記憶體的大小在類加載完成後便可完全确定,為對象配置設定空間就是把一塊确定大小的記憶體從Java堆中劃分出來。 這個步驟存在兩個問題需要思考:
- 1.如何劃分記憶體。
- 2.在并發情況下, 可能出現正在給對象A配置設定記憶體,指針還沒來得及修改,對象B又同時使用了原來的指針來配置設定記憶體的情況。
劃分記憶體的方法:
“指針碰撞”(Bump the Pointer)(預設用指針碰撞)
如果Java堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閑的記憶體放在另一邊,中間放着一個指針作為分界點 的訓示器,那所配置設定記憶體就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離。
“空閑清單”(Free List)
如果Java堆中的記憶體并不是規整的,已使用的記憶體和空閑的記憶體互相交錯,那就沒有辦法簡單地進行指針碰撞了,虛拟機就必須維護一個清單,記錄上哪些記憶體塊是可用的,在配置設定的時候從清單中找到一塊足夠大的空間劃分給對象執行個體,并更新清單上的記錄。
解決并發問題的方法:
CAS(compare and swap)
虛拟機采用CAS配上失敗重試的方式保證更新操作的原子性來對配置設定記憶體空間的動作進行同步處理。
本地線程配置設定緩沖(Thread Local Allocation Buffer即TLAB)
把記憶體配置設定的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先配置設定一小塊記憶體。
通過XX:+/ UseTLAB參數來設定虛拟機是否使用TLAB(JVM會預設開啟XX:+UseTLAB),XX:TLABSize 指定TLAB大小。
初始化
記憶體配置設定完成後,虛拟機需要将配置設定到的記憶體空間都初始化為零值【參數類型對應的零值】(不包括對象頭), 如果使用TLAB,這一工作過程也可以提前至TLAB配置設定時進行。這一步操作保證了對象的執行個體字段在Java代碼中可以不賦初始值就直接使用,程式能通路到這些字段的資料類型所對應的零值。
設定對象頭
初始化零值之後,虛拟機要對對象進行必要的設定,例如這個對象是哪個類的執行個體、如何才能找到類的中繼資料資訊、對象的哈希碼、對象的GC分代年齡等資訊。
這些資訊存放在對象的對象頭Object Header之中。 在HotSpot虛拟機中,對象在記憶體中存儲的布局可以分為3塊區域:
- 對象頭(Header)
- 執行個體資料(Instance Data)
- 對齊填充(Padding)
HotSpot虛拟機的對象頭包括兩部分資訊:
- 用于存儲對象自身的運作時資料 :如哈希碼(HashCode)、GC分代年齡、鎖狀态标志、線程持有的鎖、偏向線程ID、偏向時間戳等。
- 類型指針 :即對象指向它的類中繼資料的指針,虛拟機通過這個指針來确定這個對象是哪個類的執行個體。:
這裡我們以32位系統為例來分析一下對象頭包含了什麼内容:
Mark Word
Mark Word記錄了對象和鎖有關的資訊,當這個對象被synchronized關鍵字當成同步鎖時,圍繞這個鎖的一系列操作都和Mark Word有關。
Mark Word在32位JVM中的長度是32bit,在64位JVM中長度是64bit。
Mark Word在不同的鎖狀态下存儲的内容不同,在32位JVM中是這麼存的:
PS:其中無鎖和偏向鎖的鎖标志位都是01,隻是在前面的1bit區分了這是無鎖狀态還是偏向鎖狀态。
指向類的指針
該指針在32位JVM中的長度是32bit,在64位JVM中長度是64bit。
PS:Java對象的類資料儲存在方法區。
數組長度
PS:隻有數組對象儲存了這部分資料。
該資料在32位和64位JVM中長度都是32bit。
PS:關于對象頭的解析,這篇文章寫得不錯~
執行<init>方法
執行<init>方法,即對象按照程式員的意願進行初始化。
對應到語言層面上講,就是為屬性指派(PS:這與上面的賦零值不同,這是由程式員賦的值),執行構造方法。
二、對象大小
我們要分析一個對象結構的話,可以借助一個JOL的工具來幫助我們檢視,首先先引入maven依賴:
然後編寫一個測試類:
1 package com.happyfat.day3.test;
2
3 import org.openjdk.jol.info.ClassLayout;
4
5 /**
6 * JOL工具計算對象的大小
7 * @author 有夢想的肥宅
8 */
9 public class JOLTest {
10 public static void main(String[] args) {
11 //列印一個Object對象的大小
12 ClassLayout layout = ClassLayout.parseInstance(new Object());
13 System.out.println(layout.toPrintable());
14 System.out.println();
15
16 //列印一個int數組對象的大小
17 ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
18 System.out.println(layout1.toPrintable());
19 System.out.println();
20
21 //列印一個類A的對象的大小
22 ClassLayout layout2 = ClassLayout.parseInstance(new A());
23 System.out.println(layout2.toPrintable());
24 }
25
26 // ‐XX:+UseCompressedOops 預設開啟的壓縮所有指針
27 // ‐XX:+UseCompressedClassPointers 預設開啟的壓縮對象頭裡的類型指針Klass Pointer
28 // Oops : Ordinary Object Pointers
29 public static class A {
30 /* 8B mark word */
31 /* 4B Klass Pointer */
32 //PS:如果關閉壓縮‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,則占用8B
33 int id; //4B
34 String name; //4B 【如果關閉壓縮‐XX:‐UseCompressedOops,則占用8B】
35 byte b; //1B
36 Object o; //4B 【如果關閉壓縮‐XX:‐UseCompressedOops,則占用8B】
37 }
38 }
JOL工具計算對象的大小
我們對代碼進行分析:
PS:8個位元組對齊是對象尋址最優效率最高的一種方式(對齊後對象大小會是8的整數倍,這樣的對象存儲效率是最高的)
指針壓縮的作用:縮小對象的大小,減少占用堆記憶體的空間,也減少了GC的次數(idk1.6以後預設是開啟指針壓縮的)
我們繼續對代碼進行分析:
三、指針壓縮
什麼是java對象的指針壓縮?
- 1.jdk1.6開始,在64bit作業系統中,JVM支援指針壓縮
- 2.jvm配置參數-:XX:+UseCompressedOops。compressed【壓縮】、oop(ordinary object pointer)【對象指針】
- 3.啟用指針壓縮-:XX:+UseCompressedOops(預設開啟),禁止指針壓縮-:XX:-UseCompressedOops
為什麼要進行指針壓縮?
- 1.在64位平台的HotSpot中使用32位指針,記憶體使用會多出1.5倍左右,使用較大指針在主記憶體和緩存之間移動資料,占用較大寬帶,同時GC也會承受較大壓力
- 2.為了減少64位平台下記憶體的消耗,啟用指針壓縮功能
- 3.在jvm中,32位位址最大支援4G記憶體(2的32次方bit),可以通過對對象指針的壓縮編碼、解碼方式進行優化,使得jvm隻用32位位址就可以支援更大的記憶體配置(小于等于32G)
- 4.堆記憶體小于4G時,不需要啟用指針壓縮,jvm會直接去除高32位位址,即使用低虛拟位址空間
- 5.堆記憶體大于32G時,壓縮指針會失效,會強制使用64位(即8位元組)來對java對象尋址,這就會出現1的問題,是以堆記憶體不要大于32G為好
四、對象記憶體配置設定
要了解對象記憶體配置設定,很多概念還是需要了解一波,先上個圖來看看大緻的流程是怎樣的:
對象棧上配置設定
我們通過JVM記憶體配置設定可以知道JAVA中的對象都是在堆上進行配置設定,當對象沒有被引用的時候,需要依靠GC進行回收記憶體,如果對象數量較多的時候,會給GC帶來較大壓力,也間接影響了應用的性能。
對象棧上配置設定原因:為了減少臨時對象在堆内配置設定的數量,JVM通過逃逸分析确定該對象不會被外部通路。如果不會逃逸可以将該對象在棧上配置設定記憶體,這樣該對象所占用的記憶體空間就可以随棧幀出棧而銷毀,就減輕了垃圾回收的壓力。
對象逃逸分析:就是分析對象動态作用域。當一個對象在方法中被定義後,分析它是否被外部方法所引用,例如作為調用參數傳遞到其他地方中。
标量替換:通過逃逸分析确定該對象不會被外部通路,并且對象可以被進一步分解時,JVM不會建立該對象,而是将該對象成員變量分解若幹個被這個方法使用的成員變量所代替,這些代替的成員變量在棧幀或寄存器上配置設定空間,這樣就不會因為沒有一大塊連續空間導緻對象記憶體不夠配置設定。開啟标量替換參數(-XX:+EliminateAllocations),JDK7之後預設開啟。
标量與聚合量:标量即不可被進一步分解的量,而JAVA的基本資料類型就是标量(如:int,long等基本資料類型以及reference類型等)。聚合量就是可以被進一步分解的量,而在JAVA中對象就是可以被進一步分解的聚合量。
棧上配置設定示例
PS:棧上配置設定依賴于逃逸分析和标量替換。
對象在Eden區配置設定
大多數情況下,對象在新生代中 Eden 區配置設定,當 Eden 區沒有足夠空間進行配置設定時,虛拟機将發起一次Minor GC。
- Minor GC/Young GC:指發生新生代的的垃圾收集動作,Minor GC非常頻繁,回收速度一般也比較快。
- Major GC/Full GC:一般會回收老年代 ,年輕代,方法區的垃圾,Major GC的速度一般會比Minor GC的慢10倍以上。
PS:Eden與Survivor區預設8:1:1【可設定-XX:+UseAdaptiveSizePolicy(預設開啟)參數讓比例自動變化】
大對象直接進入老年代
大對象:需要大量連續記憶體空間的對象(比如:字元串、數組)。
參數設定:-XX:PretenureSizeThreshold=位元組數 可以設定多大的對象算是大對象,如果對象超過設定大小會直接進入老年代,不會進入年輕代,這個參數隻在 Serial 和ParNew兩個收集器下有效。
設計目的:為了避免為大對象在幸存者區之間複制操作而降低效率。
長期存活的對象将進入老年代
既然虛拟機采用了分代收集的思想來管理記憶體,那麼記憶體回收時就必須能識别哪些對象應放在新生代,哪些對象應放在老年代中。為了做到這一點,虛拟機給每個對象一個對象年齡(Age)計數器。
對象年齡增長規則:如果對象在 Eden 出生并經過第一次 Minor GC 後仍然能夠存活,并且能被 Survivor 容納的話,将被移動到 Survivor 空間中,并将對象年齡設為1。對象在 Survivor 中每熬過一次 MinorGC,年齡就增加1歲,當它的年齡增加到一定程度 (預設為15歲,CMS收集器預設6歲,不同的垃圾收集器會略微有點不同),就會被晉升到老年代中。對象晉升到老年代的年齡門檻值,可以通過參數 -XX:MaxTenuringThreshold 來設定。
對象動态年齡判斷
算法:Survivor區域裡現在有一批對象,年齡1+年齡2+年齡n的多個年齡對象總和超過了Survivor區域的50%(-XX:TargetSurvivorRatio可以指定),此時就會把年齡n(含)以上的對象都放入老年代。
目的:這個規則其實是希望那些可能是長期存活的對象,盡早進入老年代,省去了在幸存者區來回複制的功夫,影響性能。
PS:對象動态年齡判斷機制一般是在minor gc之後觸發的。
老年代空間配置設定擔保機制
大緻流程以下圖為小結:
五、對象記憶體回收
堆中幾乎放着所有的對象執行個體,對堆垃圾回收前的第一步就是要判斷哪些對象已經死亡(即不能再被任何途徑使用的對象)。
引用計數法
算法:給對象中添加一個引用計數器,每當有一個地方引用它,計數器就加1;當引用失效,計數器就減1;任何時候計數器為0的對象就是不可能再被使用的。
弊端:這個方法實作簡單,效率高,但是目前主流的虛拟機中并沒有選擇這個算法來管理記憶體,其最主要的原因是它很難解決對象之間互相循環引用的問題。
可達性分析算法【推薦】
将“GC Roots” 對象作為起點,從這些節點開始向下搜尋引用的對象,找到的對象都标記為非垃圾對象,其餘未标記的對象都是垃圾對象。
GC Roots根節點:線程棧的本地變量、靜态變量、本地方法棧的變量等等。
常見引用類型
java的引用類型一般分為四種:強引用、軟引用、弱引用、虛引用。
強引用
普通的變量引用。
1 public static User user = new User();
軟引用
将對象用SoftReference軟引用類型的對象包裹,正常情況不會被回收,但是GC做完後發現釋放不出空間存放新的對象時,則會把這些軟引用的對象回收掉。軟引用可用來實作記憶體敏感的高速緩存。
1 public static SoftReference<User> user = new SoftReference<User>(new User());
軟引用在實際中有重要的應用,例如浏覽器的後退按鈕。按後退時,這個後退時顯示的網頁内容是重新進行請求還是從緩存中取出呢?這就要看具體的實作政策了。
- (1)如果一個網頁在浏覽結束時就進行内容的回收,則按後退檢視前面浏覽過的頁面時,需要重新建構
- (2)如果将浏覽過的網頁存儲到記憶體中會造成記憶體的大量浪費,甚至會造成記憶體溢出
是以這裡可以使用軟引用來緩存前面浏覽過的頁面内容,如果記憶體夠的時候,“後退”按鈕可以直接從軟引用對象中讀取出前一個頁面的内容,如果記憶體不夠,也會直接把這些軟引用給回收掉,也不影響正常的新對象建立。
弱引用
将對象用WeakReference軟引用類型的對象包裹,弱引用跟沒引用差不多,GC會直接回收掉,很少用。
1 public static WeakReference<User> user = new WeakReference<User>(new User());
虛引用
虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關系,幾乎不用。
六、小問答
Q:伺服器的記憶體并不是越大越好?為啥?
一般伺服器記憶體大小不超過32G,因為超過32G的時候,記憶體位址尋址就要用到35位以上的位址,目前JVM隻支援将35位以内的記憶體位址壓縮成32位儲存,尋址時再将其解壓;若記憶體大于32G,此時就會直接用到35位以上的記憶體位址來存取我們的資料,效率上會慢一些。
PS:詳見本文第三點指針壓縮相關的解析。
Q:什麼時候會去設定分代年齡門檻值【超過分代年齡進入老年代】?
當我們可以推斷大部分對象不會長期存活,即方法執行時間不會太長的時候,可以根據經驗适當縮小分代年齡門檻值,省出更多的年輕代空間【因為老年區的空間往往更大,而年輕代需要頻繁的GC以及在幸存者區之間互相複制而影響性能】,提高效率。