内容回顧:
一、堆(Heap)的核心概述
1.1 堆記憶體的描述
- 一個JVM執行個體隻存在一個堆記憶體,堆也是Java記憶體管理的核心區域。
- Java堆區在JVM啟動的時候即被建立,其空間大小也就确定了。是JVM管理的最大一塊記憶體空間。
- 堆記憶體的大小是可以調節的。
- 《Java虛拟機規範》規定,堆可以處于實體上不連續的記憶體空間,但在邏輯上它應該被視為連續的。
- 所有的線程共享Java堆,在這裡還可以劃分線程私有的緩沖區(Thread Local Allocation Buffer,TLAB)。
例:
package com.atguigu.java;
/**
* -Xms10m -Xmx10m
*
* @author shkstart [email protected]
* @create 2020 16:41
*/
public class HeapDemo {
public static void main(String[] args) {
System.out.println("start...");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
}
}
package com.atguigu.java;
/**
* -Xms20m -Xmx20m
* @author shkstart [email protected]
* @create 2020 16:42
*/
public class HeapDemo1 {
public static void main(String[] args) {
System.out.println("start...");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
}
}
執行兩個程式
打開jdk/bin目錄下的jvisualvm.exe工具
- 《Java虛拟機規範》中對Java堆的描述是:所有的對象執行個體以及數組都應當運作時配置設定在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
- 我要說的是:“幾乎”所有的對象執行個體都在這裡配置設定記憶體。——從實際使用角度看的。
- 數組和對象可能永遠不會存儲在棧上,因為棧幀中儲存引用,這個引用指向對象或者數組在堆中的位置。
- 在方法結束後,堆中的對象不會馬上被移除,僅僅在垃圾收集的時候才會被移除。
- 堆,是GC(Garbage Collection,垃圾收集器)執行垃圾回收的重點區域。
例:下邊例子和下圖對應
1.2 堆的記憶體細分
現代垃圾收集器大部分都基于分代收集理論設計,堆空間細分為:
約定:新生區<->新生代<->年輕代 養老區<->老年區<->老年代 永久區<->永久代
1.2.3 堆空間内部結構
列印上邊例子中SimpleHeap.java類的GC詳情,執行程式
二、設定堆記憶體大小與OOM
2.1 設定堆記憶體大小
- Java堆區用于存儲Java對象執行個體,那麼堆的大小在JVM啟動時就已經設定好了,大家可以通過選項“-Xmx”和“-Xms”來進行設定。
- “-Xms”用于表示堆區的起始記憶體,等價于-XX:InitialHeapSize
- “-Xmx”則用于表示堆區的最大記憶體,等價于-XX:MaxHeapSize
- 一旦堆區中的記憶體大小超過“-Xmx”所指定的最大記憶體時,将會抛出OutOfMemoryError異常。
- 通常會将-Xms和-Xmx兩個參數配置相同的值,其目的是為了能夠在java垃圾回收機制清理完堆區後不需要重新分隔計算堆區的大小,進而提供性能。
- 預設情況下,初始記憶體大小:實體電腦記憶體大小 / 64 ;最大記憶體大小:實體電腦記憶體大小 / 4
例:
package com.atguigu.java;
/**
* 1. 設定堆空間大小的參數
* -Xms 用來設定堆空間(年輕代+老年代)的初始記憶體大小
* -X 是jvm的運作參數
* ms 是memory start
* -Xmx 用來設定堆空間(年輕代+老年代)的最大記憶體大小
*
* 2. 預設堆空間的大小
* 初始記憶體大小:實體電腦記憶體大小 / 64
* 最大記憶體大小:實體電腦記憶體大小 / 4
*
* 3. 手動設定:-Xms600m -Xmx600m
* 開發中建議将初始堆記憶體和最大的堆記憶體設定成相同的值。
*
* 4. 檢視設定的參數:方式一: jps -l / jstat -gc 程序id
* 方式二:-XX:+PrintGCDetails
*
* @author shkstart [email protected]
* @create 2020 20:15
*/
public class HeapSpaceInitial {
public static void main(String[] args) {
//傳回Java虛拟機中的堆記憶體總量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//傳回Java虛拟機試圖使用的最大堆記憶體量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms : " + initialMemory + "M");
System.out.println("-Xmx : " + maxMemory + "M");
System.out.println("系統記憶體大小為:" + initialMemory * 64.0 / 1024 + "G");
System.out.println("系統記憶體大小為:" + maxMemory * 4.0 / 1024 + "G");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2.2 OOM
例:
package com.atguigu.java;
import java.util.ArrayList;
import java.util.Random;
/**
* -Xms600m -Xmx600m
* @author shkstart [email protected]
* @create 2020 21:12
*/
public class OOMTest {
public static void main(String[] args) {
ArrayList<Picture> list = new ArrayList<>();
while(true){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 在堆空間建立很多 Picture對象
list.add(new Picture(new Random().nextInt(1024 * 1024)));
}
}
}
class Picture{
//在堆空間中
private byte[] pixels;
public Picture(int length) {
this.pixels = new byte[length];
}
}
結果
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.atguigu.java.Picture.<init>(OOMTest.java:31)
at com.atguigu.java.OOMTest.main(OOMTest.java:21)
三、年輕代與老年代
- 存儲在jvm中的Java對象可以被劃分為兩類:
- 一類是聲明周期較短的瞬時對象,這類對象的建立和消亡都非常迅速
- 另外一類對象的聲明周期卻非常長,在某些極端的情況下還能夠與JVM的生命周期保持一緻。
- Java堆區進一步細分的話,可以劃分為年輕代(YoungGen)和老年代(OldGen)
- 其中年輕代又可以劃分為Eden空間、Survivor0空間和Survivor1空間(有時也叫做from區、to區)。
下面這參數開發中一般不會調:
- 配置新生代與老年代在堆結構的占比。
- 預設-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整個堆的1/3
- 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整個堆的1/5
例:設定新生代和老年代比例、設定新生代中Eden區和Survivor0、Survivor1區比例
package com.atguigu.java1;
/**
* -Xms600m -Xmx600m
* <p>
* -XX:NewRatio : 設定新生代與老年代的比例。預設值是2.
* <p>
* -XX:SurvivorRatio :設定新生代中Eden區與Survivor區的比例 8:1:1。預設值是8
* -XX:-UseAdaptiveSizePolicy :關閉自适應的記憶體配置設定政策 (暫時用不到)
* <p>
* -Xmn:設定新生代的空間的大小。 (一般不設定)
*
* @author shkstart [email protected]
* @create 2020 17:23
*/
public class EdenSurvivorTest {
public static void main(String[] args) {
System.out.println("我隻是來打個醬油~");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
檢視新生代和老年代的比例:
方式一:
方式二:
Eden區和survivor0、survivor1區比例:
- 在HotSpot中,Eden空間和另外兩個Survivor空間預設所占的比例是8:1:1
- 開發人員可以通過選項“-XX:SurvivorRatio”調整這個空間比例。比如-XX:SurvivorRatio=8
- 幾乎所有的Java對象都是在Eden區被new出來的
- 絕大部分的Java對象的銷毀都在新生代進行了。
- IBM公司的專門研究表明,新生代中80%的對象都是“朝生夕死”的。
- 可以使用選項“-Xmn”設定新生代最大記憶體大小
- 這個參數一般使用預設值就可以了。
注:顯示使用-Xmn指定新生代最大記憶體大小,則以該設定值為準,如上述例子中設定堆空間記憶體位600M,新生代和老年代比例按照預設為1:2,則新生代空間為200M,但是使用-Xmn100m設定後,則新生代的空間就是100m。——(一般不設定該值)
上述例子中Eden:survivor0和survivor1區比例采用預設值8:1:1,但是我們用jvisualvm工具看到的是150:25:25,其實是6:1:1,為什麼呢?這裡有一個自适應機制,使用這個指令關閉自适應:-XX:-UseAdaptiveSizePolicy(關閉自适應後還是6:1:1),或者顯示的加上-XX:ServivorRatio=8,這樣就能看到8:1:1。
新生代和老年代的對象執行個體互動:
四、圖解對象配置設定過程
4.1 概述
對象配置設定過程:
為新對象配置設定記憶體是一件非常嚴謹和複雜的任務,JVM的設計者們不僅需要考慮記憶體如何配置設定、在哪裡配置設定等問題,并且由于記憶體配置設定算法與記憶體回收算法密切相關,是以還需要考慮GC執行完記憶體回收後是否會在記憶體空間中産生記憶體碎片。
- new的對象先放伊甸園區。此區有大小限制。
- 當伊甸園的空間填滿時,程式有需要建立對象,JVM的垃圾回收器将對伊甸園區進行垃圾回收(Minor GC),将伊甸園區中的不再被其他對象所引用的對象進行銷毀。再加載新的對象放到伊甸園區
- 然後将伊甸園中的剩餘對象移動到幸存者0區
- 如果再次觸發垃圾回收,此時上次幸存下來的放到幸存者0區的,如果沒有回收,就會放到幸存者1區。
- 如果再次經理垃圾回收,此時會重新放回幸存者0區,接着再去幸存者1區。
- 啥時候能去養老區呢?可以設定次數。預設是15次。(可以設定參數:-XX:MaxTenuringThreshold=<N>進行設定)
- 在養老區,相對悠閑。當養老區記憶體不足時,再次觸發GC:Major GC,進行養老區的記憶體清理。
- 若養老區執行了Major GC之後發現依然無法進行對象的儲存,就會産生OOM異常。
new的新對象開始都是放在Eden區,由于Eden區空間有限,當Eden區空間占滿的時候,就會發生一次GC,即:YGC(youngGC)或叫Minor GC,會将垃圾對象回收(紅色),不是垃圾的對象(綠色)放在Survivor0區,每個對象都有一個年齡計數器,即:從Eden區到Survivor0區的對象年齡計數器就會加1;此時Eden區的資料就會完全清空;此時Survivor0區為from區,Survivor1區為to區,在此之前Survivor0為to區(to區為Eden區的對象下次GC會将對象放在哪個區域),Survivor1區為from區。
接着繼續在Eden區存放new對象,直到Eden區又滿了,再次出發YGC/Minor GC,此時将Eden區中存活的對象放在Survivor1區(即放在兩個幸存者區Survivor中空的那個),年齡計數器加1,同時判斷Survivor0區中的對象是否存活,存活的話也放入Survivor1區,年齡計數器加1,變為2;此時,Survivor1區變為from區,Survivor0區變為to區。
注意:Survivor的兩個區域并沒有先後之分,總是有一個為空。
以此循環:Eden區new對象,滿了繁盛GC,再存入Survivor0/1區,當Eden區滿了,發生YGC/Minor GC,(假設此時to區為Survivor0區),此時發現Survivor1區中一個對象還在用,則放入Survivor0區,剩下兩個存活的對象情況特殊,年齡計數器達到15,是以要将這兩個對象進行晉升(Promotion),存放在老年代。
注意:YGC/Minor GC觸發的條件是Eden區滿了,Survivor區滿了不會觸發。Eden區滿觸發YGC/Minor GC同時會回收Survivor區。
總結:
- 針對幸存者s0、s1區的總結:複制之後有交換,誰空誰是to。
- 關于垃圾回收:頻繁在新生區收集,很少在養老區收集,幾乎不再永久區/元空間收集。
4.2 特殊情況
4.3 代碼舉例與JvisualVM示範對象的配置設定過程
package com.atguigu.java1;
import java.util.ArrayList;
import java.util.Random;
/**
* -Xms600m -Xmx600m
* @author shkstart [email protected]
* @create 2020 17:51
*/
public class HeapInstanceTest {
byte[] buffer = new byte[new Random().nextInt(1024 * 200)];
public static void main(String[] args) {
ArrayList<HeapInstanceTest> list = new ArrayList<HeapInstanceTest>();
while (true) {
list.add(new HeapInstanceTest());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
JvisualVM視圖:
4.4 常用的調優工具
- JDK指令行
- Eclipse:Memory Analyzer Tool
- Jconsole
- VisualVM
- Jprofiler
- Java Flight Recorder
- GCViewer
- GC Easy
4.5 Minor GC、Major GC與Full GC
JVM在進行GC時,并非每次都對上面三個記憶體(新生代、老年代;方法區)區域一起回收的,大部分時候回收的都是指新生代。
針對HotSpot VM的實作,它裡面的GC按照回收區域又分為兩大種類型:一種是部分收集(Partial GC),一種是整堆收集(Full GC)
- 部分收集:不是完整收集整個Java堆的垃圾收集。其中又分為:
- 新生代收集(Minor GC / Young GC):隻是新生代(Eden\S0、S1)的垃圾收集
- 老年代收集(Major GC / Old GC):隻是老年代的垃圾收集。
- 目前隻有CMS GC會有單獨收集老年代的行為。
- 注意,很多時候Major GC會和Full GC混淆使用,需要具體分辨是老年代回收還是整堆回收。
- 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集。
- 目前,隻有G1 GC會有這種行為
- 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。
4.5.1 分代式GC政策的觸發條件
最簡單的分代式GC政策的觸發條件:
- 年輕代GC(Minor GC)觸發機制:
- 當年輕代空間不足時,就會觸發Minor GC,這裡的年輕代滿指的是Eden代滿,Survivor滿不會引發GC。(每次Minor GC會清理年輕代的記憶體)
- 因為Java對象大多都具備朝生夕死的特性,是以Minor GC非常頻繁,一般回收速度也比較快。這一定義既清晰又易于了解
- Minor GC會引發STW,暫停其他使用者的線程,等垃圾回收結束,使用者線程才恢複運作。
- 老年代GC(Major GC / Full GC)觸發機制:
- 指發生在老年代的GC,對象從老年代消失時,我們說“Major GC”或“Full GC”發生了。
- 出現Major GC,經常會伴随至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集政策裡就有直接進行Major GC的政策選擇過程)。
- 也就是在老年代空間不足時,會先嘗試觸發Minor GC。如果之後空間還不足,則觸發Major GC
- Major GC的速度一般會比Minor GC慢10倍以上,STW的時間更長。
- 如果Major GC後,記憶體還不足,就報OOM了。
- Major GC的速度一般會比Minor GC慢10倍以上。
- Full GC觸發條件:(後面細講)
- 觸發Full GC執行的情況有如下五種:
- 調用System.gc()時,系統建議執行Full GC,但是不必然執行
- 老年代空間不足
- 方法區空間不足
- 通過Minor GC後進入老年代的平均大小大于老年代的可用記憶體
- 有Eden區、survivor space0(From Space)區向survivor space1(To Space)區複制時,對象大小大于To Space可用記憶體,則把該對象轉存到老年代,且老年代的可用記憶體小于該對象大小
- 觸發Full GC執行的情況有如下五種:
說明:Full GC是開發或調優中盡量要避免的。這樣暫停時間會斷一些。
例:
package com.atguigu.java1;
import java.util.ArrayList;
import java.util.List;
/**
* 測試MinorGC 、 MajorGC、FullGC
* -Xms9m -Xmx9m -XX:+PrintGCDetails
* @author shkstart [email protected]
* @create 2020 14:19
*/
public class GCTest {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "atguigu.com";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable t) {
t.printStackTrace();
System.out.println("周遊次數為:" + i);
}
}
}
4.6 堆空間分代思想
為什麼需要把Java堆分代?不分代就不能正常工作了嗎?
- 經研究,不同對象的生命周期不同。70%——99%的對象是臨時對象。
- 新生代:有Eden、兩塊大小相同的Survivor(又稱為from/to,s0/s1)構成,to總為空。
- 老年代:存放新生代中經曆多次GC仍然存活的對象。
- 其實不分代完全可以,分代的唯一理由就是優化GC性能。如果沒有分代,那所有的對象都在一起,就如同把一個學校的人都關在一個教室。GC的時候要找到哪些對象沒用,這樣就會對堆的所有區域進行掃描。而很多對象都是朝生夕死的,如果分代的話,把新建立的對象放到某一地方,當GC的時候先把這塊存儲“朝生夕死”對象的區域進行回收,這樣就會騰出很大的空間出來。
4.7 記憶體配置設定政策
如果對象在Eden出生并經過第一次Minor GC後仍然存活,并且能被Survivor容納的話,将被移動到Survivor空間中,并将對象年齡設為1.對象在Survivor區總每熬過一次Minor GC,年齡就會增加1歲,當它的年齡增加到一定程度(預設為15歲,其實每個JVM、每個GC都有所不同)時,就會被晉升到老年代中。
對象晉升老年代的年齡門檻值,可以通過選項 -XX:MaxTenuringThreshold來設定
針對不同年齡段的對象配置設定原則如下所示:
- 優先配置設定到Eden
- 大對象直接配置設定到老年代
- 盡量避免程式中出現過多的大對象
- 長期存活的對象配置設定到老年代
- 動态對象年齡判斷
- 如果Survivor區中相同年齡的所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象可以直接進入老年代,無需等到MacTenuringThreshold中要求的年齡。
- 空間配置設定擔保
- -XX:HandlePromotionFailure
例:大對象直接配置設定到老年代
package com.atguigu.java1;
/** 測試:大對象直接進入老年代
* -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
* @author shkstart [email protected]
* @create 2020 21:48
*/
public class YoungOldAreaTest {
public static void main(String[] args) {
byte[] buffer = new byte[1024 * 1024 * 20];//20m
}
}
4.8 為對象配置設定記憶體
為什麼有TLAB(Thread Local Allocation Buffer)?
- 堆區是線程共享區域,任何線程都可以通路到堆區中的共享資料
- 由于對象執行個體的建立在JVM中非常頻繁,是以在并發環境下從堆區中劃分記憶體空間是線程不安全的
- 為避免多個線程操作同一位址,需要使用加鎖等機制,進而影響配置設定速度。
什麼是TLAB?
- 從記憶體模型而不是垃圾收集的角度,堆Eden區域繼續進行劃分,JVM為每個線程配置設定了一個私有緩存區域,它包含在Eden空間内。
- 多線程同時配置設定記憶體時,使用TLAB可以避免一系列的非線程安全問題,同時還能夠提升記憶體配置設定的吞吐量,是以我們可以将這種記憶體配置設定方式稱之為快速配置設定政策。
- 據我所知所有OpenJDK衍生出來的JVM都提供了TLAB的設計。
TLAB的說明:
- 盡管不是所有的對象執行個體都能夠在TLAB中成功配置設定記憶體,但JVM确實是将TLAB作為記憶體配置設定的首選。
- 在程式中,開發人員可以通過選項“-XX:UseTLAB”設定是否開啟TLAB空間。
- 在預設情況下,TLAB空間的記憶體非常小,僅占有整個Eden空間的1%,當然我們可以通過選項“-XX:TLABWasteTargetPercent”設定TLAB空間所占用Eden空間的百分比大小。
- 一旦對象在TLAB空間配置設定記憶體失敗時,JVM就會嘗試着通過使用加鎖機制確定資料操作的原子性,進而直接在Eden空間中配置設定記憶體。
例:
package com.atguigu.java1;
/**
* 測試-XX:UseTLAB參數是否開啟的情況:預設情況是開啟的
*
* @author shkstart [email protected]
* @create 2020 16:16
*/
public class TLABArgsTest {
public static void main(String[] args) {
System.out.println("我隻是來打個醬油~");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
對象配置設定流程圖:
位元組碼經過加載(加載、連結、初始化),new關鍵字建立對象先在TLAB區域進行配置設定空間,若配置設定成功,則完成執行個體化,若配置設定失敗,則在Eden區(非TLAB區域)配置設定空間(走正常流程)。
4.9 小結堆空間的參數設定
官網說明:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
- -XX:+PrintFlagsInitial:檢視所有參數的預設初始值
- -XX:+PrintFlagsFinal:檢視所有參數的最終值(可能會存在修改,不再是初始值)
- -Xms:初始堆空間記憶體(預設為實體記憶體的1/64)
- -Xmx:最大堆空間記憶體(預設為實體記憶體的1/4)
- -Xmn:設定新生代的大小。(初始值及最大值)
- -XX:NewRatio:配置新生代與老年代在堆結構的占比
- -XX:SurvivorRatio:設定新生代中Eden和S0/S1空間的比例
- -XX:MaxTenuringThreshold:設定新生代垃圾的最大年齡
- -XX:+PrintGCDetails:輸出詳細的GC處理日志
- 列印gc簡要資訊:-XX:+PrintGC -verbose:gc
- -XX:HandlePromotionFailure:是否設定空間配置設定擔保
例:
package com.atguigu.java1;
/**
* 測試堆空間常用的jvm參數:
* -XX:+PrintFlagsInitial : 檢視所有的參數的預設初始值
* -XX:+PrintFlagsFinal :檢視所有的參數的最終值(可能會存在修改,不再是初始值)
* -XX:+PrintFlagsFinal -XX:SurvivorRatio=5
* 具體檢視某個參數的指令: jps:檢視目前運作中的程序
* jinfo -flag SurvivorRatio 程序id
*
* -Xms:初始堆空間記憶體 (預設為實體記憶體的1/64)
* -Xmx:最大堆空間記憶體(預設為實體記憶體的1/4)
* -Xmn:設定新生代的大小。(初始值及最大值)
* -XX:NewRatio:配置新生代與老年代在堆結構的占比
* -XX:SurvivorRatio:設定新生代中Eden和S0/S1空間的比例
* -XX:MaxTenuringThreshold:設定新生代垃圾的最大年齡
* -XX:+PrintGCDetails:輸出詳細的GC處理日志
* 列印gc簡要資訊:① -XX:+PrintGC ② -verbose:gc
* -XX:HandlePromotionFailure:是否設定空間配置設定擔保
*
* @author shkstart [email protected]
* @create 2020 17:18
*/
public class HeapArgsTest {
public static void main(String[] args) {
}
}
在發生Minor GC之前,虛拟機會檢查老年代最大可用的連續空間是否大于新生代所有對象的總空間。
- 如果大于,則此次Minor GC是安全的
- 如果小于,則虛拟機會檢視-XX:HandlePromotionFailure設定值是否允許擔保失敗。
- 如果HandlePromotionFailure=true,那麼會繼續檢查老年代最大可用連續空間是否大于曆次晉升到老年代的對象的平均大小。
- 如果大于,則嘗試進行一次Minor GC,但這次Minor GC依然是有風險的;
- 如果小于,則改為進行一次Full GC。
- 如果HandlePromotionFailure=false,則改為進行一次Full GC。
- 如果HandlePromotionFailure=true,那麼會繼續檢查老年代最大可用連續空間是否大于曆次晉升到老年代的對象的平均大小。
在JDK6 Update24之後(JDK7),HandlePromotionFailure參數不會再影響到虛拟機的空間配置設定擔保政策,觀察OpenJDK中的源碼變化,雖然源碼中還定義了HandlePromotionFailure參數,但是在代碼中已經不會再使用它。JDK6 Update24之後的規則變為隻要老年代的連續空間大于新生代對象總大小或者曆次晉升的平均大小就會進行Minor GC,否則将進行Full GC。
4.10 堆是配置設定對象存儲的唯一選擇嗎?
在《深入了解Java虛拟機》中關于Java堆記憶體有這樣一段描述:
随着JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上配置設定、标量替換優化技術将會導緻一些微妙的變化,所有的對象都配置設定到堆上也漸漸變得不那麼絕對了。
在Java虛拟機中,對象是在Java堆中配置設定記憶體的,這是一個普遍的常識。但是有一種特殊情況,那就是如果經過逃逸分析(Escape Analysis)後發現,一個對象并沒有逃逸出方法的話,那麼就可能被優化成棧上配置設定。這樣就無需再堆上配置設定記憶體,也無需進行垃圾回收了。這也是最常見的堆外存儲技術。
此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中創新的GCIH(GC invisible heap)技術實作off-heap,将生命周期較長的Java對象從heap中移至heap外,并且GC不能管理GCIH内部的Java對象,以此達到降低GC的回收頻率和提升GC的回收效率的目的。
4.10.1 逃逸分析
- 如何将堆上的對象配置設定到棧,需要使用逃逸分析手段。
- 這是一種可以有效減少Java程式中同步負載和記憶體堆配置設定壓力的跨函數全局資料流分析算法
- 通過逃逸分析,Java HotSpot編譯器能夠分析出一個新的對象的引用的使用範圍進而決定是否要将這個對象配置設定到堆上。
- 逃逸分析的基本行為就是分析對象動态作用域:
- 當一個對象在方法中被定義後,對象隻在方法内部使用,則認為沒有發生逃逸。
- 當以對象在方法中被定義後,它被外部方法所引用,則認為發生逃逸。例如作為調用參數傳遞到其他方法中。
例:
public void my_method() {
V v = new V();
...
v = null;
}
沒有發生逃逸的對象,則可以配置設定到棧上,随着方法執行的結束,棧空間就被移除。
例:
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
上述方法中建立的StringBuffer對象由于被傳回到調用方法中,是以發生逃逸,如果想要sb對象不發生逃逸,可以改成如下:
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
逃逸分析有以下幾種情況:
package com.atguigu.java2;
/**
* 逃逸分析
*
* 如何快速的判斷是否發生了逃逸分析,大家就看new的對象實體是否有可能在方法外被調用。
* @author shkstart
* @create 2020 下午 4:00
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
方法傳回EscapeAnalysis對象,發生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
為成員屬性指派,發生逃逸
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
//思考:如果目前的obj引用聲明為static的?仍然會發生逃逸。
/*
對象的作用域僅在目前方法中有效,沒有發生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成員變量的值,發生逃逸
*/
public void useEscapeAnalysis1(){
EscapeAnalysis e = getInstance();
//getInstance().xxx()同樣會發生逃逸
}
}
參數設定:
- 在JDK 6u23版本之後,HotSpot中預設就已經開啟了逃逸分析。
- 如果使用的是較早的版本,開發人員則可以通過:
- 選項:-XX:+DoEscapeanalysis 顯示開啟逃逸分析
- 通過選項: -XX:+PringEscapeAnalysis 檢視逃逸分析的篩選結果
結論:開發中能使用局部變量的,就不要使用在方法外定義。
4.10.2 代碼優化
使用逃逸分析,編譯器可以堆代碼做如下優化:
- 棧上配置設定。将堆配置設定轉化為棧配置設定。如果一個對象在子程式中被配置設定,要使指向該對象的指針永遠不會逃逸,對象可能是棧配置設定的候選,而不是堆配置設定。
- 同步省略。如果一個對象被發現隻能從一個線程被通路到,那麼對于這個對象的操作可以不考慮同步。
- 分離對象或标量替換。有的對象可能不需要作為一個連續的記憶體結構存在也可以被通路到,那麼對象的部分(或全部)可以不存儲在記憶體,而是存儲在CPU寄存器中。
代碼優化之棧上配置設定
- JIT編譯器在編譯期間根據逃逸分析的結果,發現如果一個對象并沒有逃逸出方法的話,就可能被優化成棧上配置設定。配置設定完成後,繼續在調用棧内執行,最後線程結束,棧空間被回收,局部變量對象也被回收。這樣就無須進行垃圾回收了。
- 常見的棧上配置設定的場景
- 在逃逸分析中,已經說明了。分别是給成員變量指派、方法傳回值、執行個體引用傳遞,會發生逃逸
例:-XX:-DoEscapeAnalysis,減号表示不開啟逃逸分析
package com.atguigu.java2;
/**
* 棧上配置設定測試
* <p>
* 測試1
* 未開啟逃逸分析: -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* 開啟逃逸分析: -Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
* <p>
* 都沒有産生gc日志,隻是開啟逃逸分析時,執行的比較快
* <p>
* 測試2
* 未開啟逃逸分析: -Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* 産生了gc日志
* <p>
* 開啟逃逸分析: -Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
* 沒有産生gc日志
*
* @author shkstart [email protected]
* @create 2020 10:31
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
// 檢視執行時間
long end = System.currentTimeMillis();
System.out.println("花費的時間為: " + (end - start) + " ms");
// 為了友善檢視堆記憶體中對象個數,線程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();//未發生逃逸
}
static class User {
}
}
使用Java Visual VM檢視
-XX:+DoEscapeAnalysis,+表示開啟逃逸分析,開啟逃逸分析後看看記憶體配置設定:
代碼優化之同步省略(消除)
- 線程同步的代價是相當高的,同步的後果是降低并發性和性能。
- 在動态編譯同步塊的時候,JIT編譯器可以借助逃逸分析來判斷同步塊所使用的鎖對象是否隻能夠被一個線程通路而沒有被釋出到其他線程。如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消堆這部分代碼的同步。這樣就能大大提高并發性和性能。這個取消同步的過程就叫同步省略,也就鎖消除。
例:
代碼中對hollis這個對象進行加鎖,但是hollis對象的生命周期隻在f()方法中,并不會被其他線程所通路到,是以在JIT編譯階段就會被優化掉。優化為如下:
例:
package com.atguigu.java2;
/**
* 同步省略說明
* @author shkstart [email protected]
* @create 2020 11:07
*/
public class SynchronizedTest {
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
}
代碼優化之标量替換
标量(Scalar) 是指一個無法再分解成更小的資料的資料。Java中的原始資料類型就是标量。
相對的,那些還可以分解的資料叫做聚合量(Aggregate),Java中的對象就是聚合量,因為它可以分解成其他聚合量和标量。
在JIT階段,如果經過逃逸分析,發現一個對象不會被外界通路的話,那麼經過JIT優化,就會把這個對象拆解成若幹個成員變量來代替。這個過程就是标量替換。
例:
以上代碼,結果标量替換後,就會變成:
可以看到,Point這個聚合量經過逃逸分析後,發現他并沒有逃逸,就被替換成兩個标量了。那麼标量替換有什麼好處呢?就是 可以大大減少堆記憶體的占用。因為一旦不需要建立對象了,那麼就不在需要配置設定堆記憶體了。
标量替換為棧上配置設定提供了很好的基礎。
标量替換參數設定:
參數-XX:+EliminateAllocations:開啟了标量替換(預設打開),允許将對象打散配置設定在棧上。
例:
package com.atguigu.java2;
/**
* 标量替換測試
* 未開啟标量替換: -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
* <p>
* 開啟标量替換 : -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
* 結論:開啟标量替換時,花費的時間更少,沒有産生gc
*
* @author shkstart [email protected]
* @create 2020 12:01
*/
public class ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User u = new User();//未發生逃逸
u.id = 5;
u.name = "www.atguigu.com";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花費的時間為: " + (end - start) + " ms");
}
}
/*
class Customer{
String name;
int id;
Account acct;
}
class Account{
double balance;
}
*/
未開啟标量替換結果:
開啟标量替換結果:
上述代碼在主函數中進行了1億次alloc。調用進行對象建立,由于User對象執行個體需要占據約16位元組的空間,是以累計配置設定空間達到将近1.5GB。如果堆空間小于這個值,就必然會發生GC。使用如下參數運作上述代碼:
-server -Xmx100m -Xms100m -XXL+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
這裡使用參數如下:
- 參數-server:啟動Server模式,因為在Server模式下,才可以啟用逃逸分析。
- 參數-XX:+DoEscapeAnalysis:啟用逃逸分析
- 參數-Xmx100m:指定了堆空間最大為100MB
- 參數-XX:+PrintGC:将列印GC日志
- 參數-XX:+EliminateAllocations:開啟了标量替換(預設打開),允許将對象打散配置設定在棧上,比如對象擁有id和name兩個字段,name這兩個字段将會被視為兩個獨立的局部變量進行配置設定。
總結:(逃逸分析并不成熟)
- 關于逃逸分析的論文在1999年就已經發表了,但知道JDK 1.6才有實作,而且這項技術到如今也并不是十分成熟的。
- 其根本原因就是無法保證逃逸分析的性能消耗一定高于其他的消耗。雖然經過逃逸分析可以做标量替換、棧上配置設定、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。
- 一個極端的例子,就是經過逃逸分析之後,發現沒有一個對象是不逃逸的。那這個逃逸分析的過程就拜拜浪費掉了。
- 雖然這項技術并不十分成熟,但是它也是即時編譯器優化技術中一個十分重要的手段。
- 注意到有一些觀點,認為通過逃逸分析,JVM會在棧上配置設定那些不會逃逸的對象,這在理論上是可行的,但是取決于JVM設計者的選擇。據我所知,Oracle HotSpot JVM中并未這麼做,這一點在逃逸分析相關的文檔裡赢說明,是以可以明确所有的對象執行個體都是建立在堆上。
- 目前很多書籍還是基于JDK7以前的版本,JDK已經發生了很大變化,intern字元串的緩存和靜态變量曾經都被配置設定在永久代上,而永久代已經被中繼資料區取代。但是intern字元串緩存和靜态變量并不是被轉移到中繼資料去,而是直接在堆上配置設定,是以這一點同樣符合前面一點的結論:對象執行個體都是配置設定在堆上。
本章小結:
- 年輕代是對象的誕生、成長、消亡的區域,一個對象在這裡産生、應用,最後被垃圾回收器收集、結束生命。
- 老年代防止長生命周期的對象,通常都是從Survivor區域篩選拷貝過來的Java對象。當然,也有特殊情況,我們知道普通的對象會被配置設定在TLAB上,如果對象較大,JVM會試圖直接配置設定在Eden其他位置上;如果對象太大,完全無法再新生代找到足夠長的連續空閑空間,JVM就會直接配置設定到老年代。
- 當GC隻發生在年輕代中,回收年輕代對象的行為被稱為Minor GC,當GC發生在老年代時則被稱為Major GC或者Full GC。一般的,Minor GC的發生頻率要比Major GC 高很多,即老年代中垃圾回收發生的頻率将大大低于年輕代。