最新消息:欢迎访问Android开发中文站!商务联系微信:loading_in

Java异常详解

Java基础 loading 173浏览 0评论

hi,大家好,我是开发者FTD。今天我们来聊一聊Java语言中的异常处理机制。

Java 语言诞生于1995年,距现在已经有26年的时间了。作为一门比较老的语言依然拥有强大的生命力,Java在很多方面(例如高并发,移植性等)具有明显的优势,当然在一些方面(例如图像处理)也有不足,今天要给大家介绍的异常就是Java语言中提供的一个强大的,可以让我们正确合理的应对程序中发生错误的机制。

一、异常介绍

什么是异常?

异常是指程序在运行过程中发生的,由于外部问题导致的程序运行异常事件,异常的发生往往会中断程序的运行。在 Java 这种面向对象的编程语言中,万物都是对象,异常本身也是一个对象,程序发生异常就会产生一个异常对象。

异常的分类

讲到异常的分类,就不能不说一下Java异常的继承结构。如下图所示:

Throwable

 

从图中可以看到,异常主要有以下类构成:

  • Throwable
  • Error
  • Exception

接下来我们就分别介绍一下这几个基类的作用。

Throwable

Throwable 类是 Java 语言中所有错误或异常的顶层父类,其他异常类都继承于该类。Throwable类有两个重要的子类:**Exception(异常)**和 Error(错误),二者都是 Java 异常处理的重要子类,各自都包含大量子类。

只有当对象是此类或其子类的实例时,才能通过 Java 虚拟机或者 Java throw 语句抛出。类似地,只有此类或其子类才可以是 catch 子句中的参数类型。

Throwable 对象中包含了其线程创建时线程执行堆栈的快照,它还包含了给出有关错误更多信息的消息字符串。

最后,它还可以包含 cause(原因):另一个导致此 throwable 抛出的 throwable。此 cause 设施在 1.4 版本中首次出现。它也称为异常链设施,因为 cause 自身也会有 cause,依此类推,就形成了异常链,每个异常都是由另一个异常引起的。

Error

Error 是 Throwable 的子类,通常情况下应用程序不应该试图捕获的严重问题

Error 是程序无法处理的错误,表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。

例如:Java虚拟机运行错误(Virtual MachineError),当 JVM 不再有继续执行操作所需的内存资源时,将出现 OutOfMemoryError。这些异常发生时,Java虚拟机(JVM)一般会选择线程终止。

这些错误表示故障发生于虚拟机自身、或者发生在虚拟机试图执行应用时,如Java虚拟机运行错误(Virtual MachineError)、类定义错误(NoClassDefFoundError)等。这些错误是不可查的,因为它们在应用程序的控制和处理能力之 外,而且绝大多数是程序运行时不允许出现的状况。对于设计合理的应用程序来说,即使确实发生了错误,本质上也不应该试图去处理它所引起的异常状况。在 Java中,错误通过Error的子类描述。

Exception

Exception以及它的子类,代表程序运行时发送的各种不期望发生的事件。可以被Java异常处理机制使用,是异常处理的核心。

Exception 异常主要分为两类:

1、非检查性异常(unchecked exception)

Error 和 RuntimeException 以及他们的子类。Java语言在编译时,不会提示和发现这样的异常,不要求在程序中处理这些异常。所以我们可以在程序中编写代码来处理(使用try…catch…finally)这样的异常,也可以不做任何处理。对于这些错误或异常,我们应该修正代码,而不是去通过异常处理器处理。这样的异常发生的原因多半是由于我们的代码逻辑出现了问题。

例如:

  • 当程序中用数字除以0时,就会抛出ArithmeticException异常;

  • 在类型转换时,错误的强制类型转换会抛出ClassCastException类型转换异常;

  • 当使用集合进行数组索引越界时就会抛出ArrayIndexOutOfBoundsException异常;

  • 当程序中使用了空对象进行操作时就会抛出注明的空指针NullPointerException异常等。

常见的非检查性异常有

异常 描述
ArithmeticException 当出现异常的运算条件时,抛出异常。例如,一个整数“除以零”时,抛出此类的一个实例。
ArrayIndexOutOfBoundsException 用非法索引访问数组时跑出的异常。如果索引为负或大于等于数组大小,则该索引为非法索引。
ArrayStoreException 试图将错误类型的对象存储到一个对象数组时,抛出的异常。
ClassCastException 试图将对象强制转换为不是同一个类型或其子类的实例时,抛出的异常。
IllegalArgumentException 当向一个方法传递非法或不正确的参数时,抛出该异常。
IllegalMonitorStateException 当某一线程已经试图等待对象的监视器,或者通知其他正在等待该对象监视器的线程,而该线程本身没有获得指定监视器时抛出该异常。
IllegalStateException 在非法或不适当的时间调用方法时产生的信号。或者说Java环境或应用程序没有处于请求操作所要求的适当状态下。
IllegalThreadStateException 线程没有处于请求操作所要求的适当状态时,抛出该异常。
IndexOutOfBoundsException 当某种排序的索引超出范围时抛出的异常,例如,一个数组,字符串或一个向量的排序等。
NegativeArraySizeException 如果应用程序试图创建大小为负的数组时,抛出该异常。
NullPointerException 当应用程序在需要操作对象的时候而获得的对象实例是null时抛出该异常。
NumberFormatException 当应用程序试图将字符串转换成一种数值类型,但该字符串不能转换为适当格式时,抛出该异常。
SecurityException 由安全管理器抛出的异常,指示存在安全侵犯。
StringIndexOutOfBoundsException 此异常由String方法抛出,说明索引为负或者超出了字符串的大小。

2、检查性异常(checked exception)

除了Error 和 RuntimeException的其它异常。Java语言强制要求程序员为这样的异常做预备处理工作(使用try…catch…finally或者throws)。在方法中要么用try-catch语句捕获它并处理,要么用throws子句声明抛出它,否则编译不会通过。这样的异常一般是由程序的运行环境导致的。因为程序可能被运行在各种未知的环境下,而程序员无法干预用户如何使用他编写的程序,于是程序员就应该为这样的异常时刻准备着。如SQLException,IOException,ClassNotFoundException 等。

检查性异常就是指,编译器在编译期间要求必须得到处理的那些异常,你必须在编译期处理了。

常见的检查性异常有

异常 描述
ClassNotFoundException 当应用程序试图加载一个类,通过名字查找时却发现没有该类的定义时,抛出该异常。
CloneNotSupportedException 当去克隆一个对象时,发现该对象没有实现Cloneable接口时,抛出该异常。
IllegalAccessException 当应用程序尝试通过反射的方式来访问类、成员变量或调用方法时,却无法访问这些类、成员变量或方法的定义时,抛出该异常。
InstantiationException 当试图使用Class类中的newInstance方法创建一个类的实例,而制定的类对象因为是一个接口或是一个抽象类而无法实例化时,抛出该异常。
InterruptedException 一个线程被另一个线程中断时,抛出该异常。
NoSuchFieldException 当找不到指定的变量字段时,抛出该异常、
NoSuchMethodException 当找不到指定的类方法时,抛出该异常。

二、初识异常

下面我们通过一个简单实例,让大家更直观的认识一下Java的异常。

下面的代码会抛出著名的空指针异常:NullPointerException。

public class Test {
    private int a = 1;
    private int b = 2;

    public static void main(String[] args) {
        Test t1 = new Test();
        Test t2 = null;
        System.out.println(t1.a);
        System.out.println(t2.a);
        System.out.println(t2.c());
    }

    public String c() {
        return "微信公众号:我是开发者FTD";
    }
}

运行程序,控制台输出结果如下:

1
Exception in thread "main" java.lang.NullPointerException
 at cc.devclub.ftd.Test.main(Test.java:11)

Process finished with exit code 1

从控制台输出可以看到,程序打印了 “1”,然后在程序的第11行的位置抛出了 java.lang.NullPointerException ,然后程序就终止运行了。

三、异常处理机制

在编写代码处理异常时,对于检查性异常,有两种不同的处理方式:

  • 使用 try…catch…finally… 语句块处理
  • 在方法中使用 throws/throw 关键词将异常交给方法调用者去处理
try…catch…finally… 关键字
  • 使用 try 和 catch 关键字可以捕获异常。
  • try/catch 代码块放在异常可能发生的地方。

try/catch代码块中的代码称为保护代码,使用 try/catch 的语法如下:

try {
    ...
} catch (IOException ioException) {
    ...
} catch (Exception exception) {
    ...
} finally {
    ...
}

try 块:

  • try块中放可能发生异常的代码。
  • 如果执行完try且不发生异常,则接着去执行finally块中的代码和finally后面的代码(如果有的话)。
  • 如果程序发生异常,则尝试去匹配对应的catch块。

catch 块:

  • 每一个catch块用于捕获并处理一个特定的异常,或者这异常类型的子类。Java7中可以将多个异常声明在一个catch中。
  • catch后面的括号定义了异常类型和异常参数。如果异常与之匹配且是最先匹配到的,则虚拟机将使用这个catch块来处理异常。
  • 在catch块中可以使用这个块的异常参数来获取异常的相关信息。异常参数是这个catch块中的局部变量,其它块不能访问。
  • 如果当前try块中发生的异常在后续的所有catch中都没捕获到,则先去执行finally,然后到这个方法的外部调用者中去匹配异常处理器。
  • 如果try中没有发生异常,则所有的catch块将被忽略。

需要注意的地方

1、try块中的局部变量和catch块中的局部变量(包括异常变量),以及finally中的局部变量,他们之间不可共享使用。

2、每一个catch块用于处理一个异常。异常匹配是按照catch块的顺序从上往下寻找的,只有第一个匹配的catch会得到执行。匹配时,不仅运行精确匹配,也支持父类匹配,因此,如果同一个try块下的多个catch异常类型有父子关系,应该将子类异常放在前面,父类异常放在后面,这样保证每个catch块都有存在的意义。

3、Java中,异常处理的任务就是将执行控制流从异常发生的地方转移到能够处理这种异常的地方去。也就是说:当一个方法的某条语句发生异常时,这条语句的后面的语句不会再执行,它失去了焦点。执行流跳转到最近的匹配的异常处理catch代码块去执行,异常被处理完后,执行流会接着在“处理了这个异常的catch代码块”后面接着执行。

finally 块:

  • finally块不是必须的,通常是可选的。

  • 无论异常是否发生,异常是否匹配被处理,finally中的代码都会执行。

  • 一个try至少要有一个catch块,否则, 至少要有1个finally块。但是finally不是用来处理异常的,finally不会捕获和处理异常,处理异常的只能是catch块。

  • finally主要做一些清理工作,如流的关闭,数据库连接的关闭等。

  • finally块不管异常是否发生,只要对应的try执行了,则它一定也执行。只有一种方法让finally块不执行:System.exit()

大家需要养成**良好的编程习惯是:**在try块中打开资源,在finally块中清理并释放这些资源,以免造成内存泄露。

需要注意的地方:

1、在同一try…catch…finally…块中,如果try中抛出异常,且有匹配的catch块,则先执行catch块,再执行finally块。如果没有catch块匹配,则先执行finally,然后去到上层的调用者中寻找合适的catch块。

2、在同一try…catch…finally…块中 ,try发生异常,且匹配的catch块中处理异常时也抛出异常,那么后面的finally也会执行:首先执行finally块,然后去上层调用者中寻找合适的catch块。

throws/throw 关键字
  • throws 关键字

如果一个方法内部的代码会抛出检查性异常(checked exception),而方法自己又没有对这些异常完全处理掉,则java的编译器会要求你必须在方法的签名上使用 throws 关键字声明这些可能抛出的异常,否则编译不通过。

throws 是另一种处理异常的方式,它不同于try…catch…finally…,throws 关键字仅仅是将方法中可能出现的异常向调用者抛出,而自己则不具体处理。

采取这种异常处理的原因可能是:方法本身不知道如何处理这样的异常,或者说让调用者处理更好,调用者需要为可能发生的异常负责。

  • throw 关键字

我们也可以通过 throw 语句手动显式的抛出一个异常,throw语句的后面必须是一个异常对象。语法如下:

throw exceptionObject

throw 语句必须写在方法中,执行throw 语句的地方就是一个异常抛出点,它和由JRE自动形成的异常抛出点没有任何差别。

public void save(User user) {
    if (user == null)
        throw new IllegalArgumentException("User对象为空");
    //......
}
try-catch-finally 的执行顺序

try-catch-finally 执行顺序的相关问题可以说是各种面试中的「常客」了,尤其是 finally 块中带有 return 语句的情况。我们直接看几道面试题:

面试题一:

public static void main(String[] args) {
    int result = test1();
    System.out.println(result);
}

public static int test1() {
    int i = 1;
    try {
        i++;
        System.out.println("try block, i = " + i);
    } catch (Exception e) {
        i--;
        System.out.println("catch block i = " + i);
    } finally {
        i = 10;
        System.out.println("finally block i = " + i);
    }
    return i;
}

大家不妨算一算程序员最终运行的结果是什么。

输出结果如下:

try block, i = 2
finally block i = 10
10

这算一个相当简单的问题了,没有坑,下面我们稍微改动一下:

public static int test2() {
    int i = 1;
    try {
        i++;
        throw new Exception();
    } catch (Exception e) {
        i--;
        System.out.println("catch block i = " + i);
    } finally {
        i = 10;
        System.out.println("finally block i = " + i);
    }
    return i;
}

输出结果如下:

catch block i = 1
finally block i = 10
10

运行结果想必也是意料之中吧,程序抛出一个异常,然后被本方法的 catch 块捕获并进行了处理。

面试题二:

public static void main(String[] args) {
    int result = test3();
    System.out.println(result);
}

public static int test3() {
    //try 语句块中有 return 语句时的整体执行顺序
    int i = 1;
    try {
        i++;
        System.out.println("try block, i = " + i);
        return i;
    } catch (Exception e) {
        i++;
        System.out.println("catch block i = " + i);
        return i;
    } finally {
        i = 10;
        System.out.println("finally block i = " + i);
    }
}

输出结果如下:

try block, i = 2
finally block i = 10
2

是不是有点疑惑?明明我 try 语句块中有 return 语句,可为什么最终还是执行了 finally 块中的代码?

我们反编译这个类,看看这个 test3 方法编译后的字节码的实现:

0: iconst_1         //将 1 加载进操作数栈
1: istore_0         //将操作数栈 0 位置的元素存进局部变量表
2: iinc          0, 1   //将局部变量表 0 位置的元素直接加一(i=2)
5: getstatic     #3     // 5-27 行执行的 println 方法                
8: new           #5                  
11: dup
12: invokespecial #6                                                     
15: ldc           #7 
17: invokevirtual #8                                                     
20: iload_0         
21: invokevirtual #9                                                     
24: invokevirtual #10                
27: invokevirtual #11                 
30: iload_0         //将局部变量表 0 位置的元素加载进操作栈(2)
31: istore_1        //把操作栈顶的元素存入局部变量表位置 1 处
32: bipush        10 //加载一个常量到操作栈(10)
34: istore_0        //将 10 存入局部变量表 0 处
35: getstatic     #3  //35-57 行执行 finally中的println方法             
38: new           #5                  
41: dup
42: invokespecial #6                  
45: ldc           #12                 
47: invokevirtual #8                  
50: iload_0
51: invokevirtual #9                
54: invokevirtual #10                 
57: invokevirtual #11                 
60: iload_1         //将局部变量表 1 位置的元素加载进操作栈(2)
61: ireturn         //将操作栈顶元素返回(2)
-------------------try + finally 结束 ------------
------------------下面是 catch + finally,类似的 ------------
62: astore_1
63: iinc          0, 1
.......
.......

从我们的分析中可以看出来,finally 代码块中的内容始终会被执行,无论程序是否出现异常的原因就是,编译器会将 finally 块中的代码复制两份并分别添加在 try 和 catch 的后面

可能有人会所疑惑,原本我们的 i 就被存储在局部变量表 0 位置,而最后 finally 中的代码也的确将 slot 0 位置填充了数值 10,可为什么最后程序依然返回的数值 2 呢?

仔细看字节码,你会发现在 return 语句返回之前,虚拟机会将待返回的值压入操作数栈,等待返回,即使 finally 语句块对 i 进行了修改,但是待返回的值已经确实的存在于操作数栈中了,所以不会影响程序返回结果。

面试题三:

public static int test4() {
    //finally 语句块中有 return 语句
    int i = 1;
    try {
        i++;
        System.out.println("try block, i = " + i);
        return i;
    } catch (Exception e) {
        i++;
        System.out.println("catch block i = " + i);
        return i;
    } finally {
        i++;
        System.out.println("finally block i = " + i);
        return i;
    }
}

运行结果:

try block, i = 2
finally block i = 3
3

其实你从它的字节码指令去看整个过程,而不要单单死记它的执行过程。

你会发现程序最终会采用 finally 代码块中的 return 语句进行返回,而直接忽略 try 语句块中的 return 指令。

自定义异常

Java 的异常机制中所定义的所有异常不可能预见所有可能出现的错误,某些特定的情境下,则需要我们自定义异常类型来向上报告某些错误信息。

而自定义异常类型也是相当简单的,你可以选择继承 Throwable,Exception 或它们的子类,甚至你不需要实现和重写父类的任何方法即可完成一个异常类型的定义。

例如:

public class MyException extends RuntimeException{ } 
public class MyException extends Exception{ }

按照国际惯例,自定义的异常应该总是包含如下的构造函数:

  • 一个无参构造函数
  • 一个带有String参数的构造函数,并传递给父类的构造函数。
  • 一个带有String参数和Throwable参数,并都传递给父类构造函数
  • 一个带有Throwable 参数的构造函数,并传递给父类的构造函数。

下面是IOException类的完整源代码,我们可以参考:

public class IOException extends Exception {
    static final long serialVersionUID = 7818375828146090155L;

    public IOException() {
        super();
    }

    public IOException(String message) {
        super(message);
    }

    public IOException(String message, Throwable cause) {
        super(message, cause);
    }

    public IOException(Throwable cause) {
        super(cause);
    }
}

异常的注意事项

1、当子类重写父类的带有 throws声明的函数时,其throws声明的异常必须在父类异常的可控范围内——用于处理父类的throws方法的异常处理器,必须也适用于子类的这个带throws方法 。这是为了支持多态。

例如,父类方法throws 的是2个异常,子类就不能throws 3个及以上的异常。父类throws IOException,子类就必须throws IOException或者IOException的子类。

2、Java程序可以是多线程的。每一个线程都是一个独立的执行流,独立的函数调用栈。如果程序只有一个线程,那么没有被任何代码处理的异常 会导致程序终止。如果是多线程的,那么没有被任何代码处理的异常仅仅会导致异常所在的线程结束。

也就是说,Java中的异常是线程独立的,线程的问题应该由线程自己来解决,而不要委托到外部,也不会直接影响到其它线程的执行。

异常使用时的常见错误

1、将异常直接显示在页面或客户端

将异常直接打印在客户端的例子屡见不鲜,一旦程序运行出现异常,默认情况下容器将异常堆栈信息直接打印在页面上。从客户角度来说,任何异常都没有实际意义,绝大多数的客户也根本看不懂异常信息,软件开发也要尽量避免将异常直接呈现给用户,一定要在前端展示层对异常进行封装后展示。目前绝大多数应用都是前后端分离的模式,这种直接打印异常的情况已经相对改善了很多,不过我们在编码时还是要特别注意下这个原则。

2、忽略异常

如下异常处理只是将异常输出到控制台,没有任何意义。而且这里出现了异常并没有中断程序,进而调用代码继续执行,导致更多的异常。

public void retrieveObjectById(Long id) {
    try {
        //..some code that throws SQLException
    } catch (SQLException ex) {
        /**
          *了解的人都知道,这里的异常打印毫无意义,仅仅是将错误堆栈输出到控制台。
          * 而在 Production 环境中,需要将错误堆栈输出到日志。
          * 而且这里 catch 处理之后程序继续执行,会导致进一步的问题*/

        ex.printStacktrace();
    }
}

捕获了异常缺不进行处理,这是我们在写代码时候的大忌,可以重构成:

public void retrieveObjectById(Long id) {
    try {
        //..some code that throws SQLException
    } catch (SQLException ex) {
        throw new RuntimeException("Exception in retieveObjectById”, ex);
    } finally {
        //clean up resultset, statement, connection etc
    }
}
3、将异常包含在循环语句块中

如下代码所示,异常包含在 for 循环语句块中。

for (int i = 0; i < 100; i++) {
    try {
    } catch (XXXException e) {
        //....
    }
}

我们都知道异常处理占用系统资源。一看,大家都认为不会犯这样的错误。换个角度,类 A 中执行了一段循环,循环中调用了 B 类的方法,B 类中被调用的方法却又包含 try-catch 这样的语句块。褪去类的层次结构,代码和上面如出一辙。

4、利用 Exception 捕捉所有潜在的异常

一段方法执行过程中抛出了几个不同类型的异常,为了代码简洁,利用基类 Exception 捕捉所有潜在的异常,如下例所示:

public void retrieveObjectById(Long id) {
    try {
        //...抛出 IOException 的代码调用
        //...抛出 SQLException 的代码调用
    } catch (Exception e) {
        //这里利用基类 Exception 捕捉的所有潜在的异常,如果多个层次这样捕捉,会丢失原始异常的有效信息
        throw new RuntimeException("Exception in retieveObjectById”, e);
    }
}

估计大部分程序员都会有这种写法,为了省事简便,直接一个顶层的exception来捕获所有可能出现的异常,这样虽然可以保证异常肯定会被捕捉到,但是程序却无法针对不同的错误异常进行对应正确的处理,可以重构成:

public void retrieveObjectById(Long id) {
    try {
        //..some code that throws RuntimeException, IOException, SQLException
    } catch (IOException e) {
        //仅仅捕捉 IOException
        throw new RuntimeException(/*指定这里 IOException 对应的错误代码*/code, "Exception in retieveObjectById”, e);
    } catch (SQLException e) {
        //仅仅捕捉 SQLException
        throw new RuntimeException(/*指定这里 SQLException 对应的错误代码*/code, "Exception in retieveObjectById”, e);
    }
}
5、异常包含的信息不能充分定位问题

异常不仅要能够让开发人员知道哪里出了问题,更多时候开发人员还需要知道是什么原因导致的问题,我们知道 java .lang.Exception 有字符串类型参数的构造方法,这个字符串可以自定义成通俗易懂的提示信息。

简单的自定义信息开发人员只能知道哪里出现了异常,但是很多的情况下,开发人员更需要知道是什么参数导致了这样的异常。这个时候我们就需要将方法调用的参数信息追加到自定义信息中。下例只列举了一个参数的情况,多个参数的情况下,可以单独写一个工具类组织这样的字符串。

public void retieveObjectById(Long id) {
    try {
        //..some code that throws SQLException
    } catch (SQLException ex) {
        //将参数信息添加到异常信息中
        throw new RuntimeException("Exception in retieveObjectById with Object Id :"+ id, ex);
    }
}

总结

异常作为Java语言中重要的错误处理机制,也作为查找程序原因,提升程序健壮性,前端产品良好体验的重要保障,所以掌握异常的使用是非常有必要的,希望本文能对大家有所帮助,如果有什么疑问或问题欢迎随时骚扰。

创作不易,如果大家喜欢本文,欢迎点赞,转发,你的关注是我们继续前进的动力 ^_^

参考
关于作者
联系作者
  • 微信号:ForTheDeveloper
微信号

  • 公众号:ForTheDevelopers
公众号

转载请注明:Android开发中文站 » Java异常详解

您必须 登录 才能发表评论!