对于java的异常处理,相信每一个作为java程序员的同学都是再也熟悉不过了,但是我也相信存在一部分同学像我一样,从来没有真正去看过java的Throwable,Exception,RuntimeException的源码,甚至对于他们的用法也是稀里糊涂的。
最近在阅读各个系统的代码的时候,发现很多对于异常的处理不够规范,甚至是错误的处理方式。这种比较细微的低级错误看似对系统没有太大影响,但是事实上,无论是对于后续错误的排查还是当前的代码的可阅读性,都会造成很不必要的麻烦,因此忙里偷闲,通过翻阅资料,对于异常的正确打开方式,做了一些调研。
接下来我将结合最近阅读代码的时候遇到的一些典型场景作为切入点,分析当前的做法存在或者潜在的问题,并且后续会给出正确处理异常的建议:
1. 在定义系统自己的异常时候,没有正确的选择是检查异常还是运行时异常。
相信大家都很熟悉下图中java的异常体系:刨除Error我们暂且不讨论(error是虚拟机级别的异常);我们都知道java的异常体系中分为检测异常和运行时异常,但是在实际应用中常常会混淆这两种异常的用法。其实对于异常的使用场景,概括起来无非分为以下两种:- 代码不能继续往下执行,需要立即终止。出现这种情况的可能性比较多,但是细分起来又可以分为业务上的异常和非业务异常。业务上的异常典型的场景如:参数错误--查询商品信息的时候没有传入商品id,或者传入的商品id非法;非业务异常比如数据库连接不上等。这时候程序已经没有往下执行的必要,而且这些异常场景是程序运行的时候才能确定是否发生,因此比较适合使用非检测异常,且不需要显示的捕捉和处理,代码看起来也比较简洁明了。
- 调用的代码需要进一步的处理——比如释放资源。一个比较常见的场景——将SQLException定义为检测型异常,因此一旦发生此类异常,需要在finally代码快中显式的去释放数据库连接,否则会造成数据库连接泄露;再比如IOException也同样需要定义成检测型异常,因为出现IOException之后也需要去显示的释放文件句柄,防止句柄泄露。正式由于这些常见的异常定义为检测型异常,所以才会强迫开发人员去显示的捕捉并且统一处理进行资源的释放。当然释放完资源之后,可以再抛出非检测异常,去阻止程序继续往下执行。根据观测,检测型异常通常使用于工具类中,起到一种提示性的作用。
2. 在自定义异常的时候,构造方法中没有显示的通过super()调用父类的构造方法,导致在打印异常日志的时候异常堆栈的丢失。
我们先来看一个目前商品系统中的一个反面的例子,异常定义如下:
public class ItemException extends Exception {
private int code;
private String desc;
public ItemException(int code, String desc) {
this.code = code;
this.desc = desc;
}
public ItemException(FailCodeEnum failCodeEnum) {
this.code = failCodeEnum.getValue();
this.desc = failCodeEnum.getDesc();
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
}
ItemException这个自定义异常显然存在两个问题:
(1) 该异常属于业务异常,一般都是一些运行的异常场景,例如参数非法,比如数据库中存在脏数据等等,这些场景的出现应该向上抛出RuntimeException,终止程序的继续向下执行,调用客户端可以自己决定是捕获还是继续向上抛出由顶层的调用客户端做统一的兜底处理,这样整个调用链路的代码会非常的整洁。显然此处显然应该继承RuntimeException。这个问题此处先不去深入探讨。
(2) 第二个非常严重的问题就是,该自定义的异常中,两个构造方法都没有显示的调用父类的构造方法。这样造成的问题就是,异常抛出之后,在打印日志的时候就会造成异常堆栈的丢失,给后续定位问题造成非常大的困扰。我们知道,在排查问题的时候,除了看日志里面的错误提示信息,最大的价值就是一场的调用堆栈信息,堆栈信息能帮助我们精准的定位到出问题的代码是哪一行。
那么问题来了,如何让我们自定义的异常类,在打日志的时候把调用堆栈信息打印出来呢?我们不妨看下Throwable类的源码:
/**
* The stack trace, as returned by {@link #getStackTrace()}.
*
* The field is initialized to a zero-length array. A {@code
* null} value of this field indicates subsequent calls to {@link
* #setStackTrace(StackTraceElement[])} and {@link
* #fillInStackTrace()} will be be no-ops.
*
* @serial
* @since 1.4
*/
private StackTraceElement[] stackTrace = UNASSIGNED_STACK;
不难看到,Throwable是通过一个StackTraceElement数组来保存当前线程的调用堆栈信息,那么线程的堆栈信息是在由谁来塞进去的呢?这个时候我们不妨考虑一下自定义异常通常的用法一般如下:
if(condition) {
throw new RuntimeException(“error msg");
}
我们看下我们如果去看RuntimeException类的构造方法的话,会发现该类的构造器会通过super()先调用父类Exception的构造器,而同样的Exception也会通过super()先调动父类Throwable的构造器。我们不妨先看下Throwable的无参构造器:
/**
* Constructs a new throwable with {@code null} as its detail message.
* The cause is not initialized, and may subsequently be initialized by a
* call to {@link #initCause}.
*
* <p>The {@link #fillInStackTrace()} method is called to initialize
* the stack trace data in the newly created throwable.
*/
public Throwable() {
fillInStackTrace();
}
/**
* Fills in the execution stack trace. This method records within this
* {@code Throwable} object information about the current state of
* the stack frames for the current thread.
*
* <p>If the stack trace of this {@code Throwable} {@linkplain
* Throwable#Throwable(String, Throwable, boolean, boolean) is not
* writable}, calling this method has no effect.
*
* @return a reference to this {@code Throwable} instance.
* @see java.lang.Throwable#printStackTrace()
*/
public synchronized Throwable fillInStackTrace() {
if (stackTrace != null ||
backtrace != null /* Out of protocol state */ ) {
fillInStackTrace(0);
stackTrace = UNASSIGNED_STACK;
}
return this;
}
private native Throwable fillInStackTrace(int dummy);
不难看出,构造器会调用 fillInStackTrace()
,fillInStackTrace()
则会调用fillInStackTrace(int dummy)
。而很清楚的可以看到该方法是一个native
方法,也就是说归根到底,抛出异常的时候,当前线程的调用堆栈信息是虚拟机放到异常的StackTraceElement
数组中的。
private native Throwable fillInStackTrace(int dummy);
因此基于以上的讨论可以得出结论:自定义异常的构造方法一定要在构造器中显示的调用父类的构造器,否则会造成线程调用堆栈信息的丢失!!
3. 代码中所有的地方都避免使用异常,而是使用返回值(true/false,或者返回一个result)给上层,从而将异常场景信息返回给调用方。
目前一些业务系统中,很多代码,从方法的入口层开始,一直到最底层的DAO层调用,对于异常场景的处理,竟然全部没有采用想上层抛出异常的方式,而是每一层都向上层返回一个result对象,由上层的调用客户端来判断调用是否成功,从而决定程序是否继续往下执行。举一个比较典型的例子:创建商品。我们暂且把创建商品的逻辑简单的抽象成下面几个步骤:
A.入参校验—> B.风控校验—> C.商品信息入库;
以上三个步骤,任何一个步骤出现问题,就应该终止商品的创建流程。我们的一些系统对于这种场景是怎么玩儿的呢?伪代码如下:
Result createItem(Item item){
//参数校验
Result result1 = validateParam(item);
if(!result1.isSuccess) {
Logger.warn(“error”);
return result1;
}
//风控校验
Result result2 = riskManage(item);
if(!result2.isSuccess) {
Logger.warn(“error”);
return result2;
}
//商品信息落库
Result result3 = flush2DB(item);
if(!result3.isSuccess) {
Logger.warn(“error”);
return result3;
}
return new Result(true);
}
我们可以看到,主要的业务处理代码,只有三行就能搞定,但是这个方法却写了一大坨。这一大坨大部分都是非业务逻辑,大部分都在进行判断调用结果,从而进行程序是否需要终止的决策;导致方法的主要逻辑不清晰,可读性极差。开篇的时候我们已经提到异常的用处之一就有:代码不能继续往下执行,需要立即终止。我们在来看一下使用抛出异常的代码:
Result createItem(Item item){
Try{
validateParam(item);
riskManage(item);
flush2DB(item);
} catch (Exception) {
Logger.warn(“error");
Return new Result(false,”error");
}
}
不难看出第二段代码业务主逻辑更加清晰,可读性也更好。
我们不禁疑问,为什么放着java提供的现成的异常中断机制不用,非要回到c语言的刀耕火种时代呢?
跟一些童鞋聊了一下,有些可能是编程习惯的原因;有些则是出于性能的考虑,理由是相比返回result的方式,抛出异常的代价更大一些。
的确,java在抛出异常的时候,由于要创建一个异常对象,所以比普通意义上直接返回true/false或者返回一个字符串代价大一些,但是如果向上返回的是一个Result的对象,真的比抛出一个异常代价小很多吗?
其实并不见得,以下是笔者写了一个test case:
public class ExceptionTest {
public static void main(String[] args) {
test1();
test1();
System.out.println("----------------------");
test2();
test2();
}
static void test1() {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000 ; i++) {
try{
throw new RuntimeException("error" + i);
} catch (Exception e) {
}
}
System.out.println("exception time = " + (System.currentTimeMillis() - start));
}
static void test2() {
long start = System.currentTimeMillis();
for (int i = 0; i <100000 ; i++) {
new Result(String.valueOf(i),"erro"+i);
}
System.out.println("result time = " + (System.currentTimeMillis() - start));
}
static class Result{
private String errorCode;
private String errorMsg;
public Result(String errorCode, String errorMsg) {
this.errorCode = errorCode;
this.errorMsg = errorMsg;
}
}
}
执行结果如下(两个方法的执行结果均已第二次为准,第一次作为预热)
可以看到,虽然返回返回result的性能表现是抛出异常的7倍,但是我们同时也看到,这里抛出100000次异常总用时才149ms,也就是说每次用时平局0.00149ms。当然实际的场景肯定会比我构造的复杂,异常调用链会深一些,但是总的来说,抛出一个异常的代价对于性能影响可以忽略。试想,难道我们的系统真的对于性能的优化已经上升到禁止使用异常的层次了吗?并没有!!因此为了追求这零点几毫秒的所谓性能优势,而让整个代码失去了可读性,是否值得?所以我们给出的建议是,请大胆的使用异常,少年!!!
4. 代码没有做异常的兜底
对于这个问题,我们一般有一个共性的认识,那就是系统对于异常的处理一定要有兜底方案。永远不要让外部的程序(框架)来给你做异常的兜底。万一没有兜住,有可能导致整个服务就挂掉了。因此在我们对外提供dubbo服务的时候,在最外层的入口层一定是
try{
……
} catch (Exception e){
return new Result(false)
}
去做兜底的。这样保证无论系统内部出现什么样的异常,都不会把异常扩散到系统外部。
作为反面的例子,我们来看一段wd-item的代码:
@Override
public Result setItemRateInfos(Long sellerId, List<RateInfo> rateInfos, Integer isItemRateInfo) throws Exception{
try{
if(RequestUtil.isEmptyValue(rateInfos) || RequestUtil.isEmptyValue(sellerId)){
return ResultUtils.wrapFailure(FailCodeEnum.ERR_PARAM, "item_id or seller_id null");
}
for(RateInfo rateInfo : rateInfos){
ItemEditInfo itemEditInfo = new ItemEditInfo();
itemEditInfo.setItemId(rateInfo.getItemId());
itemEditInfo.setSellerId(sellerId);
Map<String, FlagValue> flagValueMap = new HashMap<>();
FlagValue flagValue = new FlagValue();
flagValue.setName(ItemFlagEnum.IS_ITEM_RATE_INFO.getFlagName());
flagValue.setValue(BooleanValueConsts.getBoolValueFromInt(isItemRateInfo));
if(isItemRateInfo.equals(BooleanValueConsts.INT_TRUE)) {
String json = objectMapper.writeValueAsString(rateInfo);
flagValue.setExtend(json);
}
flagValueMap.put(ItemFlagEnum.IS_ITEM_RATE_INFO.getFlagName(), flagValue);
itemEditInfo.setFlagInfo(flagValueMap);
Result result = itemCenterWriteRpc.updateItem(itemEditInfo);
if(!result.isSuccess()){
return result;
}
}
return ResultUtils.wrapSuccess();
}catch (Exception e){
logger.error("setItemCommodityCommission fail" + e);
throw e;
}
}
注意最后对异常的处理,为什么catch住之后还要向上抛出?这是要让dubbo框架去做异常的兜底么?
5. 抛出的异常和抽象不在同一个层次上
换句话说,一个方法所抛出的异常应该在一个抽象层次上定义,该抽象层次与该方法做什么相一致,而不一定与方法的底层实现细节相一致。例如,一个从文件、数据库或者 JNDI 装载资源的方法在不能找到资源时,应该抛出某种 ResourceNotFound 异常(通常使用异常链来保存隐含的原因),而不是更底层的 IOException 、 SQLException 或者NamingException 。