Java异常

发现错误的理想时机是在编译阶段。然而,编译期间并不能找出所有的错误。对于编译期间无法找出的错误,必须在运行期间解决。
这就需要错误源能通过某种方式,把适当的信息传递给某个接受者——该接受者直到如何正确处理这个问题。
改进的错误恢复机制是提供代码健壮性的最强有力的方式。Java使用异常来提供一致的错误报告模型,使得构件能够与客户端代码可靠地沟通问题。
Java的异常处理的目的在于通过使用少量的代码来简化大型、可靠的程序的生成,并且通过这种方式来保证程序没有未处理的错误。
异常处理是Java中唯一正式的错误报告机制,有必要掌握。

基本概念

C以及其他早期语言常常具有多种错误处理模式,这些模式往往建立在约定俗成的基础之上,而并不属于语言的一部分。通常会返回某个
特殊值或设置某个标志,并且假定接收者将对这个返回值或标志进行检查,以判定是否发生了错误。随着时间的推移,程序员不去规范检查
错误情形。
虽然在调用方法的时候进行错误检查,代码很可能变得难以阅读,但是对于构建大型、健壮、可维护的程序而言,这种错误处理模式是必要的。
用强制规定的形式来消除错误处理的方式的做法由来已久,对异常处理的实现可以追溯到20世纪60年代的操作系统,甚至BASIC语言中的on error goto语句。C++的异常处理机制基于Ada,Java的异常处理机制则建立在C++基础之上。
使用异常除了将问题提交到一个更高级别的环境中,以帮助作出正确的决定外,异常还能能够降低错误处理代码的复杂度。如果不使用异常,那么就必须检查特定的错误,并在程序中的许多地方去处理它。而如果使用异常,那就不必在方法调用处进行检查,因为异常机制将保证能够捕获这个异常,并且只需在一个地方处理错误,即所谓的异常处理程序中。

基本异常

所谓的异常情形(Exceptional Condition)是指阻止当前方法或作用域继续执行的问题。把异常情形与普通问题相区分很重要。所谓的普通问题是指,在当前环境下能得到足够的信息,总能处理这个问题(主要指可程序员不借助异常直接解决的问题,如主动置空对象的引用、清除文件等)。而对于异常情形,因为在当前环境下无法得到必要的信息来解决问题,所以需要将问题提交给上一级环境。这就是抛出异常时所发生的事情。
当抛出异常后,首先,同Java中其他对象的创建一样,将使用new在堆上创建异常对象。然后,当前的执行路径(它不能继续下去了)被终止,并且从当前环境中弹出对异常对象的引用。此时,异常处理机制接管程序,并开始寻找一个恰当的地方来继续执行程序。这个恰当的地方就是异常处理程序,它的任务是将程序从错误状态中恢复,以使程序能要么换一种方式运行,要么继续运行下去。
以对象引用为例,在使用对象引用前,可能该引用为空,所以在使用这个对象引用前,还需进行判空检查。如果使用异常处理这种问题,可以创建一个代表错误信息的对象,并把它从当前环境中“抛出”。示例代码如下:

if (null == customObjectInstance) {
    throw new NullPointerException();
}

异常的目的是为了提高代码的健壮性,异常的实现本质可以类比C语言里goto语句的作用。异常会破坏程序的顺序执行结构,提高编码的工作量和程序的复杂度。但相对于程序的健壮性而言,这部分工作是很有必要的。

抛出异常

在使用new在堆上创建异常对象之后,此对象的引用将传递给throw。尽管返回的异常对象其类型通常与方法设计的返回值不同,但从效果上看,它就像是从方法“返回”的。可以简单地把异常处理看成一种不同的返回机制,当前不可过分强调这种类比。另外,还能用抛出异常的方式从当前的作用域退出。在这两种情况下,将会返回一个异常对象,然后退出方法或作用域。与正常方法返回不同的是,异常的返回是在最近的捕获异常的地方,如果没有,则会直接返回到客户端。
注意,能够抛出任意类型的Throwable对象,它是异常类型的基类。

捕获异常

要明白异常是如何被捕获的,必须先理解监控区域(guarded region)的概念。它是一段可能产生异常的代码,并且后面跟着处理这些异常的代码。

try块

如果在方法内部抛出(或者在方法内部调用的其他方法抛出了异常),这个方法将在抛出异常的过程中结束。要是不希望方法就此结束,可在方法内设置一个特殊的块来捕获异常。因为在这个块里“尝试”各种(可能产生异常的)方法调用,所以称为try块。它是跟在try关键字之后的普通程序块:

try {
    // Code here that might generate exceptions
}

异常处理程序

抛出的异常必须在某处得到处理。这个“地方”就是异常处理程序,而且针对每个要捕获的异常,得准备相应的处理程序。异常处理程序紧跟在try块之后,以关键字catch表示:

try {
    // Code here that might generate exceptions
} catch(ExceptionType1 exceptionType1) {
    // Handle exceptions of ExceptionType1
} catch(ExceptionType2 exceptionType2) {
    // Handle exceptions of ExceptionType2
}

每个catch子句(异常处理程序)看起来就像是接收一个且仅接收一个特殊类型的参数的方法。异常处理程序必须紧跟在try块之后。当异常被抛出时,此时认为异常得到了处理。一旦catch子句结束,则处理程序的查找过程结束。注意,只有匹配的catch子句才能得到执行;这与switch子句不同,switch子句需要在每一个case后面跟一个break,以避免执行后续的case子句。

终止与恢复

异常处理理论上有两种基本模型。Java支持终止模型。在这种模型中,将假设错误非常关键,以至于程序无法返回到异常发生的地方继续执行。一旦异常被抛出,就表明错误已无法挽回,也不能回来继续执行。
另一种称为恢复模型。意思是异常处理程序的工作是修正错误,然后重新尝试调用出问题的方法,并认为第二次能成功。对于恢复模型,通常希望异常被处理之后能继续执行程序。恢复模型的本质是识别并修正错误。
尽管程序员们使用的操作系统支持恢复模型的异常处理,但他们最终还是转向使用类似“终止模型”的代码,并且忽略恢复行为。虽然恢复模型开始显得很吸引人,但不是很实用。其中的主要原因可能是它所导致的耦合:恢复性的处理程序需要了解异常抛出的地点,这势必要包含依赖于抛出位置的非通用性代码。这增加了代码编写和维护的苦难,对于异常可能会从许多地方抛出的大型程序来说,更是如此。
简言之,尽管异常处理理论上有两种基本模型(终止模型恢复模型),但因恢复模型带来的代码耦合性,终止模型是异常处理的实际标准。

创建自定义异常

Java提供的异常体系不可能遇见所有的希望加以报告的错误,所以可以自己定义异常类来表示程序中可能遇到的特殊问题。
要定义自己的异常类,必须从已有的异常类继承,最好是选择意思相近的异常类继承。建立新的异常类型最简单的方法是让编译器自己产生默认构造器。示例代码如下:

class CustomException extends Exception {
}
public class CustomExceptionDemo {
    public void test throws CustomException {
        // do something
        throw new CustomException();
    }
}

在实际的开发中,都会统一异常。如提供自定义的BussinessException供业务侧使用。当然,为了保证异常的可处理性,还会使用统一的业务错误码,实现对不同业务异常的区分。另外,为了实现国际化,还可能将异常与语言包关联。此外,还会将异常信息打印到日志系统里。这里不再一一展开。后续会单独提供实践文档。

异常说明

Java鼓励开发人员将方法可能会抛出的异常告知此方法的调用侧。这是一种优雅的做法,这使得调用者能够确切知道如何编写代码捕获已知异常。为了避免用户侧代码通过分析源码才能获知方法可能抛出的异常,Java强制在方法中提供throws关键字来显式声明可能会抛出的异常类型。这样调用侧代码在方法的声明中就可获知该方法潜在的异常类型的列表。注意,方法可以不抛出任何异常。
代码必须与异常说明保持一致。如果方法里的代码产生了异常却没有进行处理,编译器会检测出该问题并给出提示:要么处理这个异常,要么在异常说明中表明此方法将产生异常。通过这种自顶向下强制执行的异常说明机制,Java在编译期就可以保证一定水平的异常正确性。这种在编译期被强制检查的异常称为“被检查的异常”。
异常说明的思想从C++继承(C++从CLU继承)。这样,就可以使用编程的方式在方法的特征签名中,声明这个方法将抛出异常。异常说明可能有两种意思。一个是“我的代码会产生这种异常,这由你来处理”。另一个是“我的代码忽略了这些异常,这由你来处理”。学习异常时,要注意异常说明所表达的完整含义。

捕获所有异常

可以只写一个异常处理程序来捕获所有类型的异常,但不建议这么做。通常捕获类型的基类Exception,就可以做到这一点,示例代码如下:

try {
    // Code here that might generate exceptions
} catch(Exception exception) {
    // Handle exceptions of Exception
}

上述代码可以捕获所有异常,所以最好把它放在处理程序列表的末尾,以防止它抢在其他处理程序之前先把异常捕获了。

栈轨迹

printStackTrace()方法所提供的信息可以通过getStackTrace()方法来直接访问,这个方法将返回一个由栈轨迹中的元素锁构成的数组,其中每个元素都表示栈中的一帧。栈顶元素是调用序列的最后一个方法调用。栈底元素时调用序列中的第一个方法调用。

重新抛出异常

有时候希望把刚捕获的异常重新抛出,尤其是在使用Exception捕获所有异常的时候。更一般的原因,我们期望抛出的异常都是统一后的异常。这样就可进一步规范错误码。重新抛出异常的示例代码如下:

try {
    // may create excepiton
} catch(Exception e) {
    throw new BussinessException(e);
}

重新抛出异常会把异常抛给上一级环境中的异常处理程序,同一个try块后的后续catch子句将被忽略。

异常链

常常会想要在捕获一个异常后抛出另一个异常,并且希望把原始异常的信息保存下来,这被称为异常链。在所有Throw的子类在构造器中都可以接受一个cause对象作为参数。这个cause就用来表示原始异常,这样就可把当前异常传递个新的异常,并通过这个异常链追踪到异常最初发生的位置。
注意,在Throwable的子类中,只有三种基本的异常类提供了带cause参数的构造器,它们是Error(用于Java虚拟机报告系统错误)、Exception以及RuntimeException。在实际生产中,一般使用统一后的RuntimeException的子类。

Java标准异常

Throwable类是所有异常类的基类。Throwable可以细分为两种类型(指从Throwable继承而得到的类型):Error用来表示编译时和系统错误;Exception是可以被抛出的基本类型,在Java类库、用户方法以及运行时故障中可能抛出Exception的异常。Exception又可细分为运行时异常(RuntimeException及其子类)和编译期异常(运行时异常之外的Exception),两者区别如下:
RuntimeException:程序运行过程中才可能发生的异常。一般为代码的逻辑错误。例如:类型错误转换,数组下标访问越界,空指针异常、找不到指定类等等。
运行时异常之外的Exception:编译期间可以检查到的异常,要求编码时必须必须处理(捕获或者抛到调用侧)这些异常,如果不处理,则编译无法通过。例如:IOException, FileNotFoundException等等。
其异常划分如下:
在这里插入图片描述

注意,Java异常的数量在持续增加,对异常来说,关键是理解概念以及如何使用,实现举一反三。
异常的基本的概念是用名称代表发生的问题,并且异常的名称可以见名知意。如IOException表示输入/输出异常、NullPointerExcption表示空指针异常,等等。

RuntimeException

属于运行时异常的类型有很多,它们会自动被Java虚拟机抛出。这些异常构成了一组具有相同特征和行为的异常类型。注意,不需显式在异常声明方法将抛出RuntimeException类型的异常(或者任何从RuntimeException继承的异常),它们也称为“不受检查异常”。这种异常属于错误,将被自动捕获。
如果不捕获这种类型的异常,因为编译器没有进行强制检查,RuntimeException类型的异常也许会穿越所有的执行路径直达main方法,而不会被捕获。
强调:只能在代码中忽略RuntimeException(及其子类)类型的异常,其他类型异常的处理都是由编译器强制实施的。这是因为RuntimeException代表的是编程错误:
(1) 无法预料的错误。比如在控制范围之外传递进来null引用;
(2) 在编码过程中,应该对可能的错误进行检查(如数组越界、类型转换异常等);

使用finally进行清理

在编码过程中,可能会希望无论try块中的异常是否被抛出,有些代码都能得到执行(如连接释放操作)。为了达到这个效果,可以在异常程序后面加上finally子句。示例如下:

try {
    // may throw Exception
} catch(Exception e){
    // handle the exception
} finally {
    // activities that happen every time
}

运行代码可以发现,无论异常是否被抛出,finally子句代码总能被执行。

异常丢失

在使用finally子句时,要避免异常丢失。如在finally中抛出新异常或直接返回。示例如下:

public void test() {
    try {
        // may throw Exception
    } catch(Exception e) {
        // handle the exception
    } finally {
        // 抛出新异常(会导致丢失try和catch中的异常)
        throw new BussinessException();
        // 直接返回(也会导致丢失try和catch中的异常)
        // return;
    }
}

异常的限制

当子类覆盖基类的方法时,只能抛出在基类方法的异常说明里列出的那些异常。这个限制的作用是确保,当基类使用的代码应用到其子类对象的时候,一样能够正常工作(面向对象的继承特性),异常也不例外。
注意,上述异常限制对构造器不起作用。子类构造器可以抛出任何异常,而不必关心基类构造器是否抛出该异常。然而,因为基类的构造器可能会被调用(这里指默认构造器会被自动调用),派生类构造器的异常说明必须包含基类构造器的异常说明。
尽管在继承过程中,编译器会对异常说明做强制要求,但异常说明本身并不属于方法类型的一部分,方法类型是由方法的名字和参数的类型组成的。因此,不能基于异常说明来重载方法。此外,一个出现在基类方法的异常说明中的异常,不一定出现在派生类方法的异常说明里。这点同继承的规则不同。在继承中,基类的方法必须出现在派生类里,换句话说,在集成和覆盖的过程中,某个特定方法的“异常说明的接口”不是变大了,而是变小了——这恰好和类接口在继承时的情形相反。

异常匹配

抛出异常的时候,异常处理系统会按照代码的书写顺序找出“最近”的处理程序。找到匹配的处理程序后,它就认为异常将得到处理,然后就不再继续查找。
注意,查找的时候,并不要求抛出的异常同处理程序所声明的异常完全匹配。派生类的对象也可以匹配其基类的处理程序。示例代码如下:

class BaseDemoException extends Exception {

}

class ChildDemoException extends BaseDemoException {

}

public class Demo {
    public static void main(String[] args) {
        try {
            throw new ChildDemoException();
        } catch(ChildDemoException e) {
            // handle ChildDemoExceptio
        } catch(BaseDemoException e) {
            // handle BaseDemoExceptio
        }
    }
}

从示例代码可以看到,catch BaseDemoException处理程序会捕获ChildBaseDemoException异常。这样带来的一个问题是可能屏蔽ChildDemoException的异常处理。示例如下:

class BaseDemoException extends Exception {

}

class ChildDemoException extends BaseDemoException {

}

public class Demo {
    // 错误的异常处理示例
    public static void main(String[] args) {
        try {
            throw new ChildDemoException();
        } catch(BaseDemoException e) {
            // handle BaseDemoException
        } catch(ChildDemoException e) {
            // handle ChildDemoException
        }
    }
}

注意,使用这种方式编排异常处理程序后,ChildDemoException的异常处理程序将永远不会被执行,因为从上向下匹配异常类型时,基类的处理程序在遇到ChildDemoException时也会执行。
所以,在遇到需要catch多个异常时,异常处理程序应按照从子类到基类的原则。

其他可选方式

异常处理程序使得编码能够放弃程序的正常执行序列。当“异常情形”发生时,正常的执行已变得不可能或不需要了。开发异常处理系统的原因是,如果为每个方法所有可能发生的错误都进行处理的话,任务就显得过于繁重。开发异常处理程序的初衷是为了方便程序员处理错误。
异常处理的一个重要原则是“只有在知道如何处理的情况下才捕获异常”。异常处理的一个重要目标就是把错误处理的代码同错误发生的地点相分离。这使得可专注到要完成的事情,至于错误处理,则放在调用侧完成。同时,通过允许一个处理程序去处理多个出错点,异常处理还使得错误处理代码的数量趋向于减少。但是,这里也要注意,错误的“吞掉”异常导致的真正问题的隐藏(吞食有害,harmful if swallowed)。

多线程场景下异常处理

多线程场景下,要考虑线程故障问题。在线程中调用任务时,如果任务代码未知或者不可信时,应使用try-catch代码块调用这些任务,并主动捕获可能的异常;或者使用Thread API中提供的UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。
在单线程程序中,由于发生了一个未捕获异常而终止时,程序将停止运行,并产生与程序正常输出完全不同的栈堆栈信息。而多线程程序中,某个线程故障后,通常现象不会如此明显。尽管在控制台可能会输出栈追踪信息,但没有人会观察控制台。此外,当线程故障时,应用程序可能看起来仍在工作,所以这个失败的线程操作可能会被忽略。由此,在多线程程序中,需要检测并防止程序中“遗漏”的线程。(“线程泄露”)
导致线程提前死亡的最主要原因是RuntimeException。对于RuntimeException,用来表示出现某种编程错误或其他不可修复的错误,因此通常不会被捕获。它们不会在调用堆栈中逐层传递,而是默认在控制台输出栈跟踪信息,并终止线程。
线程非正常退出可能是良性的,也可能是恶性的,这要取决于线程在应用程序中的作用。所以要根据影响制定相应的处理策略。如在线程池中一个线程的丢失并不会带来严重的影响,而在GUI程序中如果丢失事件分派线程,会造成应用程序停止处理事件,并且GUI将会失去响应。
任何代码都可能抛出一个RuntimeException,每当调用另一个方法时,都要对它的行为保持怀疑,不要盲目地认为它一定会正常返回,或者一定抛出在方法中声明的某个已检查异常。对调用的代码越不熟悉,就越应该对其代码行为保持怀疑。
在线程中调用任务时,如果任务代码未知或者不可信时,应使用try-catch代码块调用这些任务,这样就能捕获那些未检查的异常或者考虑捕获RuntimeException。线程池内部构建的工作线程代码结构示例如下:

public void run() {
    Throwable thrown = null;
    try {
        while(!isInterrupted()) {
            runTask(getTaskFromWorkQueue());
        }
    } catch(Throwable e) {
        thrown = e;
    } finally {
        threadExited(this, thrown);
    }
}

除了使用主动的方法来解决未检查异常,在Thread API中同样提供了UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。这两种方法互为补充,可有效的防止“线程泄露”问题。
当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器。如果未提供任何异常处理器,那么默认的行为是将栈跟踪信息输出到System.err。UncaughtExceptionHandler接口定义如下:

public interface UncaughtExceptionHandler {
    void uncaughtException(Thread thread, Throwable e);
}

异常处理器如何处理未捕获异常,取决于对服务质量的需求。最常见的响应方式是将一个错误信息以及相应的堆栈信息写入到日志中。将异常写入日志的 UncaughtExceptionHandler实现类的示例代码如下:

public class UEHLogger implements Thread.UncaughtExceptionHandler {
    public void uncaughtException(Thread t, Throwable e) {
        Logger logger = Logger.getAnonymousLogger();
        logger.log(Level.SEVER, "Thread terminated with exception: " + t.getName(), e);
    }
}

注意,**只有通过execute提交任务,才能将它抛出的异常交给UncaughtExceptionHandler,而通过submit提交的任务,无论是抛出的未检查异常还是已检查异常,都将被任务时任务返回状态的一部分。**如果一个由submit提交的任务抛出了异常,那么这个异常将被Future.get封装在ExecutionException中重新抛出。

总结

异常是Java程序设计中不可分割的一部分,如果不了解如何使用它们,那就只能完成很有限的工作。
异常处理的优点之一就是它使得开发者可以在某处集中精力处理要解决的问题,而在另一处处理编写上述代码产生的错误。尽管异常可以在运行时报告错误并从错误中恢复,但实际过程中,还是倾向于终结异常。Java坚定地强调将所有的错误都以异常形式报告。因为一致的错误报告系统意味着,开发者在编写功能时,再也不必对所写的每一段代码,都质问自己“错误是否能够得到正确处理”(注意,在异常处理时,要谨慎使用“吞噬”异常的策略)。

参考

《Java编程思想(第四版)》 第十二章 通过异常处理错误
《java并发编程实战》 Brian Goetz 等著 童云兰 等译
https://blog.csdn.net/pipizhen_/article/details/107387343 Java中异常分类
https://www.cnblogs.com/aspirant/p/10790803.html Java 异常体系

原创不易,如果本文对您有帮助,欢迎关注我,谢谢 ~_~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值