定义
java中的异常提供了一种识别及响应错误情况的一致性机制,若有效地处理异常能使程序更加的健壮,且更易于调试。异常之所以是一种强大的调试手段,在于其回答了三个问题:什么出了错?在哪出的错?为什么出错?
异常的类型回答了“什么”被抛出(什么出了错),异常的堆栈信息回答了“在哪”抛出(在哪出的错),异常的函数调用信息回答了“为什么”会抛出(为什么出错),如果你的异常没有回答以上全部问题,那么可能你没有很好地使用它们。
异常分类
异常整体上可以分为以下3种类型:
-
运行时异常:是
RuntimeException
类及其子类标识的异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,我们需要从程序逻辑的角度尽可能避免这类异常的发生。(运行时异常的特点是Java编译器不会检查它) - 检查性异常:正确的程序在运行中,很容易出现的、情理可容的异常状况。检查性异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,需要采取某种方式进行处理(捕获处理或者继续抛出)。(这些异常在编译时不能被简单地忽略,必须捕获处理或继续抛出)
-
错误:
Error
类型及子类是程序所无法处理的错误,表示运行的应用程序中的较严重问题。大多数错误与代码编写者执行的操作无关,而是表示代码运行时JVM(Java 虚拟机)出现的问题。如:当JVM不再有继续执行操作所需的内存资源时将出现OutOfMemoryError
等。
具体使用
略。(本篇文章不打算介绍基本使用)
异常处理三原则
使用以下三个原则可以帮助我们在调试过程中最大限度地使用好异常机制。
具体明确
当我们需要抛出一个异常时,应尽可能的使用能描述具体问题的异常子类,而不是new
一个通用的基类(如错误写法:throw new Exception("test exception");
)。其目的是为了在捕获异常的时候,我们能根据异常的类型一眼就能分辨什么出了错,另外更重要的是捕获异常处理时,能将异常类型对应到不同的catch块,以便针对不同的异常做出不同的处理。
比如,异常IOException
更加特化的异常FileNotFoundException
、EOFException
和ObjectStreamException
,这些都是IOException
的子类,每一种都描述了一类特定的I/O
错误:分别是文件不存在,异常文件结尾和错误的序列化对象流。提早抛出
提早抛出异常的意思是,在可预见的异常前通过程序代码检查可能的异常(特别是运行时异常),要么通过if条件过滤即将抛出的异常使整个函数调用立即返回,要么构造一个更加特例的异常提前抛出。其目的是为了能更清晰的定义一些可能预知的异常、避免不必要的对象构造或资源占用,比如文件或网络连接,还能避开了资源操作所带来的清理动作。
比如:
testException.readValueFromFile(null); //测试传入一个为null的文件名
public int readValueFromFile(String filename) {
int size = 0;
InputStream in = null;
try {
in = new FileInputStream(filename); //FileInputStream会抛出异常
} catch (Exception e) {
} finally {
in.close();
}
return 0;
}
将会抛出一下异常:
java.lang.NullPointerException: Attempt to invoke virtual method 'char[] java.lang.String.toCharArray()' on a null object reference
at java.io.File.fixSlashes(File.java:183)
at java.io.File.<init>(File.java:130)
at java.io.FileInputStream.<init>(FileInputStream.java:103)
at com.android.test.demo.exception.TestException.readValueFromFile(TestException.java:22)
at com.android.test.demo.MainActivity.testException(MainActivity.java:56)
at com.android.test.demo.MainActivity.onCreate(MainActivity.java:41)
从异常堆栈信息看:是FileInputStream
的构造函数中抛出了NullPointerException
异常,而JDK API一般不会出错的,很可能是我们的调用逻辑有问题,但异常堆栈却不能清晰的看出到底是什么为空导致的空指针,假设我们回退堆栈和检查程序最终还是会发现是filename
参数传了空导致的。
如果我们在实例化FileInputStream
之前做一次参数检查,若为空时提早抛出IllegalArgumentException
,如下所示:
if (filename == null){
throw new IllegalArgumentException("filename is null");
}
则会抛出这样的堆栈信息:
java.lang.IllegalArgumentException: filename is null
at com.android.test.demo.exception.TestException.readValueFromFile(TestException.java:23)
at com.android.test.demo.MainActivity.testException(MainActivity.java:56)
at com.android.test.demo.MainActivity.onCreate(MainActivity.java:41)
此时堆栈信息不会深入到FileInputStream
中去了(一般情况不会是JDK的API出现异常),而是明确的告诉了我们三个问题:出了什么错(提供了非法参数值),为什么出错(文件名不能为空值),以及哪里出的错(readValueFromFile()函数)。
-
延迟捕获
延迟捕获的意思是,当函数出现异常且在当前函数无法做出有效处理时,应当将其抛给函数的调用者去处理,以便调用者有机会通过不同的参数从异常中恢复出来。比如,在调用者第一次传入错误的参数导致异常后,调用者仍有机会(在catch{}
中)再一次传入某个默认参数来使该函数调用成功。
大多数人可能都会犯的一个错是,在程序有能力处理异常之前就捕获了它。Java编译器要求检查出的异常必须被捕获或抛出间接助长了这种行为,大家自然而然的做法就是立即将代码用try
块包装起来,并使用catch
捕获异常,以免编译器报错。如果当前函数有能力处理异常还好,不然只能仅仅打印一下异常堆栈信息,无法通过异常机制对函数的执行提供有效的帮助。
finally{}语句块的执行情况
一般的描述是:try{}
里有一个return
语句,那么紧跟在这个try
后的finally{}
里的code会不会被执行,什么时候被执行,在return
前还是后。会不会影响返回值?
先给结论:finally
块的语句在try
或catch
中的return
语句执行之后返回之前执行,且finally
里的修改语句可能影响也可能不影响try
或catch
中return
已经确定的返回值(由返回值的传递类型决定:传值还是传地址),若finally
里也有return
语句则覆盖try
或catch
中的return
语句直接返回。
测试1:
//测试调用
final int value = testException.testFinally1();
Log.d(TAG, "testFinally1 return size: " + value);
public int testFinally1() {
int x = 1;
try {
++x;
Log.d(TAG, "try{} x: " + x);
return returnSize(x);
} finally { //finally块在retrun语句执行执行之后,返回之前执行
++x;
Log.d(TAG, "finally{} x: " + x);
}
}
private int returnSize(int size) {
Log.d(TAG, "enter returnSize()");
return size;
}
执行结果:
TestException( 1468): try{} x: 2
TestException( 1468): enter returnSize()
TestException( 1468): finally{} x: 3
TestException( 1468): testFinally1 return size: 2
可以看到函数returnSize
在finally{}
块之前执行了(finally{}
在return
语句执行之后,返回之前执行),且finally{}
的++x
没有生效(return
的时候是复制了一份变量然后返回,所以之后finally
操作的变量如果是基本类型的话不会影响返回值)
测试2:
//测试调用
Map<String, String> map = testException.testFinally2();
Log.d(TAG, "testFinally2 map: " + (map != null ? map.get("key") : "null"));
public Map<String, String> testFinally2() {
Map<String, String> result = new HashMap<>();
result.put("key", "start");
try {
result.put("key", "try");
return result;
} catch (Exception e) {
result.put("key", "catch");
} finally {
result.put("key", "finally"); //此处生效
result = null; //此处不生效
}
return result;
}
执行结果:
TestException( 1468): testFinally2 map: finally
从测试结果我们可以看到,finally{}
的code生效了,这是因为返回值为引用类型,虽然在return
的时候是复制了一份变量然后返回,但该变量指向的是同一个对象,因此这里的操作会反映到返回结果中;result = null;
这句代码不生效的原因同测试1。
另外,若finally
里也有return
语句时,则覆盖try
或catch
中的return
语句直接返回。----原因也可以从上面两个测试例子中得出结论。
自定义异常
在开发过程中,自我认知范围内需要使用自定义异常的情况总结为以下两种:
- 自定义异常继承自某个相关异常,抛出自定义异常时,信息可以根据情况自定义,从而使得异常可以隐藏底层的异常中,使得信息更安全、也更加直观。因为自定义异常可以抛出我们自己想要抛出的信息,也可以通过抛出的信息区分异常发生的位置、根据异常名我们就可以知道哪里有异常,根据异常提示信息进行程序修改。比如空指针异常NullPointException,我们可以抛出信息为“xxx为空”定位异常位置,而不用输出堆栈信息。
测试:
//自定义异常类
public class TestGHException extends RuntimeException {
public TestGHException(Throwable cause) {
super(cause);
}
public TestGHException(String message, Throwable cause, boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
public TestGHException(String message) {
super(message);
}
public TestGHException(String message, Throwable cause) {
super(message, cause);
}
}
测试1(不使用自定义异常):
public void testGHException() {
String value = "test";
value = null;
final String sub = value.substring(0, 1);
}
输出异常信息:
Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String java.lang.String.substring(int, int)' on a null object reference
at com.android.test.demo.exception.TestException.testGHException(TestException.java:112)
at com.android.test.demo.MainActivity.testException(MainActivity.java:64)
at com.android.test.demo.MainActivity.onCreate(MainActivity.java:40)
at android.app.Activity.performCreate(Activity.java:6362)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1122)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2656)
测试2(使用自定义异常):
public void testGHException() {
try {
String value = "test";
value = null;
final String sub = value.substring(0, 1);
} catch (NullPointerException e) {
throw new TestGHException("string value == null", e);
}
}
输出异常信息:
Caused by: com.android.test.demo.exception.TestGHException: string value == null
at com.android.test.demo.exception.TestException.testGHException(TestException.java:105)
at com.android.test.demo.MainActivity.testException(MainActivity.java:64)
at com.android.test.demo.MainActivity.onCreate(MainActivity.java:40)
at android.app.Activity.performCreate(Activity.java:6362)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1122)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2656)
结论:通过比较上面两个测试例子可以看出,自定义异常使得异常堆栈信息更加直观,一样就能看到出现问题的地方。
- 可以实现异常在受检异常和运行时异常之间互相转换。有时候引用的某些API抛出了运行时异常(由于不需要编译器检查,却又有可能异常),为了具体业务的需要,可以在该API上再包一层将这个运行时异常转换成受检异常,以便提醒调用者注意该异常,或者反之,简化繁琐的受检异常,典型的用例是jOOR中对反射接口的受检异常(ClassNotFoundException、NoSuchMethodException、NoSuchFieldException等)统一转化成ReflectException定义的运行时异常。
jOOR代码:
//ReflectException自定义异常
public class ReflectException extends RuntimeException {
}
//函数执行接口,
public Reflect call(String name, Object... args) throws ReflectException {
Class<?>[] types = types(args);
// Try invoking the "canonical" method, i.e. the one with exact
// matching argument types
try {
Method method = exactMethod(name, types);
return on(method, object, args);
}
// If there is no exact match, try to find a method that has a "similar"
// signature if primitive argument types are converted to their wrappers
catch (NoSuchMethodException e) {
try {
Method method = similarMethod(name, types);
return on(method, object, args);
} catch (NoSuchMethodException e1) {
throw new ReflectException(e1); //此处将受检异常转换成了运行时异常
}
}
}
//调用时 ,省去了检查操作
public static boolean isSplitMode(Context context) {
return Reflect.on("meizu.splitmode.FlymeSplitModeManager")
.call("getInstance", context)
.call("isSplitMode").get();
}
常见影响app的崩溃率指标的异常分析
准备在单独章节中持续更新。
总结
有经验的的开发人员都知道,调试程序的最大难点不在于修复bug,而在于从海量的代码中找出bug的藏身之处。因此,我们要做的就是在写代码过程中尽可能的暴露bug的可能出处。
在《Effective Java》中对异常的使用给出了以下指导原则:
- 不要将异常处理用于正常的控制流(设计良好的API不应该强迫它的调用者为了正常的控制流而使用异常)
- 对可以恢复的情况使用受检异常,对编程错误使用运行时异常
- 避免不必要的使用受检异常(可以通过一些状态检测手段来避免异常的发生)
- 优先使用标准的异常
- 每个方法抛出的异常都要有文档
- 保持异常的原子性
- 不要在catch中忽略掉捕获到的异常*