參考資料:尚硅谷JVM教程
6.1. 堆(Heap)的核心概述
堆針對一個JVM進程來說是唯一的,也就是一個進程只有一個JVM,但是進程包含多個線程,他們是共享同一堆空間的。
一個JVM實例只存在一個堆內存,堆也是Java內存管理的核心區域。
Java堆區在JVM啟動的時候即被創建,其空間大小也就確定了。是JVM管理的最大一塊內存空間。
- 堆內存的大小是可以調節的。
//程序1
public class HeapDemo {
public static void main(String[] args) {
System.out.println("start");
try {
//睡眠1000秒
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end");
}
}
//程序2
public class HeapDemo1 {
public static void main(String[] args) {
System.out.println("start");
try {
//睡眠1000秒
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end");
}
}
使用-Xms -Xmx設置初始堆大小和最大堆大小
運行程序,使用jdk自帶工具jvisualvm查看堆空間情況:
《Java虛擬機規范》規定,堆可以處於物理上不連續的內存空間中,但在邏輯上它應該被視為連續的。
所有的線程共享Java堆,在這里還可以划分線程私有的緩沖區(Thread Local Allocation Buffer,TLAB)。
《Java虛擬機規范》中對Java堆的描述是:所有的對象實例以及數組都應當在運行時分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated
)
數組和對象可能永遠不會存儲在棧上,因為棧幀中保存引用,這個引用指向對象或者數組在堆中的位置。
在方法結束后,堆中的對象不會馬上被移除,僅僅在垃圾收集的時候才會被移除。
堆,是GC(Garbage Collection,垃圾收集器)執行垃圾回收的重點區域。
public class SimpleHeap {
private int id;
public SimpleHeap(int id) {
this.id = id;
}
public static void main(String[] args) {
SimpleHeap s1 = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);
int[] array = new int[2];
Object[] array1 = new Object[2];
}
}
6.1.1. 堆內存細分
Java 7及之前堆內存邏輯上分為三部分:新生區+養老區+永久區
-
Young Generation Space 新生區 Young/New 又被划分為Eden區和Survivor區
-
Tenure generation space 養老區 Old/Tenure
-
Permanent Space 永久區 Perm
Java 8及之后堆內存邏輯上分為三部分:新生區+養老區+元空間
-
Young Generation Space 新生區 Young/New 又被划分為Eden區和Survivor區
-
Tenure generation space 養老區 Old/Tenure
-
Meta Space 元空間 Meta
約定:新生區(代)<=>年輕代 、 養老區<=>老年區(代)、 永久區<=>永久代
6.1.2. 堆空間內部結構(JDK7)
6.1.3. 堆空間內部結構(JDK8)
public class SimpleHeap {
private int id;
public SimpleHeap(int id) {
this.id = id;
}
public static void main(String[] args) {
SimpleHeap s1 = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);
int[] array = new int[2];
Object[] array1 = new Object[2];
}
}
6.2. 設置堆內存大小與OOM
6.2.1. 堆空間大小的設置
Java堆區用於存儲Java對象實例,那么堆的大小在JVM啟動時就已經設定好了,大家可以通過選項"-Xmx"和"-Xms"來進行設置。
-
“-Xms"用於表示堆區的起始內存,等價於
-XX:InitialHeapSize
-
“-Xmx"則用於表示堆區的最大內存,等價於
-XX:MaxHeapSize
一旦堆區中的內存大小超過“-Xmx"所指定的最大內存時,將會拋出OutOfMemoryError異常。
通常會將-Xms和-Xmx兩個參數配置相同的值,其目的是為了能夠在Java垃圾回收機制清理完堆區后不需要重新分隔計算堆區的大小,從而提高性能。
默認情況下
-
初始內存大小:物理電腦內存大小 / 64
-
最大內存大小:物理電腦內存大小 / 4
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java堆內存總量,一個Runtime就是一個Java虛擬機實例
long initialMemory = Runtime.getRuntime().totalMemory()/1024/1024;//默認是字節,/1024/1024轉換成M
//返回Java內存最大總量
long maxMemory = Runtime.getRuntime().maxMemory()/1024/1024;
System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");
}
}
默認情況:
將初始大小和最大都設置成600M
這里是否會有一個疑惑,我們明明設置的是600M,為什么這里只有575M。我們通過-XX:+PrintGCDetails來看出分配細節
179200+409600=588,800(K)/1024=575(M)。 179200 = 153600 + 25600。明明有兩個25600這里只算一個,原因我們后面會說到。
第二種查看方式,使用jdk自帶工具:jps查看進程,然后使用jstat -gc 進程id即可查看詳細信息。
6.2.2. OutOfMemory舉例
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();
}
list.add(new Picture(new Random().nextInt(1024*1024)));
}
}
}
class Picture{
private byte[] pixels;
public Picture(int pic) {
this.pixels = new byte[pic];
}
}
6.3. 年輕代與老年代
存儲在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
在HotSpot中,Eden空間和另外兩個survivor空間缺省所占的比例是8:1:1
當然開發人員可以通過選項“-xx:SurvivorRatio
”調整這個空間比例。比如-xx:SurvivorRatio=8
默認情況下新生代內的Eden和survivor並不是8,有一個自適應的內存分配策略,想要關閉自適應策略可以使用-XX:-UseAdaptiveSizePolicy
即可,不過可能效果不明顯,最好還是顯示設置比率。
幾乎所有的Java對象都是在Eden區被new出來的。絕大部分的Java對象的銷毀都在新生代進行了。
- IBM公司的專門研究表明,新生代中80%的對象都是“朝生夕死”的。
可以使用選項"-Xmn
"設置新生代最大內存大小,這個參數一般使用默認值就可以了。
public class EdenSurvivorTest {
public static void main(String[] args) {
System.out.println("我是打醬油");
try {
//模擬睡眠1000秒
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
默認情況新生代和老年代比率為2
也可以使用jps配合jinfo查看
顯示設置比率:-XX:NewRatio 值
一般情況下不要修改新生代和老年代比率。在新生代中有分為三個區域:Eden區、Survivor0區、Survivor1區。這三者比率默認是8:1:1
明明默認是8:1:1為什么這里確是6:1:1。JVM默認情況下開啟了一個內存自適應的分配策略,我們可以通過設置-XX:-UseAdaptiveSizePolicy
來取消自適應。可能有時候這個設置不怎么管用,我們還是推薦直接默認設置比率即可。
6.4. 圖解對象分配過程
為新對象分配內存是一件非常嚴謹和復雜的任務,JVM的設計者們不僅需要考慮內存如何分配、在哪里分配等問題,並且由於內存分配算法與內存回收算法密切相關,所以還需要考慮GC執行完內存回收后是否會在內存空間中產生內存碎片。
-
new的對象先放伊甸園區。此區有大小限制。
-
當伊甸園的空間填滿時,程序又需要創建對象,JVM的垃圾回收器將對伊甸園區進行垃圾回收(MinorGC),將伊甸園區中的不再被其他對象所引用的對象進行銷毀。再加載新的對象放到伊甸園區
-
然后將伊甸園中的剩余對象移動到幸存者0區。
-
如果再次觸發垃圾回收,此時上次幸存下來的放到幸存者0區的,如果沒有回收,就會放到幸存者1區。
-
如果再次經歷垃圾回收,此時會重新放回幸存者0區,接着再去幸存者1區。
-
啥時候能去養老區呢?可以設置次數。默認是15次。
-
- 可以設置參數:進行設置
-Xx:MaxTenuringThreshold= N
- 可以設置參數:進行設置
-
在養老區,相對悠閑。當養老區內存不足時,再次觸發GC:Major GC,進行養老區的內存清理
-
若養老區執行了Major GC之后,發現依然無法進行對象的保存,就會產生OOM異常。
總結:針對s0,s1區總結,復制之后有交換,誰空誰是to
關於垃圾回收算法:頻繁的在新生區收集,很少在養老區收集,幾乎不在永久區/元空間收集。
常用調優工具
-
JDK命令行
-
Eclipse:Memory Analyzer Tool
-
Jconsole
-
VisualVM
-
Jprofiler
-
Java Flight Recorder
-
GCViewer
-
GC Easy
public class HeapInstanceTest {
byte[] buff = new byte[new Random().nextInt(1024*200)];
public static void main(String[] args) {
ArrayList<HeapInstanceTest> list = new ArrayList<>();
while(true){
list.add(new HeapInstanceTest());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
6.5. Minor GC,MajorGC、Full GC
JVM在進行GC時,並非每次都對上面三個內存區域(新生代、老年代、方法區)一起回收的,大部分時候回收的都是指新生代。
針對Hotspot VM的實現,它里面的GC按照回收區域又分為兩大種類型:一種是部分收集(Partial GC),一種是整堆收集(FullGC)
-
部分收集:不是完整收集整個Java堆的垃圾收集。其中又分為:
-
- 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
-
- 老年代收集(Major GC / Old GC):只是老年代的圾收集。
-
-
- 目前,只有CMSGC會有單獨收集老年代的行為。
-
-
-
- 注意,很多時候Major GC會和Full GC混淆使用,需要具體分辨是老年代回收還是整堆回收。
-
-
- 混合收集(MixedGC):收集整個新生代以及部分老年代的垃圾收集。
-
-
- 目前,只有G1 GC會有這種行為
-
-
整堆收集(Full GC):收集整個java堆和方法區的垃圾收集。
6.5.1. 最簡單的分代式GC策略的觸發條件
年輕代GC(Minor GC)觸發機制
-
當年輕代空間不足時,就會觸發MinorGC,這里的年輕代滿指的是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(但非絕對的,在Paralle1 Scavenge收集器的收集策略里就有直接進行MajorGC的策略選擇過程)
-
- 也就是在老年代空間不足時,會先嘗試觸發Minor GC。如果之后空間還不足,則觸發Major GC
-
Major GC的速度一般會比Minor GC慢10倍以上,STW的時間更長
-
如果Major GC后,內存還不足,就報OOM了
Full GC觸發機制(后面細講)
觸發Full GC執行的情況有如下五種:
-
調用System.gc()時,系統建議執行Full GC,但是不必然執行
-
老年代空間不足
-
方法區空間不足
-
通過Minor GC后進入老年代的平均大小大於老年代的可用內存
-
由Eden區、survivor space0(From Space)區向survivor space1(To Space)區復制時,對象大小大於To Space可用內存,則把該對象轉存到老年代,且老年代的可用內存小於該對象大小
說明:Full GC 是開發或調優中盡量要避免的。這樣暫時時間會短一些
public class GCTest {
public static void main(String[] args) {
int i = 0;
try{
List<String> list = new ArrayList<>();
String a = "wuyazi.com";
while(true){
list.add(a);
a = a + a;
i++;
}
}catch(Throwable t){
t.printStackTrace();
System.out.println("遍歷次數:" + i);
}
}
}
6.6. 堆空間分代思想
為什么要把Java堆分代?不分代就不能正常工作了嗎?
經研究,不同對象的生命周期不同。70%-99%的對象是臨時對象。
-
新生代:有Eden、兩塊大小相同的survivor(又稱為from/to,s0/s1)構成,to總為空。
-
老年代:存放新生代中經歷多次GC仍然存活的對象。
其實不分代完全可以,分代的唯一理由就是優化GC性能。如果沒有分代,那所有的對象都在一塊,就如同把一個學校的人都關在一個教室。GC的時候要找到哪些對象沒用,這樣就會對堆的所有區域進行掃描。而很多對象都是朝生夕死的,如果分代的話,把新創建的對象放到某一地方,當GC的時候先把這塊存儲“朝生夕死”對象的區域進行回收,這樣就會騰出很大的空間出來。
6.7. 內存分配策略
如果對象在Eden出生並經過第一次Minor GC后仍然存活,並且能被Survivor容納的話,將被移動到survivor空間中,並將對象年齡設為1。對象在survivor區中每熬過一次MinorGC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15歲,其實每個JVM、每個GC都有所不同)時,就會被晉升到老年代
對象晉升老年代的年齡閥值,可以通過選項-XX:MaxTenuringThreshold
來設置
針對不同年齡段的對象分配原則如下所示:
-
優先分配到Eden
-
大對象直接分配到老年代(盡量避免程序中出現過多的大對象)
-
長期存活的對象分配到老年代
-
動態對象年齡判斷:如果survivor區中相同年齡的所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象可以直接進入老年代,無須等到
MaxTenuringThreshold
中要求的年齡。 -
空間分配擔保:
-XX:HandlePromotionFailure
public class OldAreaTest {
public static void main(String[] args) {
byte[] buffer = new byte[1024*1024*20];//創建一個20M對象
}
}
6.8. 為對象分配內存:TLAB
6.8.1. 為什么有TLAB(Thread Local Allocation Buffer)?
-
堆區是線程共享區域,任何線程都可以訪問到堆區中的共享數據
-
由於對象實例的創建在JVM中非常頻繁,因此在並發環境下從堆區中划分內存空間是線程不安全的
-
為避免多個線程操作同一地址,需要使用加鎖等機制,進而影響分配速度。
6.8.2. 什么是TLAB?
-
從內存模型而不是垃圾收集的角度,對Eden區域繼續進行划分,JVM為每個線程分配了一個私有緩存區域,它包含在Eden空間內。
-
多線程同時分配內存時,使用TLAB可以避免一系列的非線程安全問題,同時還能夠提升內存分配的吞吐量,因此我們可以將這種內存分配方式稱之為快速分配策略。
-
據我所知所有OpenJDK衍生出來的JVM都提供了TLAB的設計。
6.8.3. TLAB的再說明
-
盡管不是所有的對象實例都能夠在TLAB中成功分配內存,但JVM確實是將TLAB作為內存分配的首選。
-
在程序中,開發人員可以通過選項“
-XX:UseTLAB
”設置是否開啟TLAB空間。 -
默認情況下,TLAB空間的內存非常小,僅占有整個Eden空間的1%,當然我們可以通過選項 “
-XX:TLABWasteTargetPercent
” 設置TLAB空間所占用Eden空間的百分比大小。 -
一旦對象在TLAB空間分配內存失敗時,JVM就會嘗試着通過使用加鎖機制確保數據操作的原子性,從而直接在Eden空間中分配內存。
6.9. 小結:堆空間的參數設置
官網地址:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/index.html
// 詳細的參數內容會在JVM下篇:性能監控與調優篇中進行詳細介紹,這里先熟悉下
-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:HandlePromotionFalilure://是否設置空間分配擔保
在發生Minor GC之前,虛擬機會檢查老年代最大可用的連續空間是否大於新生代所有對象的總空間。
-
如果大於,則此次Minor GC是安全的
-
如果小於,則虛擬機會查看
-XX:HandlePromotionFailure
設置值是否允擔保失敗。 -
- 如果
HandlePromotionFailure=true
,那么會繼續檢查老年代最大可用連續空間是否大於歷次晉升到老年代的對象的平均大小。
- 如果
-
-
- 如果大於,則嘗試進行一次Minor GC,但這次Minor GC依然是有風險的;
-
-
-
- 如果小於,則改為進行一次Full GC。
-
-
- 如果
HandlePromotionFailure=false
,則改為進行一次Full Gc。
- 如果
在JDK6 Update24之后,HandlePromotionFailure參數不會再影響到虛擬機的空間分配擔保策略,觀察openJDK中的源碼變化,雖然源碼中還定義了HandlePromotionFailure參數,但是在代碼中已經不會再使用它。JDK6 Update 24之后的規則變為只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行FullGC。
6.10. 堆是分配對象的唯一選擇么?
在《深入理解Java虛擬機》中關於Java堆內存有這樣一段描述:
隨着JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的對象都分配到堆上也漸漸變得不那么“絕對”了。
在Java虛擬機中,對象是在Java堆中分配內存的,這是一個普遍的常識。但是,有一種特殊情況,那就是如果經過逃逸分析(Escape Analysis)后發現,一個對象並沒有逃逸出方法的話,那么就可能被優化成棧上分配。這樣就無需在堆上分配內存,也無須進行垃圾回收了。這也是最常見的堆外存儲技術。
此外,前面提到的基於OpenJDK深度定制的TaoBaoVM,其中創新的GCIH(GC invisible heap)技術實現off-heap,將生命周期較長的Java對象從heap中移至heap外,並且GC不能管理GCIH內部的Java對象,以此達到降低GC的回收頻率和提升GC的回收效率的目的。
6.10.1. 逃逸分析概述
如何將堆上的對象分配到棧,需要使用逃逸分析手段。
這是一種可以有效減少Java程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法。
通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的對象的引用的使用范圍從而決定是否要將這個對象分配到堆上。
逃逸分析的基本行為就是分析對象動態作用域:
-
當一個對象在方法中被定義后,對象只在方法內部使用,則認為沒有發生逃逸。
-
當一個對象在方法中被定義后,它被外部方法所引用,則認為發生逃逸。例如作為調用參數傳遞到其他地方中。
public void my_method() {
V v = new V();
// use v
// ....
v = null;
}
沒有發生逃逸的對象,則可以分配到棧上,隨着方法執行的結束,棧空間就被移除,每個棧里面包含了很多棧幀
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;//發生逃逸,在外部可能使用這個sb對象
}
//改進
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
public class EscapeAnalysis {
public EscapeAnalysis obj;
/**
* 方法返回EscapeAnalysis對象,發生逃逸
* @return
*/
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis() : obj;
}
/**
* 為成員屬性賦值,發生逃逸
*/
public void setObj() {
this.obj = new EscapeAnalysis();
}
/**
* 對象的作用於僅在當前方法中有效,沒有發生逃逸
*/
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}
/**
* 引用成員變量的值,發生逃逸
*/
public void useEscapeAnalysis2() {
EscapeAnalysis e = getInstance();
}
}
參數設置
在JDK 6u23 版本之后,HotSpot中默認就已經開啟了逃逸分析
如果使用的是較早的版本,開發人員則可以通過:
-
選項“
-XX:+DoEscapeAnalysis
"顯式開啟逃逸分析 -
通過選項“
-XX:+PrintEscapeAnalysis
"查看逃逸分析的篩選結果
結論:開發中能使用局部變量的,就不要使用在方法外定義。
6.10.2. 逃逸分析:代碼優化
使用逃逸分析,編譯器可以對代碼做如下優化:
一、棧上分配:將堆分配轉化為棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會發生逃逸,對象可能是棧上分配的候選,而不是堆上分配
二、同步省略:如果一個對象被發現只有一個線程被訪問到,那么對於這個對象的操作可以不考慮同步。
三、分離對象或標量替換:有的對象可能不需要作為一個連續的內存結構存在也可以被訪問到,那么對象的部分(或全部)可以不存儲在內存,而是存儲在CPU寄存器中。
棧上分配
JIT編譯器在編譯期間根據逃逸分析的結果,發現如果一個對象並沒有逃逸出方法的話,就可能被優化成棧上分配。分配完成后,繼續在調用棧內執行,最后線程結束,棧空間被回收,局部變量對象也被回收。這樣就無須進行垃圾回收了。
常見的棧上分配的場景
在逃逸分析中,已經說明了。分別是給成員變量賦值、方法返回值、實例引用傳遞。
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");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
static class User{};
private static void alloc(){
User user = new User();//未發生逃逸
}
}
分配初始內存1G,最大內存1G,關閉逃逸分析
開啟逃逸分析:
當分配內存初始為256M,最大為256M,不開啟逃逸分析情況,會觸發垃圾回收機制
開啟逃逸分析之后不會觸發垃圾回收機制
同步省略
線程同步的代價是相當高的,同步的后果是降低並發性和性能。
在動態編譯同步塊的時候,JIT編譯器可以借助逃逸分析來判斷同步塊所使用的鎖對象是否只能夠被一個線程訪問而沒有被發布到其他線程。如果沒有,那么JIT編譯器在編譯這個同步塊的時候就會取消對這部分代碼的同步。這樣就能大大提高並發性和性能。這個取消同步的過程就叫同步省略,也叫鎖消除。
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
//優化
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
標量替換
標量(scalar)是指一個無法再分解成更小的數據的數據。Java中的原始數據類型就是標量。
相對的,那些還可以分解的數據叫做聚合量(Aggregate),Java中的對象就是聚合量,因為他可以分解成其他聚合量和標量。
在JIT階段,如果經過逃逸分析,發現一個對象不會被外界訪問的話,那么經過JIT優化,就會把這個對象拆解成若干個其中包含的若干個成員變量來代替。這個過程就是標量替換。
public static void main(String args[]) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
private int x;
private int y;
}
//標量替換
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
可以看到,Point這個聚合量經過逃逸分析后,發現他並沒有逃逸,就被替換成兩個標量了。那么標量替換有什么好處呢?就是可以大大減少堆內存的占用。因為一旦不需要創建對象了,那么就不再需要分配堆內存了。 標量替換為棧上分配提供了很好的基礎。
標量替換參數設置
參數-XX:EliminateAllocations
:開啟了標量替換(默認打開),允許將對象打散分配到棧上。
上述代碼在主函數中進行了1億次alloc。調用進行對象創建,由於User對象實例需要占據約16字節的空間,因此累計分配空間達到將近1.5GB。如果堆空間小於這個值,就必然會發生GC。使用如下參數運行上述代碼:
-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
這里設置參數如下:
-
參數
-server
:啟動Server模式,因為在server模式下,才可以啟用逃逸分析。 -
參數
-XX:+DoEscapeAnalysis
:啟用逃逸分析 -
參數
-Xmx10m
:指定了堆空間最大為10MB -
參數
-XX:+PrintGC
:將打印Gc日志 -
參數
-XX:+EliminateAllocations
:開啟了標量替換(默認打開),允許將對象打散分配在棧上,比如對象擁有id和name兩個字段,那么這兩個字段將會被視為兩個獨立的局部變量進行分配
6.10.3. 逃逸分析小結:逃逸分析並不成熟
關於逃逸分析的論文在1999年就已經發表了,但直到JDK1.6才有實現,而且這項技術到如今也並不是十分成熟。
其根本原因就是無法保證逃逸分析的性能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列復雜的分析的,這其實也是一個相對耗時的過程。
一個極端的例子,就是經過逃逸分析之后,發現沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。
雖然這項技術並不十分成熟,但是它也是即時編譯器優化技術中一個十分重要的手段。
注意到有一些觀點,認為通過逃逸分析,JVM會在棧上分配那些不會逃逸的對象,這在理論上是可行的,但是取決於JVM設計者的選擇。據我所知,Oracle Hotspot JVM中並未這么做,這一點在逃逸分析相關的文檔里已經說明,所以可以明確所有的對象實例都是創建在堆上。
目前很多書籍還是基於JDK7以前的版本,JDK已經發生了很大變化,intern字符串的緩存和靜態變量曾經都被分配在永久代上,而永久代已經被元數據區取代。但是,intern字符串緩存和靜態變量並不是被轉移到元數據區,而是直接在堆上分配,所以這一點同樣符合前面一點的結論:對象實例都是分配在堆上。
本章小結
年輕代是對象的誕生、成長、消亡的區域,一個對象在這里產生、應用,最后被垃圾回收器收集、結束生命。
老年代放置長生命周期的對象,通常都是從survivor區域篩選拷貝過來的Java對象。當然,也有特殊情況,我們知道普通的對象會被分配在TLAB上;如果對象較大,JVM會試圖直接分配在Eden其他位置上;如果對象太大,完全無法在新生代找到足夠長的連續空閑空間,JVM就會直接分配到老年代。當GC只發生在年輕代中,回收年輕代對象的行為被稱為MinorGc。
當GC發生在老年代時則被稱為MajorGc或者FullGC。一般的,MinorGc的發生頻率要比MajorGC高很多,即老年代中垃圾回收發生的頻率將大大低於年輕代。