27. 自定义线程池线程名,方便看日志
发布于 • 阅读量 0
27. 自定义线程池线程名,方便看日志
前面用 ThreadPoolExecutor 跑任务时,控制台里看到的线程名一般是这样的:
pool-1-thread-1
pool-1-thread-2
pool-1-thread-3
学习阶段看着还行。
但如果放到真实项目里,这种线程名就不太够用了。
因为一个项目里可能不止一个线程池:
PDF 水印线程池;
文件上传线程池;
消息通知线程池;
定时任务线程池;
数据同步线程池。
如果日志里全是 pool-1-thread-1、pool-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 就是为了解决“任务完成后继续干下一步”这类问题。