一、對象的加載過程
之前研究過類的加載過程。具體詳情可查看文章:https://www.cnblogs.com/ITPower/p/15356099.html
那么,當一個對象被new的時候,是如何加載的呢?有哪些步驟,如何分配內存空間的呢?
1.1 對象創建的主要流程
還是這段代碼為例說明:
public static void main(String[] args) {
Math math = new Math();
math.compute();
new Thread().start();
}
當我們new一個Math對象的時候,其實是執行了一個new指令創建對象。我們之前研究過類加載的流程,那么創建一個對象的流程是怎樣的呢?如下圖所示。下面我們一個環節一個環節的分析。

1.1.1類加載檢查
當虛擬機執行到一條new指令時,首先去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,並且檢查這個
符號引用代表的類是否已經被加載、解析和初始化過(也就是檢查類是否已經被加載過)。如果沒有,那必須先執行相應的類加載流程。
1.1.2分配內存空間
類加載檢查通過以后,接下來就是給new的這個對象分配內存空間。對象需要多大內存是在類加載的時候就已經確定了的。為對象分配空間的過程就是從java堆中划分出一塊確定大小的內存給到這個對象。那么到底如何划分內存呢?如果存在並發,多個對象同時都想占用同一塊內存該如何處理呢?
1)如何給對象划分內存空間?
通常,給對象分配內存有兩種方式:一種是指針碰撞,另一種是空閑列表。
- 指針碰撞
指針碰撞(Bump the Pointer),默認采用的是指針碰撞的方式。如果Java堆中內存是絕對規整的,所有用過的內存都放在一邊,空閑的內存放在另一邊,中間放着一個指針作為分界點的指示器,那所分配內存就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離。

- 空閑列表
如果Java堆中的內存不是規整的,已使用的內存和空閑的內存相互交錯,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間划分給對象實例,並更新列表上的記錄

不同的內存分配方式,在垃圾回收的時候采用不同的方法。
2)如何解決多個對象並發占用空間的問題?
當有多個線程同時啟動的時候,多個線程new的對象都要分配內存,不管內存分配使用的是哪種方式,指針碰撞也好,空閑列表也好,這些對象都要去爭搶這塊內存。當多個線程都想爭搶某一塊內存的時候,這時該如何處理呢?通常有兩種方式:CAS和本地線程分配緩沖。
- CAS(compare and swap)
CAS可以理解為多個線程同時去爭搶一個快內存,搶到了的就使用,沒搶到的就重試去搶下一塊內存。
虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性來對分配內存空間的動作進行同步處理。
- 本地線程分配緩沖(Thread Local Allocation Buffer,TLAB)
什么是TLAB呢?簡單說,TLAB是為了避免多線程爭搶內存,在每個線程初始化的時候,就在堆空間中為線程分配一塊專屬的內存。自己線程的對象就往自己專屬的那塊內存存放就可以了。這樣多個線程之間就不會去哄搶同一塊內存了。jdk8默認使用的就是TLAB的方式分配內存。
把內存分配的動作按照線程划分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存。通過-XX:+UseTLAB參數來設定虛擬機是否使用TLAB(JVM會默認開啟-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。
1.1.3 初始化
內存分配完成后,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭), 如果使用TLAB,這一工作過程也
可以提前至TLAB分配時進行。這一步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問
到這些字段的數據類型所對應的零值。
1.1.4 設置對象頭
我們來看看這個類:
public class Math {
public static int initData = 666;
public static User user = new User();
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
new Thread().start();
}
}
對於一個類,通常我們看到的是成員變量和方法,但並不是說一個類的信息只有我們目光所及的這些內容。在對象初始化零值之后,虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的GC分代年齡等信息。這些信息存放在對象的對象頭Object Header中。 在HotSpot虛擬機中,對象在內存中包含3個部分:
- 對象頭(Header)
- 實例數據(Instance Data)
- 對象填充(Padding)
實例數據就不多說了,就是我們經常看到的並使用的數據。對象頭和填充數據下面我們重點研究。先來說對象頭。
1. 對象頭的組成部分
HotSpot虛擬機的對象頭包括以下幾部分信息:
第一部分:Mark Word標記字段,32位占4個字節,64位占8個字節。用於存儲對象自身的運行時數據, 如哈希碼(HashCode)、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等。
第二部分:Klass Pointer類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。 開啟壓縮占4個字節,關閉壓縮占8個字節。
第三部分:數組長度,通常是4字節,只有對象數組才有。
2.Mark Word標記字段
如下圖所示是一個32位機器的對象頭的mark word標記字段。對象不同的狀態對應的對象頭的結構也是不一樣的。根據鎖狀態划分對象有5種狀態,分別是:無狀態、輕量級鎖、重量級鎖、GC標記、偏向鎖。

無鎖狀態,就是普通對象的狀態。一個對象被new出來以后,沒有任何的加鎖標記,這時候他的對象頭分配是
- 25位:用來存儲對象的hashcode
- 4位:用來存儲分代年齡。之前說過一個新生對象的年齡超過15還沒有被回收就會被放入到老年代。為什么年齡設置為15呢?因為分代年齡用4個字節存儲,最大就是15了。
- 1位:存儲是否是偏向鎖
- 2位:存儲鎖標志位
最后這兩個就和並發編程有關系了,后面我們會重點研究並發編程的時候研究這一塊。
3.Klass Pointer類型指針
在64位機器下,類型指針占8個字節,但是當開啟壓縮以后,占4個字節
一個對象new出來以后是被放在堆里的,類的元數據信息是放在方法區里的,在new對象的頭部有一個指針指向方法區中該類的元數據信息。這個頭部的指針就是Klass Pointer。而當代碼執行到math.compute()方法調用的時候,是怎么找到compute()方法的呢?實際上就是通過類型指針去找到的。(知道了math指向的對象的地址,再根據對象的類型指針找到方法區中的源代碼數據,再從源代碼數據中找到compute()方法)。
public static void main(String[] args) {
Math math = new Math();
math.compute();
}

對於Math類來說,他還有一個類對象, 如下代碼所示:
Class<? extends Math> mathClass = math.getClass();
這個類對象是存儲在哪里的呢?這個類對象是方法區中的元數據對象么?不是的。這個類對象實際上是jvm虛擬機在堆中創建的一塊和方法區中源代碼相似的信息。如下圖堆空間右上角。

那么在堆中的類對象和在方法區中的類元對象有什么區別呢?
類的元數據信息是放在方法區的。堆中的類信息,可以理解為是類裝載后jvm給java開發人員提供的方便的訪問類的信息。通過類的反射我們知道,我們可以通過Math的class拿到這個類的名稱,方法,屬性,繼承關系,接口等等。我們知道jvm的大部分實現是通過c++實現的,jvm在拿到Math類的時候,他不會通過堆中的類信息(上圖堆右上角math類信息)拿到,而是直接通過類型指針找到方法區中元數據實現的,這塊類型指針也是c++實現的。在方法區中的類元數據信息都是c++獲取實現的。而我們java開發人員要想獲得類元數據信息是通過堆中的類信息獲得的。堆中的class類是不會存儲元數據信息的。我們可以吧堆中的類信息理解為是方法區中類元數據信息的一個鏡像。
Klass Pointer類型指針的含義:Klass不是class,class pointer是類的指針;而Klass Pointer指的是底層c++對應的類的指針
4.數組長度
如果一個對象是數組的話,除了Mark Word標記字段和Klass Pointer類型指針意外,還會有一個數組長度。用來記錄數組的長度,通常占4個字節。
對象頭在hotspot的C++源碼里的注釋如下:
5.對象對齊(Object alignment)
我們上面說了對象有三塊:對象頭,實體,對象對齊。那么什么是對象對齊呢?
對於一個對象來說,有的時候有對象對齊,有的時候沒有。JVM內部會將對象的讀取信息按照8個字節對齊。至於為什么要按8個字節對齊呢?這是計算機底層原理了,經過大量的實踐證明,對象按照8個字節讀取效率會非常高。也就是說,最后要求字節數是8的整數倍。可以是8,16,24,32.
6.代碼查看對象結構
如何查看對象的內部結構和大小呢?我們可以通過引用jol-core包,然后調用里面的幾個方法即可查看
引入jar包
引入jar包:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
測試代碼
import org.openjdk.jol.info.ClassLayout;
/**
* 查詢類的內部結構和大小
*/
public class JOLTest {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println();
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new Object());
System.out.println(layout2.toPrintable());
}
class A {
int id;
String name;
byte b;
Object o;
}
}
執行代碼運行結果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
16 0 int [I.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
com.lxl.jvm.JOLTest$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758)
12 4 int A.id 0
16 1 byte A.b 0
17 3 (alignment/padding gap)
20 4 java.lang.String A.name null
24 4 java.lang.Object A.o null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
Object對象的內部結構:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
這里一共有四行:
- 前兩行是對象頭(Mark Word), 占用8個字節;
- 第三行是Klass Pointer類型指針,占用4個字節,如果不壓縮的話會占用8個字節;
- 第四行是Object Alignment對象對齊,對象對齊是為了保證整個對象占用的位數是8的倍數。
數組對象的內部結構
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
16 0 int [I.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
這里一共有5行:
- 頭兩行是Mark word標記字段,占了8位;
- 第三行是Klass Pointer類型指針,占了4位;
- 第四行是數組特有的,標記數組長度的,占了4位。
- 第五行是對象對齊object alignment,由於前面4行一共是16位,所以這里不需要進行補齊
A(自定義)對象的內部結構
com.lxl.jvm.JOLTest$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758)
12 4 int A.id 0
16 1 byte A.b 0
17 3 (alignment/padding gap)
20 4 java.lang.String A.name null
24 4 java.lang.Object A.o null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
這一共有四行:
- 前兩行是對象頭(Mark Word), 占用8個字節;
- 第三行是Klass Pointer類型指針,占用4個字節,如果不壓縮的話會占用8個字節;
- 第四行是int類型 占4位。
- 第五行是byte類型:占1位。
- 第六行是byte補位:步3位。
- 第七行是String類型:占4位
- 第八行是Object類型:占4位
- 第九行是object alignment對象對齊補4位。前面28位,不是8的倍數,所以補4位。
1.1.5.執行
方法
這里的init方法,不是構造方法,是c++調用的init方法。執行
1.1.6 指針壓縮
1. 什么是java對象的指針壓縮?
從jdk1.6開始,在64位操作系統中,jvm默認開啟指針壓縮。指針壓縮就是將Klass Pointer類型指針進行壓縮,已經Object對象,String對象進行指針壓縮。看下面的例子:
import org.openjdk.jol.info.ClassLayout;
/**
* 查詢類的內部結構和大小
*/
public class JOLTest {
public static void main(String[] args) {
System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());
}
public static class A {
int id;
String name;
byte b;
Object o;
}
}
運行這段代碼,A的類結構:
com.lxl.jvm.JOLTest$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758)
12 4 int A.id 0
16 1 byte A.b 0
17 3 (alignment/padding gap)
20 4 java.lang.String A.name null
24 4 java.lang.Object A.o null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
默認情況下是開啟指針壓縮的。上面分析過這個類結構,這里主要看第三行Klass Pointer和第七行String占4位,第八行Object占4位。我們知道這里保存的都是指針的地址。
下面我們手動設置關閉指針壓縮:
指針壓縮的命令有兩個:UseCompressedOops(壓縮所有的指針對象,包括header頭和其他) 和 UseCompressedClassPointers(只壓縮指針對象)
開啟指針壓縮: -XX:+UseCompressedOops(默認開啟),
禁止指針壓縮: -XX:-UseCompressedOops
參數的含義:
compressed:壓縮的意思
oop(ordinary object pointer):對象指針
在main方法的VM配置參數中設置XX:-UseCompressedOops

然后再來看運行結果:
com.lxl.jvm.JOLTest$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) d0 0c be 26 (11010000 00001100 10111110 00100110) (649989328)
12 4 (object header) 02 00 00 00 (00000010 00000000 00000000 00000000) (2)
16 4 int A.id 0
20 1 byte A.b 0
21 3 (alignment/padding gap)
24 8 java.lang.String A.name null
32 8 java.lang.Object A.o null
Instance size: 40 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total
來看變化點:
- Klass Pointer類型指針原來是4位,現在多了4位。類型指針占了8位
- String對象原來占用4位,不壓縮是8位
- Object對象原來占用4位,不壓縮占用8位
從現象上可以看出壓縮和不壓縮的區別。那么為什么要進行指針壓縮呢?
2.為什么要進行指針壓縮?
1.在64位平台的HotSpot中使用32位指針,內存使用會多出1.5倍左右,使用較大指針在主內存和緩存之間移動數據, 占用較大寬帶,同時GC也會承受較大壓力(占用內存少,可以存儲更多對象,觸發GC的頻率降低)。為了減少64位平台下內存的消耗,默認啟用指針壓縮功能 。
2.在jvm中,32位地址最大支持4G內存(2的32次方),可以通過對對象指針的壓縮編碼、解碼方式進行優化,使得jvm只用32位地址就可以支持更大的內存配置(小於等於32G)
3.堆內存小於4G時,不需要啟用指針壓縮,jvm會直接去除高32位地址,即使用低虛擬地址空間
4.堆內存大於32G時,壓縮指針會失效,會強制使用64位(即8字節)來對java對象尋址,這就會出現1的問題,所以堆內 存不要大於32G為好.
二、對象的內存分配
對象的內存分配流程如下:

對象創建的過程中會給對象分配內存,分配內存的整體流程如下:
第一步:判斷棧上是否有足夠的空間。
這里和之前理解有所差別。之前一直都認為new出來的對象都是分配在堆上的,其實不是,在滿足一定的條件,會先分配在棧上。那么為什么要在棧上分配?什么時候分配在棧上?分配在棧上的對象如何進行回收呢?下面來詳細分析。
1.為什么要分配在棧上?
通過JVM內存模型中,我們知道Java的對象都是分配在堆上的。當堆空間(新生代或者老年代)快滿的時候,會觸發GC,沒有被任何其他對象引用的對象將被回收。如果堆上出現大量這樣的垃圾對象,將會頻繁的觸發GC,影響應用的性能。其實這些對象都是臨時產生的對象,如果能夠減少這樣的對象進入堆的概率,那么就可以成功減少觸發GC的次數了。我們可以把這樣的對象放在堆上,這樣該對象所占用的內存空間就可以隨棧幀出棧而銷毀,就減輕了垃圾回收的壓力。
2.什么情況下會分配在棧上?
為了減少臨時對象在堆內分配的數量,JVM通過逃逸分析確定該對象會不會被外部訪問。如果不會逃逸可以將該對象在棧上分配內存。隨棧幀出棧而銷毀,減輕GC的壓力。
3.什么是逃逸?
那么什么是逃逸分析呢?要知道逃逸分析,先要知道什么是逃逸?我們來看一個例子
public class Test {
public User test1() {
User user = new User();
user.setId(1);
user.setName("張三");
return user;
}
public void test2() {
User user = new User();
user.setId(2);
user.setName("李四");
}
}
Test里有兩個方法,test1()方法構建了user對象,並且返回了user,返回回去的對象肯定是要被外部使用的。這種情況就是user對象逃逸出了test1()方法。
而test2()方法也是構建了user對象,但是這個對象僅僅是在test2()方法的內部有效,不會在方法外部使用,這種就是user對象沒有逃逸。
判斷一個對象是否是逃逸對象,就看這個對象能否被外部對象訪問到。
結合棧上分配來理解為何沒有逃逸出去的對象為什么應該分配在棧上呢?來看下圖:
Test2()方法的user對象只會在當前方法內有效,如果放在堆里,在方法結束后,其實這個對象就已經是垃圾的,但卻在堆里占用堆內存空間。如果將這個對象放入棧中,隨着方法入棧,邏輯處理結束,對象就變成垃圾了,再隨着棧幀出棧。這樣可以節約堆空間。尤其是這種非逃逸對象很多的時候。可以節省大量的堆空間,降低GC的次數。
4.什么是對象的逃逸分析?
就是分析對象動態作用域,當一個對象在方法中被定義后,它可能被外部方法所引用,例如作為參數傳遞到其他地方中。 上面的例子中,很顯然test1()方法中的user對象被返回了,這個對象的作用域范圍不確定,test2方法中的user對象我們可以確定當方法結束這個對象就可以認為是無效對象了,對於這樣的對象我們其實可以將其分配在棧內存里,讓其在方法結束時跟隨棧內存一起被回收掉。
大白話說就是:判斷user對象是否會逃逸到方法外,如果不會逃逸到方法外,那么就建議在堆中分配一塊內存空間,用來存儲臨時的變量。是不是不會逃逸到方法外的對象就一定會分配到堆空間呢?不是的,需要滿足一定的條件:第一個條件是JVM開啟了逃逸分析。可以通過設置參數來開啟/關閉逃逸分析。
-XX:+DoEscapeAnalysis 開啟逃逸分析
-XX:-DoEscapeAnalysis 關閉逃逸分析
JVM對於這種情況可以通過開啟逃逸分析參數(-XX:+DoEscapeAnalysis)來優化對象內存分配位置,使其通過標量替換優先分配在棧上(棧上分配),JDK7之后默認開啟逃逸分析,如果要關閉使用參數(-XX:-DoEscapeAnalysis)
5.什么是標量替換?
如果一個對象通過逃逸分析能過確定他可以在棧上分配,但是我們知道一個線程棧的空間默認也就1M,棧幀空間就更小了。而對象分配需要一塊連續的空間,經過計算如果這個對象可以放在棧幀上,但是棧幀的空間不是連續的,對於一個對象來說,這樣是不行的,因為對象需要一塊連續的空間。那怎么辦呢?這時JVM做了一個優化,即便在棧幀中沒有一塊連續的空間方法下這個對象,他也能夠通過其他的方式,讓這個對象放到棧幀里面去,這個辦法就是標量替換。
什么是標量替換呢?
如果有一個對象,通過逃逸分析確定在棧上分配了,以User為例,為了能夠在有限的空間里能夠放下User中所有的東西,我們不會在棧上new一個完整的對象了,而是只是將對象中的成員變量放到棧幀里面去。如下圖:

棧幀空間中沒有一塊完整的空間放User對象,為了能夠放下,我們采用標量替換的方式,不是將整個User對象放到棧幀中,而是將User中的成員變量拿出來分別放在每一塊空閑空間中。這種不是放一個完整的對象,而是將對象打散成一個個的成員變量放到棧幀上,當然會有一個地方標識這個屬性是屬於那個對象的,這就是標量替換。
通過逃逸分析確定該對象不會被外部訪問,並且對象可以被進一步分解時,JVM不會創建該對象,而是將該對象成員變量分解若干個被這個方法使用的成員變量所代替,這些代替的成員變量在棧幀或寄存器上分配空間,這樣就不會因為沒有一大塊連續空間導致對象內存不夠分配了。開啟標量替換參數是
-XX:+EliminateAllocations
JDK7之后默認開啟。
6.標量替換與聚合量
那什么是標量,什么是聚合量呢?
標量即不可被進一步分解的量,而JAVA的基本數據類型就是標量(如:int,long等基本數據類型以及 reference類型等),標量的對立就是可以被進一步分解的量,而這種量稱之為聚合量。而在JAVA中對象就是可以被進一步分解的聚合量。
7. 總結+案例分析
new出來的一部分對象是可以放在棧上的,那什么樣的對象放在棧上呢?通過逃逸分析判斷一個對象是否會逃逸到方法外,如果不會逃逸到方法外,那么就建議在堆中分配一塊內存空間來存儲這樣的變量。那是不是說所有不會逃逸到方法外的對象就一定會分配到堆空間呢?不是的,需要滿足一定的條件:
- 開啟逃逸分析
- 開啟標量替換
下面舉例分析:
public class AllotOnStack {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end-start);
}
private static void alloc() {
User user = new User();
user.setId(1);
user.setName("zhuge");
}
}
上面有一段代碼,在main方法中調用1億次alloc()方法。在alloc()方法中,new了User對象,但是這個對象是沒有逃逸出alloc()方法的。for循環運行了1億次,這時會產生1億個對象,如果分配在堆上,那么會有大量的GC產生;如果分配在棧上,那么幾乎不會有GC產生。這里說的是幾乎,也就是不一定完全沒有gc產生,產生gc還可能是因為其他情況。
為了能夠看到在棧上分配的明顯的效果,我們分幾種情況來分析:
- 默認情況下
設置參數:
我當前使用的是jdk8,默認開啟逃逸分析(‐XX:+DoEscapeAnalysis),開啟標量替換的(‐XX:+EliminateAllocations)。
-Xmx15m -Xms15m -XX:+PrintGC
設置上面的參數:將堆內存設置的小一些,並且設置打印GC日志,方便我們清晰的看到結果。
運行結果:
10
我們看到沒有產生任何的GC。因為開啟了逃逸分析,開啟了標量替換。這就說明,對象沒有分配在堆上,而是分配在棧上了。
有沒有疑惑,為什么棧上可以放1億對象?
因為產生一個對象,當這個方法執行完的時候,對象會隨棧幀一起被回收。然后分配下一個對象,這個對象執行完再次被回收。以此類推。
- 關閉逃逸分析,開啟標量替換
這種情況是關閉了逃逸分析,開啟了標量替換。設置jvm參數如下:
-Xmx15m -Xms15m -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations
其實只有開啟了逃逸分析,標量替換才會生效。所以,這種情況是不會將對象分配在棧上的,都分配在堆上,那么會產生大量的GC。我們來看運行結果:
[GC (Allocation Failure) 4842K->746K(15872K), 0.0003706 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0003987 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0004303 secs]
......
[GC (Allocation Failure) 4842K->746K(15872K), 0.0004012 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0003712 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0003978 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0003969 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0011955 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0004206 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0004172 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0013991 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0006041 secs]
[GC (Allocation Failure) 4842K->746K(15872K), 0.0003653 secs]
773
我們看到產生了大量的GC,並且耗時從原來的10毫秒延長到773毫秒
- 開啟逃逸分析,關閉標量替換
這種情況是關閉了逃逸分析,開啟了標量替換。設置jvm參數如下:
-Xmx15m -Xms15m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:-EliminateAllocations
其實只有開啟了逃逸分析,標量替換不生效,表示的含義是如果對象在棧空間放不下了,那么會直接放到堆空間里。我們來看運行結果:
[GC (Allocation Failure) 4844K->748K(15872K), 0.0003809 secs]
[GC (Allocation Failure) 4844K->748K(15872K), 0.0003817 secs]
.......
[GC (Allocation Failure) 4844K->748K(15872K), 0.0003751 secs]
[GC (Allocation Failure) 4844K->748K(15872K), 0.0004613 secs]
[GC (Allocation Failure) 4844K->748K(15872K), 0.0005310 secs]
[GC (Allocation Failure) 4844K->748K(15872K), 0.0003402 secs]
[GC (Allocation Failure) 4844K->748K(15872K), 0.0003661 secs]
[GC (Allocation Failure) 4844K->748K(15872K), 0.0004457 secs]
[GC (Allocation Failure) 4844K->748K(15872K), 0.0004528 secs]
[GC (Allocation Failure) 4844K->748K(15872K), 0.0005270 secs]
657
我們看到開啟了逃逸分析,但是沒有開啟標量替換也產生了大量的GC。
通常,我們都是同時開啟逃逸分析和標量替換。
第二步:判斷是否是大對象,不是放到Eden區
判斷是否是大對象,如果是則直接放入到老年代中。如果不是,則判斷是否是TLAB?如果是則在Eden去分配一小塊空間給線程,把這個對象放在Eden區。如果不采用TLAB,則直接放到Eden區。
什么是TLAB呢?本地線程分配緩沖(Thread Local Allocation Buffer,TLAB)。簡單說,TLAB是為了避免多線程爭搶內存,在每個線程初始化的時候,就在堆空間中為線程分配一塊專屬的內存。自己線程的對象就往自己專屬的那塊內存存放就可以了。這樣多個線程之間就不會去哄搶同一塊內存了。jdk8默認使用的就是TLAB的方式分配內存。
通過-XX:+UseTLAB參數來設定虛擬機是否啟用TLAB(JVM會默認開啟-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。
1.對象是如何在Eden區分配的呢?
這一塊的詳細信息參考文章:https://www.cnblogs.com/ITPower/p/15384588.html
這里放上內存分配的圖,然后我們案例來證實:

案例代碼:
public class GCTest {
public static void main(String[] args) throws InterruptedException {
byte[] allocation1, allocation2;
allocation1 = new byte[60000*1024];
}
}
來看這段代碼,定義了一個字節數組allocation2,給他分配了一塊內存空間60M。
來看看程序運行的效果,這里為了方便檢測效果,設置一下jvm參數打印GC日志詳情
-XX:+PrintGCDetails 打印GC相信信息
a) Eden去剛好可以放得下對象
運行結果:
Heap
PSYoungGen total 76288K, used 65536K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
eden space 65536K, 100% used [0x000000076ab00000,0x000000076eb00000,0x000000076eb00000)
from space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
to space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
ParOldGen total 175104K, used 0K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
object space 175104K, 0% used [0x00000006c0000000,0x00000006c0000000,0x00000006cab00000)
Metaspace used 3322K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 365K, capacity 388K, committed 512K, reserved 1048576K
- 新生代約76M
- Eden區約65M,占用了100%
- from/to月1M,占用0%
- 老年代月175M,占用0%
- 元數據空間約3M,占用365k。
我們看到新生代Eden區被放滿了。其實我們的對象只有60M,Eden區有65M,為什么會被放滿呢?因為Eden區還存放了JVM啟動的一些類。因為Eden區能夠放得下,所以不會放到老年代里。
元數據空間約3M是存放的方法區中類代碼信息的鏡像。我們在上面類型指針里面說過方法區中元數據信息在堆中的鏡像。
對於Math類來說,他還有一個類對象, 如下代碼所示:
Class<? extends Math> mathClass = math.getClass();
這個類對象是存儲在哪里的呢?這個類對象是方法區中的元數據對象么?不是的。這個類對象實際上是jvm虛擬機在堆中創建的一塊和方法區中源代碼相似的信息。如下圖堆空間右上角。

b) Eden區滿了,會觸發GC
public class GCTest {
public static void main(String[] args) throws InterruptedException {
byte[] allocation1, allocation2;
/*, allocation3, allocation4, allocation5, allocation6*/
allocation1 = new byte[60000*1024];
allocation2 = new byte[8000*1024];
}
}
來看這個案例,剛剛設置allocation1=60M Eden區剛好滿了,這時候在為對象allocation2分配8M,因為Eden滿了,這是會觸發GC,60M from/to都放不下,會直接放到old老年代,然后將allocation2的8M放到Eden區。來看運行結果:
[GC (Allocation Failure) [PSYoungGen: 65245K->688K(76288K)] 65245K->60696K(251392K), 0.0505367 secs] [Times: user=0.25 sys=0.04, real=0.05 secs]
Heap
PSYoungGen total 76288K, used 9343K [0x000000076ab00000, 0x0000000774000000, 0x00000007c0000000)
eden space 65536K, 13% used [0x000000076ab00000,0x000000076b373ef8,0x000000076eb00000)
from space 10752K, 6% used [0x000000076eb00000,0x000000076ebac010,0x000000076f580000)
to space 10752K, 0% used [0x0000000773580000,0x0000000773580000,0x0000000774000000)
ParOldGen total 175104K, used 60008K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
object space 175104K, 34% used [0x00000006c0000000,0x00000006c3a9a010,0x00000006cab00000)
Metaspace used 3323K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 365K, capacity 388K, committed 512K, reserved 1048576K
和我們預測的一樣
- 年輕代76M,已用9343k
- Eden65M,占用了13%,這13%就有allocation2分配的80M,另外的部分是jvm運行產生的
- from區10M,占用6%。這里面存的肯定不是allocation1的60M,因為存不下,這里的應該是和jvm有關的數據
- to去10M,占用0%
- 老年代175M,占用了60M,這60M就是allocation1回收過來的
- 元數據占用3M,使用365k。這一塊數據沒有發生變化,因為元數據信息沒有變。
2.對象動態年齡判斷
當前放對象的Survivor區域里(其中一塊區域,放對象的那塊s區),一批對象的總大小大於這塊Survivor區域內存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此時大於等於這批對象年齡最大值的對象,就可以直接進入老年代了,
例如:Survivor區域里現在有一批對象,年齡1+年齡2+年齡n的多個年齡對象總和超過了Survivor區域的50%,此時就會把年齡n(含)以上的對象都放入老年代。這個規則其實是希望那些可能是長期存活的對象,盡早進入老年代。
對象動態年齡判斷機制一般是在minor gc之后觸發的。
3.老年代空間分配擔保機制

- 年輕代每次minor gc之前JVM都會計算下老年代剩余可用空間。如果這個可用空間小於年輕代里現有的所有對象大小之和(包括垃圾對象),就會看一個“-XX:-HandlePromotionFailure”(jdk1.8默認就設置了)的參數是否設置了,如果有這個參數,就會看看老年代的可用內存大小,是否大於之前每一次minor gc后進入老年代的對象的平均大小。
- 如果上一步結果是小於或者之前說的參數沒有設置,那么就會直接觸發一次Full GC,然后在觸發Minor GC, 如果回收完還是沒有足夠空間存放新的對象就會發生"OOM"
- 如果minor gc之后剩余存活的需要挪動到老年代的對象大小還是大於老年代可用空間,那么也會觸發full GC,Full GC完之后如果還是沒有空間放minor gc之后的存活對象,則也會發生“OOM”。
在梳理一下這塊邏輯,為什么叫擔保機制。在觸發Minor GC的時候,進行了一個條件判斷,預估老年代空間是否能夠放的下新生代的對象,如果能夠放得下,那么就直接觸發Minor GC, 如果放不下,那么先觸發Full GC。在觸發Full GC的時候設置了擔保參數會增加異步判斷,而不是直接觸發Full GC。判斷老年代剩余可用空間 是否小於 歷史每次Minor GC后進入老年代對象的平均值。這樣的判斷可以減少Full GC的次數。因為新生代在觸發Full GC以后是會回收一部分內存的,剩余部分再放入老年代,可能就能放下了。
第三步 是大對象 放入到老年代
1.什么是大對象?
- Eden園區放不下了肯定是大對象。
- 通過參數設置什么是大對象。-XX:PretenureSizeThreshold=1000000 (單位是字節) -XX:+UseSerialGC。如果對象超過設置大小會直接進入老年代,不會進入年輕代,這個參數只在 Serial 和ParNew兩個收集器下有效。
- 長期存活的對象將進入老年代。虛擬機采用分代收集的思想來管理內存,虛擬機給每個對象設置了一個對象年齡(Age)計數器。 如果對象在 Eden 出生並經過第一次 Minor GC 后仍然能夠存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並將對象年齡設為1。對象在 Survivor 中每熬過一次 MinorGC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15歲,CMS收集器默認6歲,不同的垃圾收集器會略微有點不同),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 來設置。
2.為什么要將大對象直接放入到老年代呢?
為了避免為大對象分配內存時的復制操作而降低效率。
3.什么情況要手動設置分代年齡呢?
如果我的系統里80%的對象都是有用的對象,那么經過15次GC后會在Survivor中來回翻轉,這時候不如就將分代年齡設置為5或者8,這樣減少在Survivor中來回翻轉的次數,直接放入到老年代,節省了年輕代的空間。
4.如何判斷一個類是無用的類?
方法區主要回收的是無用的類,那么如何判斷一個類是無用的類呢?類需要同時滿足下面三個條件,才是“無用的類”。
- 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例。
- 加載該類的ClassLoader已經被回收
- 該類對應的java.lang.Class培優在任何地方被引用,無法在任何地方通過放射訪問該類的方法。
這里重點說一下“無用的類”第二個條件,加載該類的ClassLoader已經被回收。來看看Classloader的結果圖:

其中,前三個類加載器(引導類加載器,擴展類加載器,應用程序類加載器)是系統類加載器,他們加載了很多的類,這些類加載器被回收,幾乎是不可能的。但是對於自定義類加載器,是可以被回收的。以tomcat自定義類加載器為例,一個jsp就會自動生成一個類加載器,當這個類加載器處理完當前jsp頁面,就會被回收了。也就是,通常只有自定義類加載器才會被回收。