首页 > 编程笔记

什么是异常,Java异常详解

Java 中的错误我们大致可以分为两类,一类是编译时错误,一般指语法错误;另一类是运行时错误。

我们知道,Java 程序运行首先需要进行编译,将 Java 文件编译成计算机能够识别的字节码文件,这类错误在程序编译时就会暴露出来,会导致程序编译失败。IDE 集成开发环境都会对这种错误进行提示,即我们在编写代码时能看到的语法错误,就叫编译时错误。因为可以即时看到,所以这种错误一般都能避免。

运行时错误在我们编写代码的过程中以及程序编译期间都难以发现,甚至可以正常编译通过,但一旦运行就会报错,这类错误一般不容易发现。编写代码的过程中往往会因为疏忽导致运行时错误的出现,例如数组下标越界,把 0 当作除数等。

Java 是一门面向对象的编程语言,世间万物都可以看作对象,那么同理错误也可以看作一个对象。Java 中有一组类专门来描述各种不同的运行时错误,叫作异常类。

Java 结合异常类提供了处理错误的机制,具体步骤就是当程序出现错误时,会创建一个包含错误信息的异常类的实例化对象,并将该对象提交给系统,由系统转交给能处理该异常的代码进行处理。

Java 将异常分为两类,包括 Error 和 Exception。Error 指系统错误,由 Java 虚拟机生成,我们编写的程序无法处理。Exception 指程序运行期间出现的错误,我们编写的程序可以对其进行处理。

举个生活中的例子来类比 Error 和 Exception。当你骑自行车出去玩时,如果半路自行车链条掉了,我们把链条重新安装就可以继续骑行,这属于自己可以处理的问题,就是 Exception。如果前方的路塌陷了,所有车都无法通过了,这属于我们无法处理的问题,就是 Error。

异常的使用

异常的使用需要用到两个关键字 try 和 catch,并且这两个关键字需要结合起来使用,用 try 来监听可能会抛出异常的代码,一旦捕获到异常,生成异常对象并交给 catch 来处理,基本语法如下:
try{
     //可能抛出异常
}catch(异常对象){
     //处理异常
}
举个简单的例子:
public class Test {
    public static void main(String[] args) {
        try {
            int num = 10/0;
        }catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }
    }
}
运行结果为:

java.lang.ArithmeticException: / by zero
      at Test.main(test.java:4)

可以看到因为“int num = 10/0;”代码中将 0 作为除数,所以程序在执行时会产生错误并自动生成一个 Exception 对象,在 catch 代码块中捕获 Exception 对象并进行处理,将错误信息打印出来。

如果我们将代码修改为“int num = 10/10;”再次运行,就不会看到异常信息了。因为此时没有发生错误,就不会产生 Exception 对象,catch 代码块不执行。

以上代码是异常最基本的使用,通常除了使用 try 和 catch 关键字,我们还需要用到 finally 关键字,这个关键字有什么作用呢?无论程序是否抛出异常,finally 代码块中的程序都会执行。finally 一般跟在 catch 代码块后面,基本语法如下:
try{
     //可能抛出异常
}catch(异常对象){
     //处理异常
}finally{
     //必须执行的代码
}
例如:
public class Test {
    public static void main(String[] args) {
        try {
            int num = 10/0;
        }catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }finally {
            System.out.println("finally...");
        }
    }
}
运行结果为:

java.lang.ArithmeticException: / by zero
finally...
      at Test.main(Test.java:4)


现在我们对代码进行修改,不使用 finally,而在 catch 代码块后面直接执行“System.out.println("finally...");”,代码如下:
public class Test {
    public static void main(String[] args) {
        try {
            int num = 10/0;
        }catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }
        System.out.println("finally...");
    }
}
运行结果为:

java.lang.ArithmeticException: / by zero
finally...
      at Test.main(Test.java:4)

可以看到结果完全一样,那么大家可能就会有疑问了,既然结果完全一样,那么为什么要使用 finally 呢?别着急,看完下面这个例子你就明白了。

定义一个带有返回值的方法 test,在该方法中加入 try-catch,例如:
public class Test {
    public static void main(String[] args) {
        System.out.println(test());
}
    public static int test() {
        try {
            System.out.println("try...");
            return 10;
        }catch (Exception e) {
            // TODO: handle exception
        }
        System.out.println("finally...");
        return 20;
    }
}
运行结果为:

try...
10

通过结果可以看到,try 代码块中进行了 return 操作,所以后续的代码都不会执行。

现在对代码进行修改,代码如下:
public class Test {
    public static void main(String[] args) {
        System.out.println(test());
}
    public static int test() {
        try {
            System.out.println("try...");
            return 10;
        }catch (Exception e) {
            // TODO: handle exception
        }finally {
            System.out.println("finally...");
            return 20;
        }
    }
}
运行结果为:

try...
finally...
20

通过结果可以看到,虽然 try 代码块中执行了 return 操作,但是 finally 代码块中的程序依然会执行,并且会覆盖 try 中 return 的结果,返回给外部调用者,正是因为 finally 的这个特性,一般会在 finally 中进行释放资源的操作。

异常类

Java 将运行时出现的错误全部封装成类,并且不是一个类,而是一组类。同时这些类之间是有层级关系的,由树状结构一层层向下分级,处在最顶端的类是 Throwable,是所有异常类的根结点。Throwable 有两个直接子类:Error 和 Exception,这两个类前面已经提到了。Error 表示系统错误,程序无法解决;Exception 指程序运行时出现的错误,程序可以处理。Throwable、Error 和 Exception 都存放在 java.lang 包中。

Error 常见的子类有 VirtualMachineError、AWTError、IOError。VirtualMachineError 的常见的子类有 StackOverflowError 和 OutOfMemoryError,用来描述内存溢出等系统问题。VirtualMachineError、StackOverflowError 和 OutOfMemoryError 都存放在 java.lang 包中,AWTError 存放在 java.awt 包中,IOError 存放在 java.io 包中。

Exception 常见的子类主要有 IOException 和 RuntimeException,IOException 存放在 java.io 包中,RuntimeException 存放在 java.lang 包中。Exception 类要重点关注,因为这部分异常是需要我们在编写代码的过程中手动进行处理的。

IOException 的常用子类有 FileLockInterruptionException、FileNotFoundException 和 FilerException,这些异常通常都是处理通过 IO 流进行文件传输时发生的错误,在后面 IO 流的章节我们会详细讲解。FileLockInterruptionException 存放在 java.nio.channels 包中,FileNotFoundException 存放在 java.io 包中,FilerException 存放在 javax.annotation.processing 包中。

RuntimeException的常用子类如下。
它们全部存放在 java.lang 包中。异常类的体系结构如下图所示。

图 1 Java异常类的体系结构

以上我们列举出了实际开发中经常使用到的异常类,还有很多没有列举出来,感兴趣的读者可以自己去查找 API 文档。除了使用 Java 官方提供的异常类,我们也可以根据需求自定义异常类。

throw和throws

throw 和 throws 是 Java 在处理异常时使用的两个关键字,都用来抛出异常,但是使用的方式以及表示的含义完全不同,接下来就带领大家一起来学习二者的区别。

Java 中抛出异常有 3 种方式,第 1 种是我们之前介绍过的使用 try-catch 代码块捕获异常。这种方式其实是一种防范机制,即代码中有可能会抛出异常。如果抛出,则捕获并进行处理;如果不抛出,则程序继续向后执行。例如:
public class Test {
    public static void main(String[] args) {
        String str = "Java";
        try {
            Integer num = Integer.parseInt(str);
        }catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }
    }
}
运行结果为:

java.lang.NumberFormatException: For input string: "Java"
     at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
     at java.base/java.lang.Integer.parseInt(Integer.java:668)
     at java.base/java.lang.Integer.parseInt(Integer.java:786)
     at Test.main(Test.java:5)

可以看到结果抛出了类型转换的异常,不能将 String 类型的"Java"转为 int 类型的数据。同时在代码中我们不添加 try-catch 代码块,但结果是一样的:
public class Test {
    public static void main(String[] args) {
        String str = "Java";
        Integer num = Integer.parseInt(str);
    }
}
运行结果为:

Exception in thread "main" java.lang.NumberFormatException: For input string: "Java"
     at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
     at java.base/java.lang.Integer.parseInt(Integer.java:668)
     at java.base/java.lang.Integer.parseInt(Integer.java:786)
     at Test.main(Test.java:4)

可以看到结果完全一样,为什么我们不添加 try-catch 代码块,程序同样可以抛出异常呢?

答案是 Java 有非常完善的错误处理机制,即使开发者不主动在程序中进行异常捕获,Java 虚拟机也会自动完成异常处理。这相当于员工因为疏忽没有把工作做好,老板会在汇总工作时发现问题并帮员工解决,保证最终的工作成果没有问题。但是很显然这种方式不是很好,不能总让老板去处理错误,员工在做本职工作时应该把所有问题都处理好。

回到代码中也是一样的道理,我们在编写程序时应尽量将异常进行处理,这个工作不要交给 Java 虚拟机去处理。如果我们进行如下修改,程序就不会抛出异常:
public class Test {
    public static void main(String[] args) {
        String str = "10";
        Integer num = Integer.parseInt(str);
    }
}
使用 throw 是开发者主动抛出异常,即读到 throw 代码就一定会抛出异常,基本语法:“throw new Exception();”,这是一种基于代码的逻辑判断从而主动抛出异常的方式,例如:
public class Test {
    public static void main(String[] args) {
        String str = "Java";
        if(str.equals("Java")) {
            throw new NumberFormatException();
        }else {
            int num = Integer.parseInt(str);
        }
    }
}
运行结果为:

Exception in thread "main" java.lang.NumberFormatException
     at Test.main(Test.java:5)

在上述代码中,我们主动对 str 进行判断,如果 str 的值为"Java",则直接抛出 Number- FormatException 异常。所以 try-catch 是捕获可能抛出的异常,throw 是确定会抛出异常,这是二者的区别。

那么 throws 是如何抛出异常的呢?try-catch 和 throw 都是作用于具体的逻辑代码,throws 则是作用于方法,用来描述该方法可能会抛出的异常,具体实现如下:
public class Test {
    public static void main(String[] args) {
        try {
            test();
        } catch (NumberFormatException e) {
            // TODO: handle exception
            e.printStackTrace();
        }
    }
    public static void test() throws NumberFormatException{
        String str = "Java";
        int num = Integer.parseInt(str);
    }
}
test() 方法在定义时通过 throws 关键字声明了该方法可能会抛出 NumberFormatException 异常,所以我们在调用该方法时,需要手动使用 try-catch 进行捕获。同时 catch 代码块中可以捕获 NumberFormatException,也可以捕获 Exception,这两种方式都是没问题的,例如:
public class Test {
    public static void main(String[] args) {
        try {
            test();
        } catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }
    }
    public static void test() throws NumberFormatException{
        String str = "Java";
        int num = Integer.parseInt(str);
    }
}
为什么这里捕获 NumberFormatException 和 Exception 都可以呢?因为 Java 的多态特性。

我们在前面的章节中介绍过面向对象的三大特征之一的多态,NumberFormatException 是具体的数值类型转换异常,Exception 是所有异常的父类。NumberFormatException 也可以理解成 Exception 的另外一种表现形式,这里我们使用这两类异常都是可以的。同时在调用 test 方法时,可以使用 try-catch 主动捕获,也可以不添加 try-catch,直接交给 Java 虚拟机来处理异常。所以这种情况下,加不加 try-catch 都是可以的,但是建议添加。

既然我们在 catch 中可以使用多态来捕获异常,那么在方法定义时也可以使用多态来描述可能发生的异常,即代码可以做如下修改:
public class Test {
    public static void main(String[] args) {
        try {
            test();
        } catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }
    }
    public static void test() throws Exception{
        String str = "Java";
        int num = Integer.parseInt(str);
    }
}
但是在这种情况下,main 方法在调用 test 方法时就必须手动进行捕获。这里需要注意,如果方法抛出 RunntimeException 异常或者其子类异常,外部调用该方法时可以不进行 try-catch 捕获。如果方法抛出的是 Exception 异常或者其子类异常,则外部调用时必须进行 try-catch 捕获,否则会报错,如下图所示。


图 2 报错

如果不添加 try-cath,也可以通过让 main 方法抛出该异常的方式来解决这个错误,代码如下:
public class Test {
    public static void main(String[] args) throws Exception {
        test();
    }
    public static void test() throws Exception{
        String str = "Java";
        int num = Integer.parseInt(str);
    }
}
test() 方法声明时会抛出 Exception,主方法中的代码在调用 test() 方法时就需要对异常进行处理,这里选择将异常抛出,那么抛出的异常是交给谁来处理的呢?你一定知道答案的!没错,就是交给 Java 虚拟机来处理了。

自定义异常类

在实际开发中,我们除了使用 Java 提供的异常类之外,也可以根据需求来自定义异常类,比如定义一个方法,对传入的参数进行 ++ 操作并返回,同时要求参数必须是整数类型,如果传入的参数不是整数类型则抛出自定义异常,具体实现如下:
public class MyNumberException extends Exception {
    public MyNumberException(String error) {
        super(error);
    }
}

public class Test {
    public static void main(String[] args) {
        Test test = new Test();
        try {
            int num = test.add("hello");
        } catch (MyNumberException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
    public int add(Object object) throws MyNumberException {
        if(!(object instanceof Integer)) {
            String error = "传入的参数不是整数类型";
            throw new MyNumberException(error);
        }else {
            int num = (int) object;
            return num++;
        }
    }
}
add() 方法定义时声明了可能会抛出 MyNumberException 异常,是 Exception 的子类,所以在 main 方法中调用 add() 方法时需要手动进行处理,上述代码我们是通过 try-catch 的方式处理的,同时也可以直接让 main 方法抛出异常,代码如下:
public class Test {
    public static void main(String[] args) throws MyNumberException {
        Test test = new Test();
        int num = test.add("hello");
    }
    public int add(Object object) throws MyNumberException {
        if(!(object instanceof Integer)) {
            String error = "传入的参数不是整数类型";
            throw new MyNumberException(error);
        }else {
            int num = (int) object;
            return num++;
        }
    }
}
两种方式的运行结果一样。

推荐使用手动 try-catch 的方式,谁调用谁处理,不要把所有的任务都交给 Java 虚拟机来处理。

这里我们需要注意,Java 中有些异常在 throw 之后,还需要在方法定义处添加 throws 声明,有些异常则不需要,直接 throw 即可。这是因为 Exception 的异常分 checked exception 和 runtime exception,checked exception 表示需要强制去处理的异常,即 throw 异常之后需要立即处理,要么自己 try-catch,要么抛给上一层去处理,否则会报错,例如“Unhandled exception type Exception”。而 runtime exception 没有这个限制,throw 之后可以不处理。

直接继承自 Exception 的类就是 checked exception,继承自 RuntimeException 的类就是 runtime exception。我们自定义的 MyNumberExcpetion 是直接继承 Exception的,所以需要在 add() 方法定义处声明 throws MyNumberExcpetion。

推荐阅读