Java异常处理机制与最佳实践

  这周小组内的学习是探讨Java异常处理的最佳实践,今天周末,外面太闷,宅在家里对Java的异常处理作一个总结,若有不对的地方欢迎指正~java

一. 谈谈我的对Java异常处理的见解

  维基百科对于异常处理的定义是:web

异常处理,是编程语言或计算机硬件里的一种机制,用于处理软件或信息系统中出现的异常情况(即超出程序正常执行流程的某些特殊条件)。

  Java语言从设计之初就提供了对异常处理的支持,而且不一样于其它语言,Java对于异常采起了强校验机制,即对于编译期异常须要API调用方显式地对异常进行处理,这种强校验机制被一部分人所钟爱,也有一部分人狂吐槽它。持支持观点的人认为这种机制能够极大的提高系统的稳定性,当存在潜在异常的时候强制开发人员去处理异常,而反对的人则认为强制异常处理下降了代码的可读性,一串串的try-catch让代码看起来不够简洁,而且不正确的使用不只达不到提高系统稳定性的目的,反而成为了bug的良好栖息之地。算法

  Java的异常处理是一把双刃剑,这是我一贯持有的观点。我的认为咱们不能对Java的异常处理片面的下一个好或者很差的定义,黑格尔说“存在即合理”,既然Java的设计者强制要求咱们去处理Java的异常,那么与其在那里吐槽,还不如去学习如何用好Java的异常处理,让其为提高程序的稳定性所服务。不过从笔者的亲身感觉来看,用好Java的异常处理门槛并不低!编程

二. Java的异常继承体系

输入图片说明
  上图展现了Java的异常继承体系,Throwable是整个Java异常处理的最高抽象(但它不是抽象类哦),它实现了Serializable接口,而后Java将异常分为了Error和Exception两大类。Error定义了资源不足、约束失败、其它使程序没法继续执行的条件,通常咱们在程序中不须要本身去定义额外的Error,Java设计者提供的Error场景几乎覆盖了全部可预见的状况。当程序中存在Error时,咱们也无须去处理它,由于这种错误通常都是致命的,让程序挂掉是最好的处理方法。数组

  咱们通常常常接触到的是Exception,Error被翻译为错误,而Exception则被翻译成异常,异常能够理解为是轻微的错误,不少时候是不致命的,咱们能够catch以后,通过处理让程序继续执行。但有时候一些错误虽然是轻微的,但依靠程序自己也无力挽救,即错误和异常之间没有明显的边界,为了解决这个问题,Exception被设计成了CheckedException和UnCheckedException两大类,咱们能够把UnCheckedException看作是介于Exception和Error的中间产物,有时候它能够被catch,有时候咱们也但愿它可让程序当即挂掉。安全

  • CheckedException:也称做编译期异常,这类异常强制软件研发人员进行catch处理,若是不处理则没法编译经过,这类异常不少时候都是能够恢复的
  • UnCheckedException:也称做运行时异常,这类异常不强制要求软件研发人员进行catch处理,若是不处理则出现该异常的时候程序会挂掉,这个时候有点接近于Error,虽然不强制,咱们也能够主动去catch这些异常,处理以后让程序继续执行,这个时候有点接近于通常的Exception

三. 最佳实践

  • 异常应该使用在程序会发生异常的地方

  Java异常处理体系的缺点不光在于下降了程序的可读性,JVM在对待有try-catch代码块的时候的时候每每不能更好的优化,甚至不优化,而且对于一个异常的处理在时间开销上相对是比较昂贵的,因此对于正常的状况,咱们不该该使用异常机制去达到本身所揣测的JVM对于代码的优化,由于JVM在不断的发展和进步中,任何一种优化的教条都不能保证在将来的某个时刻不会被JVM用更好的方式替换掉,因此咱们在编码的时候更多的应该是去专一代码的逻辑正确和简洁,而不是去琢磨如何编码才能让JVM更好地优化,此外咱们也不该该用异常去作流程控制,由于前面说过,异常的处理过程开销是昂贵的。总的说来就是咱们应该针对潜在异常的程序才使用Java的异常处理机制,而对于正常的程序流程则不该该试图利用异常去达到某种目的,这样每每会弄巧成拙。架构

@Benchmark
@BenchmarkMode(Mode.SampleTime)
public void badTry() {
    int result = 0;
    int i = 0;
    try {
        while (true) {
            result += this.a[i++];
        }
    } catch (ArrayIndexOutOfBoundsException e) {
        // do nothing
    }
}
@Benchmark
@BenchmarkMode(Mode.SampleTime)
public void goodTry() {
    int result = 0;
    for (int i = 0; i < ARRAY_SIZE; i++) {
        result += this.a[i];
    }
}

  上面两个函数都实现了对数组的遍历求和过程,可是两种方法使用不一样的方式来结束这个过程,badTry利用异常机制来终止遍历的过程,而goodTry则是咱们经常使用的foreach迭代。咋看一眼,你可能会对badTry的用法感到不屑,谁特么会去这样写程序,可是若是对JVM的内部优化过程有必定了解的话,那么badTry的写法也彷佛有那么点意思,首先咱们来看一下利用JMH对这两个方法进行测试的时间开销:并发

# Run complete. Total time: 00:07:41
Benchmark                     Mode      Cnt    Score    Error  Units
EffectiveException.badTry   sample     1819  117.413 ±  3.423  ms/op
EffectiveException.goodTry  sample  5290340   ≈ 10⁻⁴           ms/op

偏题一下:我通常推荐你们在统计程序运行时间的时候用JMH工具,而不是利用System.currentTimeMillis()这种方式去作时间打点。JMH是Java的微基准测试框架,相对于时间打点的方式来讲,JMH统计的是程序预热以后CPU真正的执行时间,剔除了解释、等待CPU分配时间片等时间,因此统计结果更加具有真实性框架

  回到正题,从上面JMH通过200次迭代的统计结果来看,goodTry执行的时间约为10⁻⁴ms,而badTry则耗费了117.413ms(正负误差3.423ms),因此能够看异常处理开销仍是比较昂贵的,对于这种正常的流程,咱们利用异常的机制去控制程序的执行每每会拔苗助长。
  对于数组的遍历,Java在每次访问元素的时候都会去检查一下数组下标是否越界,若是越界则会抛出IndexOutOfBoundsException,这样给Java程序猿在编码上带来了便利,可是若是对于一个数组的频繁访问,那么这种边界检查将会是一笔不小的开销,但倒是不能省去的步骤,为了提高执行效率,JVM通常会采起以下优化措施:编程语言

1. 若是下标是常量的状况,那么能够在编译器经过数据流分析就能够断定运行的时候是否须要检查
2. 若是是在循环中利用变量去访问一个数组,那么JVM也能够在编译器分析循环的范围是否在数组边界以内,从而能够消除边界检查

  上面例子中的badTry写法,编码者应该是但愿利用Java的 隐式异常处理机制 来提高程序的运行效率。

/*
 * 用户调用
 */
obj.toString();

  上面的代码为用户的一次普通调用obj.toString(),对于该调用,Java会去判断是否存在空指针异常,因此JVM会将这部分代码编译成下面这个样子:

/*
 * JVM编译
 */
if(null != obj) {
    obj.toString();
} else {
    throw new NullPointerException();
}

  若是对于obj.toString()进行频繁的调用,那么这样的优化势必会形成每一次调用都要去判空,这但是一笔不小的开销,因此JVM会利用隐式异常处理机制对上面这部分代码进行再次优化:

/*
 * JVM隐式异常优化
 */
try {
    obj.toString();
} catch (segment_fault) {  // Segment Fault信号异常处理器
    /**
     * 传入异常处理器中进行恢复并抛出NullPointerException
     * 用户态->内核态->用户态
     */
    exception_handler();
}

  隐式异常处理经过异常机制来减免对于空指针的断定,也就是先执行代码主体,只要不抛异常就继续正常执行,一旦跑了异常说明obj为空指针,就转去处理异常,而后抛出NullPointerException来告知用户,这样就不用每次调用以前都去判空了。可是隐式异常也存在一个缺陷,若是上面的代码频繁的抛异常,那么每次JVM都要转去处理异常,而后再返回,这个过程是须要由用户态转到内核态处理,处理完成以后再返回用户态,最后向用户抛出NullPointerException的过程,这可比一次判空的代价要昂贵多了,因此对于频繁异常的状况,咱们试图利用异常去控制流程是不合适的,就像咱们在最开始的例子给出,当利用ArrayIndexOutOfBoundsException来结束数组遍历过程的开销是很大的,因此不要用异常去处理正常的执行流程,或者不要用异常去作流程控制。

  • 若是API的调用方可以很好的处理异常,就抛checked exception,不然unchecked exception更加合适

  对于Java的CheckedExceptionUnCheckedException,以及Error,咱们在使用的时候可能常常会去疑惑到底使用哪种,我始终以为这没有具体的教条可寻,好比哪一种状况必定用哪种异常,可是咱们仍是能够总结出一些基本的使用原则。

1.何时使用Error?

  Error通常被用于定义资源不足、约束失败、其它使程序没法继续执行的条件的场景,咱们常常会在JVM中看到Error的状况,好比OOM,因此对于Error而言,通常不推荐在程序中去主动使用,也不推荐去实现本身的Error,对于有这种需求的状况咱们彻底能够利用UncheckedException代替。

2.何时使用CheckedException?

  对于CheckedException的使用,若是同时知足以下两条原则,则推荐使用,若是不能知足则使用UnCheckedException让程序早点挂掉应该是一种更好的选择:

1. API的调用方正确的使用API不能阻止异常的发生
2. 一旦异常发生,调用方能够采起有效的应对措施

  在设计API的时候,抛出CheckedException可以暗示调用者对异常手动处理,提高系统稳定性,可是若是调用方也不知道若是更好的处理,那么把异常抛给调用方也没有任何意义,并且API每抛出一个CheckedException,也就意味着API调用方须要多一个catch,则将让程序变的不够简洁。
  有些状况下,咱们在设计API时,能够经过一些技巧来避免抛出CheckException。好比下面这段代码中,代码的意图在于设计了一个File类,而整个File类须要对外提供提供一个执行的方法exec,可是在执行的时候须要验证该文件的MD5值是否正确,execShell里面给出了两种方案,第一种是只对外提供一个方法,在该方法中先验证MD5值,而后执行文件,在验证MD5值的时候,会抛出CheckedException,因而exec函数向外抛出了一个CodeException,调用该函数的程序不得不去处理该异常,而方案二则是对外提供了两个函数:isCheckSumRightexec2,前者执行MD5值验证逻辑,当验证经过则返回true,不然返回false,exec2则是函数的执行主体。这样设计API,调用时先调用isCheckSumRight,而后在掉用exec2,这样能够免去CheckedException,让程序更加美观,同时API也能够更加灵活。可是这样去重构有两个不太适用的场景,一个是当并发调用时,若是没有作线程安全控制,那么会存在线程安全问题,由于在isCheckSumRightexec2之间的瞬间可能会发生状态的改变,另一个就是若是拆分红两个函数以后,这两个函数之间有重复的逻辑,那么为了性能考虑,这样的拆分也不值得。好比状态检查函数里面是检查一个文件是否能够被打开,若是能够被打开就在主体函数里面去执行读取文件操做,可是在主体文件中读取文件时咱们仍然须要将文件打开一次,因而这个时候就存在了重复,下降了性能,为了代码的美观,去作这样的设计不是好的设计。

public void execShell(File file) {
    /*
     * 方案一
     */
    try {
        file.exec();
    } catch (CodecException e) {
        // TODO: handle this exception
    }
    /*
     * 方案二
     * 不适用的情景:
     * 1.没有同步措施的并发访问
     * 2.状态检查的执行逻辑与正文函数重复
     */
    if (file.isCheckSumRight()) {
        file.exec2();
    } else {
        // TODO: do something
    }
}
/**
 * File的定义 
 * @author zhenchao.wang 2016-08-09 16:29
 * @version 1.0.0
 */
public class File<T> {
    private T content;
    private String md5Checksum;
    public File(T content, String md5Checksum, boolean isCheckSum) {
        this.content = content;
        this.md5Checksum = md5Checksum;
    }
    /**
     * 执行文件
     * v1.0
     *
     * @throws CodecException
     */
    public void exec() throws CodecException {
        String md5Value;
        try {
            md5Value = CodecUtil.MD5(String.valueOf(this.content));
        } catch (NoSuchAlgorithmException e) {
            throw new CodecException("no such no such algorithm", e);
        } catch (UnsupportedEncodingException e) {
            throw new CodecException("unsupported encoding", e);
        }
        if (StringUtils.isNotEmpty(md5Value) && StringUtils.equals(this.md5Checksum, md5Value)) {
            // TODO: do something
        } else {
            // TODO: do something
        }
    }
    /**
     * 文件内容校验
     *
     * @return
     */
    public boolean isCheckSumRight() {
        boolean checkResult = false;
        String md5Value;
        try {
            md5Value = CodecUtil.MD5(String.valueOf(this.content));
        } catch (NoSuchAlgorithmException e) {
            return checkResult;
        } catch (UnsupportedEncodingException e) {
            return checkResult;
        }
        if (StringUtils.isNotEmpty(md5Value) && StringUtils.equals(this.md5Checksum, md5Value)) {
            checkResult = true;
        }
        return checkResult;
    }
    /**
     * 执行文件
     * v2.0
     *
     */
    public void exec2() {
        // TODO: do something
    }
}

3.何时使用UnCheckedException?

  Error和UnCheckedException的共同点都是UnChecked,可是以前有说过通常咱们不该该主动使用Error,因此当须要抛出Unchecked异常的时候,UnCheckedException是咱们最好的选择,咱们也能够本身去继承RuntimeException类来定义本身的UncheckedException。简单的说,当咱们发现CheckedException不适用的时候,咱们应该去使用UncheckedException,而不是Error。

  • 尽可能使用jdk提供的异常类型

  “不要重复发明车轮”是软件开发中的一句至理名言,在异常的选择上也一样适用,JDK内置了许多Exception,当咱们须要使用的时候咱们应该先去检查JDK是否有提供,而不是去实现一个自定义的异常。这样作主要有以下两点好处:
1.这样的API设计更加容易让调用方理解,减小了调用方的学习成本
2.减小了异常类的数目,能够下降程序编译、加载的时间
  在使用JDK提供的Exception类的时候,必定要去阅读一下docs对于该Exception类的说明,不能只是简单的依据类名去揣测类的用途,一旦揣测错误,将会给API的调用方形成困惑。

  • 使用“异常转译”让语义更清晰

  在软件设计的时候,咱们一般会进行分层处理,典型的就是“三层软件设计架构”,即web层、业务逻辑层(service),以及持久化层(orm),三层之间相互隔离,不能跨层调用。虽然不少开发者在开发的时候会去注重分层,可是在异常处理方面仍是会出现“跨层传播”的现象,好比咱们在orm层抛出的dao异常,由于在service里面没有通过处理就直接抛给了web层,这样就出现了“跨层传播”,这样没有任何好处,web开发人员在调用服务的时候,须要去捕获一个SQLException,除了不知道如何去处理,也会让开发人员对于底层的设计疑惑,因此在这种时候,咱们能够经过“异常转译”,在service对orm层抛出的异常进行处理以后,封装成service层的异常再抛给web层,以下面的代码:

public User getUserInfo(long userId) throws ServiceException {
    User user = new User();
    try {
        user = userDao.findUserById(userId);
    } catch (SQLException e) {
        log.error("DB operate exception!", e);
        // 异常转译
        throw new ServiceException("DB operate exception!", e);
    }
    return user;
}

  异常转译值得提倡,可是不能滥用,咱们仍是要坚持对CheckedException处理的原则,不能仅仅捕获后就转译抛出,这样只是让异常在语义上更加易于理解,可是对API的调用并无起到实质性的帮助。

  • 推荐为方法的checken exception和unchecken exception编写文档

  在方法的声明中,咱们应该为每一个方法编写可能会抛出的全部异常(CheckedException和UnCheckedException)利用@throws关键字编写文档,包括异常的名称,是CheckedException仍是UnCheckedException,异常发生的条件等,从而让API的调用方可以正确的使用。
  这里不得不吐槽一下,到目前为止我所接触的历史项目就没有一个是注释及格的(个人要求并不高~),一行注释都没有的也是大有所在,因此每次去看别人的代码都很痛苦。这里还得感谢一下飞哥(@陈飞),在阿里的时候,飞哥做为个人导师对个人编码风格的纠正起到了很是重要的做用。

  • 留下罪症,不要偷吃

  当异常发生的时候,咱们一般会将其记录到日志文件,过后经过分析日志来查明形成错误的缘由,异常在被抛出的时候,咱们也能够在栈轨迹中添加一些描述信息,从而让异常更加易于理解,对于描述信息的设置,咱们最主要的是要“保留做案现场”,即形成异常的实时条件,好比当形成ArrayIndexOutOfBoundsException的数组的上下界,以及当前访问的下标等数组,这样会为咱们在后面排错起到极大的帮助做用,由于有些bug是很难被重现的。   与“保留做案现场”这一良好习惯背道而驰的是“吃异常”,若是不是真的须要,千万不要把异常给吃了,哪怕你不去处理,用日志记录一下都比吃掉它要强不少。说一个故事背景,有一次在重构一个“订单审核服务”项目的时候,将服务部署启动以后,启动日志一切正常,一切都是那么的美好,可是订单审核的结果始终不正确,可是日志就是没有错误,无奈只能去看源码,而后在历史代码里面发现了下面这样一串:

try{
    // 具体算法模型文件加载逻辑
}catch (FileNotFoundException e) {
    try{
        throw new IOException(e);
    } catch (IOException ee) {
        // 他把异常给吃了!!!
    }
}

  先不去讨论这段代码写的是有多奇葩,形成上面现象的主要缘由是当算法模型文件找不到,发生异常的时候,这个异常吃掉了,catch以后不作任何处理,连用日志记录一下都没有,它就这样无声无息地从这个宇宙中消失了,给我形成了10000点伤害。因此若是不是必须,咱们在代码里面千万不要去把异常吃掉,这样会让本来提高系统稳定性的java异常处理机制成为bug的良好栖息之地!

最后,若是你们对于java异常处理体系有更深的理解或者更好的实践,也欢迎在评论中提出,你们一块儿探讨,共同进步~


参考
  1. Effective Java 2nd Edition
  2. 透过JVM看Exception的本质
相关文章
相关标签/搜索