一、問題:Java最大支持棧深度有多大?
1.分析
有JVM的內存結構我們可知:
- 隨着線程棧的大小越大,能夠支持越多的方法調用,也即是能夠存儲更多的棧幀;
- 局部變量表內容越多,那么棧幀就越大,棧深度就越小。
2.詳解
從Java運行時數據區域我們知道,線程中的虛擬機棧結構如下:
每個棧幀包含:本地變量表,操作數棧,動態鏈接,返回地址等東西。也就是說棧調用深度越大,棧幀就越多,就越耗內存。
3、測試案例
1.1、測試線程棧大小對棧深度的影響
下面我們用一個測試例子來說明:
有如下遞歸方法:
public class StackTest {
private int count = 0;
public void recursiveCalls(String a){
count++;
System.out.println("stack depth: " + count);
recursiveCalls(a);
}
public void test(){
try {
recursiveCalls("a");
} catch (Exception e) {
System.out.println(e);
}
}
public static void main(String[] args) {
new StackTest().test();
}
}
我們設置啟動參數
-Xms256m -Xmx256m -Xmn128m -Xss256k
輸出內容:
stack depth: 1556 Exception in thread "main" java.lang.StackOverflowError at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
可以發現,棧深度為1556的時候,就報 StackOverflowError了。
接下來我們調整-Xss線程棧大小為 512k,輸出內容:
stack depth: 3249 Exception in thread "main" java.lang.StackOverflowError at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
發現棧深度變味了3249,說明了:
隨着線程棧的大小越大,能夠支持越多的方法調用,也即是能夠存儲更多的棧幀。
1.2、測試方法參數個對棧深度的影響
這里我們固定設置-Xss為256k。
我們知道此時的深度為:1556。
接下來我們給方法添加參數:
public class StackTest {
private int count = 0;
public void recursiveCalls(String a){
count++;
System.out.println("stack depth: " + count);
recursiveCalls(a);
}
public void test(){
try {
recursiveCalls("a");
} catch (Exception e) {
System.out.println(e);
}
}
public static void main(String[] args) {
new StackTest().test();
}
}
為何要添加參數呢,因為添加參數之后,棧幀中的本地變量表就會增加內容,我們可以嘗試使用以下命令查看下Class文件的匯編指令:
javap -v StackTest.class
可以發現recursiveCalls方法的本地變量表的確增加了,對應方法的入參 a:
LocalVariableTable:
Start Length Slot Name Signature
0 44 0 this Lcom/itzhai/jvm/stacks/StackTest;
0 44 1 a Ljava/lang/String;
這個時候我們在執行程序看看結果:
stack depth: 1318 Exception in thread "main" java.lang.StackOverflowError at java.nio.Buffer.<init>(Buffer.java:201)
可以發現,棧深度由原來的1556編程了1318。
可以得出結論:
局部變量表內容越多,那么棧幀就越大,棧深度就越小。
二、JVM體系
1. JDK,JRE,JVM的聯系是啥?
JVM Java Virtual Machine
JDK Java Development Kit
JRE Java Runtime Environment
直接上官網上的介紹的圖片,一目了然。
2. JVM的作用是啥?

JVM有2個特別有意思的特性,語言無關性和平台無關性。
- 語言無關性:是指實現了Java虛擬機規范的語言對可以在JVM上運行,如Groovy,和在大數據領域比較火的語言Scala,因為JVM最終運行的是class文件,只要最終的class文件復合規范就可以在JVM上運行。
- 平台無關性:是指安裝在不同平台的JVM會把class文件解釋為本地的機器指令,從而實現Write Once,Run Anywhere
3.JVM運行時數據區
Java虛擬機在執行Java程序的過程中會把它所管理的內存划分為若干個不同的數據區域。這些區域都有各自的用途,以及創建和銷毀的時間,有的區域隨着虛擬機進程的啟動而存在,有些區域則依賴用戶線程的啟動和結束而建立和銷毀。
Java虛擬機所管理的內存將會包括以下幾個運行時數據區域

其中方法區和堆是所有線程共享的數據區程序計數器,虛擬機棧,本地方法棧是線程隔離的數據區,畫一個邏輯圖

3.1程序計數器
程序計數器是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器
為什么要記錄當前線程所執行的字節碼的行號?直接執行完不就可以了嗎?
因為代碼是在線程中運行的,線程有可能被掛起。即CPU一會執行線程A,線程A還沒有執行完被掛起了,接着執行線程B,最后又來執行線程A了,CPU得知道執行線程A的哪一部分指令,線程計數器會告訴CPU。
3.2虛擬機棧
虛擬機棧存儲當前線程運行方法所需要的數據,指令,返回地址等。
虛擬機棧描述的是Java方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀用於存儲局部變量表,操作數棧,動態鏈接,方法出口等信息。每個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中從入棧道出棧的過程。
3.2.1、局部變量表
局部變量表是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量,其中存放的數據的類型是編譯期可知的各種基本數據類型、對象引用(reference)和returnAddress類型(它指向了一條字節碼指令的地址)。也即基本基本數據類型,則存在局部變量表中,如果是引用類型。如String,局部變量表中存的是引用,而實例在堆中。局部變量表所需的內存空間在編譯期間完成分配,即在Java程序被編譯成Class文件時,就確定了所需分配的最大局部變量表的容量。當進入一個方法時,這個方法需要在棧中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。來看一個例子:引用類型(new出來的對象)的數據如何存儲的,
public int methodOne(int a, int b) {
Object obj = new Object();
return a + b;
}

假如methodOne方法調用methodTwo方法時, 虛擬機棧的情況如下:

當虛擬機棧無法再放下棧幀的時候,就會出現StackOverflowError。
拓展:
局部變量表的容量以變量槽(Slot)為最小單位。在虛擬機規范中並沒有明確指明一個Slot應占用的內存空間大小(允許其隨着處理器、操作系統或虛擬機的不同而發生變化),一個Slot可以存放一個32位以內的數據類型:boolean、byte、char、short、int、float、reference和returnAddresss。reference是對象的引用類型,returnAddress是為字節指令服務的,它執行了一條字節碼指令的地址。對於64位的數據類型(long和double),虛擬機會以高位在前的方式為其分配兩個連續的Slot空間。
虛擬機通過索引定位的方式使用局部變量表,索引值的范圍是從0開始到局部變量表最大的Slot數量,對於32位數據類型的變量,索引n代表第n個Slot,對於64位的,索引n代表第n和第n+1兩個Slot。
在方法執行時,虛擬機是使用局部變量表來完成參數值到參數變量列表的傳遞過程的,如果是實例方法(非static),則局部變量表中的第0位索引的Slot默認是用於傳遞方法所屬對象實例的引用,在方法中可以通過關鍵字“this”來訪問這個隱含的參數。其余參數則按照參數表的順序來排列,占用從1開始的局部變量Slot,參數表分配完畢后,再根據方法體內部定義的變量順序和作用域分配其余的Slot。
局部變量表中的Slot是可重用的,方法體中定義的變量,作用域並不一定會覆蓋整個方法體,如果當前字節碼PC計數器的值已經超過了某個變量的作用域,那么這個變量對應的Slot就可以交給其他變量使用。這樣的設計不僅僅是為了節省空間,在某些情況下Slot的復用會直接影響到系統的而垃圾收集行為。
3.2.2、操作數棧
操作數棧又常被稱為操作棧,操作數棧的最大深度也是在編譯的時候就確定了。32位數據類型所占的棧容量為1, 64位數據類型所占的棧容量為2。
當一個方法開始執行時,它的操作棧是空的,在方法的執行過程中,會有各種字節碼指令(比如:加操作、賦值元算等)向操作棧中寫入和提取內容,也就是入棧和出棧操作。
Java虛擬機的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的“棧”就是操作數棧。因此我們也稱Java虛擬機是基於棧的,這點不同於Android虛擬機,Android虛擬機是基於寄存器的。
- 基於棧的指令集最主要的優點是可移植性強,主要的缺點是執行速度相對會慢些;
- 由於寄存器由硬件直接提供,所以基於寄存器指令集最主要的優點是執行速度快,主要的缺點是可移植性差。
接着解釋一下操作數棧,還是比較容易理解的。假如Test.java中有如下方法,
public int getSum(int a, int b) {
return a + b;
}
反編譯生成的Test.class文件,並輸出到show.txt中
javap -v Test.class > show.txt
show.txt的內容如下,簡單2個數相加都會用到棧,這個棧就是操作數棧。
public int getSum(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: iload_1 # 局部變量1壓棧
1: iload_2 # 局部變量2壓棧
2: iadd # 棧頂2個元素相加,計算結果壓棧
3: ireturn
LineNumberTable:
line 12: 0
3.2.3、動態連接
每個棧幀都包含一個指向運行時常量池(在方法區中,后面介紹)中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態連接。Class文件的常量池中存在有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用為參數。這些符號引用,一部分會在類加載階段或第一次使用的時候轉化為直接引用(如final、static域等),稱為靜態解析,另一部分將在每一次的運行期間轉化為直接引用,這部分稱為動態連接。
3.2.4、方法返回地址
當一個方法被執行后,有兩種方式退出該方法:執行引擎遇到了任意一個方法返回的字節碼指令或遇到了異常,並且該異常沒有在方法體內得到處理。無論采用何種退出方式,在方法退出之后,都需要返回到方法被調用的位置,程序才能繼續執行。方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,調用者的PC計數器的值就可以作為返回地址,棧幀中很可能保存了這個計數器值,而方法異常退出時,返回地址是要通過異常處理器來確定的,棧幀中一般不會保存這部分信息。
方法退出的過程實際上等同於把當前棧幀出站,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,如果有返回值,則把它壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令后面的一條指令。
3.3本地方法棧
本地方法棧(Native Method Stack)與虛擬機棧發揮的作用是非常相似的,他們之間的區別不過是:
- 虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,
- 而本地方法棧則為虛擬機使用到的Native方法服務。
3.4堆
- 對於大多數應用來說,Java堆(Java Heap)是Java虛擬機鎖管理的內存中最大的一塊。
- Java堆是所有線程共享的一塊內存區域,在虛擬機啟動時創建,在JVM中只有一個。
- 此內存區域的唯一目的就是存放對象實例以及數組(當然,數組引用是存放在Java棧中的),幾乎所有的對象實例都在這里分配內存。
- 這部分空間也是Java垃圾收集器管理的主要區域。
3.5方法區
方法區(Method Area)與Java堆一樣,是各個線程共享的內存區域,它用於存儲以下信息(不僅限於):
- 已被虛擬機加載的類的信息(包括類的名稱、方法信息、字段信息)
- 靜態變量
- 常量
- 編譯器編譯后的代碼。
在Class文件中除了類的字段、方法、接口等描述信息外,還有一項信息是常量池,用來存儲編譯期間生成的字面量和符號引用。
在方法區中有一個非常重要的部分就是運行時常量池,它是每一個類或接口的常量池的運行時表示形式,在類和接口被加載到JVM后,對應的運行時常量池就被創建出來。當然並非Class文件常量池中的內容才能進入運行時常量池,在運行期間也可將新的常量放入運行時常量池中,比如String的intern方法。
在JVM規范中,沒有強制要求方法區必須實現垃圾回收。很多人習慣將方法區稱為“永久代”,是因為HotSpot虛擬機以永久代來實現方法區,從而JVM的垃圾收集器可以像管理堆區一樣管理這部分區域,從而不需要專門為這部分設計垃圾回收機制。不過自從JDK7之后,Hotspot虛擬機便將運行時常量池從永久代移除了。
4.JVM堆內存模型
它是JVM用來存儲對象實例以及數組值的區域,可以認為Java中所有通過new創建的對象的內存都在此分配,Heap中的對象的內存需要等待GC進行回收。
(1) 堆是JVM中所有線程共享的,因此在其上進行對象內存的分配均需要進行加鎖,這也導致了new對象的開銷是比較大的
(2) Sun Hotspot JVM為了提升對象內存分配的效率,對於所創建的線程都會分配一塊獨立的空間TLAB(Thread Local Allocation Buffer),其大小由JVM根據運行的情況計算而得,在TLAB上分配對象時不需要加鎖,因此JVM在給線程的對象分配內存時會盡量的在TLAB上分配,在這種情況下JVM中分配對象內存的性能和C基本是一樣高效的,但如果對象過大的話則仍然是直接使用堆空間分配
(3) TLAB僅作用於新生代的Eden Space,因此在編寫Java程序時,通常多個小的對象比大的對象分配起來更加高效。
(4) 所有新創建的Object 都將會存儲在新生代Yong Generation中。如果Young Generation的數據在一次或多次GC后存活下來,那么將被轉移到OldGeneration。新的Object總是創建在Eden Space。

由顏色可以看出,jdk1.8之前,堆內存被分為新生代,老年代,永久代,jdk1.8及以后堆內存被分成了新生代和老年代和元空間,元空間可以理解為直接的物理內存。新生代的區域又分為eden區,s0區,s1區,默認比例是8:1:1,

5.JVM垃圾回收
GC (Garbage Collection)的基本原理:將內存中不再被使用的對象進行回收,GC中用於回收的方法稱為收集器,由於GC需要消耗一些資源和時間,Java在對對象的生命周期特征進行分析后,按照新生代、舊生代的方式來對對象進行收集,以盡可能的縮短GC對應用造成的暫停
(1)對新生代的對象的收集稱為minor GC;
(2)對舊生代的對象的收集稱為Full GC;
(3)程序中主動調用System.gc()強制執行的GC為Full GC。
不同的對象引用類型, GC會采用不同的方法進行回收,JVM對象的引用分為了四種類型:
(1)強引用:默認情況下,對象采用的均為強引用(這個對象的實例沒有其他對象引用,GC時才會被回收)
(2)軟引用:軟引用是Java中提供的一種比較適合於緩存場景的應用(只有在內存不夠用的情況下才會被GC)
(3)弱引用:在GC時一定會被GC回收
(4)虛引用:由於虛引用只是用來得知對象是否被GC
參考文檔
- https://www.cnblogs.com/dolphin0520/p/3613043.html
- https://blog.csdn.net/ns_code/article/details/17565503
- https://www.itzhai.com/articles/how-stack-frame-can-a-thread-hold.html
- https://www.itzhai.com/articles/how-java-runtime-data-area-works.html
- https://zhuanlan.zhihu.com/p/109794172
了解更多知識,關注我。

