文档章节

Java 线程池的异常处理机制

编走编想
 编走编想
发布于 2017/04/20 22:05
字数 2037
阅读 659
收藏 2

一、前言

线程池技术是服务器端开发中常用的技术。不论是直接还是间接,各种服务器端功能的执行总是离不开线程池的调度。关于线程池的各种文章,多数是关注任务的创建和执行方面,对于异常处理和任务取消(包括线程池关闭)关注的偏少。

接下来,本文将从 Java 原生线程、两种主要线程池 ThreadPoolExecutorScheduledThreadPoolExecutor 这三方面介绍 Java 中线程的异常处理机制。

二、Thread

在谈线程池的异常处理之前,我们先来看 Java 中线程中的异常是如何被处理的。大家都知道如何创建一个线程任务:

代码1

Thread t = new Thread(() -> System.out.println("Execute in a thread"));
t.start();

为了简化代码,这里使用了 Java 8 的 Lambda 表达式。() -> System.out.println("Execute in a thread") 等同于在 Runnable 中执行 System.out.println 方法。后面不再解释。

如果这个任务抛出了异常,那又会怎样:

代码2

Thread t = new Thread(() -> System.out.println(1 / 0));
t.start();

如果我们执行上面这段代码,会在控制台上看到异常输出。可能多数同学会对此不会觉得问题,但是问题在于,通常情况下绝大多数线上应用不会将控制台作为日志输出地址,而是另有日志输出。这种情况下,上面的代码所抛出异常便会丢失。

那为了将异常输出到日志中,我们会这样写代码:

代码3

Thread t = new Thread(() -> {
    try {
        System.out.println(1 / 0);
    } catch (Exception e) {
        LOGGER.error(e.getMessage(), e);
    }
});
t.start();

这样我们就能异常栈输出到日志中,而不是控制台,从而避免异常的丢失。

过了一段时间,问题又来了,可能好多线程任务默认的异常处理机制都是相同的。比如都是将异常输出到日志文件。按照上面的写法会造成重复代码。虽然重复的不多,但是有代码洁癖的小伙伴可能也会觉得不舒服。

那我们该如何解决这个问题呢?其实 JDK 已经为我们想到了,Thread 类中有个接口 UncaughtExceptionHandler。通过实现这个接口,并调用 Thread.setUncaughtExceptionHandler(UncaughtExceptionHandler) 方法,我们就能为一个线程设置默认的异常处理机制,避免重复的 try...catch 了。

除此以外,我们还可以通过 Thread.setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler) 设置全局的默认异常处理机制。此外,ThreadGroup 也实现了 UncaughtExceptionHandler 接口,所以通过 ThreadGroup 还可以为一组线程设置默认的异常处理机制。

其实,之所以代码2在执行之后我们能在控制台上看到异常,也是因为 UncaughtExceptionHandler 机制。ThreadGroup 默认提供了异常处理机制如下:

代码4

public void uncaughtException(Thread t, Throwable e) {
    if (parent != null) {
        parent.uncaughtException(t, e);
    } else {
        Thread.UncaughtExceptionHandler ueh =
            Thread.getDefaultUncaughtExceptionHandler();
        if (ueh != null) {
            ueh.uncaughtException(t, e);
        } else if (!(e instanceof ThreadDeath)) {
            // 最终执行如下代码
            System.err.print("Exception in thread \""
                             + t.getName() + "\" ");
            e.printStackTrace(System.err);
        }
    }
}

三、ThreadPoolExecutor

在 Java 5 发布之后,线程池便开始越来越广泛地用于创建并发任务。多数时候,当说到 Java 的线程池时,我们一般指的就是 ThreadPoolExecutor。那在 ThreadPoolExecutor 中是如何处理异常的呢?

代码5

Executors.newSingleThreadExecutor().execute(() -> {
    throw new RuntimeException("My runtime exception");
});

上面的代码的异常处理机制其实同直接使用 Thread 是一样的。所以也有同样的问题,异常信息无法反映在日志文件中。解决这个问题的方法同上一节一样:在每个 Runnable 中编写 try ... catch 语句;或者使用 UncaughtExceptionHandler 机制。

我们先来看如何为线程池中的工作线程设置 UncaughtExceptionHandler

为线程池工作线程设置 UncaughtExceptionHandler

简单来说,就是通过 ThreadFactory。通过 ThreadPoolExecutor 的构造函数和 Executors 中的工具方法,我们都可以为新创建的线程池设置 ThreadFactory

ThreadFactory 是个接口,它只定义了一个方法 Thread newThread(Runnable r)。在这个方法中,我们可以为新创建出来的线程设置 UncaughtExceptionHandler。当然,这样写起来显得很麻烦,好在 Apache Commons 和 Google Guava 这两个最有名的 Java 工具类库都为我们提供了相应的类库以简化配置 ThreadFactory 的工作。下面以 Apache Commons 提供的 BasicThreadFactoryBuilder 为例

代码6

ThreadFactory executorThreadFactory = new BasicThreadFactory.Builder()
        .namingPattern("task-scanner-executor-%d")
        .uncaughtExceptionHandler(new LogUncaughtExceptionHandler(LOGGER))
        .build();
Executors.newSingleThreadExecutor(executorThreadFactory);

UncaughtExceptionHandler 一定起作用吗?

此话怎讲呢?其实 ThreadPoolExecutor 为执行并发任务提供了两种方法:execute(Runnable)submit(Callable/Runnable)。之前的代码示例只演示了执行 execute(Runnable) 时的情况。那在设置了默认的 UncaughtExceptionHandler 之后,当执行 submit(Callable/Runnable) 方法,抛出抛异常之后有会如何?

看下面的代码

代码7

ThreadFactory threadFactory = new ThreadFactoryBuilder()
        .setUncaughtExceptionHandler(new LogExceptionHandler())
        .build();
Executors.newSingleThreadExecutor(threadFactory)
        .submit(() -> {
            throw new RuntimeException("test");
        });

上面的程序执行完之后,不会在控制台或日志中看到任何输出,虽然设置了 UncaughtExceptionHandler。要弄清原因,就要看一下 ThreadPoolExecutor 的源代码

代码8

public Future<?> submit(Runnable task) {
    if (task == null) throw new NullPointerException();
    RunnableFuture<Void> ftask = newTaskFor(task, null);
    execute(ftask);
    return ftask;
}

submit 方法是调用 execute 实现任务执行的。但是在调用 execute 之前,任务会被封装进 FutureTask 类中,然后最终工作线程执行的是 FutureTask 中的 run 方法。

代码9:FutureTask.run

try {
    result = c.call();
    ran = true;
} catch (Throwable ex) {
    result = null;
    ran = false;
    setException(ex);
}

protected void setException(Throwable t) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = t;
        UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
        finishCompletion();
    }
}

由上面的代码可以看出,不同于直接调用 execute 方法,调用 submit 方法后,如果任务抛出异常,会被 setException 方法赋给代表执行结果的 outcome 变量,而不会继续抛出。因此,UncaughtExceptionHandler 也没有机会处理。

如果想知道 submit 的执行结果是成功还是失败,必须调用 Future.get() 方法。

UncaughtExceptionHandler 是否适合在线程池中使用

从上面的分析中可以看出,使用 UncaughtExceptionHandler,可以处理到使用 execute 方法执行任务所抛出的异常,但是对 submit 方法无效。那如果只是用 execute 方法,我们是否可以通过设置 UncaughtExceptionHandler 从而添加一种默认的异常处理机制,以避免重复的 try...catch 代码呢?

答案是不能。原因在于,如果在执行 execute 方法时不在 Runnable.run 方法中写 try...catch 方法,自然异常会交由 UncaughtExceptionHandler 处理,但是,在这之前,线程的工作线程会因为异常而退出。虽然线程池会创建一个新的工作线程,但是如果这个步骤反复执行,效率自然会下降很多。

四、ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor 是另一种常用的线程池,常用了执行延迟任务或定时任务。常用的方法为 scheduleXXX 系列。那在这个线程池中异常是如何处理的呢?

其实,如果看过前面的部分,到这里也基本能猜出来了。ScheduledThreadPoolExecutor 用来封装任务的是 ScheduledFutureTaskScheduledFutureTaskFutureTask 的子类,所以,异常也会被复制给 outcome

但是,这里还是有一些差异的。在使用 ThreadPoolExecutor.submitScheduledThreadPoolExecutor.schedule 方法时,我们可以通过这两个方法返回的 Future 来获得执行结果,这包括正常结果,也包括异常结果。但是,对于 ScheduledThreadPoolExecutor.scheduleWithFixedDelayscheduleAtFixedRate 这两个方法,其返回的 Future 只会用来取消任务,而不是得到结果。原因也很容易理解,因为这两个方法执行的是定时任务,是反复执行的。这也是为什么这两个方法的任务定义使用了 Runnable 接口,而不是有返回值的 Callable 接口。因此,对于这两个方法来说,在 Runnable.run 方法中加 try...catch 是必须的,否则很有可能出错了却毫不知情。

五、结论

Thread 中,我们可以通过 UncaughtExceptionHandler 来实现默认的异常处理机制。但是在使用 ThreadPoolExecutorScheduledThreadPoolExecutor 这两个 JDK 最主要的线程池时,使用 UncaughtExceptionHandler 是不合适的。所以,try...catch 往往是不可避免的,否则你的任务很有可能失败的悄无声息。

© 著作权归作者所有

共有 人打赏支持
编走编想
粉丝 149
博文 127
码字总数 109248
作品 0
海淀
程序员
私信 提问
深入Java核心:JVM中的栈和局部变量

Java开发中,每当我们在程序中使用new生成一个对象,对象的引用存放在栈里,而对象是存放在堆里的。可以看出栈在Java核心的重要位置。今天我们就继续深入Java核心这个系列,为您介绍Java中的...

郭二翔
2011/12/19
0
0
深入理解 Java 虚拟机学习(一) -- java 内存区域与内存溢出异常

java 内存区域 要进行 java 虚拟机的深入学习,首先要了解的是 java 的内存划分。大部分程序员一开始接触 java ,对于内存的划分是印象是堆内存和栈内存,而这仅仅适合于入门的学习,实际上 ...

偷星辰夜
2017/09/28
0
0
Java虚拟机基础——2JVM运行时数据区

Java虚拟机整体篇幅如下: Java虚拟机基础——1Java的内存模型 Java虚拟机基础——2JVM运行时数据区 Java虚拟机基础——3类加载机制 Java虚拟机基础——4内存回收机制 本篇文章主要讲解JVM运...

隔壁老李头
2018/10/03
0
0
虚拟机学习之一:java内存区域与内存溢出异常

1.运行时数据区域 java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途和创建、销毁时间,有的区域伴随虚拟机进程的启动而存在,有些区...

贾峰uk
2018/10/21
0
0
weixin-java-tools 1.0.5 发布

weixin-java-tools 1.0.5 发布! 发布说明 issue #32 发送客服消息图文消息时,拼接对象错误 issue #36 News写成Mews的错误 fix 修改消息路由器,使用固定大小的线程池,并在构造函数里提供线...

jarchan
2014/12/01
4.7K
13

没有更多内容

加载失败,请刷新页面

加载更多

开始看《ES6标准入门》

写得非常好,全是干货。还以为是很容易上手的,结果一看还是要有一些JS基础才行的 1.ES6简介。可以了解一下,特别是ES6和ES2015的关系 2.Let和Const命令。这个感觉是帮JS打补丁了。之前的Var...

max佩恩
18分钟前
1
0
SpringBoot入门系列HelloWorld

根据咱们程序员学习的惯例,学习一门新技术都是从HelloWorld开始的。 感觉编程是一件非常富有意义的事情,程序员也是一群可爱的人,渴望被关怀和关注,因为我们总在和世界say Hi. 好了进入正...

凌宇之蓝
57分钟前
3
0
Linux之《荒岛余生》(二)CPU篇

温馨提示,动图已压缩,流量党放心查看。CPU方面内容不多,我们顺便学点命令。本篇是《荒岛余生》系列第二篇,垂直观测CPU。其余参见: Linux之《荒岛余生》(一)准备篇 如何做一个CPU cpu...

mskk
今天
1
0
Java基础教程,第十讲,继承

前面我们学习了类和对象的概念,学习了方法的重载,今天我们将学习面向对象另一个重要特性,继承(inheritance)。利用继承,我们可以基于一个已经存在的类狗仔一个新的类,继承已经存在的类...

程序员补给栈
今天
2
0
nginx的日志

nginx的日志包括错误日志和访问日志,分别使用不同的指令来定义其输出位置和相应的级别。 下面介绍其各自的用途。 错误日志 nginx提供了error_log指令来指定错误日志的输出文件和级别。 指令...

xtof
今天
3
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部