为什么线程池出现异常,没有打印日志?

609 阅读6分钟

一、问题描述

频繁的创建、销毁线程和线程池,会给系统带来额外的开销。未经池化及统一管理的线程,则会导致系统内线程数上限不可控。 例如如下代码,每次发送邮件都会创建一个新的线程池,并且业务结束之后线程池也未随之销毁。

public static boolean sendMail(MailInfo mailInfo, MailServerInfo mailServerInfo) {
    try {
        ExecutorService executorService = Executors.newCachedThreadPool();
        Future<Boolean> future = executorService.submit(() -> {
            try {
                return asyncSendEmail(mailInfo, mailServerInfo);
            } catch (Exception e) {
                return false;
            }
        });
        return future.get(10, TimeUnit.SECONDS);
    } catch (Exception e) {
        LOG.error(e.getMessage(), e);
        return false;
    }
}

这种情况下,随着访问数增加,系统内线程数持续增长,CPU负载逐步提高。极端情况下,甚至可能会导致CPU资源被吃满,整个服务不可用。 为了解决上述问题,可增加统一线程池配置,替换掉自建线程和线程池。

二、自建线程池

ThreadPoolConfig中,创建我们项目统一的线程池,并交给spring管理。

@Configuration
@EnableAsync
public class ThreadPoolConfig implements AsyncConfigurer {
    /**
     * 项目共用线程池
     */
    public static final String MALLCHAT_EXECUTOR = "mallchatExecutor";
    /**
     * websocket通信线程池
     */
    public static final String WS_EXECUTOR = "websocketExecutor";

    @Override
    public Executor getAsyncExecutor() {
        return mallchatExecutor();
    }

    @Bean(MALLCHAT_EXECUTOR)
    @Primary
    public ThreadPoolTaskExecutor mallchatExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("mallchat-executor-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());//满了调用线程执行,认为重要任务
        executor.initialize();
        return executor;
    }
}

这里面做了两件事,创建一个统一线程池,并且还通过实现AsyncConfigurer 设置了@async注解也使用我们的统一线程池,这样方便统一管理。 我们的线程池没有用Excutors快速创建。是因为Excutors创建的线程池用的无界队列,有oom的风险(小考点)。 executor.setThreadNamePrefix("mallchat-executor-")设置线程前缀,这样排查cpu占用,死锁问题或者其他bug的时候根据线程名,可以比较容易看出是业务问题还是底层框架问题。

三、优雅停机

当项目关闭的时候,需要通过jvm的shutdownHook回调线程池,等队列里任务执行完再停机。保证任务不丢失。 shutdownHook会回调spring容器,所以我们实现spring的DisposableBean的destroy方法也可以达到一样的效果,在里面调用executor.shutdown()并等待线程池执行完毕。 由于我们用的就是spring管理的线程池

image

连优雅停机的事,都可以直接交给spring自己来管理了,非常方便。 内部源码,点进去可以看见。

@Override
public void destroy() {
    shutdown();
}

/**
 * Perform a shutdown on the underlying ExecutorService.
 * @see java.util.concurrent.ExecutorService#shutdown()
 * @see java.util.concurrent.ExecutorService#shutdownNow()
 */
public void shutdown() {
    if (logger.isDebugEnabled()) {
        logger.debug("Shutting down ExecutorService" + (this.beanName != null ? " '" + this.beanName + "'" : ""));
    }
    if (this.executor != null) {
        if (this.waitForTasksToCompleteOnShutdown) {
            this.executor.shutdown();
        }
        else {
            for (Runnable remainingTask : this.executor.shutdownNow()) {
                cancelRemainingTask(remainingTask);
            }
        }
        awaitTerminationIfNecessary(this.executor);
    }
}

四、线程池使用

我们放进容器的线程池设置了beanName。

image

业务需要用,也可以根据beanName取出想用的线程池。

image

或者是直接在方法上加上异步注解@async

image

五、异常捕获

搭建我们的项目的线程池,千万别忘了一点,就是线程运行抛异常了,要怎么处理。昨天一位哥们在群里问到这个问题,才发现很容易被人忽略,我们来看一看。

public static void main(String[] args) {
    Thread thread =new Thread(()->{
        log.info("111");
        throw new RuntimeException("运行时异常了");
    });
    thread.start();
}

看看这样一个语句,子线程执行报错,会打印错误日志吗?

image

结果是这样的,异常并不会打印日志,只会在控制台输出。为啥呢?

如果出了问题,却不打印error日志,那问题就被隐藏了,非常危险

想要搞明白这个,首先要明白子线程的异常抛到哪里去了?

异常去了哪里

传统模式下,我们一般会通过try catch的方法去捕获线程的异常,并且打印到日志中。

Thread thread =new Thread(()->{
    try{
        log.info("111");
        throw new RuntimeException("运行时异常了");
    }catch (Exception e){
        log.error("异常发生",e);
    }
});
thread.start();

image

你会发现一个有意思的现象,当我们捕获了异常,就没有控制台的告警了,全都是日志打印。 其实,如果一个异常未被捕获,从线程中抛了出来。JVM会回调一个方法 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);
}

这个方法在Thread类中,会进行默认的异常处理,其实就是获取一个默认的异常处理器。默认的异常处理器是 ThreadGroup实现的异常捕获方法。前面看到的控制台ERR打印,就出自这里。

如何捕获线程异常

我们要做的很简单,就是给线程添加一个异常捕获处理器,以后抛了异常,就给它转成error日志。这样才能及时发现问题。 Thread有两个属性,一个类静态变量,一个实例对象。都可以设置异常捕获。区别在于一个生效的范围是单个thread对象,一个生效的范围是全局的thread。

// null unless explicitly set
private volatile UncaughtExceptionHandler uncaughtExceptionHandler;

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

我们一般选择给每个thread实例都加一个异常捕获。毕竟别人的thread咱们别管,只管自己创建的thread。

Thread thread = new Thread(() -> {
    log.info("111");
    throw new RuntimeException("运行时异常了");
});
Thread.UncaughtExceptionHandler uncaughtExceptionHandler =(t,e)->{
    log.error("Exception in thread ",e);
};
thread.setUncaughtExceptionHandler(uncaughtExceptionHandler);
thread.start();

image

线程池的异常捕获

我们工作中一般不直接创建对象,都用的线程池。这下要怎么去给线程设置异常捕获呢? 用线程池的ThreadFactory,创建线程的工厂,创建线程的时候给线程添加异常捕获。不了解的去补一补线程池基础

private static ExecutorService executor = new ThreadPoolExecutor(1, 1,
    0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<Runnable>(500), 
    new NamedThreadFactory("refresh-ipDetail",null, false,
                           new MyUncaughtExceptionHandler()));

这是我的ip解析线程池,直接在工厂里添加一个异常捕获处理器就好了。它在创建thread的时候,会把这个异常捕获赋值给thread。

image 如果是这么简单,那一切到这儿就结束了。 我们还有两个线程池,用到了Spring的线程池。由于Spring的封装,想要给线程工厂设置一个捕获器,可是很困难的。 代码位置:com.abin.mallchat.common.common.config.ThreadPoolConfig#websocketExecutor

 @Bean(WS_EXECUTOR)
public ThreadPoolTaskExecutor websocketExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(16);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(1000);//支持同时推送1000人
    executor.setThreadNamePrefix("websocket-executor-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());//满了直接丢弃,默认为不重要消息推送
    executor.initialize();
    return executor;
}

可以看到它自己实现了ThreadFactory。在CustomizableThreadFactory类的位置

image

点进去可以看见它内部封装好的创建线程的方法

image

压根就没有机会去设置一个线程捕获器。 它的抽象类ExecutorConfigurationSupport将自己赋值给线程工厂,提供了一个解耦的机会。

image

如果我们把这个线程工厂换了,那么它的线程创建方法就会失效。线程名,优先级啥的全都得我们一并做了。而我们只是想扩展一个线程捕获。 这时候一个设计模式浮出脑海,装饰器模式 装饰器模式不会改变原有的功能,而是在功能前后做一个扩展点 。完全适合我们这次的改动。 首先先写一个自己的线程工厂,把spring的线程工厂传进来。调用它的线程创建后,再扩展设置我们的异常捕获

public class MyThreadFactory implements ThreadFactory {

    private ThreadFactory original;

    @Override
    public Thread newThread(Runnable r) {
        Thread thread = original.newThread(r);
        thread.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());//异常捕获
        return thread;
    }
}

第二步,替换spring线程池的线程工厂。

image

一个完美的装饰器模式就这么写完了。 有人问,这么复杂,为啥得要用spring的线程池,不用原生的呢?因为spring提供了很多优雅关闭等功能啊,看看 上面的文章。