聊聊Java中的异常

什么是异常

关于Java的异常,我们认为符合大致分为以下几种情况:

  1. 程序逻辑运行结果不符合预期。
  2. 程序执行时抛出各种exception。
  3. 因为各种原因导致服务崩溃。

Java异常类体系结构如下图所示:

聊聊Java中的异常_第1张图片

Exception和Error的区别

Exception

Exception或者Exception自类的异常对象是可以进行捕获和处理的,例如下面这段算术异常,我们就可以手动捕获并打印处理掉:

 try {
            for (int i = 0; i < 30_0000; i++) {

                int num = 10 / 0;
                System.out.println("Fnum:" + num);
            }

        } catch (Exception e) {
            System.out.println("outerTryCatch 报错");
        }

Exception包含受检异常和非受检异常,他们区别是:

  1. 受检异常的方法在调用时需要抛出的潜在风险进行处理,要么捕获,要么向上抛。
  2. 非受检异常即运行时才可能知晓的异常,该异常可由开发人员结合业务场景确定是否捕获。

关于受检异常和非受检异常我们后文给出示例。

Error

这种错误一般都是OOM、Java虚拟机运行错误(Virtual MachineError)、或者类定义错误(NoClassDefFoundError),基本都是服务崩溃,无法捕获处理,只能由开发人员在发现问题后通过日志定位异常代码修复重启才能保证服务正常运行。

受检异常和非受检异常

受检异常

受检异常一般在调用时,用户就需要对其进行处理,如果不处理则不会通过编译。

例如FileInputStream,如果我们没有对其构造方法抛出的错误(即受检异常)进行处理,我们是无法通过编译的。

聊聊Java中的异常_第2张图片

查看该构造方法定义,可以FileInputStream受检的异常FileNotFoundException已经定义在方法签名上。

public FileInputStream(String name) throws FileNotFoundException {
        this(name != null ? new File(name) : null);
    }

非受检异常

非受检异常一般是运行时异常,常见的有空指针异常、非法参数异常、算数异常、数组越界等,这类异常可以按需进行捕获处理。
例如下面这段代码就会抛出算术异常,我们即使没有使用try代码块,代码也能通过编译并运行(直到报错)。

 int num = 10 / 0;
System.out.println("Fnum:" + num);

throw与throws的区别

它们唯一的区别就在定义的位置:

  1. throws放在函数上,throw放在函数内。
  2. throws可以跟多个错误类,throw只能跟单个异常对象

异常常用方法和使用示例

Throwable常用方法

getMessage方法

返回异常的错误的简要信息,它不会打印异常堆栈,只是打印异常的原因,例如这段代码,它的打印结果就是/ by zero,并不会输出运行报错的具体调用堆栈信息。

try {
            for (int i = 0; i < 30_0000; i++) {

                int num = 10 / 0;
                System.out.println("Fnum:" + num);
            }

        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
toString

我们在catch模块调用异常类的toString方法,它会返回异常发生时的异常信息,同样不会打印堆栈结果,所以对于只需要看到异常信息的话,可以使用toString。

catch (Exception e) {
            System.out.println(e.toString());
        }

输出结果:

java.lang.ArithmeticException: / by zero
getLocalizedMessage方法

返回异常本地化信息。

catch (Exception e) {
            System.out.println(e.getLocalizedMessage());
        }

输出结果:

/ by zero
printStackTrace方法

在控制台上打印堆栈追踪信息,我们可以详细看到异常的调用堆栈信息和错误原因。

 catch (Exception e) {
           e.printStackTrace();
        }

输出结果:

java.lang.ArithmeticException: / by zero
	at com.sharkChili.base.Main.outerTryCatch(Main.java:17)
	at com.sharkChili.base.Main.main(Main.java:7)

受检异常示例

我们自定义一个受检异常

public class ArithmeticException extends Exception {
    @Override
    public String getMessage() {
        return "自定义算术异常";
    }
}

编写一个除法的函数

private int calculate(int  number,int divNum) throws ArithmeticException {
        if (divNum==0){
            throw new ArithmeticException();
        }
        return number / divNum;
    }

测试代码如下,因为是受检异常,所以运行时需要手动捕获处理一下。

@Test
    public void calculateTest(){
        int number=20;
        try {
            int result = calculate(number,0);
            System.out.println(result);
        } catch (ArithmeticException e) {
            logger.error("calculateTest计算失败,请求参数:[{}],错误原因[{}]",number,e.getMessage(),e);
        }
    }

输出结果:

[main] ERROR com.guide.exception.ExceptionTest - calculateTest计算失败,请求参数:[20],错误原因[自定义算术异常]

try-catch运行原理

try-catch感知和捕获异常的工作过程如下:

  1. try块代码执行报错若有catch模块catch则会对其进行捕获处理。
  2. 根据捕获到的异常创建一个异常对象。
  3. 用户根据这个异常对象进行进一步处理。

例如我们上文捕获了ArithmeticException ,那么catch (ArithmeticException e) 实质上会做一个Exception e = new ArithmeticException()的动作,进而按照用户的想法进行错误捕获逻辑处理。

异常使用注意事项

多异常捕获处理技巧

对于多异常需要捕获处理时,我们建议符合以下三大原则:

  1. 有几个异常就处理几个异常,如果无法处理就抛出。
  2. 父类exception放在最下方。
  3. 多异常建议使用|进行归类整理。

如下所示,我们自定义一个自定义错误函数

private int calculate(int  number,int divNum) throws ArithmeticException,FileNotFoundException, UnknownHostException, IOException {
        if (divNum==0){
            throw new ArithmeticException();
        }
        return number / divNum;
    }

假定UnknownHostException 是用户配置问题,我们无法处理,那么就抛出,其他错误一一捕获,所以我们的代码可能是这样。

@Test
    public void calculateTest(){
        int number=20;

        int result = 0;
        try {
            result = calculate(number,0);
        } catch (ArithmeticException e) {
            e.printStackTrace();
        } catch (FileNotFoundException e){

        }catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println(result);

    }

实际在为了让代码更加整洁高效。生成的catch块也只是一个公共的代码块,所以我们最终的代码应该是下面这个样子

@Test
    public void calculateTest()throws UnknownHostException{
        int number=20;

        int result = 0;
        try {
            result = calculate(number,0);
        } catch (ArithmeticException|FileNotFoundException e) {
            logger.error("calculateTest执行报错,用户执行出错或者文件数据获取失败,请求参数[{}],错误信息[{}]",number,e.getMessage(),e);
        } catch (IOException e) {
            logger.error("calculateTest执行报错,文件操作异常,请求参数[{}],错误信息[{}]",number,e.getMessage(),e);
        }catch (Exception e){
            logger.error("calculateTest执行报错,请求参数[{}],错误信息[{}]",number,e.getMessage(),e);

        }
        System.out.println(result);

    }

注意,使用|运算符之后e会变为final变量,用户无法改变引用的指向(这个似乎对我们没有说明影响)

聊聊Java中的异常_第3张图片

特殊的异常对象 RuntimeException

使用runtime异常类时,在函数内throw则函数上不用throws,编译可以正常通过。如下代码,即使没有throws ArithmeticException(ArithmeticException为runtime的子类),编译照样通过,代码可以正常运行,直到报错。

class Demo{
	int div(int a,int b)throws Exception//throws ArithmeticException{
		
		if(b<0)
			throw new Exception("出现了除数为负数了");
		if(b==0)
			throw new ArithmeticException("被零除啦");
		return a/b;
	}
}

RuntimeException可以编译通过且不用捕获的原因也很简单,设计者认为运行时异常是特定情况下运行时错误,是否处理需要让开发人员自行判断。

finally关键字

简介

finally无论异常是否执行,在try块结束后必定会运行的,需要注意的是如果程序出现异常,finally中有return语句的话,catch块的return将没有任何作用,代码如下所示

public static int func() {
        try {
            int i = 1 / 0;
        } catch (Exception e) {
            return 2;
        } finally {
            return 1;
        }
    }

使用场景

finally最常用于资源释放:

//读取文本文件的内容
        Scanner scanner = null;
        try {
            scanner = new Scanner(new File("D://read.txt"));
            while (scanner.hasNext()) {
                System.out.println(scanner.nextLine());
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (scanner != null) {
                scanner.close();
            }
        }

补充一句,对于资源释放,我们现在更简易使用try-with-resources代码简洁易读。上述那段关闭流的代码十分冗长,可读性十分差劲,对于继承CloseableAutoCloseable的类都可以使用以下语法完成资源加载和释放

 try (Scanner scanner = new Scanner(new File("D://read.txt"))) {
            while (scanner.hasNext()) {
                System.out.println(scanner.nextLine());
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

finally 中的代码一定会执行吗

不一定,如下代码所示,当虚拟机执行退出的话,finally是不会被执行的

@Test
    public void finallyNoRun() {
        try {
            System.out.println("try code run.....");
            System.exit(0);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println("finally run...");
        }

    
    }

输出结果如下,可以看到finally方法并没有被运行到。

try code run.....

异常使用注意事项

不要在finnally中使用return

函数执行的try块返回值会被缓存的本地变量中,当finally进行return操作就会覆盖这个本地变量。


    public void finallyReturnTest() {
        System.out.println(finallyReturnFun());
      
    }

    private String finallyReturnFun() {
        try {
            int ten = 10;
            return "tryRetunVal";
        } catch (Exception e) {
            System.out.println("报错了。。。。");
        } finally {
            return "finallyReturnVal";
        }

    }

输出结果为finallyReturnVal,因为finally优先于try块中的代码,当finally进行return操作就会覆盖这个本地变量。


finallyReturnVal
        

异常不处理就抛出

捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之,如果不想处理它,请
将该异常抛给它的调用者。最外层的业务使用者,必须处理异常,将其转化为用户可以理解的内容。

自定义一个异常

public class MyIllegalArgumentException extends Exception {

    public MyIllegalArgumentException(String msg) {
        super(msg);
    }

    @Override
    public String getMessage() {
        return super.getMessage();
    }
}

测试代码


    public void checkTest() {
        String param = null;
        try {
            check(null);
        } catch (MyIllegalArgumentException e) {

            logger.info("参数校验异常,请求参数[{}],错误信息[{}]", param, e.getMessage(), e);
    
        }
    }


    private void check(String str) throws MyIllegalArgumentException {
        if (str == null || str.length() <= 0) {
            throw new MyIllegalArgumentException("字符串不可为空");
        }
    }

输出结果:

[main] INFO com.guide.exception.ExceptionTest - 参数校验异常,请求参数[null],错误信息[字符串不可为空]

不要用异常控制流程

不要使用try块语句控制业务执行流程,原因如下:

  1. try-catch阻止JVM试图进行的优化,所以当我们要使用try块时,使用的粒度尽可能要小一些。
  2. 现代标准遍历模式并不会导致冗余检查,所以我们无需为了避免越界检查而使用try块解决问题。

错误示例,不仅可读性差,而且性能不佳。

public static void stackPopByCatch() {
        long start = System.currentTimeMillis();
        try {
            //插入1000w个元素
            Stack stack = new Stack();
            for (int i = 0; i < 1000_0000; i++) {
                stack.push(i);
            }


            //使用pop抛出的异常判断出栈是否结束
            while (true) {
                try {
                    stack.pop();
                } catch (Exception e) {
                    System.out.println("出栈结束");
                    break;
                }
            }
        } catch (Exception e) {

        }


        long end = System.currentTimeMillis();
        System.out.println("使用try进行异常捕获,执行时间:" + (end - start));


        start = System.currentTimeMillis();
        Stack stack2 = new Stack();
        for (int i = 0; i < 1000_0000; i++) {
            stack2.push(i);

        }

        //使用for循环控制代码流程
        int size = stack2.size();

        for (int i = 0; i < size; i++) {
            stack2.pop();
        }
        end = System.currentTimeMillis();
        System.out.println("使用逻辑进行出栈操作,执行时间:" + (end - start));
    }

输出结果,可以看到用try块控制业务流程性能很差:

出栈结束
使用try进行异常捕获,执行时间:2613
使用逻辑进行出栈操作,执行时间:1481

规范异常日志打印

  1. 不要使用JSON工具,因为某些get方法可能会抛出异常。
  2. 记录参数,错误信息,堆栈信息。

    public void logShowTest() {
        Map inputParam = new JSONObject().fluentPut("key", "value");
        try {
            logShow(inputParam);
        } catch (ArithmeticException e) {
            logger.error("inputParam:{} ,errorMessage:{}", inputParam.toString(), e.getMessage(), e);
        
        }
    }


    private int logShow(Map inputParam) throws ArithmeticException {
        int zero = 0;
        if (zero==0){
           throw new ArithmeticException();
        }
        return 19 / zero;
    }

输出结果:


[main] ERROR com.guide.exception.ExceptionTest - inputParam:{"key":"value"} ,errorMessage:自定义算术异常
com.guide.exception.ArithmeticException: 自定义算术异常
	at com.guide.exception.ExceptionTest.logShow(ExceptionTest.java:166)

避免频繁抛出和捕获异常

如下代码所示,可以看到频繁抛出和捕获对象是非常耗时的,所以我们不建议使用异常来作为处理逻辑,我们完全可以和前端协商好错误码从而避免没必要的性能开销

private int testTimes;

    public ExceptionTest(int testTimes) {
        this.testTimes = testTimes;
    }

    public void newObject() {
        long l = System.currentTimeMillis();
        for (int i = 0; i < testTimes; i++) {
            new Object();
        }
        System.out.println("建立对象:" + (System.currentTimeMillis() - l));
    }

    public void newException() {
        long l = System.currentTimeMillis();
        for (int i = 0; i < testTimes; i++) {
            new Exception();
        }
        System.out.println("建立异常对象:" + (System.currentTimeMillis() - l));
    }

    public void catchException() {
        long l = System.currentTimeMillis();
        for (int i = 0; i < testTimes; i++) {
            try {
                throw new Exception();
            } catch (Exception e) {
            }
        }
        System.out.println("建立、抛出并接住异常对象:" + (System.currentTimeMillis() - l));
    }

    public void catchObj() {
        long l = System.currentTimeMillis();
        for (int i = 0; i < testTimes; i++) {
            try {
                new Object();
            } catch (Exception e) {
            }
        }
        System.out.println("建立,普通对象并catch:" + (System.currentTimeMillis() - l));
    }

    public static void main(String[] args) {
        ExceptionTest test = new ExceptionTest(100_0000);
        test.newObject();
        test.newException();
        test.catchException();
        test.catchObj();
     
    }

输出结果:


输出结果
建立对象:3
建立异常对象:484
建立、抛出并接住异常对象:539
建立,普通对象并catch3

尽可能在for循环外捕获异常

上文提到try-catch时会捕获并创建异常对象,所以如果在for循环内频繁捕获异常会创建大量的异常对象:

public static void main(String[] args) {
        outerTryCatch();
        innerTryCatch();

    }

	//for 外部捕获异常
    private static void outerTryCatch() {
        long memory = Runtime.getRuntime().freeMemory();
        try {
            for (int i = 0; i < 30_0000; i++) {

                int num = 10 / 0;
                System.out.println("Fnum:" + num);
            }

        } catch (Exception e) {
        }
        long useMemory = (memory - Runtime.getRuntime().freeMemory()) / 1024 / 1024;

        System.out.println("cost memory:" + useMemory + "M");
    }

	//for 内部捕获异常
    private static void innerTryCatch() {
        long memory = Runtime.getRuntime().freeMemory();

        for (int i = 0; i < 30_0000; i++) {
            try {
                int num = 10 / 0;
                System.out.println("num:" + num);
            } catch (Exception e) {
            }
        }
        long useMemory = (memory - Runtime.getRuntime().freeMemory()) / 1024 / 1024;

        System.out.println("cost memory:" + useMemory + "M");
    }

输出结果如下,可以看到for循环内部捕获异常消耗了22M的堆内存。原因很简单,for外部捕获异常时,会直接终止for循环,而在for循环内部捕获异常仅结束本次循环的,所以如果for循环频繁报错,那么在内部捕获异常尽可能创建大量的异常对象。

cost memory:0M
cost memory:22M

参考文献

Java基础常见面试题总结(下):https://javaguide.cn/java/basis/java-basic-questions-03.html#项目相关

Effective Java中文版(第3版):https://book.douban.com/subject/30412517/

阿里巴巴Java开发手册:https://book.douban.com/subject/27605355/

Java核心技术·卷 I(原书第11版):https://book.douban.com/subject/34898994/

Java 基础 - 异常机制详解:https://www.pdai.tech/md/java/basic/java-basic-x-exception.html#异常是否耗时为什么会耗时

面试官问我 ,try catch 应该在 for 循环里面还是外面?:https://mp.weixin.qq.com/s/TQgBSYAhs_kUaScw7occDg

你可能感兴趣的:(Java,java,exception,多态,设计模式,jvm)