Java堆
Java堆的基本概念
Java 堆是虛擬機器所管理的記憶體中最大的一塊,是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一作用就是存放物件例項,幾乎所有的物件例項都是在這裡分配的(不絕對,在虛擬機器的優化策略下,也會存在棧上分配、標量替換的情況)。Java 堆是 GC 回收的主要區域,因此很多時候也被稱為 GC 堆。從記憶體回收的角度看,Java 堆還可以被細分為新生代和老年代;再細一點新生代還可以被劃分為 Eden Space、From Survivor Space、To Survivor Space。從記憶體回收的角度看,執行緒共享的 Java 堆可能劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。「屬於執行緒共享的記憶體區域」
堆中儲存著所有引用型別的真實資訊,以方便執行器執行。堆在邏輯上分為三個區域:
Java7:
Java8:
可以看到,在Java7時代,堆分為新生區(新生區包含伊甸園區和倖存區,倖存區又包含倖存者0區和倖存者1區。此外,倖存者0區又稱為From區,倖存者1區又稱為To區,From區和To區並不是固定的,複製之後互動,誰空誰是To),養老區和永久代;在Java8中,永久代已經被移除,被一個稱為元空間的區域所取代。元空間的本質和永久代類似。
永久代:
元空間:
元空間與永久代之間最大的區別在於:永久代使用的JVM的堆記憶體,但是java8以後的元空間並不在虛擬機器中而是使用本機實體記憶體(所以在上圖中,我用虛線表示)。
堆之所以要分割槽是因為:Java程式中不同物件的生命週期不同,70%~99%物件都是臨時物件,這類物件在新生區“朝生夕死”。如果沒有分割槽,GC時蒐集垃圾需要對整個堆記憶體進行掃描;分割槽後,回收這些“朝生夕死”的物件,只需要在小範圍的區域中(新生區)蒐集垃圾。所以,分割槽的唯一理由就是為了優化GC效能。
堆空間物件分配過程
下面通過一個例子來講述這幾個區的互動邏輯:
1.幾乎任何新的物件都是在伊甸園區被new出來建立,剛開始的時候兩個倖存者區和養老區都是空的:
2.隨著物件的不斷建立,伊甸園區空間逐漸被填滿:
3.這時候將觸發一次Minor GC(Young GC),刪除未引用的物件,GC剩下來的還存在引用的物件將移動到倖存者0區,然後清空伊甸園區:
4.隨著物件的建立,伊甸園區空間又滿了,再一次觸發Minor GC,刪除未引用的物件,留下存在引用的物件。這次和上一次Minor GC有些不同,這輪GC留下的物件將被移動到倖存者1區,並且上一輪GC留下來的儲存在倖存者0區的物件年齡遞增並移動到倖存者1區。當所有幸存物件都移動到倖存者1區後,倖存者0區和伊甸園區空間清除:
5.隨著物件的建立伊甸園區空間再一次滿了,觸發了第三次Minor GC,這一次倖存區空間將發生互換,GC留下來的倖存者將移動到倖存者0區,倖存者1區的倖存物件年齡遞增後也移動到倖存者0區,然後伊甸園區和倖存者1區的空間被清除:
6.隨著Minor GC的不斷髮生,倖存物件在兩個倖存區不斷地交換儲存,年齡也不斷遞增。如此反反覆覆之後,當倖存物件的年齡達到指定的閾值(這個例子中是8,由JVM引數MaxTenuringThreshold決定)後,它們將被移動到養老區:
7.隨著上述過程的不斷出現,當養老區快滿時,將觸發Major GC(Full GC)進行養老區的記憶體清理。若養老區執行了GC之後發現依然無法進行物件的儲存,就會產生OOM異常。
一個物件被放置到養老區除了它的年齡達到閾值外,以下幾種情況也會使得該物件直接被放置到養老區:
- 物件建立後,無法放置到伊甸園區(比如伊甸園區的大小為10m,新的物件大小為11m,伊甸園區不夠放,觸發YGC。YGC後伊甸園區被清空,但還是無法容下11m的“超大物件”,所以直接放置到養老區。當然如果養老區放置不下則會觸發FGC,FGC後還放不下則OOM);
- YGC後,物件無法放置到倖存者To區也會直接晉升到養老區;
- 如果倖存區中相同年齡的所有物件大小大於倖存區空間的一半,年齡大於或等於這些物件年齡的物件可以直接進入養老區,無需等到年齡閾值。
堆引數
以JDK1.8+HotSpot為例,常用的可調整的堆引數有:
引數 | 含義 |
---|---|
-Xms,等價於-XX:InitialHeapSize | 設定堆的初始記憶體大小,預設為實體記憶體的1/64 |
-Xmx,等價於-XX:MaxHeapSize | 設定堆的最大記憶體大小,預設為實體記憶體的1/4 |
-XX:Newratio | 設定新生區和養老區的比例,比如值為2(預設值),則養老區是新生區的2倍,即養老區佔據堆記憶體的2/3 |
-XX:Surviorratio | 設定伊甸園區和一個倖存區的比例,比如值為8(預設值)則表示伊甸園區佔新生區的8/10(兩個倖存區是一樣大的) |
-Xmn | 設定堆新生區的記憶體大小(一般不使用) |
-XX:MaxTenuringThreshold | 設定轉入養老區的存活次數,預設值為15 |
-XX:+PrintFlagsInitial | 檢視所有引數的預設初始值 |
-XX:+PrintFlagsFinal | 檢視所有引數的最終值(被我們修改後的值不再是預設初始值) |
剩下所有可用引數可以檢視oracle官方文件:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html。
生產環境中,推薦將-Xms和-Xmx設定為一樣大,因為這樣做的話在Java垃圾回收清理完堆區後不需要重新計算堆區大小,從而提高效能。此外,要在程式中輸出詳細的GC處理日誌,可以使用-XX:+PrintGCDetails
。
比如,我的電腦記憶體為32GB,所以堆的預設初始值大小為500MB左右,堆的最大值大約為8000MB左右:
public class Test {
public static void main(String[] args) {
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println("堆記憶體的初始值" + totalMemory / 1024 / 1024 + "mb");
System.out.println("堆記憶體的最大值" + maxMemory / 1024 / 1024 + "mb");
}
}
程式輸出:
堆記憶體的初始值491mb
堆記憶體的最大值7282mb
可以通過IDEA調整堆的大小:
我們將堆記憶體的初始大小和最大值都設定為10mb,並且開啟GC日誌列印,重新執行下面這段程式:
public class Test {
public static void main(String[] args) {
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println("堆記憶體的初始值" + totalMemory / 1024 + "kb");
System.out.println("堆記憶體的最大值" + maxMemory / 1024 + "kb");
}
}
輸出如下所示:
堆記憶體的初始值9728kb
堆記憶體的最大值9728kb
Heap
PSYoungGen total 2560K, used 1388K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 2048K, 67% used [0x00000007bfd00000,0x00000007bfe5b370,0x00000007bff00000)
from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
to space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
ParOldGen total 7168K, used 0K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
object space 7168K, 0% used [0x00000007bf600000,0x00000007bf600000,0x00000007bfd00000)
Metaspace used 2947K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 320K, capacity 388K, committed 512K, reserved 1048576K
可以看到,PSYoungGen(新生區)的總記憶體大小為2560k,ParOldGen(養老區)的總記憶體大小為7168k,總和剛好是9728K,這也說明了:Java8後的堆物理上只分為新生區和養老區,Metaspace(元空間)不佔用堆記憶體,而是直接使用實體記憶體。
那為什麼我們設定的堆記憶體大小是10m(10240kb),控制檯輸出卻只有9728kb呢?從上面的例子我們知道,倖存者區分為0區和1區,根據複製演算法的特點,這兩個區同一時刻總有一個區是空的,所以控制檯輸出的記憶體計算方式為:2048K(eden space)+512K(from space or to space)+7168K(ParOldGen)=9728K。9728K再加一個倖存區的大小512K剛好是10240K。
再舉個OOM的例子,使用剛剛-Xms10m -Xmx10m -XX:+PrintGCDetails
的設定,執行下面這段程式:
public class Test {
public static void main(String[] args) {
String value = "hello";
while (true) {
value += value + new Random().nextInt(1000000000) + new Random().nextInt(1000000000);
}
}
}
輸出如下:
[GC (Allocation Failure) [PSYoungGen: 1893K->491K(2560K)] 1893K->597K(9728K), 0.0007246 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2207K->496K(2560K)] 2313K->1153K(9728K), 0.0008383 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2007K->496K(2560K)] 2664K->1897K(9728K), 0.0009456 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 2021K->496K(2560K)] 4894K->4113K(9728K), 0.0010814 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 1359K->496K(2560K)] 6448K->5600K(9728K), 0.0015792 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 496K->496K(1536K)] 5600K->5600K(8704K), 0.0006416 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 496K->0K(1536K)] [ParOldGen: 5104K->2585K(7168K)] 5600K->2585K(8704K), [Metaspace: 2982K->2982K(1056768K)], 0.0044783 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 61K->192K(2048K)] 7061K->7192K(9216K), 0.0012566 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 192K->0K(2048K)] [ParOldGen: 7000K->1840K(7168K)] 7192K->1840K(9216K), [Metaspace: 3042K->3042K(1056768K)], 0.0072023 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 65K->160K(2048K)] 6321K->6416K(9216K), 0.0022603 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 160K->0K(2048K)] [ParOldGen: 6256K->4785K(7168K)] 6416K->4785K(9216K), [Metaspace: 3076K->3076K(1056768K)], 0.0056740 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] 4785K->4785K(9216K), 0.0003871 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] [ParOldGen: 4785K->4765K(7168K)] 4785K->4765K(9216K), [Metaspace: 3076K->3076K(1056768K)], 0.0049903 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 2048K, used 59K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 1024K, 5% used [0x00000007bfd00000,0x00000007bfd0efb8,0x00000007bfe00000)
from space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
to space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
ParOldGen total 7168K, used 4765K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
object space 7168K, 66% used [0x00000007bf600000,0x00000007bfaa77b8,0x00000007bfd00000)
Metaspace used 3113K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 339K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:674)
at java.lang.StringBuilder.append(StringBuilder.java:208)
at cc.mrbird.Test.main(Test.java:19)
可以看到,經過數次的GC和Full GC後,堆記憶體還是無法騰出空間,最終丟擲OOM錯誤。日誌的含義如下圖所示:
Young GC(Minor GC):
Full GC(Major GC):
TLAB
JVM對伊甸園區繼續進行劃分,為每個執行緒分配了一個私有快取區域,這塊區域就是TLAB(Thread Local Allocation Buffer)。多執行緒同時分配記憶體時,使用TLAB可以避免一系列非執行緒安全問題,同時還能夠提升記憶體分配的吞吐量。儘管不是所有的物件例項都能夠在TLAB中成功分配記憶體,但JVM確實是將TLAB作為記憶體分配的首選:
我們可以使用-XX:UseTLAB
設定是否開啟TLAB,舉個例子:
public class Test {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(100);
}
}
執行main方法:
可以看到TLAB預設是開啟的。
TLAB空間的記憶體非常小,僅佔整個伊甸園區的1%,可以通過-XX:TLABWasteTargetPercent
設定TLAB空間所佔用伊甸園區空間的百分比。
有了TLAB的概念後,我們就不能說堆空間一定是執行緒共享的了。