大多Java程序員知道他們的程序通常不會被編譯為本機代碼而是被編譯為由java虛擬機(JVM)執行的字節碼格式。然而,很少有java程序員曾經看過字節碼因為他們的工具不鼓勵他們去看。大多Java 調試工具不允許單步執行字節碼,它們要麼顯示源代碼行,要麼什麼也不顯示。
幸運的是JDK提供了javap,一個命令行工具,它使得查看字節碼很容易。讓我們看一個范例:
public class ByteCodeDemo {
public static void main(String[] args) {
System.out.println("Hello world");
}
}
在編譯這個類後,你可以用十六進制編輯器打開.class文件然後參照虛擬機規范翻譯字節碼。幸運的是有更簡單的方法。JDK包含一個命令行的反匯編器:javap,它可以轉換字節碼為一種可讀的助記符形式,可以像下面這樣通過傳遞'-c'參數給javap得到字節碼列表:
javap -c ByteCodeDemo
你應該會看到輸出類似這樣:
public class ByteCodeDemo extends java.lang.Object {
public ByteCodeDemo();
public static void main(java.lang.String[]);
}
Method ByteCodeDemo()
0 aload_0
1 invokespecial #1
4 return
Method void main(java.lang.String[])
0 getstatic #2
3 ldc #3
5 invokevirtual #4
8 return
僅僅從這個短小的列表你可以學到很多字節碼的知識。從main方法的第一個指令開始:
0 getstatic #2
開始的整數是方法中的指令的偏移值,因此第一個指令以0開始。緊隨偏移量是指令的助記符(mnemonic)。在這個范例中,'getstatic' 指令將一個靜態成員壓入一個稱為操作數堆棧的數據結構,後續的指令可以引用這個數據結構中的成員。getstatic 指令後是要壓入的成員。在這個例子中,要壓入的成員是"#2 " 。如果你直接檢查字節碼,你會看到成員信息沒有直接嵌入指令而是像所有由java類使用的常量那樣存儲在一個共享池中。將成員信息存儲在一個常量池中可以減小字節碼指令的大小,因為指令只需要存儲常量池中的一個索引而不是整個常量。在這個例子中,成員信息位於常量池中的#2處。常量池中的項目的順序是和編譯器相關的,因此在你的環境中看到的可能不是'#2' 。
分析完第一個指令後很容易猜到其它指令的意思。'ldc' (load constant) 指令將常量"Hello, World."壓入操作數棧。'invokevirtual'指令調用println方法,它從操作數棧彈出它的兩個參數。不要忘記一個像println這樣的實例方法有兩個參數:上面的字符串,加上隱含的'this'引用。
字節碼如何預防內存錯誤
Java語言經常被吹捧為開發互聯網軟件的"安全的"語言。表面上和c++如此相似的代碼如何體現安全呢?它引入的一個重要的安全概念是防止內存相關的錯誤。計算機罪犯利用內存錯誤在其它情況下安全的程序中插入自己的惡意的代碼。Java字節碼是第一個可以預防這種攻擊的,像下面的范例展示的:
public float add(float f, int n) {
return f + n;
}
如果你將這個方法加入上面的范例中,重新編譯它,然後運行javap,你將看到的字節碼類似這個:
Method float add(float, int)
0 fload_1
1 iload_2
2 i2f
3 fadd
4 freturn
在方法的開始,虛擬機將方法的參數放入一個稱為局部變量表的數據結構中。將像名字暗示的那樣,局部變量表也包含了你聲明的任何局部變量。在這個例子中,方法以三個局部變量表的項開始,這些都是add方法的參數,位置0保存this引用,而位置1和2分別保存float和int參數。
為了實際的操作這些變量,它們必須被加載(壓入)到操作數棧。第一個指令fload_1將位置1處的float壓入操作數棧,第二個指令iload_2將位置2處的int壓入操作數棧。這些指令的一個引起注意的事情是指令中的'i'和'f'前綴,這說明Java字節碼指令是強類型的。如果參數的類型和字節碼的類型不匹配,VM將該字節碼作為不安全的而加以拒絕。更好的是,字節碼被設計為只需在類被加載時執行一次這樣的類型安全檢查。
這個類型安全是如何加強安全的?如果一個攻擊者能夠欺騙虛擬機將一個int作為一個float或者相反,它就可以很容易的以一個預期的的方法破壞計算。如果這些計算涉及銀行結余,那麼隱含的安全性是很明顯的。更危險的是欺騙VM將一個int作為一個Object引用。在大多情況下,這將導致VM崩潰,但是攻擊者只需要找到一個漏洞。不要忘記攻擊者不會手工搜索這個漏洞--寫出一個程序產生數以億計的錯誤字節碼的排列是相當容易的,這些排列試圖找到危害VM的幸運的那個。
字節碼的另一個內存安全防護是數組操作。'aastore' 和 'aaload' 字節碼操作Java數組並且它們總是檢查數組邊界。如果調用程序越過了數組尾,這些字節碼將拋出一個ArrayIndexOutOfBoundsException。也許所有最重要的檢查都使用分支指令,例如,以if開始的字節碼。在字節碼中,分支指令只能轉移到同一方法中的其它指令。在方法外可以傳遞的唯一控制是使它返回:拋出一個異常或者執行一個'invoke'指令。這不僅關閉了很多攻擊,同時也防止由於搖蕩引用(dangling reference)或者堆棧沖突而引發的令人厭惡的錯誤。如果你曾經使用系統調試器打開你的程序並定位到代碼中的一個隨機的位置,那麼你會很熟悉這些錯誤。
所有這些檢查中需要記住的重要的一點是它們是由虛擬機在字節碼級進行的而不是僅僅由編譯器在源代碼級進行的。一個例如c++這樣的語言的編譯器可能在編譯時預防上面討論的某些內存錯誤,但是這些保護只是在源代碼級應用。操作系統將很樂意加載執行任何機器碼,無論這些代碼是由精細的c++編譯器產生的還是心懷惡意的攻擊者產生的。簡單的講,C++僅僅是在源代碼級上面向對象而Java的面向對象的特性擴展到編譯過的代碼級。
分析字節碼提升代碼質量
Java字節碼的內存和安全保護無論我們是否注意都是存在地,那麼我們為什麼還費心查看字節碼呢?在很多情況下,知道編譯器如何將你的代碼轉換為字節碼可以幫助你寫出更高效的代碼,而且在某些情況下可以防止不易發覺的錯誤。考慮下面的例子:
//返回 str1+str2 的串連
String concat(String str1, String str2) {
return str1 + str2;
}
//將 str2 附加到 str1
void concat(StringBuffer str1, String str2) {
str1.append(str2);
}
猜猜每個方法需要多少個方法調用。現在編譯這些方法並且運行javap,你會得到類似下面的輸出:
Method java.lang.String concat1(java.lang.String, java.lang.String)
0 new #5
3 dup
4 invokespecial #6
7 aload_1
8 invokevirtual #7
11 aload_2
12 invokevirtual #7
15 invokevirtual #8
18 areturn
Method void concat2(java.lang.StringBuffer, java.lang.String)
0 aload_1
1 aload_2
2 invokevirtual #7
5 pop
6 return
concat1方法執行了5個方法調用s: new, invokespecial和三個invokevirtuals,這比concat2方法執行了更多的工作,後者只執行了一個invokevirtual調用。大多Java程序員已經得到過警告,因為String是不可變的,而使用StringBuffer進行字符串連接效率更高。使用javap分析這個使得這點變得很生動。如果你不能肯定兩個語言構造在性能上是否相等,你應該使用javap分析字節碼。然而,對just-in-time (JIT)編譯器要小心,因為JIT編譯器將字節碼重新編譯為本機代碼而能執行一些javap不能揭示的附加優化。除非你有你的虛擬機的源代碼,否則你應該補充你的字節碼的基准性能分析。
最後的一個范例展示了檢查字節碼如何幫助防止程序中的錯誤。像下面那樣創建兩個類,確保它們在獨立的文件中。
public class ChangeALot {
public static final boolean debug=false;
public static boolean log=false;
}
public class EternallyConstant {
public static void main(String [] args) {
System.out.println("EternallyConstant beginning execution");
if (ChangeALot.debug)
System.out.println("Debug mode is on");
if (ChangeALot.log)
System.out.println("Logging mode is on");
}
}
如果你運行EternallyConstant,你會得到信息:
EternallyConstant beginning execution.
現在試著編輯ChangeALot,修改debug和log變量的值為true(兩個都為true)。只重新編譯ChangeALot。再次運行EternallyConstant,你將看到下面的輸出:
EternallyConstant beginning execution
Logging mode is on
debug變量怎麼了?即使你將debug設置為true,信息"Debug mode is on"並沒有出現。答案在字節碼中。對 EternallyConstant運行javap你會看到:
Method void main(java.lang.String[])
0 getstatic #2
3 ldc #3
5 invokevirtual #4
8 getstatic #5
11 ifeq 22
14 getstatic #2
17 ldc #6
19 invokevirtual #4
22 return
驚奇吧!在log成員上有一個'ifeq'檢查,而代碼根本沒有檢查debug成員。因為debug成員被標記為final類型,編譯器知道debug成員在運行時永遠不會改變,因此它通過移除'if'聲明進行優化。這確實是一個非常有用的優化,因為它允許你在程序中嵌入調試代碼而在將它設置為false時不用付出運行時的代價。不幸的是這個優化能夠導致主要的編譯時混亂。如果你改變一個final成員,你必須記住重新編譯任何可能引用該成員的類。這是因為這個'reference'可能已經經過優化了。Java開發環境不能總是發現這個微妙的相關性,一些能導致非常奇怪的錯誤。因此,古老的C++格言對於java環境仍然有效:"When in doubt, rebuild all."(有疑問,重新編譯所有的代碼)。
知道一些字節碼的知識對於使用java編程的程序員都是有價值的。javap工具使得查看字節碼很容易。有時候,使用javap檢查你的代碼以期提高性能和捕獲特殊的不易察覺的錯誤時是沒有用的。