概述
BEAM 報告的結果文件是通過 build.xml 中 --beam::complaint_file 所定義的,在這 裡,本文假設其為 BEAM-messages。BEAM-messages 記錄著報出的所有代碼缺陷,這些缺陷 分為 ERROR,MISTAKE 和 WARNING 三大類,嚴重程度依次遞減。每一個具體的 ERROR, MISTAKE 和 WARNING 都代表著一個錯誤模式,本文接下來就通過實例分析理解其中的某些 重要錯誤模式,告訴讀者在寫 Java 代碼時如何避免這些錯誤模式的發生,從而寫出高質量 的代碼。
由於篇幅原因,本文只主要重點介紹四個常見的錯誤模式,並在最後簡單介紹一下在編 程時還應該注意的一些其它技巧,文章結構如下:
操作空對象
數組訪問越界
除 0 錯誤
內存洩漏
其它技巧
操作空對象
這是報出的 ERROR2 錯誤模式。據個人項目經驗,這種錯誤模式出現最為頻繁,但是編 程人員卻往往很難發現,因為這種編譯器發現不了的錯誤可能在代碼運行很長時間時都不會 發生,可是一旦出現,程序就會終止運行,並拋出 runtime 異常 java.lang.NullPointerException。通常有以下這些情況會導致操作空對象錯誤模式的發生 。
調用空對象的方法
訪問或修改空對象的域
訪問或修改空數組對象的數組元素
同步空對象
傳入空對象參數
下面讓我們用簡單易懂的例子一一介紹它們。
調用空對象的方法
清單 1. 調用空 String 對象的 charAt() 方法
String str = null;
int a = 0;
if( a > 0 ) {
str = new String[]{ "developer " , "Works"};
}
char ch = str.charAt(0);
這是最典型的調用空對象方法的例子,調用一個未初始化的 String 對象的 chatAt() 方法。
清單 2. 調用未初始化數組成員的方法
Integer[] array = null;
try{
array = new Integer[] { new Integer(2/0), new Integer(3), new Integer(4) };
} catch ( Exception e ) {
//Do nothing here
}
int i = array[0].intValue();
數組 array 的三個 Integer 成員因為除數為 0 的異常並沒有被初始化(這裡只是用典 型的除數為 0 的異常舉例,其實實際工程中,初始化時發生的異常有時很難被發現,沒有 如此明顯),但是接下來仍然調用其第 0 個成員的 intValue() 方法。
總結:調用空對象方法的錯誤非常常見,導致其出現的原因通常有兩點:
在某個方法開始處定義了空對象,程序員准備在其後的代碼中對其進行初始化,初始化 完畢後再調用該對象的方法。但是有時由於初始化代碼中的某個不常見的 if 之類的條件不 成立或者 for/while 循環的條件不成立,導致接下來的賦值動作並沒有進行,其結果就是 之前定義的空對象並沒有被初始化,然後又調用該對象的方法,從而造成了 java.lang.NullPointerException,如清單 1 所示。
初始化對象時出現了異常,但是沒有對異常進行特殊處理,程序接下來繼續運行,導致 最終調用了該空對象的方法,如清單 2 所示。
這種代碼缺陷在大型代碼工程中往往很難被發現,因為編譯器不會報錯,而且代碼在實 際運行中,可能 99% 的時候 if 條件都是滿足的,初始化也是成功的,所以程序員很難在 測試中發現該問題,但是這種代碼一旦交付到用戶手中,發現一次就是災難性的。
建議的解決方法:一定要明確知道即將引用的對象是否是空對象。如果在某個方法中需 要調用某個對象,而此對象又不是在本方法中定義(如:通過參數傳遞),這時就很難在此 方法中明確知道此對象是否為空,那麼一定要在調用此對象方法之前先判斷其是否為空,如 果不為空,然後再調用其方法,如:if( obj != null ) { obj.method() … }。
訪問或修改空對象的域
定義了某個類的對象,在沒有對其初始化之前就試圖訪問或修改其中的域,同樣會導致 java.lang.NullPointerException 異常。這種情況也非常常見,舉一個比較典型的數組對 象的例子,如清單 3 所示:
清單 3. 訪問未初始化數組的 length
String[] str = null;
int a = 0;
while( a > 0 ) {
str = new String[]{"developer", "Works"};
}
System.out.println( str.length );
數組 str 由於某些條件並沒有被初始化,但是卻訪問其 public final 域 length 想得 到其長度。
總結:訪問或修改某個空對象的域的起因與調用空對象的方法類似,通常是由於某些特 殊情況導致原本應該初始化的數組對象沒有被初始化,從而接下來訪問或修改其域時產生 java.lang.NullPointerException異常。
建議的解決方法:與調用空對象的方法類似,盡量在訪問或修改某些不能夠明確判斷是 否為空對象的域之前,對其進行空對象判斷,從而避免對空對象的操作。
訪問或修改空數組對象的數組元素
當某個數組為空時,試圖訪問或修改其數組元素時都會拋出 java.lang.NullPointerException 異常。
清單 4. 訪問或修改空數組對象的數組元素
1 String[] str = null;
2 System.out.println( str[0]);
3 str[0] = "developerWorks" ;
第 2 行和第 3 行都會導致 ERROR2 錯誤,其中第 2 行試圖訪問空數組對象 str 的第 0 個元素,第 3 行試圖給空數組對象 str 的第 0 個元素賦值。
總結:訪問或修改某個空數組對象的數組元素的起因與調用空對象的方法類似,通常是 由於某些特殊情況導致原本應該初始化的數組對象沒有被初始化,從而接下來訪問或修改其 數組元素時產生 java.lang.NullPointerException 異常。
建議的解決方法:與調用空對象的方法類似,盡量在訪問或修改某些不能夠明確判斷是 否為空空數組對象的數組元素之前,對其進行空對象判斷,從而避免對空數組對象的操作。
同步空對象
清單 5. 同步空對象
String s = null;
int a = 0;
switch( a ) {
case 1: s = new String("developer");
case 2: s = new String("Works");
default:
;
}
synchronized( s ){
……
}
對空對象 s 進行同步。
總結:同步空對象的起因與調用空對象的方法類似,通常是由於某些特殊情況導致原本 應該初始化的對象沒有被初始化,從而接下來導致同步空對象,並產生 java.lang.NullPointerException 異常。
建議的解決方法:與調用空對象的方法類似,盡量在同步某些不能夠明確判斷是否為空 的對象之前,對其進行空對象判斷,從而避免對空對象的操作。
傳入空對象參數
清單 6 傳入空對象參數
static int getLength( String string ) {
return string.length();
}
public static void main(String[] args) {
String string = null;
int len = getLength( string );
}
將空 String 對象 string 傳入 getLength 方法,從而導致在 getLength 方法內產生 java.lang.NullPointerException 異常。
總結:導致傳入空對象參數的原因通常是在傳參前忘記對參數對象是否為空進行檢查, 或者調用了錯誤的方法,或者假定接下來傳參的函數允許空對象參數。
建議的解決方法:如果函數的參數為對象,並且在函數體中需要操作該參數(如:訪問 參數對象的方法或域,試圖修改參數對象的域等),一定要在函數開始處對參數是否為空對 象進行判斷,如果為空則不再執行函數體,並最好作特殊處理,達到避免操作空對象的目的 。
數組訪問越界
這是報出的 ERROR7 錯誤模式。什麼是數組訪問越界呢?如果一個數組(在 Java 中, Vector,ArrayList 和 List 也算是數組類型)定義為有 n 個元素,那麼對這 n 個元素( 0~n-1)的訪問都是合法的,如果對這 n 個元素之外的訪問,就是非法的,稱為“越界”。 這種錯誤同樣不會造成編譯錯誤,會危險地“埋伏”在你的程序中。在 C/C++ 中遇到數組 訪問越界,可導致程序崩潰,甚至宕機;在 Java 中,會拋出 runtime 異常 java.lang.ArrayIndexOutOfBoundsException 或 java.lang.IndexOutOfBoundsException ,並終止程序運行。請看程序員容易犯的幾個典型數組訪問越界的例子:
清單 7. 越界訪問 String 數組元素 1
int index = 2;
String[] names = new String[] { "developer", "Works" };
System.out.println( names[index] );
index 為 2,而數組只有兩個元素,最後一個元素的下標索引是 1,所以導致數組訪問 越界。注意,如果 index 為負數,仍然是數組訪問越界。
清單 8. 越界訪問 Vector
Vector<String> vec = new Vector<String>();
for ( int i = 0; i <= vec.size(); i ++ ) {
System.out.println( vec.get(i) );
}
Vector 和 ArrayList 的起始索引是 0,所以用其數組大小作為索引會導致數組訪問越 界,其數組最後一個元素的索引應該是“數組大小 -1 ”。
清單 9. 越界訪問 String 數組元素 2
int a = 0;
String[] names = null;
StringBuffer buf = new StringBuffer();
if ( a > 0 ) {
names = new String[] { "developer", "Works" };
} else {
names = new String[] { "developerWorks" };
}
buf.append( names[0] ).append( names[1] );
程序員調用 append 時以為數組 names 中有兩個元素,其實只有一個。
清單 10. 越界訪問 ArrayList
ArrayList<String> arrList = new ArrayList<String> ();
int len = 5;
for( int i = 0; i < len; i++ ) {
arrList.add( String.valueOf(i) );
}
arrList.remove( len - 1 );
System.out.println(arrList.get( len - 1 ));
ArrayList 中最後一個元素已經被 remove 了,所以該位置已經沒有任何東西,訪問它 將導致 java.lang.ArrayIndexOutOfBoundsException。
總結:導致數組訪問越界主要有以下幾個原因:
使用某個變量作為數組索引時,沒有之前對該變量值進行檢查,變量的取值可能會超出 合法的數組索引范圍,從而導致數組訪問越界,如清單 7 。
使用與數組元素個數相同的值作為數組索引,因為數組的最後一個元素的索引是“數組 大小 -1 ”,所以導致數組訪問越界,如清單 8 。
數組初始化代碼中某個不起眼的 if 之類的條件不成立或者 for/while循環的條件不成 立,導致接下來的賦值動作並沒有進行,從而接下來訪問了未初始化完全的數組,導致數組 訪問越界,如清單 9 。
程序員編碼時忘記 Vector,ArrayList 或 List 中某些位置的元素已經被 remove 了, 後來仍然對該位置元素進行訪問,可能會導致數組訪問越界,如清單 10 。
建議的解決方法:在判斷數組是否有效不為空的同時,也要對訪問的數組元素的索引是 否超出了上下限進行檢查,如果索引是個變量,一定要確保變量取值在數組范圍之類(反例 是清單 7);如果索引不是個變量,在確保索引正確的同時還要確保之前定義的數組足夠大 (反例是清單 9)。最好是使用 try/catch 訪問數組,並對數組訪問越界異常進行捕獲, 進行特殊處理,如清單 11 。
清單 11 利用 try/catch 安全訪問數組
try {
// 訪問數組
}
catch( IndexOutOfBoundsException e ) {
// 捕獲數組訪問越界的異常並做特殊處理
}
除 0 錯誤
這是報出的 ERROR22 錯誤模式。在 Java 中,如果除數為 0,會導致 runtime 異常 java.lang.ArithmeticException 並終止程序運行,如清單 12 所示。
清單 12 除數為 0
int num = 0;
…
int a = 5 / num;
總結:導致除 0 錯誤的主要原因是使用變量作為除數,並且程序員在寫除法語句時,以 為變量值到此已經被改變(不是 0),但是實際上可能某條不被注意的語句路徑導致除數為 0,從而造成了錯誤。
建議的解決方法:做除法前,一定不能將除數直接寫為 0 ;如果除數為變量,而且該變 量值在進行除法前經過了很多運算,導致不能確定在被除前是否為 0,則在除法前,先對除 數變量進行是否為 0 的判斷,並對除數為 0 的情況做特殊處理。
內存洩漏
這是報出的ERROR23錯誤模式。內存洩漏的後果非常嚴重,即使每次運行只有少量內存洩 漏,但是長期運行之後,系統仍然會面臨徹底崩潰的危險。
在 C/C++ 中,內存洩漏(Memory Leak)一直是程序員特別頭疼的問題,因為它出錯時 的表現特征經常很不穩定(比如:錯誤表象處不唯一,出錯頻率不定等),而且出現問題的 表象處經常與內存洩漏錯誤代碼相隔甚遠,所以很難被定位查出。在 Java 中,垃圾回收器 (Garbage Collection,GC) 的出現幫助程序員實現了自動管理內存的回收,所以很多程序 員認為 Java 不存在內存洩漏問題,其實不然,垃圾回收器並不能解決所有的內存洩漏問題 ,所以 Java 也存在內存洩漏,只是表現與 C/C++ 不同。
為什麼 Java 會出現內存洩漏呢?因為垃圾回收器只回收那些不再被引用的對象。但是 有些對象的的確確是被引用的(可達的),但是卻無用的(程序以後不再使用這些對象), 這時垃圾回收器不會回收這些對象,從而導致了內存洩漏,拋出異常 java.lang.OutOfMemoryError。以下是導致內存洩漏的常見的例子(其中某些例子 BEAM 很 難查出,這裡列出只是為了給讀者提供一個反例進行學習)。
清單 13. 內存洩漏的 Hashtable
public class HashtableLeakDemo
{
static Hashtable<Integer, String> names = new Hashtable<Integer, String>();
void leakingHash( int num ) {
for( int i = 0; i < num; i++ ) {
names.put( new Integer(i) , "developerWorks");
}
// 接下來是繼續對 names 哈希表進行的操作,但是忘了移除其中的表項
}
}
leakingHash 會往 Hashtable 中不停地加入元素,但是卻沒有相應的移除動作(remove ),而且 static 的 Hashtable 永遠都會貯存在內存中,這樣必將導致 Hashtable 越來越 大,最終內存洩漏。
清單 14. 內存洩漏的 Vector
public class VectorLeakDemo
{
static Vector<String> v = new Vector<String>();
void leakingVector( int num ) {
for( int i = 0; i < num; i++ ) {
v.add( String.valueOf(i) );
}
// 雖然進行了 remove,但是卻沒有移除干淨
for( int i = num - 1; i > 0; i-- ) {
v.remove( i );
}
}
}
每次調用 leakingVector 都會少 remove 一個 String 元素,如果 Vector 中的元素不 是 String,而是數據庫中一些非常大的記錄(record),那麼不停調用 leakingVector 將 很快導致內存耗光。
清單 15. 內存洩漏的 Buffer
public class BufferLeakDemo
{
private byte[] readBuffer;
public void readFile( String fileName ) {
File f = new File( fileName );
int len = (int)f.length();
//readBuffer 的長度只增不減
if ( readBuffer == null || readBuffer.length < len ) {
readBuffer = new byte[len];
}
readFileIntoBuf( f, readBuffer );
}
public void readFileIntoBuf( File f, byte[] buffer ) {
// 將文件內容讀取到 buffer 中
}
}
在BufferLeakDemo 對象的生命周期中,一直會有一個 readBuffer 存在,其長度等於讀 到的所有文件中最長文件的長度,而且更糟糕的是,該 readBuffer 只會增大,不會減小, 所以如果不停的讀大文件,就會很快導致內存洩漏。
清單 16. 內存洩漏的 Stream 流 1
public void writeFile( String fileName ) {
OutputStream writer = null;
writer = new FileOutputStream(fileName);
// 接下來對 writer 進行操作,但是結束後忘記關閉 close
}
文件輸出流 FileOutputStream 使用完了沒有關閉,導致 Stream 流相關的資源沒有被 釋放,內存洩漏。
清單 17. 內存洩漏的 Stream 流 2
public void writeFile( String srcFileName, String dstFileName ) {
try {
InputStream reader = new FileInputStream( srcFileName );
OutputStream writer = new FileOutputStream( dstFileName );
byte[] buffer = new byte[1024];
// 將源文件內容讀入到 buffer 中
reader.read(buffer);
// 將 buffer 中的數據寫入到目的文件中
writer.write(buffer);
reader.close();
writer.close();
} catch ( Exception e ) {
// 對異常情況進行處理
}
}
如果 reader 讀取文件時 InputStream 發生異常,那麼 writer 將不會被關閉,從而導 致內存洩漏。
總結:
一些 Collection 類,如 Hashtable,HashSet,HashMap,Vector 和 ArrayList 等, 程序員使用時一般容易忘記 remove 不再需要的項(如清單 13),或者雖然 remove,但是 remove 的不干淨(如清單 14),這些都可能會導致無用的對象殘留在系統中,這樣的程序 長時間運行,可能會導致內存洩漏。特別是當這些 Collection 類的對象被聲明為 static 時或存活於整個程序生命周期時,就更容易導致內存洩漏。
有些 buffer 在其生命周期中有時可能會很大,大到有可能導致內存洩漏(如清單 15) 。
使用 Stream 流時(如 FileOutputStream,PrintStream 等),創建並使用完畢後忘記 關閉 close(如清單 16),或者因為異常情況使得關閉 Stream 流的 close 的語句沒有被 執行(如清單 17),這些都會導致 Stream 流相關的資源沒有被釋放,從而產生內存洩漏 。
建議的解決方法:
程序員編碼時注意手動釋放一些已經明確知道不再使用的對象。最簡單的方法就是將其 置為 null,告訴垃圾回收器你已經不再引用他們,從而垃圾回收器可以替你回收這些對象 所占用的內存空間。
使用 Collection 類對象時(如 Hashtable,HashSet,HashMap,Vector 和 ArrayList 等),如果可以,盡量定義其為局部變量,減少外界對其的引用,增大垃圾回收器回收他們 的可能性。
使用 Collection 類對象時(如 Hashtable,HashSet,HashMap,Vector 和 ArrayList 等),注意手動 remove 其中不再使用的元素,減少垃圾對象的殘留。
使用事件監聽器時(event listener),記住將不再需要監聽的對象從監聽列表中解除 (remove)。
使用 Stream 流時,一定要注意創建成功的所有 Stream 流一定要在使用完畢後 close 關閉,否則資源無法被釋放。
在 try / catch 語句中,添加 finally 聲明,對 try 中某些可能因為異常而沒釋放的 資源進行釋放。
在 class 中添加 finalize() 方法,手動對某些資源進行垃圾回收。
可以使用一些可以檢測內存洩漏的工具,如 Optimizeit Profiler,JProbe Profiler, JinSight, Rational 公司的 Purify 等,來幫助找出代碼中內存洩漏的錯誤。
其它技巧
使用 Iterator 時,一定要先調用 hasNext() 後,再調用 next(),而且不要在一個 Iterator 的 hasNext() 成功後,去調用另外一個 Iterator 的 next(),如清單 18 。
清單 18. 使用 Iterator 出錯
Iterator firstnames = ( new Vector() ).iterator();
Iterator lastnames = ( new Vector() ).iterator();
while ( firstnames.hasNext() ) {
//firstnames 中存在下一個元素,但 lastnames 可能已經沒有元素了
String name = firstnames.next() + "." + lastnames.next();
}
注意 switch 語句中是否缺少 break 。有的時候程序員有意讓多個 case 語句在一次執 行,但是有的時候卻是忘寫 break,導致發生了意想不到的結果,如清單 19 。
清單 19. switch 語句中缺少 break
switch ( A )
{
// 程序員原本的意思是 A 為 0 時,B 為 0,A 為 1 時,B 為 1, 其實 B 永遠都不可能為 0
case 0: B = 0;
case 1: B = 1; break;
}
注意避免恆正確或恆錯誤的條件,如清單 20 。
清單 20. 常見的恆正確或恆錯誤的條件
例 1:
if ( S.length() >= 0 ) // S 是 String 對象,它的長度永遠大 於等於 0,條件恆正確
例 2:
// 程序員本來的意圖是想介於 MIN 和 MAX 之間的值才成立,卻誤將” && ”寫成” || ”,導致條件恆成立
if ( x >= MIN || x <= MAX )
例 3:
final boolean singleConnection = true;
// final 型的 singleConnection 永遠為 true,所以該條件恆成立,而且 connect() 永遠不會被執行
if ( singleConnection || connect() )
注意在 if 語句中是否少了 else 分支,如清單 21。
清單 21. if 語句中少了 else 分支
if ( S == “ d ” ) { … }
else if ( S == “ e ” ) { … }
else if ( S == “ v ” ) { … }
else if ( S == “ e ” ) { … }
// 少了 else 語句,漏掉的情況可能會產生異常,應該加上 else 語句對剩下 的條件進行判斷和處理
switch 語句最好對所有的 case 進行判斷,並且不要忘記對 default 情況進行處理。
結束語
本文通過簡單易懂的實例介紹了 Java 編碼中程序員容易犯的一些典型錯誤,並給出了 分析和解決方法,告訴讀者如何才能寫出高質量 Java 代碼。