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
,并终止程序运行。请看程序员容易犯的几个典型数组访问越界的例子:
int index = 2; String[] names = new String[] { "developer", "Works" }; System.out.println( names[index] ); |
index
为 2,而数组只有两个元素,最后一个元素的下标索引是 1,所以导致数组访问越界。注意,如果 index
为负数,仍然是数组访问越界。
Vector<String> vec = new Vector<String>(); for ( int i = 0; i <= vec.size(); i ++ ) { System.out.println( vec.get(i) ); } |
Vector
和 ArrayList
的起始索引是 0,所以用其数组大小作为索引会导致数组访问越界,其数组最后一个元素的索引应该是“数组大小 -1 ”。
- 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 ) {
- // 捕获数组访问越界的异常并做特殊处理
- }
这是报出的 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 很难查出,这里列出只是为了给读者提供一个反例进行学习)。
- 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
情况进行处理。