coding……
但行好事 莫问前程

Java编程拾遗『异常体系』

异常是指程序运行时(非编译时)所发生的非正常情况或错误比如空引用、数组越界、内存溢出等,这些都属于程序运行过程中的意外情况。当程序违反了语义规则时,JVM就会将出现的错误表现为一个异常并抛出,假如对这些异常置之不理,就会导致程序终止或者直接系统崩溃,所以Java中提供了一套异常机制来进行异常处理,从而提高程序的安全性和健壮性。

Java中把异常当作对象来处理,并定义了一个基类(java.lang.Throwable)作为所有异常的父类。在Java API中已经定义了许多异常类,这些异常类分为Error(错误)和Exception(异常)两大类。

  • Error ,表示系统错误,是无法处理的异常,比如OutOfMemoryError,一般发生这种异常,JVM会选择终止程序。因此我们编写程序时不需要关心这类异常。
  • Exception,表示运行时发生的与期望结果不相符的情形,是可恢复的,都可以被编译器捕获到。Exception又分为两类,checked exception和unchecked exception。

1. Error

Error继承自java.lang.Throwable,表示程序运行期间出现了非常严重的错误,并且该错误是不可恢复的,由于是JVM层次的严重错误,因此这种错误会导致程序终止执行。此外编译器不会检查Error是否被处理,因此程序中不推荐捕获Error类型的异常。这些异常发生时,JVM一般会选择终止程序,所以在编程中,我们可以不关心这类异常。

2. Exception

Exception表示可恢复的异常,是编译器可以捕获到的。它有很多子类,应用程序也可以通过继承Exception或其子类创建自定义异常,图中列出了三个直接子类:IOException(输入输出I/O异常),SQLException(数据库SQL异常),RuntimeException(运行时异常)。通常情况下,Exception可以分为两种类型:RuntimeException和checked exception。

2.1 checked exception

检查异常是在程序中最常遇到的异常,所有继承自Exception并且不是运行时异常都是检查异常,比如上图的SQLException和IOException。Java编译器强制程序去捕获此类型的异常,即把可能出现异常的代码放到try块中,把对异常处理的代码放到catch块中。这种异常一在如下几种情况中使用:

  • 异常发生并不会导致程序出错,进行处理后可进行后续的操作,例如数据库连接失败后,可以重新连接,进行后续操作。
  • 程序依赖于不可靠的外部条件,例如系统IO。

2.2 RuntimeException(unchecked exception)

RuntimeException(运行时异常)比较特殊,这里的运行时有些误导的含义,“运行时”只是一个定义,跟之前讲的程序运行期间不是一个概念,并不是说SQLException和IOException不发生在运行时。不同于检查异常的是,当这种异常发生时,编译器并没有强制对其进行捕获并处理。如果程序中没有对这种异常进行处理,当出现这种异常时,会由JVM来处理,系统会把异常一直往上层抛,直到遇到处理的代码为止。如果没有一直没有遇到处理异常的代码块,则会抛到最上层,对于多线程就是Thread.run()方法抛出,如果是单线程,就是main()方法抛出。抛出之后,如果是普通线程,那么这个线程就退出了。如果是主程序抛出,那么整个程序也就退出了。也就是说,如果不对运行时异常进行处理,后果是非常严重的,要么线程终止,要么主程序终止。其实Error及其子类也是unchecked exception。

2.3 自定义异常

除了Java API中定义的异常类,我们也可以根据具体业务场景自己定义异常类,一般通过继承Exception或者它的某个子类来实现自定义异常类。如果父类是RuntimeException或它的某个子类,则自定义异常也是unchecked exception,如果是Exception或Exception的其他子类(非RunTimeException),则自定义异常是checked exception。如下自定义类即为checked exception。

public class CustomizeException extends Exception {
    public CustomizeException() {
        super();
    }

    public CustomizeException(String message,
            Throwable cause) {
        super(message, cause);
    }

    public CustomizeException(String message) {
        super(message);
    }

    public CustomizeException(Throwable cause) {
        super(cause);
    }
}

3. Java中异常处理

3.1 使用try…catch

被try块包围的代码说明这段代码可能会发生异常,一旦发生异常,异常便会被catch捕获到,然后需要在catch块中进行异常处理。如下:

try {
  File file = new File("d:/a.txt");
  if(!file.exists())
    file.createNewFile();
} catch (IOException e) {
  // TODO: handle exception
}

3.2 直接抛出,交由调用方处理

一旦发生异常,我把这个异常抛出去,让调用者去进行处理,如下:

public class ExceptionTest {
    public static void main(String[] args) {
        try {
            createFile();
        } catch (Exception e) {
            // TODO: handle exception
        }
    }
 
    public static void createFile() throws IOException{
        File file = new File("d:/a.txt");
        if(!file.exists())
            file.createNewFile();
    }
}

createFile方法中并没有捕获异常,而是用throws关键字声明抛出异常,即告知这个方法的调用者此方法可能会抛出IOException。那么在main方法中调用createFile方法的时候,采用try…catch块进行了异常捕获处理。

4. 异常处理的一些关键字

上面在讲Java异常处理方式时,讲了try…catch和throws,这里讲一下Java中跟异常处理相关的关键词使用的一些细节。

4.1 catch

上面讲的catch用于捕获try代码块中的异常,示例代码中只catch了一种异常,其实catch是支持catch多种类型的异常的,如下:

try {
    //可能触发异常的代码
} catch (NullPointerException e) {
    System.out.println("NullPointerException " + e.getMessage());
} catch (RuntimeException e) {
    System.out.println("runtime exception " + e.getMessage());
} catch (Exception e) {
    log.info("Exception: " + e.getMessage());
}

这里catch用到了多态的概念,如果catch过程中,先catch了基类异常,然后catch了子类异常,那么catch子类异常的代码块将永远不会被执行。比如上述catch异常,如果try代码块中抛出了一个NullPointException或者NullPointException的子类异常,那么异常将被第一个catch捕获,其余的catch都不会再生效了;假如Exception在NullPointException和RunTimeException之前(第一个catch语句),那么try中所有的NullPointException和RunTimeException异常都只会被第一个catch捕获,实际上不允许这么写,会编译报错。

4.2 finally

在Java异常处理中,还有一个比较重要的关键字finally,可以直接跟在try块后或者跟在catch块后。用于保证无论出现什么情况,finally代码块中的代码都会执行。由于finally的这个特点,它一般用于释放资源,如数据库连接、文件流等。使用如下:

try{
    //可能抛出异常
}catch(Exception e){
    //捕获异常
}finally{
    //不管有无异常都执行
}

//或者
try{
    //可能抛出异常,但是没有使用catch捕获异常,将异常传递给上层调用方
}finally{
    //不管有无异常都执行
}

会有三种情况:

  • 如果没有异常发生,在try内的代码执行结束后执行。
  • 如果有异常发生且被catch捕获,在catch内的代码执行结束后执行
  • 如果有异常发生但没被捕获,则在异常被抛给上层之前执行。

下面看一下finally使用的一些细节:

4.2.1 finally中有return语句

上面讲过,finally保证无论发生什么,finally代码开中的语句都一定会执行。而程序执行return就意味着结束当前函数的调用,并跳出函数体,因此任何语句的执行都只能在return前执行,finally块里的内容肯定也是在return前执行的。如果try-catch-finally中都有return,那么finally代码块中的return语句肯定会覆盖别处的return语句,最终调用者拿到的都是finally中return的值。

public static int test(){
    int ret = 1;
    try{
        int a = 5/1;
        return ret;
    }finally{
        return 2;
    }
}

如上最终调用方会拿到的结果会是2,而不是5,也就是finally中的return覆盖了try中的return。假如try中有异常抛出,希望抛给调用方,这时候finally中同时有return语句,那么对调用方而言,根本拿不到Exception,只会拿到finally中的return值,如下:

public static int test(){
    int ret = 0;
    try{
        int a = 5/0;
        return ret;
    }finally{
        return 2;
    }
}

5/0会触发ArithmeticException,但是finally中有return语句,这个方法就会返回2,而不再向上传递异常了。

4.2.2 finally中修改return值

由于在一个方法体内部定义的变量都是存储在栈中的,当这个函数结束后,其对应占的栈就会被回收,此时在方法体定义的变量将不存在了,因此return在返回时不是直接返回变量的值,而是复制一份,然后返回这个复制的变量。所以,对于基本类型数据,在finally块中修改return的值对返回值没有任何影响,而对引用类型的数据的改变会影响最终的返回值,如下:

public class Test {
	public static int testFinally1() {
		int result = 1;
		try {
			result = 2;
			return result;
		} catch (Exception e) {
			return 0;
		} finally {
			result = 3;
			System.out.println("execute finally1");
		}
	}

	public static StringBuffer testFinally2() {
		StringBuffer s = new StringBuffer("Hello");
		try {
			return s;
		} catch (Exception e) {
			return null;
		} finally {
			s.append(" World!");
			System.out.println("execute finally2");
		}
	}

	public static void main(String[] args) {
		int resultVal = testFinally1();
		System.out.println(resultVal);

		StringBuffer resultRef = testFinally2();
		System.out.println(resultRef);
	}
}

执行结果为:

execute finally1
2
execute finally2
Hello World!

程序执行到return时,会首先将返回值保存在一个临时变量中,然后去执行finally块后再返回。在方法testFinally1中调用return前,先把result的值1存储在一个临时变量中,然后再去执行finally代码块中的代码,此时修改result的值将不会影响程序的返回结果。在testFinally2中,调用return前首先把s存储到一个临时变量中,由于s为引用类型,因此在finally中修改s将会影响程序的返回结果。

4.2.3 finally中throw Exception

finally中不仅return语句会掩盖异常,如果finally中抛出了异常,则原异常就会被掩盖,如下:

public static void test(){
	try{
		int a = 5/0;
	}finally{
		throw new RuntimeException("RuntimeException");
	}
}

finally中抛出了RuntimeException,则原异常ArithmeticException就丢失了。

基于以上原因,一般而言,应该避免在finally中使用return语句或者抛出异常,如果调用的其他代码可能抛出异常,则应该捕获异常并进行处理。

4.2.4 finally中的代码一定会执行吗

前面讲过,finally保证无论如何一定会被执行,但其实并不是一定的,有些情况,finally中的代码也是可以不被执行的。如下:

代码在进try之前就抛出了异常:

public static void main(String[] args) {
    System.out.println("main");
    int x = 5/0;
    try {
        System.out.println("try");
    } catch (Exception e) {
        System.out.println(e.getMessage());
    } finally {
        System.out.println("finally");
    }
}

运行结果:

Connected to the target VM, address: '127.0.0.1:5656', transport: 'socket'
Exception in thread "main" java.lang.ArithmeticException: / by zero
main
	at com.zhuoli.service.thinking.java.exception.Test.main(Test.java:11)
Disconnected from the target VM, address: '127.0.0.1:5656', transport: 'socket'

Process finished with exit code 1

或者在try中调用了exit方法,如下:

public static void main(String[] args) {
    System.out.println("main");
    try {
        System.out.println("try");
        exit(0);
    } catch (Exception e) {
        System.out.println(e.getMessage());
    } finally {
        System.out.println("finally");
    }
}

4.3 throws

throws用于声明一个方法可能抛出的异常,如下:

public void test() throws IOException, SQLException, NumberFormatException {
    //....
}

throws跟在方法的括号后面,可以声明多个异常,以逗号分隔。这个声明的含义是说,我这个方法内可能抛出这些异常,我没有进行处理,至少没有处理完,调用者必须进行处理。这个声明没有说明,具体什么情况会抛出什么异常,作为一个良好的实践,应该将这些信息用注释的方式进行说明,这样调用者才能更好的处理异常

对于RuntimeException(unchecked exception),是不要求使用throws进行声明的,但对于checked exception,则必须进行声明,换句话说,如果没有声明,则不能抛出。

对于checked exception,不可以抛出而不声明,但可以声明抛出但实际不抛出,不抛出声明它干嘛?主要用于在父类方法中声明,父类方法内可能没有抛出,但子类重写方法后可能就抛出了,子类不能抛出父类方法中没有声明的checked exception,所以就将所有可能抛出的异常都写到父类上了。

如果一个方法内调用了另一个声明抛出checked exception的方法,则必须处理这些checked exception,不过,处理的方式既可以是catch,也可以是继续使用throws,如下代码所示:

public void tester() throws IOException {
    try {
        test();
    }  catch (SQLException e) {
        e.printStackTrace();
    }
}

对于test抛出的SQLException,这里使用了catch,而对于IOException,则将其添加到了自己方法的throws语句中,表示当前方法也处理不了,交由上层处理。

5. checked exception和unchecked exception理解

checked exception和unchecked exception最大的区别是,checked exception必须出现在throws语句中,调用者必须处理,Java编译器会强制这一点,而RuntimeException则没有这个要求。为什么要有这个区分呢?我们自己定义异常的时候应该使用checked还是unchecked exception?对于这个问题,业界有各种各样的观点和争论,没有特别一致的结论。

一种普遍的说法是,RuntimeException(unchecked)表示编程的逻辑错误,编程时应该检查以避免这些错误,比如说像空指针异常,如果真的出现了这些异常,程序退出也是正常的(不去处理异常最终的结果肯定是程序退出),程序员应该检查程序代码的bug而不是想办法处理这种异常。Checked exception表示程序本身没问题,但由于I/O、网络、数据库等其他不可预测的错误导致的异常,调用者应该进行适当处理。

但其实编程错误也是应该进行处理的,尤其是,Java被广泛应用于服务器程序中,不能因为一个逻辑错误就使程序退出。所以,目前一种更被认同的观点是,Java中的这个区分是没有太大意义的,可以统一使用RuntimeException即unchcked exception来代替。这个观点的基本理由是,无论是checked还是unchecked异常,无论是否出现在throws声明中,我们都应该在合适的地方以适当的方式进行处理,而不是只为了满足编译器的要求,盲目处理异常,既然都要进行处理异常,checked exception的强制声明和处理就显得啰嗦,尤其是在调用层次比较深的情况下。

其实观点本身并不太重要,更重要的是一致性,一个项目中,应该对如何使用异常达成一致,按照约定使用即可。Java中已有的异常和类库也已经在哪里,我们还是要按照他们的要求进行使用。

参考链接:

  1. 《Java编程的逻辑》
  2. Java异常处理和设计
  3. 编程思想 之「异常及错误处理」

赞(0) 打赏
Zhuoli's Blog » Java编程拾遗『异常体系』
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址