Java基础:异常处理程序

前言

假设在一个Java程序运行期间出现一个错误。这个错误可能是由于文件包含了错误信息或者网络连接出现问题造成的,也有可能是因为使用无效的数组下标,或者试图使用一个没有被赋值的对象引用造成的。用户希望在出现错误时,程序能够采用一些理智的行为。如果由于出现错误而使得某些操作没有完成,程序应该:

  • 返回到一种安全状态,并能够让用户执行一些其他的命令。
  • 允许用户保存所有操作的结果,并以适当的方式终止程序。

要做到这些并不是一件很容易的事情。其原因是检测(或引发)错误条件的代码通常离那些能够让数据恢复到安全状态,或者能够保存用户操作的结果,并正常的退出程序的代码很远。异常处理的任务就是将控制权从错误产生的地方转移给能够处理这种情况的错误处理器。为了能够在程序中处理异常情况,必须研究程序中可能会出现的错误和问题,以及哪类问题需要关注。

正题

在开始文章前,有几个问题需要思考一下:

  • 异常到底是神马东西
  • 异常的整体结构
  • 异常有哪些分类
  • 异常的抛出
  • 异常的捕获
  • 异常的处理
  • 栈轨迹

1. 异常到底是神马东西

异常情形是指阻止当前方法或作用域继续执行的问题。把异常情形与普通问题相区分很重要,所谓的普通问题是指:在当前环境下能得到足够的信息,总能处理这个问题。而对于异常情形,就不能继续下去了,因为在当前环境下无法获得必要的信息来解决问题。你所能做的就是从当前环境跳出,并且把问题提交给上一级环境。这就是抛出异常时所发生的事情。

2. 异常的整体结构

在 Java 程序设计语言中,异常对象都是派生于 Throwable 类的一个实例。如果 Java 中内置的异常类不能满足需求,用户可以创建自己的异常类。

需要注意的是:所有的异常都是由 Throwable 继承而来,但在下一层立即分解为两个分支:Error 和 Exception。

  • Error 类层次结构描述了 Java 运行时系统内部错误和资源耗尽错误。应用程序不应该抛出这种类型的对象。如果出现了这样的内部错误,除了通告给用户,并尽力使程序安全地终止之外,再也无能为力了。这种情况很少出现。
  • 在设计 Java 程序时,需要关注 Exception 层次结构。这个层次结构又分解为两个分支:一个派生于 RuntimeException;另一个分支包含其他异常。划分两个分支的规则是:由程序错误导致的异常属于 RuntimeException;而程序本身没有问题,但由于像 I/O 错误这类问题导致的异常属于其他异常

派生于 RuntimeException 的异常包含下面几种情况:

  • 错误的类型转换
  • 数组访问越界
  • 访问空指针

不是派生 RuntimeException 的异常包括

  • 试图在文件尾部后面读取数据。
  • 试图打开一个错误格式的 URL。
  • 试图根据给定的字符串查找 Class 对象,而这个字符串表示的类并不存在。

" 如果出现 RuntimeException 异常,那么就一定是你的问题 "是一条相当有道理的规则。异常最重要的方面之一就是如果发生问题它们将不允许程序沿着其正常的路径继续走下去。应该通过检查数组下标是否越界来避免 ArrayIndexOutOfBoundsException 异常;应该通过在使用变量之前检测是否为空来杜绝 NullPointException 异常的发生。

3. 异常有哪些分类

Java语言规范将异常分为两类:

  • 未检查异常:编译器不要求强制处理的异常,此类异常通常是在逻辑上有错误,可以通过修改代码避免。
  • 已检查异常:编译器要求你必须处理的异常。代码没有错误,但程序运行时会因为 IO 等错误导致异常,你在编写程序阶段是预料不到的。如果不处理这些异常,程序将来肯定会出错。所以编译器会提示你要去捕获并处理这种可能发生的异常,不处理就不能通过编译。

Java 语言规范将派生于 Error 类或 RuntimeException 类的所有异常称为未检查(unchecked)异常,所有其他的异常称为已检查(checked)异常。

注意:一个方法不仅需要告诉编译器将要返回什么值,还要告诉编译器有可能发生什么错误。一个方法必须声明所有可能抛出的已检查异常,而未检查异常要么不可控(Error),要么就应该避免发生(RuntimeException)。如果方法没有声明所有可能发生的已检查异常,编译器就会给出一个错误消息。

3.1 已检查异常

代码必须与异常说明保持一致。如果方法里的代码产生了异常没有进行处理,编译器会发现这个问题并提醒你:要么处理这个异常,要么就在异常说明中表明此方法将产生异常。通过这种自顶向下强制执行的异常说明机制,Java 在编译时就可以保证一定水平的异常正确性。

不过还是有个能 "作弊" 的地方:可以声明方法将抛出异常,实际上去不抛出。编译器相信了这个声明,并强制此方法的用户像真的抛出异常那样使用这个方法。这样做的好处是:为异常先占个位子,以后就可以抛出这种异常而不用修改已有的代码。定义抽象基类和接口时这种能力很重要,这样派生类或接口实现就能够抛出这些预先声明的异常。这种在编译时被强制检查的异常称为被检查异常。

如果遇到了无法处理的情况,那么Java的方法可以抛出一个异常。这个道理很简单:一个方法不仅需要告诉编译器将要返回什么值,还要告诉编译器有可能发生什么错误。方法应该在其首部声明所有可能抛出的异常。这样可以从首部反映出这个方法可能抛出哪类已检查异常。例如,下面是标准类库中提供的FileInputStream类的一个构造器的声明:

public FileInputStream(String name) throws FileNotFoundException

这个声明表示这个构造器将根据给定的String参数产生一个FileInputStream对象,但也有可能抛出一个FileNotFoundException异常。如果发生了这种槽糕情况构造器将不会初始化一个新的FileInputStream对象,而是抛出一个FileNotFoundException类对象。如果这个方法真的抛出这样一个异常对象,运行时系统就会开始搜索异常处理器,以便知道如何处理FileNotFoundException对象

在自己编写方法时,不必将所有可能抛出的异常都进行声明。至于什么时候需要在方法中用 throws 字句声明异常,什么异常必须使用 throws 字句声明,需要记住在遇到下面4种情况时应该抛出异常:

  • 调用一个抛出已检查异常的方法,例如,FileInputStream 构造器。
  • 程序运行过程中发现错误,并且利用 throw 语句抛出一个已检查异常。
  • 程序出现错误,例如,a[-1]=0 会抛出一个 ArrayIndexOutOfBoundsException 这样的未检查异常。
  • Java 虚拟机和运行时库出现的内部异常。

如果出现前两种情况之一,则必须告诉调用这个方法的程序员有可能抛出异常。因为任何一个抛出异常的方法都有可能是一个死亡陷阱。如果没有处理器捕获这个异常,当前执行的线程会结束。

对于那些可能被他人使用的 Java 方法,应该根据异常规范,在方法的首部声明这个方法可能抛出的异常。

public class Pindy {
    ...
    public void A() throws EOFException {
        ...
    }
}

如果一个方法有可能抛出多个已检查异常,那么就必须在方法的首部列出所有的异常类,每个异常类之间用逗号隔开。

public class Pindy {
    ....
    public void A() throws EOFException, MailException {
    ...
    }
}

但是不需要声明 Java 的内部错误,即从 Error 继承的错误。任何程序代码都具有抛出那些异常的潜能,而我们对其没有任何控制能力。同样,也不应该声明从 RuntimeException 继承的那些未检查异常。

public class Pindy {
    ...
    public void A() throws ArrayIndexOutOfBoundsException {//bad style
    ...
    }
}

这些运行时错误完全在我们的控制之下。如果特别关注数组下标引发的错误,就应该将更多时间花费在修正程序中的错误上,而不是说明这些错误发生的可能性上。

警告:如果在子类中覆盖了超类的一个方法,子类方法中声明的已检查异常不能超过超类方法中声明的异常范围(也就是说,子类方法中抛出的异常范围更加小,或者根本不抛出任何异常)。特别需要说明的是,如果超类方法没有抛出任何已检查异常,子类也不能抛出任何已检查异常。

public class Pindy {

    public void A() throws FileNotFoundException{
    }
    
    public void B(){}
}

public class Kindy extends Pindy{
    //error:子类声明的已检查异常范围不能超过超类方法中声明的已检查范围
    @Override
    public void A() throws IOException {
    }

    //error:父类没有声明已检查异常子类也不能抛出任何已检查异常
    @Override
    public void B() throws FileSystemException{
    }
}

如果类中的一个方法声明将会抛出一个异常,而这个异常是某个特定类的实例时,则这个方法就有可能抛出这个类的异常,或者这个类的任意一个子类的异常。

3.2 RuntimeException

if(t == null)
    throw  new NullPointerException();

如果必须对传递给方法的每个引用都检查其是否为 null(因为无法确定调用者是否传入了非法引用),这听起来着实吓人。幸运的是,这不必由你亲自来做,它属于 Java 的标准运行时检测的一部分。如果对 null 引用进行调用,Java 会自动抛出 NullPointException 异常,所以上述代码时多余的,尽管你也许想要执行其他的检查以确保 NullPointException 不会出现。

属于运行时异常的类型有很多,它们会自动被 Java 虚拟机抛出,所以不必在异常说明中把它们列出来,这些异常都是从 RuntimeException 类继承而来,所以既体现了继承的优点,使用起来也很方便。这构成了一组具有相同特征和行为的异常类型。并且,也不需要在异常说明中声明方法将抛出 RuntimeException 类型的异常(或者任何从 RuntimeException 继承的异常),它们也被称为“不受检查异常”。这种异常属于错误,将被自动捕获,就不用你亲自动手了。要自己去检查 RuntimeException 的话,代码就显得太混乱了。不过尽管通常不用捕获 RuntimeException 异常,但还是可以在代码中抛出 RuntimeException 类型的异常。

如果不捕获这种类型的异常会发生什么事呢?因为编译器没有在这个问题上对异常说明进行强制检查,RuntimeException 类型的异常也许会穿越所有的执行路径直达 main() 方法,而不会捕获。

public class NeverCaught {
    static void f() {
        throw new RuntimeException("From f()");
    }

    static void g() {
        f();
    }

    public static void main(String[] args) {
        g();
    }
}

RuntimeException(或任何从它继承的异常)是一个特例。对于这种异常类型,编译器不需要异常说明,其输出被报告给了 System.err:

Exception in thread "main" java.lang.RuntimeException: From f()
	at com.mdj.NeverCaught.f(NeverCaught.java:5)
	at com.mdj.NeverCaught.g(NeverCaught.java:9)
	at com.mdj.NeverCaught.main(NeverCaught.java:13)

所以答案是:如果 RuntimeException 没有被捕获而直达 main,那么在程序退出前将调用异常的 printStackTrace() 方法。

请务必记住:只能在代码中忽略 RuntimeException(及其子类)类型的异常,其他类型异常的处理都是由编译器强制实施的。究其原因,RuntimeException 代表的是编程错误:

  • 无法预料的错误。比如从你控制范围之外传递进来的 null 引用
  • 作为程序员,应该在代码中进行检查的错误(比如对于 ArrayIndexOutOfBoundException,就得注意下数组的大小了)。在一个地方发生的异常,常常会在另一个地方导致错误。

值得注意的是:不应把 Java 的异常处理机制当成是单一用途的工具。是的,它被设计用来处理一些烦人的运行时错误,这些错误往往是由代码控制能力之外的因数导致的;然而,它对于发现某些编译器无法检测到的编程错误,也是非常重要的。

注意:RuntimeException 这个名字很容易让人混淆。实际上,现在讨论的所有错误都发生在运行时刻。RuntimeException 表示程序中的逻辑错误;非 RuntimeException 是由于不可预测的原因所引发的异常。

4. 异常的抛出

与使用 Java 中其他对象一样,我们总是用 new 在堆上创建异常对象,这也伴随着存储空间的分配和构造器的调用。所有标准异常类都有两个构造器:一个是默认构造器;另一个是接受字符串作为参数,以便把相关信息放入异常对象的构造器。

关键字 throw 将产生许多有趣的结果。在使用 new 创建了异常对象之后,此对象的引用将传给 throw。尽管返回的异常对象其类型通常与方法设计的返回类型不同,但从效果上看,它就像是从方法 "返回" 的。可以简单的把异常处理看成是一种不同的返回机制。另外还能用抛出异常的方式从当前的作用域退出。在这两种情况下,将会返回一个异常对象,然后退出方法或作用域。

抛出异常与方法正常返回值的相似之处到此为止。因为异常返回的 "地点" 与普通方法调用返回的 "地点" 不同。异常将在一个恰当的异常处理程序中得到解决,它的位置可能离异常被抛出的地方很远,也可能会跨越方法调用栈的许多层次。

此外,能够抛出任意类型的 Throwable 对象,它是异常类型的根类。通常,对于不同类型的错误,要抛出相应的异常。错误信息可以保存在异常对象内部或者用异常类的名称来暗示。上一层环境通过这些信息来决定如何处理异常。通常,异常对象中仅有的信息就是异常类型,除此之外不包含任何有意义的内容。

public class TryCatchClass {
    public static void f() throws Exception{
        try {
            g();
        } catch (Exception e) {
            throw e;
        }
        h();
    }

    public static void g() throws Exception {
        throw new Exception("From g()");
    }

    public static void h(){}
}

查看 f() 方法的字节码:

 public static void f() throws java.lang.Exception;
   descriptor: ()V
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=1, locals=1, args_size=0
        0: invokestatic  #2                  // Method g:()V
        3: goto          9
        6: astore_0
        7: aload_0
        8: athrow
        9: invokestatic  #4                  // Method h:()V
       12: return
     Exception table:
        from    to  target type
            0     3     6   Class java/lang/Exception

从上面的字节码可以看出,如果在执行 0~3 行指令时发生了异常,则会执行第八行 athrow 指令抛出异常,退出方法。

5. 异常的捕获

在抛出异常的过程中,Java 虚拟机会逐一地猝然结束在当前线程中开始但是还没有结束的所有表达式、语句、方法及构造器调用、初始化器和域初始化表达式。这个过程会持续到找到恰当的处理器为止,该处理器通过命名异常类或异常类的子类来表示它能够处理这类特定异常,如果没有找到这样的处理器,那么异常就会被由 "处理未捕获异常的处理器" 构成的层次结构中的某一个处理器来处理,其中,每一个处理器都会尝试着去处理,以避免异常未经处理。

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

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

try {
    //code
}

对于不支持异常处理的程序语言,要想仔细检查错误,就得在每个方法调用的前后加上设置和错误检查的代码,甚至在每次调用同一个方法时也得这么做。有了异常处理机制,可以把所有动作都放在 try 块里,然后只需在同一个地方就可以捕获所有异常。这意味着代码将更容易编写和阅读,因为完成任务的代码没有与错误检查的代码混在一起。

6. 异常的处理

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

try {
	//code
} catch(Type1 type) {
	//Handle the exception
}

每个 catch 字句(异常处理程序)看起开就像是接收一个且仅接收一个特殊类型的参数的方法。可以在处理程序的内部使用标识符,这与方法参数的使用很相似。有时可能用不到标识符,因为异常得类型已经给了你足够的信息来对异常进行处理,但标识符并不可以省略。

如果在 try 语句块中的任何代码抛出一个在 catch 字句中说明的异常类,那么

  • 程序将跳过 try 语句块的其余代码。
  • 程序将执行 catch 字句中的处理器代码。

如果在 try 语句块中的代码没有抛出任何异常,那么程序将跳过 catch 字句。如果方法中的任何代码抛出了一个在 catch 字句中没有声明的异常类型,那么这个方法就会立即退出。

7. 栈轨迹

printStackTrace() 方法所提供的信息可以通过 getStackTrace() 方法来直接访问,这个方法将返回一个由栈轨迹中元素构成的数组,其中每一个元素都表示栈中的一帧。元素 0 是栈顶元素,并且是调用序列中的最后一个方法调用(这个 Throwable 被创建和抛出之处)。数组中的最后一个元素和栈低是调用序列中的第一个方法调用。

public class WhoCalled {
    static void f() {
        try {
            throw  new Exception();
        } catch (Exception e) {
            for(StackTraceElement element : e.getStackTrace()) {
                System.out.println(element.getMethodName());
            }
        }
    }

    static void g() {
        f();
    }

    static void h() {
        g();
    }

    public static void main(String[] args) {
        f();
        System.out.println("==========");
        g();
        System.out.println("==========");
        h();
    }
}

运行结果:

f
main
==========
f
g
main
==========
f
g
h
main

8. finally 清理

对于一些代码,可能会希望无论 try 块中的异常是否抛出,它们都能得到执行。这通常适用于内存回收之外的情况(因为回收由垃圾回收器完成)。为了达到这个效果,可以在异常处理程序后面加上 finally 字句。

对于没有垃圾回收和析构函数自动调用机制的语言来说,finally 非常重要。它能使程序员保证:无论 try 块里发生了什么,内存总能得到释放。但 Java 垃圾回收机制,所以内存释放不再是问题。而且,Java 也没有析构函数可供使用。那么,Java 在什么情况下才能用到 finally 呢?

当要把内存之外的资源恢复到它们的初始状态时,就要用到 finally 字句。这种需要清理的资源包括:已经打开的文件或网络连接,在屏幕上画的图形,甚至可以是外部世界的某个开关。

public class FinallyCatch {
    public static int f() {
        try {
            g();
        } catch (Exception e)
        {
            return 2;
        } finally {
            return 3;
        }
    }

    public static void g() throws Exception {
        throw new Exception("From g()");
    }
}

在上面的代码在 finally 区域返回, 3,接下来查看 f() 方法的字节码来认识 finally 真正的工作原理:

 public static int f();
   descriptor: ()I
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=1, locals=3, args_size=0
        0: invokestatic  #2                  // Method g:()V
        3: iconst_3
        4: ireturn
        5: astore_0
        6: iconst_2
        7: istore_1
        8: iconst_3
        9: ireturn
       10: astore_2
       11: iconst_3
       12: ireturn
     Exception table:
        from    to  target type
            0     3     5   Class java/lang/Exception
            0     3    10   any
            5     8    10   any

0 - 4:在执行这几行代码过程中没有出现异常,则返回 3;

5 - 9:在执行上面的代码过程中抛出了异常则把指针变量指向第 5 行开始执行,如果在这个过程中没有出现异常,则返回 3。这里很多时候会误以为返回 2,但是会执行 iconst_3 指令把常量 3 放到栈顶返回。如果在执行过程中抛出异常,则把指令指针指到第 10 行开始执行。所以无论有没有抛出异常,都会执行 finally 区域的代码块。

从 Exception table 的信息可知:执行 0 - 3 行代码抛出 Exception 异常,就跳转到第 10 行执行;执行 0 - 3 代码块抛出了其他的异常,则跳转到 10 行代码;执行 5 -8 行代码块抛出其他异常,则跳转到 15 行代码执行。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值