java對象的內存分配流程


對象的內存分配流程如下:

對象創建的過程中會給對象分配內存,分配內存的整體流程如下:

第一步:判斷棧上是否有足夠的空間。

​ 這里和之前理解有所差別。之前一直都認為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對象沒有逃逸。

判斷一個對象是否是逃逸對象,就看這個對象能否被外部對象訪問到。

結合棧上分配來理解為何沒有逃逸出去的對象為什么應該分配在棧上呢?來看下圖:

image

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。這一塊數據沒有發生變化,因為元數據信息沒有變。

第三步 是大對象 放入到老年代

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中來回翻轉的次數,直接放入到老年代,節省了年輕代的空間。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM