一篇文章说清楚Java的全局异常处理,深入到hotspot源码

原创 吴就业 133 0 2020-03-09

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/cef242ada38e4b71aa4f74a425602579

作者:吴就业
链接:https://wujiuye.com/article/cef242ada38e4b71aa4f74a425602579
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

本篇文章写于2020年03月09日,从公众号同步过来(博客搬家),本篇为原创文章。

关于全局异常处理

关于Java全局异常处理,网上一搜都是说SpringMVC的全局异常处理。确实,使用Spring Boot开发也好,使用SSM也好,都可以使用SpringMVC的全局异常处理,也是最好不过,因为出现异常我们也要响应数据给前端。话说,关于SpringMVC的全局异常处理,你知道原理了吗?其实可以一句话概括,任何请求都先经过DispatchServlet

如果应用不是一个SpringMVC应用呢?可能很多人都不知道,Java自己就提供有全局异常处理。这个我还是从我的前领导那里听来的。但这个不适用于接口的全局异常处理。本篇将介绍如何使用Java提供的全局异常处理,以及分析一点hotspot虚拟机的源码,让大家了解虚拟机是如何将异常交给全局异常处理器处理的。

Java全局异常处理Demo

main方法中设置全局异常处理器DefaultUncaughtExceptionHandler,从名字中也可以看出,这是用于处理未捕获的异常的。不一定是从main方法中设置,在spring应用中,可以监听spring初始化完成事件,再设置。(如果是web应用,还是使用SpringMVC的全局异常处理。)

public static void main(String[] args) throws InterruptedException {
        Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                System.out.println("这里是全局异常处理 ====> " + t.getId() + "==> "+e.getLocalizedMessage());
            }
        });
}

有朋友可能会觉得奇怪了,为什么是设置在Thread里面。虽然是设置在Thread里面,但这是一个静态变量。

画重点,我们所写的代码都是在线程里面跑的。一个线程对应一个Java虚拟机栈,异常是在栈桢中发生的(即方法)。在调用发生异常时,栈桢出栈,异常一层层往上抛出,并写入调用栈信息。在整个调用栈中,如果都没有方法捕获异常,那么Java虚拟机将从当前线程的Thread对象中获取一个异常处理器,如果有,则交给异常处理器处理。走到这一步,意味着线程即将退出,这也是我从hotspot源码中寻找入口的依据。

当然,如果是设置了针对某个线程的异常处理器,则该线程发现未捕获异常时,会使用该线程设置的异常处理器,否则会使用全局默认的。这里没懂没关系,后面会详细分析。

我们接着把例子看完。在main方法中创建多个线程,并在线程的run方法中抛出异常。

private static class TaskThread extends Thread {
    @Override
    public void run() {
       throw new NullPointerException("thread-" + Thread.currentThread().getId() + " Exception");
    }
}
/**
 * 在main方法中调用startThread(),
 */
public static void startThread(){
    for (int i = 0; i < 10; i++) {
        new TaskThread().start();
    }
    // 不让主线程退出
    System.in.read();
}

程序运行结果:

这里是全局异常处理 ====> 13==> thread-13 Exception
这里是全局异常处理 ====> 16==> thread-16 Exception
这里是全局异常处理 ====> 15==> thread-15 Exception
........

前面提到,我们还可以针对某个线程设置单独的异常处理器,且优先级会高于全局默认的。如果为某个线程单独设置异常处理器,那么就这个线程而言,默认的全局异常处理器将不起作用。我们来修改一下前面例子的startThread方法,验证一下,其它不变。

/**
 * 在main方法中调用startThread(),
 */
public static void startThread(){
    for (int i = 0; i < 10; i++) {
        Thread thread = new TaskThread();
        if (i == 0) {
            thread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
                @Override
                public void uncaughtException(Thread t, Throwable e) {
                    System.out.println("这是为当前线程设置的异步处理器。===> " + t.getId());
                }
            });
        }
        thread.start();
    }
    // 不让主线程退出
    System.in.read();
}

程序输出结果如下:

这是为当前线程设置的异步处理器。===> 13
这里是全局异常处理 ====> 16==> thread-16 Exception
这里是全局异常处理 ====> 15==> thread-15 Exception
.......

很显然,id等于13的线程走了单独的异常处理器,而其它线程则走全局默认异常处理器。有朋友可能会好奇,为什么线程id13开始,其实我在以前的文章也提到过,只是忘记是哪篇了。id0的是mian线程,然后接着就是虚拟机的线程,以及gc垃圾回收的线程,由于我电脑cpu9代i7六核十二线程,所以gc线程数比较多。题外话就不扯太多了。

从源码中寻找答案

Java的全局异常处理是从jdk1.5开始加入的新特性,我也不确定是不是1.5,注释上写的。先看下异常处理器UncaughtExceptionHandler

@FunctionalInterface
public interface UncaughtExceptionHandler {
    /**
      * 未捕获异常处理
      */
    void uncaughtException(Thread t, Throwable e);
}

如果在uncaughtException方法中,比如写日记记录日常信息,结果因为写日记时发生IO异常,或者其它异常,不管是什么异常,此方法抛出的异常都将会被Java虚拟机忽略,因为线程已经要结束退出了。

Thread中声明了两个UncaughtExceptionHandler类型的变量,一个是静态变量。其中非静态变量是针对当前线程起作用的,声明为volatile原因是可能是其它线程调用设置的;另一个静态变量就是全局默认的。

public class Thread implements Runnable {
    // null unless explicitly set
    private volatile UncaughtExceptionHandler uncaughtExceptionHandler;

    // null unless explicitly set
    private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;
}

我在看源码的时候,是通过搜索查看哪个地方使用了这两个UncaughtExceptionHandler,最后在dispatchUncaughtException方法上的注释看到了关键信息。当有未捕获异常抛出时,java虚拟机会调用当前线程的Thread对象的dispatchUncaughtException方法。

/**
     * Dispatch an uncaught exception to the handler. This method is
     * intended to be called only by the JVM.
     */
    private void dispatchUncaughtException(Throwable e) {
        getUncaughtExceptionHandler().uncaughtException(this, e);
    }

dispatchUncaughtException方法中,调用了getUncaughtExceptionHandler方法获取UncaughtExceptionHandler异常处理器对象,再把异常交给拿到的异常处理器去处理。

public UncaughtExceptionHandler getUncaughtExceptionHandler() {
    return uncaughtExceptionHandler != null ? uncaughtExceptionHandler : group;
}

这里非常奇怪,并没有用到defaultUncaughtExceptionHandler这个静态变量。如果当前线程对象没有设置异常处理器,就返回一个group。首先看到这,我们就能明白,为什么针对某个线程设置的异常处理器会被优先使用。

group其实是ThreadGroup对象。在Java中,每个线程都有一个所属的线程组。在调用start方法时,会将当前线程加入一个线程组,而如果在创建Thread对象时,没有传入线程组ThreadGroup,则会获取当前线程的线程组,可能就是main线程所属的线程组了,是不是有点绕,自己看下源码就很好理解了。

public class ThreadGroup implements Thread.UncaughtExceptionHandler {
}

ThreadGroup实现了UncaughtExceptionHandler接口,也就说得通,为什么是返回一个group了,然后看ThreadGroupuncaughtException方法。

public void uncaughtException(Thread t, Throwable e) {
    if (parent != null) {
        parent.uncaughtException(t, e);
    } else {
        Thread.UncaughtExceptionHandler ueh =
            Thread.getDefaultUncaughtExceptionHandler();
        ueh.uncaughtException(t, e);
    }
}

线程组还有父线程组,这个太绕,我们不理它。可以看到,ThreadGroupuncaughtException方法中,会调用Thread.getDefaultUncaughtExceptionHandler();方法获取设置的默认异常处理器,这便是我们设置的全局默认异常处理器。

其实细心看代码,你会发现,ThreadGroupuncaughtException注释是1.0版本就已经存在了。然后我看hotspot源码,发现它会兼容旧版本,即Thread对象不存在dispatchUncaughtException方法时,是转为调用ThreadGroupuncaughtException方法的。

接下来我们就要看hotspot源码了,看下hotspot是怎么调用异常处理器处理异常的。不知道看到这,你是否还记得前面说的一句话,异常处理器会被调用,说明当前Java虚拟机栈上没有一个栈桢去捕获异常,也意味着当前线程即将退出。因此源码的入口就是thread.cpp类的exit方法。下面我将以图片方式贴代码了。

uncaughtException

(源码所在文件:vm/runtime/thread.cpp)

thread.cpp的exit方法源码

c++的知识我就不说了。看图中的红框0,调用resolve_virtual_call方法获取调用信息,即CallInfo。传递的参数分别是CallInfo的指针(分配在c++线程栈上的)、当前线程对象Thread、线程ThreadClass类结构信息KlassdispatchUncaughtException的方法名、方法签名等。

vmSymbols.hpp源码

vmsybols.hpp源码

继续看thread.cppexit方法,红框1是判断Thread是否存在dispatchUncaughtException方法,即前面说的兼容旧版本的。如果存在,则调用当前线程的Thread对象的dispatchUncaughtException方法。

javaCalls.cpp源码-旧版本

如果是jdk1.0,那么不会走红框1的代码,而是走红框2,获取当前线程的ThreadGroup对象,调用它的uncaughtException方法。调用call_virtual方法去执行java代码。参数1便是Thread对象,参数2便是异常对象。

javaCalls.cpp源码-新版本

看完本篇,我相信大家都已经了解了Java默认异常处理器是怎么被调用的了。那么,学习这个我们工作中是否能够用得到,那就看大家各自的发挥了。

#后端

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

Redis实现原子操作的两种方式与商品入库出库解决方案

想要实现针对多个key的复杂原子操作有两种方法。一种是Watch+Multi,即监视器加事务方式,另一种便是通过执行lua脚本实现。

教你如何写出高性能的Mybatis分页插件

本篇介绍是什么原因导致的`mybatis-plus`分页插件性能下降,以及如何通过使用`JsqlParser`这个开源的`sql`解析工具包与`mybatis-plus`提供的自定义`sql`优化器功能,自己实现高性能的分页插件。

一道很有意思的Redis面试题,关于Bitmap算法,我选出了一些优质评论

起源于我在一个短视频中分享的一道面试题,当然,这道面试题我确实在工作中用过,只是业务场景不同。

使用Docker部署用于学习的ElasticSearch集群

在Linux服务器上使用 Docker安装ElasticSearch集群。

使用Mybatis-Plus提高开发效率

使用`mybatis-plus`可以少写很多常用的`SQL`,通过继承`BaseMapper`使用,还可以动态拼接`SQL`。第一眼看到我还以为是`JPA`。

ElasticSearch高版本API的使用姿势

如何在`Java`项目中使用`elasticsearch-rest-high-level-client`。