Java异常体系中的秘密

相信大家每天都在使用Java异常机制,也相信大家对try-catch-finally执行流程烂熟于胸。本文将介绍Java异常机制的一些细节问题,这些问题虽然很小,但对代码性能、可读性有着较为重要的作用。

Java异常体系介绍

在学习一项技术前,一定要先站在制高点俯瞰技术全局,从宏观上把控某项技术的整个脉络结构。这样你就可以有针对性地学习该体系结构中最重要的知识点,并且在学习细节的时候不至于钻入牛角尖。所以,在介绍Java异常你所不知道的一些秘密之前,先让大家复习一下Java异常体系。

Throwable是整个Java异常体系的顶层父类,它有两个子类,分别是:Error和Exception。

Error表示系统致命错误,程序无法处理的错误,比如OutOfMemoryError、ThreadDeath等。这些错误发生时,Java虚拟机只能终止线程。

Exception是程序本身可以处理的异常,这种异常分两大类运行时异常和非运行时异常。

运行时异常都是RuntimeException类及其子类异常,如NullPointerException、IndexOutOfBoundsException等,这些异常属于unchecked异常,开发人员可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。

在Exception异常体系中,除了RuntimeException类及其子类的异常,均属于checked异常。当你调用了抛出这些异常的方法后,必须要处理这些异常。如果不处理,程序就不能编译通过。如:IOException、SQLException、用户自定义的Exception异常等。

try-with-resources

在JDK 1.7之前,处理IO操作非常麻烦。由于IOException属于checked异常,调用者必须通过try-catch处理他们;又因为IO操作完成后需要关闭资源,然而关闭资源的close()方法也会抛出checked异常,因此也需要使用try-catch处理该异常。因此,原本小小的一段IO操作代码会被复杂的try-catch嵌套包裹,从而极大地降低了程序的可读性。

一个标准的IO操作代码如下:

public class Demo {
    public static void main(String[] args) {
        BufferedInputStream bin = null;
        BufferedOutputStream bout = null;
        try {
            bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
            bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")));
            int b;
            while ((b = bin.read()) != -1) {
                bout.write(b);
            }
        }
        catch (IOException e) {
            e.printStackTrace();
        }
        finally {
            if (bin != null) {
                try {
                    bin.close();
                }
                catch (IOException e) {
                    e.printStackTrace();
                }
                finally {
                    if (bout != null) {
                        try {
                            bout.close();
                        }
                        catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }
}

上述代码使用一个输出流bin和一个输入六bout,将一个文件中的数据写入另一个文件。由于IO资源非常宝贵,因此在完成操作后,必须在finally中分别释放这两个资源。并且为了能够正确释放这两个IO资源,需要用两个finally代码块嵌套的方式完成资源的释放。

在上述40行代码中,真正处理IO操作的代码不到10行,而其余30行代码都是用于保证资源合理释放的。这显然导致代码可读性较差。不过好在JDK 1.7提供了try-with-resources解决这一问题。修改后的代码如下:

public class TryWithResource {
    public static void main(String[] args) {
        try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
             BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
            int b;
            while ((b = bin.read()) != -1) {
                bout.write(b);
            }
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}

我们需要将资源声明代码放入try后的括号中,然后将资源处理代码放入try后的{}中,catch代码块中仍然进行异常处理,并且无需写finally代码块。

那么,try-with-resources为什么能够避免大量资源释放代码呢?答案是,由Java编译器来帮我们添加finally代码块。注意,编译器只会添加finally代码块,而资源释放的过程需要资源提供者提供。

在JDK 1.7中,所有的IO类都实现了AutoCloseable接口,并且需要实现其中的close()函数,资源释放过程需要在该函数中完成。

那么,编译器在编译时,会自动添加finally代码块,并将close()函数中的资源释放代码加入finally代码块中。从而提高代码可读性。

异常屏蔽问题

在try-catch-finally代码块中,如果try块、catch块和finally块均有异常抛出,那么最终只能抛出finally块中的异常,而try块和catch块中的异常将会被屏蔽。这就是异常屏蔽问题。如下面代码所示:

public class Connection implements AutoCloseable {
    public void sendData() throws Exception {
        throw new Exception("send data");
    }
    @Override
    public void close() throws Exception {
        throw new MyException("close");
    }
}

首先定义一个Connection类,该类提供了sendData()close()方法,为了实验需要,这两个方法没有任何业务逻辑,都直接抛出一个异常。下面我们使用这个类。

public class TryWithResource {
    public static void main(String[] args) {
        try {
            test();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }
    private static void test() throws Exception {
        Connection conn = null;
        try {
            conn = new Connection();
            conn.sendData();
        }
        finally {
            if (conn != null) {
                conn.close();
            }
        }
    }
}

当执行conn.sendData()时,该方法会将异常抛给调用者main(),但在拋之前会先执行finally块。当执行finally块中的conn.close()方法时,也会向调用者抛一个异常。此时,由try块抛出的异常将会被覆盖,main方法中仅打印finally块中的异常。其结果如下所示:

basic.exception.MyException: close
    at basic.exception.Connection.close(Connection.java:10)
    at basic.exception.TryWithResource.test(TryWithResource.java:82)
    at basic.exception.TryWithResource.main(TryWithResource.java:7)
    ......

这就是try-catch-finally的异常屏蔽问题,而try-with-resources能很好地解决这一问题。那么,它是如何解决这一问题的呢?

我们首先将这段代码用try-with-resources改写:

public class TryWithResource {
    public static void main(String[] args) {
        try {
            test();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }
    private static void test() throws Exception {
        Connection conn = null;
        try (conn = new Connection();) {
            conn.sendData();
        }
    }
}

为了能清楚地了解Java编译器在try-with-resources上所做的事情,我们反编译这段代码,得到如下代码:

public class TryWithResource {
    public TryWithResource() {
    }
    public static void main(String[] args) {
        try {
            // 资源声明代码
            Connection e = new Connection();
            Throwable var2 = null;
            try {
                // 资源使用代码
                e.sendData();
            } catch (Throwable var12) {
                var2 = var12;
                throw var12;
            } finally {
                // 资源释放代码
                if(e != null) {
                    if(var2 != null) {
                        try {
                            e.close();
                        } catch (Throwable var11) {
                            var2.addSuppressed(var11);
                        }
                    } else {
                        e.close();
                    }
                }
            }
        } catch (Exception var14) {
            var14.printStackTrace();
        }
    }
}

最核心的操作是22行var2.addSuppressed(var11);。编译器将try块和catch块中的异常先存入一个局部变量,当finally块中再次抛出异常时,通过之前异常的addSuppressed()方法将当前异常添加至其异常栈中,从而保证了try块和catch块中的异常不丢失。当使用了try-with-resources后,输出结果如下所示:

java.lang.Exception: send data
    at basic.exception.Connection.sendData(Connection.java:5)
    at basic.exception.TryWithResource.main(TryWithResource.java:14)
    ......
    Suppressed: basic.exception.MyException: close
        at basic.exception.Connection.close(Connection.java:10)
        at basic.exception.TryWithResource.main(TryWithResource.java:15)
        ... 5 more

try-catch-finally执行流程

众所周知,首先执行try中代码,若未发生异常,则直接执行finally中代码;若发生异常,则先执行catch中代码后,再执行finally中代码。

相信上述流程大家都烂熟于胸,但如果try块和catch块中出现了return呢?出现了throw呢?此时执行顺序就会发生变化。

但是,万变不离其中,大家只要记住一点:fianlly中的return、throw会覆盖try、catch中的return、throw。此话怎讲?请继续往下阅读。

要解释这个问题,先来看一个例子,请问下面代码中的test()函数会返回什么结果?

public int test() {
    try {
        int a = 1;
        a = a / 0;
        return a;
    } catch (Exception e) {
        return -1;
    } finally{
        return -2;
    }
}

答案是-2。

当执行代码a = a / 0;时发生异常,try块中它之后的代码便不再执行,而是直接执行catch中代码; 在catch块中,当在执行return -1前,先会执行finally块; 由于finally块中有return语句,因此catch中的return将会被覆盖,直接执行fianlly中的return -2后程序结束。因此输出结果是-2。

同样地,将return换成throw也是一样的结果,finally会覆盖try、catch块中的return、throw。

特别提醒:禁止在finally块中使用return语句!这里举例子只是告诉你Java的这一特性,在实际开发中禁止使用!

Optional优雅解决NPE问题

空指针异常是一个运行时异常,对于这一类异常,如果没有明确的处理策略,那么最佳实践在于让程序早点挂掉,但是很多场景下,不是开发人员没有具体的处理策略,而是根本没有意识到空指针异常的存在。当异常真的发生的时候,处理策略也很简单,在存在异常的地方添加一个if语句判定即可,但是这样的应对策略会让我们的程序出现越来越多的null判定,我们知道一个良好的程序设计,应该让代码中尽量少出现null关键字,而java8所提供的Optional类则在减少NullPointException的同时,也提升了代码的美观度。但首先我们需要明确的是,它并 不是对null关键字的一种替代,而是对于null判定提供了一种更加优雅的实现,从而避免NullPointException。

假设存在如下Person类:

class Person{
    private long id;
    private String name;
    private int age;

    // 省略setter、getter
}

当我们调用某一个接口,获取到一个Person对象,此时可以通过如下方法对它进行处理:

  • ofNullable(person)
    • 将Person对象转化成Optional对象
    • 允许person为空
Optional<Person> personOpt = Optional.ofNullable(person);
  • T orElse(T other)
    • 若为空,则赋予默认值
personOpt.orElse(new Person("柴毛毛"));
  • T orElseGet(Supplier<? extends T> other)
    • 若为空,则执行相应代码,并返回默认值
personOpt.orElseGet(()->{
    Person person = new Person();
    person.setName("柴毛毛");
    person.setAge(20);
    return person;
});
  • <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier)
    • 若为空,则抛异常
personOpt.orElseThrow(CommonBizException::new);
  • <U>Optional<U> map(Function<? super T,? extends U> mapper)
    • 映射(获取person中的姓名)
String name = personOpt
                .map(Person::getName)
                .orElseThrow(CommonBizException::new)
                .map(Optional::get);

异常处理规约

  • Java 类库中定义的一类 RuntimeException 可以通过预先检查进行规避,而不应该通过 catch 来处理,比如: IndexOutOfBoundsException,NullPointerException等等。
    • 正例: if (obj != null) {...}
    • 反例: try { obj.method() } catch (NullPointerException e) {...}
  • 异常不要用来做流程控制,条件控制,因为异常的处理效率比条件分支低。
  • 对大段代码进行 try-catch,这是不负责任的表现。catch 时请分清稳定代码和非稳定代码,稳定代码指的是无论如何不会出错的代码。对于非稳定代码的catch尽可能进行区分异常类型,再做对应的异常处理。
  • 捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之,如果不想处理它,请 将该异常抛给它的调用者。最外层的业务使用者,必须处理异常,将其转化为用户可以理解的内容。
  • 有 try 块放到了事务代码中,catch 异常后,如果需要回滚事务,一定要注意手动回滚事务。
  • 不能在 finally 块中使用 return,finally 块中的 return 返回后方法结束执行,不会再执行 try 块中的 return 语句。
  • finally 块必须对资源对象、流对象进行关闭,有异常也要做 try-catch。 说明:如果 JDK7 及以上,可以使用 try-with-resources 方式。
  • 有 try 块放到了事务代码中,catch 异常后,如果需要回滚事务,一定要注意手动回滚事务。
  • 捕获异常与抛异常,必须是完全匹配,或者捕获异常是抛异常的父类。也就是抛出的异常必须是所捕获异常或其子类。这样才能让异常大而化小小而化了。
  • 本规约明确防止 NPE 是调用者的责任。即使被调用方法返回空集合或者空对象,对调用 者来说,也并非高枕无忧,必须考虑到远程调用失败,运行时异常等场景返回 null 的情况。
  • 定义时区分unchecked/checked 异常,避免直接使用RuntimeException抛出, 更不允许抛出 Exception 或者 Throwable,应使用有业务含义的自定义异常。推荐业界已定义 过的自定义异常,如:DAOException / ServiceException 等。
  • 在代码中使用“抛异常”还是“返回错误码”:
    • 对于公司外的 http/api 开放接口必须 使用“错误码”;
    • 而应用内部推荐异常抛出;
    • 跨应用间 RPC 调用优先考虑使用 Result 方式,封装 isSuccess、“错误码”、“错误简短信息”。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏程序员互动联盟

【专业技术】C++ RTTI及“反射”技术

RTTI   RTTI(Run-Time Type Information)运行时类型检查的英文缩写,它提供了运行时确定对象类型的方法。面向对象的编程语言,象C...

3185
来自专栏三丰SanFeng

Linux64位程序移植

1 概述 Linux下的程序大多充当服务器的角色,在这种情况下,随着负载量和功能的增加,服务器所使用内存必然也随之增加,然而32位系统固有的4GB虚拟地址空间限...

2507
来自专栏大内老A

yield在WCF中的错误使用——99%的开发人员都有可能犯的错误[上篇]

在定义API的时候,对于一些返回集合对象的方法,很多人喜欢将返回类型定义成IEnumerable<T>,这本没有什么问题。这里要说的是另一个问题:对于返回类型为...

1808
来自专栏用户2442861的专栏

JSON 入门指南(IBM)

尽管有许多宣传关于 XML 如何拥有跨平台,跨语言的优势,然而,除非应用于 Web Services,否则,在普通的 Web 应用中,开发者经常为 XML 的...

831
来自专栏IT派

Python字节码介绍

如果你曾经编写亦或只是使用Python语言,那么你可能已经习惯了看Python源码文件; 源码的文件名以.py结尾。或许你也已经注意到了另一种类型的文件,文件名...

1633
来自专栏令仔很忙

javascript变量:全局?还是局部?这个得注意

        如果有Var,在函数内部声明变量是局部变量,如下例,读取不到name的数据。

1003
来自专栏Kirito的技术分享

警惕不规范的变量命名

就在最近,项目组开始强调开发规范了,今天分享一个变量名命名不规范的小案例,强调一下规范的重要性。 Boolean变量名命名规范 16年底,阿里公开了《Java...

3309
来自专栏十月梦想

ES6数据传递的传值和传址

看一下上面一段代码,通过正常的理解确实这个样子,但是下面的代码我们只改变了test.y值而obj的也随之改变!这个样子是用于前一部分是传值,后面是传地址!   ...

2054
来自专栏技术记录

通讯协议序列化解读(一) Protobuf详解教程

前言:说到JSON可能大家很熟悉,是目前应用最广泛的一种序列化格式,它使用起来简单方便,而且拥有超高的可读性。但是在越来越多的应用场景里,JSON冗长的缺点导致...

1091
来自专栏算法channel

面试被问到动态内存分配时需要注意哪些坑,该怎么回答?

面试时,面试官问我们Java,Python这种语言那是必须要准确回答的,很多系统如果对性能要求高的话,底层一般会用到C/C++语言,因此被问到底层语言的相关知识...

1423

扫码关注云+社区