JVM执行篇:使用HSDIS插件分析JVM代码执行细节--转
http://www.kuqin.com/java/20111031/314144.html
在《Java虛擬機規范》之中,詳細描述了虛擬機指令集中每條指令的執行過程、執行前后對操作數棧、對局部變量表的影響等細節。這些細節描述與Sun的早期虛擬機(Sun Classic VM)高度吻合,但隨著技術的發展,高性能虛擬機真正的細節實現方式已經漸漸與虛擬機規范所描述產生越來越大的差距,虛擬機規范中的描述逐漸成了虛擬機實現的“概念模型”——即實現只能保證規范描述等效。
基于上面的原因,我們分析程序的執行語義問題(虛擬機做了什么)時,在字節碼層面上分析完全可行,但分析程序的執行行為問題(虛擬機是怎樣做的、性能如何)時,在字節碼層面上分析就沒有什么意義了,需要通過其他方式解決。
準備工作
分析程序如何執行,通過軟件調試工具(GDB、Windbg等)來斷點調試是最常見的手段,但是這樣的調試方式在JVM中會遇到很大困難,因為大量執行代碼是通過JIT編譯器動態生成到CodeBuffer中的,沒有很簡單的手段來處理這種混合模式的調試(不過相信虛擬機開發團隊內部肯定是有內部工具的)。因此我們要通過一些曲線手段來解決問題,基于這種背景下,本文的主角——HSDIS插件就正式登場了。
HSDIS是由Project Kenai(http://kenai.com/projects/base-hsdis)提供并得到Sun官方推薦的HotSpot VM JIT編譯代碼的反匯編插件,作用是讓HotSpot的-XX:+PrintAssembly指令調用它來把動態生成的本地代碼還原為匯編代碼輸出,同時還生成了大量非常有價值的注釋,這樣我們就可以通過輸出的代碼來分析問題。讀者可以根據自己的操作系統和CPU類型從Kenai的網站上下載編譯好的插件,直接放到JDK_HOME/jre/bin/client和JDK_HOME/jre/bin/server目錄中即可。如果沒有找到所需操作系統(譬如Windows的就沒有)的成品,那就得自己拿源碼編譯一下,或者去HLLVM圈子(http://hllvm.group.iteye.com/)中下載也可以。
當然,既然是通過-XX:+PrintAssembly指令使用的插件,那自然還要求一份FastDebug版的JDK,在OpenJDK網站上各個JDK版本發布時一般都伴隨有FastDebug版的可以下載,不過聽說JDK 7u02之后不再提供了,要用最新的JDK版本就可能需要自己編譯。筆者所使用的虛擬機是HotSpot B127 FastDebug(JDK 7 EA時的VM),默認為Client VM,后面的案例都基于這個運行環境之下:
代碼清單1:
>java -version java version "1.7.0-ea-fastdebug" Java(TM) SE Runtime Environment (build 1.7.0-ea-fastdebug-b127) Java HotSpot(TM) Client VM (build 20.0-b06-fastdebug, mixed mode)案例一:Java堆、棧在本地代碼中的存在形式
環境準備好后,本篇的話題正式開始。三個案例都是筆者給朋友的回信,第一個案例的問題是“在Java虛擬機規范中把虛擬機內存劃分為Java Heap、Java VM Stack、Method Area等多個運行時區域,那當ByteCode編譯為Native Code后,Java堆、棧、方法區還是原來那個嗎?在Java堆、棧、方法區中的數據是如何訪問的?”
我們通過下面這段簡單代碼的實驗來回答這個問題:
代碼清單2
public class Bar {int a = 1;static int b = 2;public int sum(int c) {return a + b + c;}public static void main(String[] args) {new Bar().sum(3);} }代碼很簡單,sum()方法使用到3個變量a、b、c,按照概念模型中的劃分,其中a是實例變量,來自Java Heap,b是類變量,來自Method Area,c是參數,來自VM Stack。那我們來看看JIT之后,它們是怎么訪問的。使用下面命令來執行上述代碼:
>java -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*Bar.sum -XX:CompileCommand=compileonly,*Bar.sum test.Bar其中,參數-Xcomp是讓虛擬機以編譯模式執行代碼,這樣代碼可以偷懶,不需要執行足夠次數來預熱都能觸發JIT編譯。兩個-XX:CompileCommand意思是讓編譯器不要內聯sum()并且只編譯sum(),-XX:+PrintAssembly就是輸出反匯編內容。如果一切順利的話,屏幕上出現類似下面代碼清單3所示的內容:
代碼清單3
[Disassembling for mach='i386'] [Entry Point] [Constants]# {method} 'sum' '(I)I' in 'test/Bar'# this: ecx = 'test/Bar'# parm0: edx = int# [sp+0x20] (sp of caller)……0x01cac407: cmp 0x4(%ecx),%eax0x01cac40a: jne 0x01c6b050 ; {runtime_call} [Verified Entry Point]0x01cac410: mov %eax,-0x8000(%esp)0x01cac417: push %ebp0x01cac418: sub $0x18,%esp ;*aload_0; - test.Bar::sum@0 (line 8);; block B0 [0, 10]0x01cac41b: mov 0x8(%ecx),%eax ;*getfield a; - test.Bar::sum@1 (line 8)0x01cac41e: mov $0x3d2fad8,%esi ; {oop(a 'java/lang/Class' = 'test/Bar')}0x01cac423: mov 0x68(%esi),%esi ;*getstatic b; - test.Bar::sum@4 (line 8)0x01cac426: add %esi,%eax0x01cac428: add %edx,%eax0x01cac42a: add $0x18,%esp0x01cac42d: pop %ebp0x01cac42e: test %eax,0x2b0100 ; {poll_return}0x01cac434: ret代碼并不多,一句一句來看:
從匯編代碼中可見,訪問Java堆、棧和方法區中的數據,都是直接訪問某個內存地址或者寄存器,之間并沒有看見有什么隔閡。HotSpot虛擬機本身是一個運行在物理機器上的程序,Java堆、棧、方法區都在Java虛擬機進程的內存中分配。在JIT編譯之后,Native Code面向的是HotSpot這個進程的內存,說變量a還在Java Heap中,應當理解為a的位置還在原來的那個內存位置上,但是Native Code是不理會Java Heap之類的概念的,因為那并不是同一個層次的概念。
案例二:循環語句的寫法以及Client和Server的性能差異
如果第一個案例還有點抽象的話,那這個案例就更具體實際一些:有位朋友給了筆者下面這段代碼(如代碼清單4所示),并提出了2個問題:
代碼清單4:
public class Client1 {public static void main(String[] args) {List<Object> list = new ArrayList<Object>();Object obj = new Object();// 填充數據for (int i = 0; i < 200000; i++) {list.add(obj);}long start;start = System.nanoTime();// 初始化時已經計算好條件for (int i = 0, n = list.size(); i < n; i++) {}System.out.println("判斷條件中計算:" + (System.nanoTime() - start) + " ns");start = System.nanoTime();// 在判斷條件中計算for (int i = 0; i < list.size(); i++) {}System.out.println("判斷條件中計算:" + (System.nanoTime() - start) + " ns");} }首先來看,代碼最終執行時,for (int i = 0, n = list.size(); i < n; i++)的寫法所生成的代碼與for (int i = 0; i < list.size(); i++)有何差別。它們反匯編的結果如下(提取循環部分的代碼):
代碼清單5:for (int i = 0, n = list.size(); i < n; i++)的循環體
0x01fcd554: inc %edx ; OopMap{[60]=Oop off=245};*if_icmplt; - Client1::main@63 (line 17) 0x01fcd555: test %eax,0x1b0100 ; {poll} 0x01fcd55b: cmp %eax,%edx ;; 124 branch [LT] [B5] 0x01fcd55d: jl 0x01fcd554 ;*if_icmplt; - Client1::main@63 (line 17)變量i放在edx中,變量n放在eax中,inc指令對應i++(被優化成++i了),test指令是在回邊處進行輪詢SafePoint,cmp是比較n和i的值,jl就是當i<n的時候進行跳轉,跳轉的地址是回到inc指令。
代碼清單6:for (int i = 0; i < list.size(); i++)的循環體
0x01b6d610: inc %esi;; block B7 [110, 118]0x01b6d611: mov %esi,0x50(%esp) 0x01b6d615: mov 0x3c(%esp),%esi 0x01b6d619: mov %esi,%ecx ;*invokeinterface size; - Client1::main@113 (line 23) 0x01b6d61b: mov %esi,0x3c(%esp) 0x01b6d61f: nop 0x01b6d620: nop 0x01b6d621: nop 0x01b6d622: mov $0xffffffff,%eax ; {oop(NULL)} 0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460};*invokeinterface size; - Client1::main@113 (line 23); {virtual_call} 0x01b6d62c: nop ; OopMap{[60]=Oop off=461};*if_icmplt; - Client1::main@118 (line 23) 0x01b6d62d: test %eax,0x160100 ; {poll} 0x01b6d633: mov 0x50(%esp),%esi 0x01b6d637: cmp %eax,%esi ;; 224 branch [LT] [B8] 0x01b6d639: jl 0x01b6d610 ;*if_icmplt; - Client1::main@118 (line 23)可以看到,除了上面原有的幾條指令外,確實還多了一次invokeinterface方法調用,執行的方法是size(),方法接收者是list對象,除此之外,其他指令都和上面的循環體一致。所以至少在HotSpot Client VM中,第一種循環的寫法是能提高性能的,因為實實在在地減少了一次方法調用。
但是這個結論并不是所有情況都能成立,譬如這里把list對象從ArrayList換成一個普通數組,把list.size()換成list.length。那將可以觀察到兩種寫法輸出的循環體是完全一樣的(都和前面第一段匯編的循環一樣),因為虛擬機不能保證ArrayList的size()方法調用一次和調用N次是否會產生不同的影響,但是對數組的length屬性則可以保證這一點。也就是for (int i = 0, n = list.length; i < n; i++)和for (int i = 0; i < list.length; i++)的性能是沒有什么差別的。
再來繼續看看為何這段代碼在Server VM下測出來的速度比Client VM還慢,這個問題不好直接比較Server VM和Client VM所生成的匯編代碼,因為Server VM經過重排序后,代碼結構完全混亂了,很難再和前面代碼的比較,不過我們還是可以注意到兩者編譯過程的不同,加入-XX:+ PrintCompilation參數后,它們的編譯過程輸出如下:
代碼清單7:Server VM和Client VM的編譯過程
// 下面是Client VM的編譯過程 VM option '+PrintCompilation'169 1 java.lang.String::hashCode (67 bytes)172 2 java.lang.String::charAt (33 bytes)174 3 java.lang.String::indexOf (87 bytes)179 4 java.lang.Object::<init> (1 bytes)185 5 java.util.ArrayList::add (29 bytes)185 6 java.util.ArrayList::ensureCapacityInternal (26 bytes)186 1% Client1::main @ 21 (79 bytes)// 下面是Server VM的編譯過程 VM option '+PrintCompilation'203 1 java.lang.String::charAt (33 bytes)218 2 java.util.ArrayList::add (29 bytes)218 3 java.util.ArrayList::ensureCapacityInternal (26 bytes)221 1% Client1::main @ 21 (79 bytes)230 1% made not entrant Client1::main @ -2 (79 bytes)231 2% Client1::main @ 51 (79 bytes)233 2% made not entrant Client1::main @ -2 (79 bytes)233 3% Client1::main @ 65 (79 bytes)可以看到,ServerVM中OSR編譯發生了3次,丟棄了其中2次(made not entrant的輸出),換句話說,在這個TestCase里面,main()方法的每個循環JIT編譯器都要折騰一下子。當然這并不是ServerVM看起來比ClientVM看起來慢的唯一原因。ServerVM的優化目的是為了長期執行生成盡可能高度優化的執行代碼,為此它會進行各種努力:譬如丟棄以前的編譯成果、在解釋器或者低級編譯器(如果開啟多層編譯的話)收集性能信息等等,這些手段在代碼實際執行時是必要和有效的,但是在Microbenchmark中就會顯得很多余并且有副作用。因此寫Microbenchmark來測試Java代碼的性能,經常會出現結果失真。
案例三:volatile變量與指令重排序
在JMM模型(特指JDK 5修復后的JMM模型)中,對volatile關鍵字賦予的其中一個語義是禁止指令重排序優化,這個語義可以保證在并發訪問volatile變量時保障一致性,在外部線程觀察volatile變量確保不會得到臟數據。這也是為何在JDK 5后,將變量聲明為volatile就可以使用DCL(Double Checked Locking)來實現單例模式的原因。那進一步的問題就是volatile變量訪問時與普通變量有何不同?它如何實現禁止重排序的呢?
首先,我們編寫一段標準的DCL單例代碼,如代碼清單8所示。觀察加入volatile和未加入volatile關鍵字時生成匯編代碼的差別。
代碼清單8:
public class Singleton {private volatile static Singleton instance;public static Singleton getInstance() {if (instance == null) {synchronized (Singleton.class) {if (instance == null) {instance = new Singleton();}}}return instance;}public static void main(String[] args) {Singleton.getInstance();} }編譯后,這段代碼對instance變量賦值部分代碼清單9所示:
代碼清單9:
0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33; {oop('Singleton')} 0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000 0x01a3de1a: shr $0x9,%esi ;...c1ee09 0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100 0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00;*putstatic instance; - Singleton::getInstance@24通過對比發現,關鍵變化在于有volatile修飾的變量,賦值后(前面mov %eax,0x150(%esi)這句便是賦值操作)多執行了一個“lock addl $0x0,(%esp)”操作,這個操作相當于一個內存屏障,只有一個CPU訪問內存時,并不需要內存屏障;但如果有兩個或更多CPU訪問同一塊內存,且其中有一個在觀測另一個,就需要內存屏障來保證一致性了。
指令“addl $0x0,(%esp)”顯然是一個空操作,關鍵在于lock前綴,查詢IA32手冊,它的作用是使得本CPU的Cache寫入了內存,該寫入動作也會引起別的CPU invalidate其Cache。所以通過這樣一個空操作,可讓前面volatile變量的修改對其他CPU立即可見。
那為何說它禁止指令重排序呢?從硬件架構上講,指令重排序是指CPU采用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理。但并不是說指令任意重排,CPU需要能正確處理指令依賴情況保障程序能得出正確的執行結果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的,它們之間的順序不能重排——(A+10)*2與A*2+10顯然不相等,但指令3可以重排到指令1、2之前或者中間,只要保證CPU執行后面依賴到A、B值的操作時能獲取到正確的A和B值即可。所以在本內CPU中,重排序看起來依然是有序的。因此,lock addl $0x0,(%esp)指令把修改同步到內存時,所有之前的操作都已經執行完成,這樣便形成了“指令重排序無法越過內存屏障”的效果。
轉載于:https://www.cnblogs.com/davidwang456/p/3464542.html
總結
以上是生活随笔為你收集整理的JVM执行篇:使用HSDIS插件分析JVM代码执行细节--转的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: jmap查看内存使用情况与生成heapd
- 下一篇: Understanding JVM In