StackOverflowError這個錯誤常出現在較深的方法調用以及遞歸方法中,平時很少會遇到。我們以一道經典的遞歸算法題為例,求1到n的和。為了查看在發生棧溢出時方法一共遞歸了多少次,我們在方法中打印當前n的值。
public class RecursionAlgorithmMain {
private static volatile int value = 0;
static int sigma(int n) {
value = n;
System.out.println("current 'n' value is " + n);
return n + sigma(n + 1);
}
public static void main(String[] args) throws IOException {
new Thread(() -> sigma(1)).start();
System.in.read();
System.out.println(value);
}
}
在默認棧大小情況下,程序拋出棧溢出錯誤并終止線程時,方法遞歸調用了6524次:
在默認棧大小的情況下,多次運行代碼,得出的結果是相差不大的。在發生StackOverflowError時,進程并沒有結束,因為一個線程的StackOverflowError并不影響整個進程。
現在我們將配置JVM的啟動參數-Xss(棧大小),以調整虛擬機棧的大小為256k。如果你是使用idea運行本例代碼,可直接在VM options配置加上-Xss256K。如果你是使用java命令運行,可在java命令后面加上-Xss256k。
運行
這次一共調用了1669次,這與調整棧大小之前似乎存在著某種關系,用棧大小調整之前程序發生棧溢出時方法的調用次數除以棧大小調整后的,結果約是3。這是不是說明棧的大小默認為1024K左右呢。當然,以這個測試結果來說明其實并不嚴謹。
我們可以通過打印虛擬機參數查看默認的棧大小。使用jinfo[1]命令行工具可查看某個Java進程當前虛擬機棧的大小,這是jdk提供的工具,不需要額外下載安裝。使用jinfo查看Java進程線程棧大小如下:
其實,不顯式設置-Xss或-XX:ThreadStackSize時,在Linux x64上ThreadStackSize的默認值就是1024KB,給Java線程創建棧會用這個參數指定z的大小。如果把-Xss或者-XX:ThreadStackSize設為0,就是使用“系統默認值”。而在Linux x64上HotSpot VM給Java棧定義的“系統默認”大小也是1MB。
除了可以使用jinfo命令行工具查看之外,我們還可以通過NAT工具查看。使用NAT還能查看方法區的大小。以使用Java命令啟動Java進程為例,在Java命令后面加上開啟NAT的配置參數NativeMemoryTracking,如下:
進程啟動后,可通過jcmd命令行工具查看該進程的內存占用信息
我們能看到當前進程Java堆分配的大小、用于存儲類元數據信息使用的內存、線程棧總共占用的內存等。圖中有每個參數的詳細說明,這里不再詳細說明。從線程棧信息來看,被查看的進程當前線程數為63,使用內存為63696K,也就是每個線程棧占用1M內存。
NAT工具也用于排查內存泄露問題,當項目中依賴了一些使用直接內存的第三方jar包時,可能會因為使用不當而造成內存泄露。如堆內存沒有用滿,但top命令查看內存使用率卻接近百分百,這種情況就很有可能是程序使用堆外直接內存造成的。-Xss參數在多線程項目中常用于JVM調優。假設項目中開啟1024個線程,那么使用默認棧大小的情況下,虛擬機棧將會占用1G的內存,而如果將棧大小調整為256K,虛擬機將只花費256M內存用于1024個棧的分配。
最后,我們也可以在HotSpot源碼中找到關于棧大小的設置。以64位Linux操作系統為例,默認棧大小為1M,編譯線程的棧大小為4M,如代碼清單所示:
棧也有最小值,在不同的操作系統及CPU環境下,棧的最小值也不一樣。如在64位的Linxu系統下,使用java命令啟動一個jar包并將-Xss配置為128K,進程將會異常終止,并提示創建Java虛擬機失敗,要求棧最小值為228K。
虛擬機棧的最小值在虛擬機啟動時解析完全局參數之后調用os::init_2方法設置。虛擬機棧的最小值受當前系統是32位還是64位的影響,也受系統頁大小影響。在64位Linxu操作系統下,HotSopt所允許設置的棧的最小值為228K[6],如代碼清單:
在Java中,Java線程與操作系統一對一綁定,Java虛擬機棧也與操作系統線程棧映射,操作系統線程在Java線程創建時創建。前面介紹-Xss配置虛擬機棧的大小便是指定操作系統線程棧的大小。
我們以Java命令啟動一個Java程序就是啟動一個JVM進程。程序中main方法是Java程序的入口,JVM會為main方法的執行分配一個線程,叫main線程。我們編寫的Java代碼都會在線程中執行,而在Java中創建Thread對象并調用start方法時,JVM會為其創建一個Java線程,并創建一個操作系統線程,將操作系統線程綁定到Java線程上。HotSpot虛擬機線程start流程如下:
雖然Java是一門面向對象的語言,但程序運行依然是基于方法的調用,每個方法對應一個棧楨,方法的調用對應棧楨的入棧和出棧。Java類中每個方法的代碼經過編譯處理后最終變為字節碼指令存儲在Code屬性中。棧與棧楨的關系如圖下所示:
在調用Thread對象的start方法時,該線程對應的虛擬機棧的第一個棧楨是run方法。run方法中每調用一個方法就對應一個棧楨的入棧,一個方法只有執行結束才會出棧。方法執行結束包括方法拋出異常結束、return命令返回。棧的大小是固定的,默認棧大小是1M,可通過-Xss參數配置。因此,從run方法開始,如果調用鏈路過深,如遞歸方法,在棧沒有足夠的空間容納下一個棧楨的入棧時,就會出現StackOverflowError錯誤,同時當前棧被銷毀,當前線程結束。HotSpot虛擬機的實現源碼如代碼清單所示。
局部變量表與操作數棧
在了解線程、棧與棧楨的關系后,我們還要重點關注棧楨中的局部變量表與操作數棧,這兩個數據結構是字節碼指令執行所依賴的。
- 局部變量表
局部變量表存儲方法中聲明的變量、方法參數,如果是非靜態方法還會存放this引用。局部變量表的大小是固定的,在編譯時就已經確定。這也是我們在操作字節碼時需要注意的一點,我們需要計算方法的局部變量表需要多大,如果設置過大就會造成內存資源的浪費。
局部變量表的結構是一個數組,數組的單位是Slot(變量槽),Slot的大小是多少個字節由虛擬機決定。在32位的HotSpot虛擬機中,一個Slot槽的大小是4個字節,而在64位的HotSpot虛擬機中,一個Slot槽的大小是8個字節,在開啟指針壓縮的情況下,一個Slot槽的大小是4個字節。局部變量表的結構如圖所示。
-
操作數棧
操作數棧與局部變量表一樣,大小也是固定的,也是在編譯期確定,單位也是Slot。但與局部變量不一樣的是,它并不是由多少個局部變量決定棧的深度的,與需要傳遞最多參數的方法調用有很大關系。因此,操作數棧的深度相對來說比較難確定。操作數棧用于存儲執行字節碼指令所需要的參數。比如獲取對象自身的字段,需要先將this引用壓入棧頂,再執行getfield字節碼指令;比如執行new指令后,棧頂會存放該new指令返回的對象的引用。操作數棧的結構如圖所示。
局部變量表與操作數棧大小的設置,也會影響到棧楨的大小,從而影響棧所能容納的棧楨的最大數量。以前面棧溢出的例子說明,默認1M大小的棧大概能調用六千次的遞歸求和方法,而如果遞歸方法中再寫得復雜些,也會導致調用次數的下降。使用ASM框架操作字節碼時,要注意合理設置這個結構的大小。
做個試驗,我們將遞歸方法寫的復雜一些:
public class RecursionAlgorithmMain {
private static volatile int value = 0;
static int sigma(int n) {
int i = 0, j = i, a = i, b = i, c = i, r = i, g = 0;
int[] arr = new int[]{i, j, a, b, c, r, g};
value = n;
System.out.println("current 'n' value is " + n);
return n + sigma(n + 1);
}
public static void main(String[] args) throws IOException {
new Thread(() -> sigma(1)).start();
System.in.read();
System.out.println(value);
}
}
再次運行后,會發下只調用5000多次,因為棧幀大了,在總容量不變的情況下,能容納的棧幀數量減少了,即方法調用次數減少了。
基于棧的指令集架構
在匯編語言中,除直接內存操作的指令外,其它指令的執行都依賴寄存器,如跳轉指令、循環指令、加減法指令等。匯編指令集是由硬件直接支持的,不同架構的CPU提供的匯編指令集也會不一樣。以一個經典的++i面試題為例,使用c語言編寫的實現如下。
int m = ++i;
反匯編后對應的32位x86 CPU的匯編指令如下。
這三條指令的意思是,先將[ebp-44h]指向的內存塊的值加1,dword ptr相當于c語言中的類型聲明。接著將自增后[ebp-44h]指向的內存塊的值放入eax寄存器,最后將eax寄存器的值放到[ebp-4ch]指向的內存塊,也就是賦值給變量m。由于i和m是在棧上分配的內存,因此[ebp-44h]對應i的內存地址,[ebp-4ch]對應m的內存地址。
匯編指令不能直接操作將一塊內存的值賦值給另一塊內存,必須要通過寄存器。32位x86 CPU包括8個通用寄存器,EAX、EBX、ECX、EDX、ESP、EBP、ESI、EDI,其中EBP、ESP用做指針寄存器,存放堆棧內存儲單元的偏移量。這些看不懂沒關系,這也不是java程序員的重點。我也不懂。
上述++i的例子使用java代碼實現如下。
public class AddByteCode {
/**
* ++i問題
*
* @param args
*/
public static void main(String[] args) {
int a = 10;
int result = ++a;
System.out.println(result);
}
}
使用javap命令輸出這段代碼的字節碼如下。
字節碼指令前面的編號我們暫時理解為行號。在本例中,行號0到7的字節碼指令完成的工作是將變量a自增后的值賦值給result變量。下面將詳細分析這幾條指令的執行過程:
-
bipush指令是將立即數10放入到操作數棧頂。
-
istore_1指令是將操作數棧頂的元素從操作數棧出彈出,并存放到局部變量表中索引為1的Slot,也就是賦值給變量a。
-
iinc這條字節碼指令比較特別,它可以直接操作局部變量表的變量,而不需要經過操作數棧。該指令是將局部變量表中索引為1的Slot所存儲的整數值自增1,也就是將局部變量a自增1。
-
iload_1指令是將自增后的變量a放入操作數棧的棧頂。
-
最后,istore_2指令是將當前操作數棧頂的元素從操作數棧彈出,并存放到局部變量表中索引為2的Slot,也就是給result變量賦值。
從++i的例子中,我們可以看出,字節碼是依賴操作數棧工作的。在虛擬機上執行的字節碼指令雖然最終也是編譯為機器碼執行,但編寫字節碼指令時并不需要我們考慮使用哪些寄存器的問題,這些交由JVM去實現。
使用匯編指令編寫代碼,我們需要考慮CPU的架構,有多少個寄存器可選,了解硬件,需要關心每條指令操作多少個字節,在使用寄存器之前需要考慮是否要備份寄存器的當前值,指令執行完之后是否需要恢復寄存器的值。而使用依賴棧工作的字節碼指令編寫代碼,我們只需要關心每條字節碼指令需要多少個參數,按順序將參數push到操作數棧頂。如果指令執行完有返回值,操作數棧頂就是返回值。
總結
本文我們從棧溢出的例子出發,了解了棧與線程的關系、棧與棧楨的關系,同時也介紹在多線程項目中如何通過配置-Xss參數調優,降低進程占用的內存,以及如何通過NAT工具查看進程使用的內存情況。在理解棧楨之后又重點分析局部變量表與操作數棧,回顧棧溢出的例子,理解一個棧楨的大小也與這兩者有很大的關系。最后通過++i的例子列舉了匯編指令與字節碼指令在架構上的不同,簡單分析字節碼解釋執行的過程。本文介紹的棧、棧楨、局部變量表與操作數棧是后續學習Java字節碼的基礎知識。