1.JVM內存模型
JVM內存模型根據jdk版本不同,有部分變化,主要是jdk1.8之后,方法區移至直接內存中的元空間處。對比圖如下所示:

由上圖可以看出來,版本之間的變化主要是共享線程區中的 方法區 的位置,jdk8之后轉移到直接內存,而不是原先的共享線程區中。
線程私有的 虛擬機棧、本地方法棧、程序計數器;線程共有的 堆、方法區、直接內存(非運行時數據區)。
1.1 虛擬機棧
虛擬機棧是線程私有的。虛擬機棧跟線程的生命周期相同,它描述的是java方法執行的內存模型,每次java方法調用的數據,都是通過棧傳遞的。
java內存可以粗糙的分為 堆內存(heap)和 棧內存(stack) ,其中棧內存就是指的虛擬機棧,或者說是虛擬機棧中局部變量表中的部分。實際上,虛擬機棧就是由一個個棧幀組成,而每個棧幀中都擁有:局部變量表、操作數棧、動態鏈接、方法出口信息。
局部變量表主要存放的是編譯期間可知的各種數據類型(八大基本數據類型)、對象引用(Reference類型,不同於對象,可能是指向對象地址的指針或者與此對象位置相關的信息)
虛擬機棧可能拋出兩種錯誤:StackOverflowError 、OutOfMemoryError。
java中方法的調用實際上就是虛擬機棧出棧的操作,每一次方法調用,都有對應的棧彈出,根據每個棧幀中的 局部變量表、操作數棧等信息,執行方法。
1.2 本地方法棧
本地方法棧的工作原理跟虛擬機棧並無區別,唯一的區別就是本地方法棧面向的不是.class字節碼,而是Native修飾的本地方法。
本地方法的執行過程,也是本地方法棧中棧幀的出棧過程。
同虛擬機棧一樣,本地方法棧也是會拋出 StackOverflowError 、OutOfMemoryError 兩種異常。
1.3 程序計數器
程序計數器是一塊較小的內存空間,可看作是當前線程所執行字節碼的行號指示器。字節碼解釋器根據這個計數器來獲取當前線程需要執行的下一條指令,分支、循環、跳轉、異常、線程恢復等功能都需要依賴程序計數器來完成。
此外,在線程爭奪CPU時間片的時候,需要線程切換,這時候,就需要這個計數器來幫助線程恢復到正確執行的位置,每一條線程有自己的程序計數器,所以才能夠保證當前程序能夠正確恢復到上次執行的步驟。
注意:程序計數器是唯一一個不會出現OOM錯誤的內存區域,它的生命周期伴隨線程的創建而創建,隨程序的消亡而消亡。
1.4 堆
堆是java內存管理中最大的一塊內存,也是所有線程共享的一塊內存,在虛擬機啟動時創建。堆中主要存放的是對象實例以及數組。幾乎 所有的對象實力和數組都在這一塊內存中分配。
隨着編譯技術的發展與逃逸分析技術的進步,棧上分配、標量優化等技術使得並不是所有的對象實例都是在堆中分配的。從1.7開始已經默認開啟了逃逸分析,如果方法中的對象引用沒有被返回或者未被外面使用(未逃逸出去),那么對象可以直接在棧中分配。
堆也是GC垃圾回收的主要區域。垃圾回收現在主要采取的是分代垃圾回收算法。為了方便垃圾回收,java堆還進行了細分,分成:新生代、和老年代;再細致還分成Eden、from survivor、to survivor空間等,如下圖:
jdk8之前:

jdk8及之后:

大部分情況下,對象都會在Eden區分配,再一次新生代垃圾回收之后,存活下來的對象進入 survivor區域,並且年齡增加;當年齡增加到一定歲數時(默認15歲),就會進入到 老生代。對象進入老生代的年齡閾值,可以根據JVM參數
-XX:MaxTenuringThreshold來設置。
1.5 方法區
方法區和堆一樣,是多線程共享的內存區域。用來存放已被虛擬機加載的類信息、常量、靜態變量、即時編譯后的代碼等數據。
方法區也被稱為永久代。但是兩者之間還是有區別的:方法區是JVM虛擬機規范中的定義,而永久代是這一規范的一種實現。 也就是說,只有HopSpot虛擬機中才會有永久代這個個概念。
可以通過一些參數來調整方法區內存大小:
jdk1.8之前:設置永久代大小
--XX: PermSize=N
--XX: MaxPermSize=N
jdk1.8 :設置元空間大小
-XX:MetaspaceSize=N //設置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //設置 Metaspace 的最大大小
之所以把永久代去掉,換成元空間,原因是元空間在直接內存中,受本機可用內存控制,雖然元空間仍然有幾率會出現溢出,但是幾率很小。元空間溢出時,會報錯:OutOfMemoryError:MetaSpace 。
1.5.1 運行時常量池
運行時常量池是方法區的一部分,存放的主要是字面量和引用。
String str = "abc";
Integer i = 2;
像這樣的都是存放在常量池中;
String str1 = new String("abc"); //存放在堆中,創建了兩個字符串對象,還有一個在常量池
1.6 直接內存
直接內存不是虛擬機運行時數據區的一部分,也不是虛擬機規范中定義的內存區域,但是這部分頻繁使用,也有可能拋出 OOM錯誤出現。
2.幾種內存溢出異常
2.1Java堆溢出
public class HeapOOM {
static class OOMObject { }
/**
* VM args: -Xms1024k 最小堆空間
* -Xmx1024k 最大堆空間 最小和最大堆空間設置相同,則表示不需要自動擴展
* -XX: +HeapDumpOnOutOfMemoryError 發生OOM錯誤時導出當前內存快照便於分析
* -XX: +PrintGCDetails 打印GC詳情
* @param args
*/
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
///out
......省略GCDetail信息
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:265)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
at java.util.ArrayList.add(ArrayList.java:462)
at com.lavendor.learn.java.basic.jvm.HeapOOM.main(HeapOOM.java:24)
注意: -Xms 是表示設置堆空間最小容量 -Xmx設置堆空間最大容量 這兩個值設置一樣,則表示堆空間不會自動擴展
2.2虛擬機棧和本地方法棧溢出
public class JavaVMStackSOF {
private int stackLength = 1;
//不斷循壞入棧
public void stackLeak() {
stackLength++;
stackLeak();
}
//使用多線程來使虛擬機棧拋出OOM
private void dontStop() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
dontStop();
}
});
thread.start();
}
}
/**
* VM args: -XX:+PrintGCDetails
*
* @param args
*/
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
//VM args: -Xss128k 設置棧空間容量,拋出棧溢出異常
oom.stackLeak(); // StackOverflowError
//VM args: -Xss128m 這個時候可以設置得稍微大些,讓線程的虛擬機棧更大,從而不需要多少線程就能夠把內 // 存撐滿
//oom.stackLeakByThread(); //OutOfMemoryError
} catch (Throwable tx) {
System.out.println("stack length: " + oom.stackLength);
throw tx;
}
}
}
注意: -Xss 設置棧大小
單線程情況下無論是棧幀太大還是虛擬機棧容量太小,都只是拋出
StackOverflowError,不是OutOfMemoryError在多線程情況下,會拋出
OutOfMemoryError,但是看起來並不像是虛擬機棧滿而導致,更像是多個線程在分配虛擬機棧空間時互相爭奪資源,導致內存空間不足,從而拋出OOM異常。從這個角度來說,這時候把棧空間設置很大,更加容易出現OOM異常。在Windows環境下不要輕易嘗試多線程拋出OOM異常,因為Windows環境Java的多線程是映射到操作系統的,可能會導致系統死機
2.3方法區和運行時常量溢出
在JDK1.7及以上版本中,逐漸會去掉“永久代”這個概念,方法區和運行時常量都會放置到堆上,-XX:PermSize 和 -XX:MaxPermSize參數也不再支持。
這個模塊溢出跟 Java堆溢出相似,通過控制堆大小可以看到OOM溢出。
public class MethodAreaOOM {
/** 使用cglib動態代理來無限生成類,去填滿方法區
* cglib生成的類不太容易被GC回收
* VM args: -Xms10m -Xmx10m
* @param args
*/
public static void main(String[] args) {
while (true){
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return method.invoke(o,objects);
}
});
enhancer.create();
}
}
static class OOMObject{}
}
2.4本地直接內存溢出
public class DirectMemoryOOM {
public static final int SINGLE_MB = 1024 * 1024;
/**
* VM args:-XX:MaxDirectMemorySize=10m
* @param args
*/
public static void main(String[] args) throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true){
unsafe.allocateMemory(SINGLE_MB);
}
}
}
-XX: MaxDirectMemorySize指定直接內存最大容量,不指定,則默認跟堆最大容量(-Xmx)相同
由直接內存導致的OOM,一個很明顯的特征是Heap Dump文件不會有明顯的異常,如果發現導出的文件很小,而且程序中直接或者間接的使用了NIO,那么可以考慮直接內存這方面的問題。
3.Java垃圾回收
程序計數器、虛擬機棧、本地方法棧這些都是線程私有的,隨線程而生,消亡而滅。棧中的棧幀隨着方法的進入和退出有條不紊的執行出棧和入棧操作。每一個棧幀中分配多少內存基本上是在類結構確定下來之后就會確定好的,因此,這幾個地方的內存是確定的,不需要過多的考慮內存回收的問題。
Java堆和方法區則不同,他們是線程共享的,程序在運行中需要創建多少對象,分配多少內存,這些都是動態的,所以這部分需要來及回收,GC關注的也是這部分內存,后面討論的回收也只是指這部分內存。
3.1 判斷對象可回收
1.引用計數算法
大體思路: 給對象一個引用計數器,有引用時+1, 無引用時-1,當引用計數器為0,則表示可以回收。
弊端: 當對象之前互相引用,並且已無意義時,不能回收。
目前虛擬機 並不是 采用這種算法。
2.可達性分析算法
大體思路: 通過一系列稱為“GC Root”的對象作為起點,從起點開始向下搜索,搜索通過的路徑稱為引用鏈(reference chain),當一個對象從GC Root開始沒有引用鏈連接,即不可達時(如下圖 object5 object6 object7三個對象),他們將會被認為是可以回收的。

這是目前虛擬機中,判斷對象是否可以被回收的主流實現方法。
有以下幾種對象可以作為GC Root:
- 虛擬機棧中(棧幀中的本地變量表)引用的對象
- 方法區中類靜態屬性引用的對象
- 方法區中常量引用的對象
- 本地方法棧(native方法)中引用的對象
3.finalize()方法拯救快要被GC回收的對象
/**
* @author yanghao
* @date 2021-08-10
* @description
* 這個例子演示了兩點:
* 1.對象可以在被GC時自我拯救
* 2.這種自救機會只有一次,因為一個對象的finalize()方法系統最多只會調用一次
*/
public class FinalizeEscape {
public static FinalizeEscape SAVE_HOOK = null;
public void isAlive(){
System.out.println("yes, I'm still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
//把當前對象指給再關聯起來,拯救自己一次
SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscape();
//對象第一次拯救自己
//SAVE_HOOK = null;
System.gc();
//finalize()方法優先級很低,等待一段時間
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else {
System.out.println("no, i am dead :(");
}
//下面這段代碼與上面完全相同,但是卻失敗了
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else {
System.out.println("no, i am dead :(");
}
}
}
3.2 垃圾回收算法及幾種實現
1.算法
目前主流的垃圾回收算法是 分代回收算法 ,所以我們堆空間分成新生代、老年代是有必要,這可以使得垃圾回收的時候根據不同年代特點選擇合適的垃圾回收算法。

- MinorGC 發生在新生代的垃圾回收動作,MinorGC非常頻繁,執行速度也非常快。
- MajorGC/FullGc 發生在老年代的垃圾回收動作,MajorGC的出現有可能會伴隨着至少一次MinorGC,MajorGC執行速度會比MinorGC慢10倍以上。
1)分代收集算法 根據對象存活周期,分成不同的分區,java中常見就是分成新生代、老年代,然后根據不同年齡代的特點分別回收。
對象分代策略:
- 對象優先在新生代分配
- 大對象直接分配到老年代
- 長期存活的對象直接分配到老年代
2)標記-清除算法 分成兩部分:標記 和 清除 。首先標記出所有不需要回收的對象,標記完成后,把沒有標記的對象全部清除掉。這個算法會有效率問題;以及空間不連續,內存碎片化的問題。
3)標記-整理算法 跟 標記-清除算法 類似,區別就是把存活對象,全部移動到一端,然后再把標記的全部清除掉,這樣可以使得清理對象后,內存連成一塊,不至於太碎片化。
4)復制算法 把內存分成大小相同的兩塊,每次使用一塊。當這一塊內存使用完后,就把還存活的對象復制到另一塊去,然后把這塊內存全部清除掉。這樣效率增加了,但是太耗費內存。
當前的主流虛擬機中,分區成Eden、From Survivor、To Survivor等空間分區,都是采用的此方法:在Eden、From Survivor區中新的對象如果還活着,就會復制到 To Survivor,然后對Eden、Survivor區進行垃圾回收
2.實現
-
Serial GC收集器 Serial Young GC + Serial Old GC (實際上是Full GC)
是最基本,歷史最久的收集器,是單線程的收集器。垃圾回收的線程是單線程,並且在回收垃圾時,需要其余所有的用戶線程暫停--Stop the world,回收線程完成之后再恢復用戶線程。
-
ParNew GC收集器
ParNew就是Serial GC的多線程版本,把垃圾回收的單線程改成多線程,其余不變。這是真正意義上的第一款並發(concurrent)收集器
並發和並行:
並發(Concurrent):指用戶線程與垃圾回收線程同時執行
並行(Parallel):值多條垃圾回收線程並行工作,但是用戶線程等待狀態
-
Parallel GC收集器 Parallel Young GC + 非並行的PS MarkSweepGC/並行的Parallel Old GC(這兩個實際上也是全局范圍內的Full GC) (JDK1.8默認GC)
並行收集器組合 Parallel Scavenge + Parallel Old 年輕代采用復制算法,老年代采用標記-整理,在回收的同時還會對內存進行壓縮。
-
CMS收集器 ParNew(Young) GC + CMS(Old)GC + Full GC for CMS算法

-
G1 GC收集器 Young GC + mixed GC(新生代GC ,再加上部分老生代) + G1 GC for CMS算法
參考 https://www.zhihu.com/question/41922036
3.3 垃圾回收器參數總結
1.GC日志理解
在jvm中添加參數 :-XX:+PrintGCDetials 即可打印出GC日志,內容和含義如下:
[GC (Allocation Failure) [PSYoungGen: 3931K->824K(9216K)] 8035K->8000K(19456K), 0.0023478 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 824K->0K(9216K)] [ParOldGen: 7176K->7888K(10240K)] 8000K->7888K(19456K), [Metaspace: 3471K->3471K(1056768K)], 0.0065113 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 6617K->859K(9216K)] 6617K->4963K(19456K), 0.0033870 secs]
|----------A------------||-----B----| |--------C--------| |--------D---------| |-------------
[Times: user=0.00 sys=0.00, real=0.00 secs]
-----------E-----------------------------|
段A:發生GC的停頓類型,出現Full GC表示發生了Stop-The-World
段B:發生GC的內存區域。此處是使用 Parallel Scavenge 回收器,所以新生區為:PSYoungGen
段C:GC前該內存區域已使用容量->GC后該內存區域已使用容量(該內存區域總容量)
段D:GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆總容量)
段E:此次GC所消耗時間
2.GC參數
| 參數 | 描述 |
|---|---|
| UseSerialGC | 虛擬機運行在client模式下的默認值,設置此參數后,使用 Serial+Serial Old的收集器組合回收 |
| UseParNewGC | 設置此參數后,使用 ParNew+Serial Old的收集器組合回收 |
| UseConMarkSweepGC | 設置此參數后,使用ParNew+CMS+Serial Old收集器組合回收,Serial Old將作為CMS回收失敗Concurrent Mode Failure的后備收集器回收 |
| UseParallelGC | 虛擬機運行在Server模式下的默認值,設置此參數后,使用 Parallel Scavenge+Serial Old(PS MarkSweep)組合進行內存回收 |
| UseParallelOldGC | 設置此參數后,使用 Parallel Scavenge+Parallel Old組合進行內存回收 |
| SurvivorRatio | 新生代Eden區:Survivor區,默認是8,即表示Eden:Survivor=8:1 |
| PretenureSizeThreshold | 直接晉升到老年代的對象大小,大於這個參數的對象,將直接放在老年代 |
| MaxTenuringThreshold | 晉升到老年代的對象的年齡。每個對象在堅持過一次Minor GC之后,年齡+1,當對象的年齡超過這個參數時,放入老年代 |
| UseAdaptiveSizePolicy | 動態調整Java堆中各個區域的大小及進入老年代的年齡 |
| ParallelGCThreads | 設置並行GC線程數,默認4個。視操作系統而定 |
| GCTimeRatio | GC時間占總時間比值,默認99,即允許1%GC時間。僅在使用 Parallel Scavenge收集器時有效 |
| MaxGCPauseMillis | 設置GC最大停頓時間,僅在使用 Parallel Scavenge收集器時有效 |
4.類加載器
4.1類加載過程
java中類加載過程大致分為 加載---->連接---->初始化 ,其中連接過程由分為 驗證---->准備---->解析 。

類的加載都是由加載器來完成的,加載就是指把.class字節碼文件加載到JVM當中去執行。
4.2 類加載器
我們常見的有三種類加載器,除了BootstrapClassLoader 另外兩種加載器都是繼承自java.lang.ClassLoader :
-
AppClassLoader 應用加載器,負責加載當前應用classpath下面的jar和類,包括我們自己正在開發的類。
-
ExtClassLoader 是 AppClassLoader 的父加載器,主要負責加載
%JRE_HOME/lib/ext%目錄下的jar包和類,或者被java.ext.dirs系統變量指定的目錄下得jar包。 -
BootstrapClassLoader 是終極的加載器,由java底層實現,是 ExtClassLoader 的父加載器,此加載器沒有父加載器。此加載器主要負責加載
%JAVA_HOME/lib%下的jar包和類,或者加載-Xbootclasspath變量指定的路徑下的jar包和類。
4.3雙親委派模式加載
每一個類都會有對應的加載器加載,java默認使用的類加載模式是雙親委派模式 。即在類加載的時候,會首先判斷類是否被加載,如果被加載則直接返回被加載的類,如果沒有被加載,就開始嘗試加載。加載的時候會首先 委派 父類加載器加載,因此所有的類加載實際上都會傳送到 BootstrapClassLoader 加載器中,如果父類不能加載,則由自己加載。如果父類加載器返回null,則會啟動 BootstrapClassLoader來作為父類加載。

雙親委派模式加載的好處:
保證了java穩定運行,可以避免類的重復加載,保護了java內部核心API不被篡改。如果不使用這種模式,那用戶也能夠被允許編寫
java.lang.Class這樣的類,就會跟java自帶的類沖突,造成混亂。
