跳到正文
hello world

27. 自定义线程池线程名,方便看日志

发布于阅读量 0

27. 自定义线程池线程名,方便看日志

前面用 ThreadPoolExecutor 跑任务时,控制台里看到的线程名一般是这样的:

pool-1-thread-1
pool-1-thread-2
pool-1-thread-3

学习阶段看着还行。

但如果放到真实项目里,这种线程名就不太够用了。

因为一个项目里可能不止一个线程池:

PDF 水印线程池;
文件上传线程池;
消息通知线程池;
定时任务线程池;
数据同步线程池。

如果日志里全是 pool-1-thread-1pool-2-thread-3,排查问题时很难一眼看出这是哪个业务的线程。

所以这一节整理一个很实用的小点:给线程池里的线程起一个有业务含义的名字


默认线程名的问题

先看默认情况。

比如我创建一个普通线程池:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
        3,
        3,
        60,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(100),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

然后提交任务:

executor.execute(() -> {
    System.out.println(Thread.currentThread().getName() + " 开始处理 PDF");
});

打印出来可能是:

pool-1-thread-1 开始处理 PDF
pool-1-thread-2 开始处理 PDF
pool-1-thread-3 开始处理 PDF

这说明任务确实在线程池里执行了。

但问题是,这个名字不够明确。

我只能知道它是某个线程池里的线程,却不知道它到底属于哪个业务。

如果系统里有很多线程池,日志就会比较难看。


我希望线程名长什么样

如果是 PDF 水印线程池,我更希望日志是这样:

pdf-watermark-thread-1 开始处理 a.pdf
pdf-watermark-thread-2 开始处理 b.pdf
pdf-watermark-thread-3 开始处理 c.pdf

这样一眼就能看出来:

这是 PDF 水印任务;
这是第几个工作线程;
哪个线程处理了哪个文件。

如果某个 PDF 处理失败,日志也更清楚:

pdf-watermark-thread-2 处理失败:b.pdf

比下面这种好很多:

pool-1-thread-2 处理失败:b.pdf

因为后者还要继续猜 pool-1 是哪个线程池。


ThreadFactory 是什么

线程池创建线程时,会用到一个东西:ThreadFactory

它的作用很直接:

线程工厂,负责创建线程。

接口大概是这样:

public interface ThreadFactory {
    Thread newThread(Runnable r);
}

线程池内部需要创建新线程时,会调用这个 newThread() 方法。

所以我可以自己实现一个 ThreadFactory,在里面控制线程名称。


自定义线程名的完整代码

新建类:

com.succos.threadpool.ThreadPoolNameDemo

代码如下:

package com.succos.threadpool;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadPoolNameDemo {

    public static void main(String[] args) {

        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                3,
                3,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(100),
                new PdfThreadFactory(),
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

        for (int i = 1; i <= 10; i++) {

            int taskId = i;

            executor.execute(() -> {
                System.out.println(Thread.currentThread().getName()
                        + " 开始处理 PDF 任务 "
                        + taskId);

                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }

                System.out.println(Thread.currentThread().getName()
                        + " 处理完成 PDF 任务 "
                        + taskId);
            });
        }

        executor.shutdown();

        try {
            boolean finished = executor.awaitTermination(1, TimeUnit.HOURS);

            if (finished) {
                System.out.println("全部任务执行完成");
            } else {
                System.out.println("等待超时,还有任务没有执行完");
            }

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    static class PdfThreadFactory implements ThreadFactory {

        private final AtomicInteger threadNumber = new AtomicInteger(1);

        @Override
        public Thread newThread(Runnable r) {

            Thread thread = new Thread(r);

            thread.setName("pdf-watermark-thread-" + threadNumber.getAndIncrement());

            return thread;
        }
    }
}

这段代码里,最关键的是这里:

new PdfThreadFactory()

我把它作为参数传给了 ThreadPoolExecutor

以后线程池创建线程时,就会走我自己写的 PdfThreadFactory


运行结果

运行后,日志会变成这样:

pdf-watermark-thread-1 开始处理 PDF 任务 1
pdf-watermark-thread-2 开始处理 PDF 任务 2
pdf-watermark-thread-3 开始处理 PDF 任务 3

等待 3 秒...

pdf-watermark-thread-1 处理完成 PDF 任务 1
pdf-watermark-thread-1 开始处理 PDF 任务 4
pdf-watermark-thread-2 处理完成 PDF 任务 2
pdf-watermark-thread-2 开始处理 PDF 任务 5

这就比默认线程名清楚很多。

我能直接看出来:

这些线程属于 PDF 水印线程池;
线程处理完一个任务后,会继续处理下一个任务;
线程是复用的,不是每个任务都创建一个新线程。

这个日志对理解线程池也有帮助。


AtomicInteger 是用来干什么的

这里用了一个 AtomicInteger

private final AtomicInteger threadNumber = new AtomicInteger(1);

它是用来生成线程编号的。

每创建一个线程,就执行:

threadNumber.getAndIncrement()

比如:

第一次返回 1;
第二次返回 2;
第三次返回 3。

所以线程名就会变成:

pdf-watermark-thread-1
pdf-watermark-thread-2
pdf-watermark-thread-3

这里为什么不用普通的 int

因为线程工厂理论上可能被多个线程调用。用 AtomicInteger 更稳一点,也不用自己加锁。

这个地方不是这节重点,先知道它适合做线程安全计数就行。


ThreadFactory 还能设置什么

自定义 ThreadFactory 不只是能设置线程名。

它还能设置一些线程属性,比如:

是否是守护线程;
线程优先级;
未捕获异常处理器。

比如可以写:

thread.setDaemon(false);
thread.setPriority(Thread.NORM_PRIORITY);

还可以设置异常处理器:

thread.setUncaughtExceptionHandler((t, e) -> {
    System.out.println("线程发生未捕获异常:" + t.getName());
    e.printStackTrace();
});

不过这节我先不展开太多。

对我现在这个 PDF 处理项目来说,最实用的就是设置线程名。


结合 PDF 水印任务

如果把它接回真实 PDF 任务,线程池可以这样写:

ThreadPoolExecutor pdfExecutor = new ThreadPoolExecutor(
        3,
        3,
        60,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(100),
        new PdfThreadFactory(),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

任务里打印日志:

pdfExecutor.execute(() -> {

    String threadName = Thread.currentThread().getName();

    System.out.println(threadName + " 开始处理:" + file.getName());

    try {
        String targetPath = addWatermark(file, outputDir);

        System.out.println(threadName
                + " 处理完成:"
                + file.getName()
                + ",输出路径:"
                + targetPath);

    } catch (Exception e) {
        System.out.println(threadName
                + " 处理失败:"
                + file.getName()
                + ",原因:"
                + e.getMessage());
    }
});

这样日志会更接近真实项目:

pdf-watermark-thread-1 开始处理:a.pdf
pdf-watermark-thread-2 开始处理:b.pdf
pdf-watermark-thread-3 开始处理:c.pdf

pdf-watermark-thread-2 处理失败:b.pdf,原因:文件损坏

这比默认线程名舒服很多。


为什么日志里线程名很重要

并发程序里,日志不是随便打印的。

很多问题如果没有线程名,很难判断。

比如:

同一个文件是不是被多个线程处理了?
某个任务到底卡在哪个线程?
线程池有没有复用线程?
任务是不是跑到了 main 线程里?
拒绝策略有没有触发 CallerRunsPolicy?

这些都可以通过线程名看出来。

尤其是 CallerRunsPolicy 触发时,任务可能不是由线程池线程执行,而是由提交任务的线程执行。

如果日志里看到:

main 开始处理 PDF 任务 10

那就说明这个任务可能是被 main 线程执行了,而不是线程池里的工作线程。

如果没有线程名,这种情况就不容易发现。


不同业务用不同线程名前缀

我觉得一个项目里,线程池命名最好带业务含义。

比如:

~~~text
pdf-watermark-thread-
file-upload-thread-
notify-thread-
report-export-thread-
data-sync-thread-

这样日志里能直接区分业务来源。

不要全都叫:

worker-thread-
async-thread-
task-thread-

这种名字虽然比默认强一点,但业务含义还是不够明确。

线程名前缀最好能回答一个问题:

这个线程池是干什么的?

比如 PDF 水印线程池,就叫:

pdf-watermark-thread-

比较直观。


线程名不是越长越好

线程名要有业务含义,但也不要太长。

比如下面这种就有点啰嗦:

pdf-watermark-background-async-task-executor-thread-1

日志里看起来会很占地方。

我会倾向于短一点:

pdf-watermark-thread-1

或者:

pdf-worker-1

关键是能区分业务。

不用写成一句话。


真实项目里可以抽一个通用 ThreadFactory

如果项目里有很多线程池,可以把线程工厂抽成一个通用类。

比如:

package com.succos.threadpool;

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

public class NamedThreadFactory implements ThreadFactory {

    private final String prefix;

    private final AtomicInteger threadNumber = new AtomicInteger(1);

    public NamedThreadFactory(String prefix) {
        this.prefix = prefix;
    }

    @Override
    public Thread newThread(Runnable r) {

        Thread thread = new Thread(r);

        thread.setName(prefix + "-" + threadNumber.getAndIncrement());

        return thread;
    }
}

使用时就可以写:

ThreadPoolExecutor pdfExecutor = new ThreadPoolExecutor(
        3,
        3,
        60,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(100),
        new NamedThreadFactory("pdf-watermark-thread"),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

如果还有通知线程池:

ThreadPoolExecutor notifyExecutor = new ThreadPoolExecutor(
        2,
        2,
        60,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(100),
        new NamedThreadFactory("notify-thread"),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

这样就不用每个线程池都重新写一个内部类。


要不要用第三方工具类

有些工具库也提供了线程工厂构建器。

比如 Guava 里有 ThreadFactoryBuilder

不过我这个学习项目里不打算引入额外依赖。

自己写一个 NamedThreadFactory 就够用了。

代码也不复杂,还能顺便理解线程池是怎么创建线程的。

等真实项目里,如果团队本来就有统一工具类,那直接用统一工具类也可以。


这一节小结

这一节我主要记住几点:

1. 线程池默认线程名不够直观;
2. 自定义 ThreadFactory 可以控制线程池创建线程的方式;
3. 最常见的用途就是设置线程名;
4. 线程名最好带业务含义,比如 pdf-watermark-thread-1;
5. 日志里打印线程名,有助于排查并发问题;
6. 多个线程池最好使用不同的线程名前缀;
7. 项目里可以抽一个通用 NamedThreadFactory。

用一句话总结:

自定义线程名不是为了好看,而是为了出问题时能看懂日志。

下一节开始进入 CompletableFuture

前面用 Future 能拿异步结果,但任务之间的后续编排比较别扭。CompletableFuture 就是为了解决“任务完成后继续干下一步”这类问题。