一、問題: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
瞭解更多知識,關注我。