1.问题及分析
1.1 问题
最近遇到一个问题。这个问题的业务场景是这样的:用户可以通过微信扫码关注医生。扫码后,系统会通过微信公众号推送小程序卡片给用户。承载这个场景的服务运行十多天后,服务开始出现异常。异常的表现是:一次推送多个卡片给用户。
1.2 分析
分析日志得出结论:微信扫码回调的处理逻辑超时,微信重试导致了重复处理扫码关注回调事件。从而导致了多次推送小程序卡片给用户。
微信回调处理逻辑比较复杂。日志记录的不详细,提供的信息比较有限。
Skywalking 的调用链追踪日志提供了更多信息。整个回调处理的耗时分布特征并不固定,有时候是这个远程调用耗时长,有时候是另外一个远程调用,有时候是服务本身访问数据库耗时长。下图是该服务通过 feign 远程调用其他服务的一个调用链。从图中我们可以这次远程调用的总体耗时是标记为“1.耗时”的时间条。而远程服务的实际耗时是标记为“2.远程服务耗时”的时间条。可以看到远程实际耗时很小,只有 90 毫秒。远程调用早早地返回了,但是调用线程似乎是卡住了,没有被正常调度运行,结束远程调用。
1.3 推测
基于以上信息,推测可能的情况是服务内部比较忙,从而导致上图中的远程调用的线程等待了很久才得到运行。不过从日志上看不出来服务在忙什么,对比了重启后不久,服务行为正常时候的日志,并没有发现特别不一样的地方。那么导致这种现象的可能的原因是服务器本身 CPU 很忙,另外一种情况服务器并不忙,只是出现问题的服务本身忙。服务器的监控显示当时 CPU 比较空闲,如图:
所以只有一个可能,出现问题的服务本身忙。我们采用 k8s 对服务进行编排,服务器是 4 核的 CPU, 设置了 CPU 最大为 1000m,也就是最大占用 1 核。服务器 CPU 性能监控图可以看出 CPU 使用率是 32% 多点。服务重启后,下降 CPU 使用率下降到 5% 左右。也就是说重启之前该服务基本上是把分配的 CPU 配额全用上了。
下图是基于 Grafana 的服务实例的 CPU 使用率监控。从图中可以看到服务的 CPU 使用率持续上升,直到达到分配的最高配额 1 个核。这就印证了我们的推测,服务很忙,导致微信回调处理过程获得的 CPU 分片不足,从而导致回调超时。
1.4 定位
从日志和 skywalking 大致统计同时段的请求数并没有出现较明显的增长。再对照Grafana 的服务实例的 CPU 使用率监控图,可以得出系统存在 CPU 资源泄露的情况。CPU 资源泄露一定是和线程相关的。在服务的工程代码中用 “thread” 关键字搜索。发现了一个可疑的地方。在 xxljob 中,创建 ThreadPoolExecutor 线程池。
BlockingDeque<Runnable> blockingDeque = new LinkedBlockingDeque<>(10);
ThreadPoolExecutor exs = new ThreadPoolExecutor(
10, 20, 60, TimeUnit.SECONDS, blockingDeque);
CompletableFuture[] completableFutures = datas.stream().map(t -> {
...
return CompletableFuture.supplyAsync(() -> doSomeThing(), exs)
.whenComplete((v, e) -> {
...
});
}
).toArray(CompletableFuture[]::new);
CompletableFuture.allOf(completableFutures).join();
这段代码的主要逻辑是创建一个核心线程数为 10 的线程池,作为 CompletableFuture 异步任务的执行线程池。最后等待所有异步任务完成。 我们知道创建线程池是耗时操作,正确的用法是创建线程池长期使用,而不是在定时任务中临时创建线程池。这里一定是存在问题的。那么撇开用法是否合理,这里的用法会导致本文描述的问题吗?
2.线程池对象超出变量有效范围后能正常释放吗?
线程池需要手动关闭吗,可以通过简单的测试验证下下到底线程池用完要不要关闭。代码如下:
public static void main(String[] args) throws Exception {
//用于获取到本java进程,进而获取总线程数
RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean();
String jvmName = runtimeBean.getName();
System.out.println("JVM Name = " + jvmName);
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
int n = 30000;
for (int i = 0; i < n; i++) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(10,20,1000,TimeUnit.SECONDS,new LinkedBlockingDeque<>());
for(int j=0;j<10;j++){
executor.execute(()->{
System.out.println("total thread count: "+bean.getThreadCount());
});
}
}
Thread.sleep(10000);
System.out.println("exit, total thread count: " + bean.getThreadCount());
}
--------------------
运行打印输出:
total thread count: 2790
thread: pool-465-thread-1
task complted, thread: pool-465-thread-1
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:714)
运行结果给我们的答案是:如果没有手动关闭线程池,那么线程池的线程并不会自动释放,会越积越多,直到进程内存耗尽。
我们再来看看官方文档怎么说,下面节选一段相关的描述:
- Finalization
A pool that is no longer referenced in a program AND has no remaining threads will beshutdown
automatically. If you would like to ensure that unreferenced pools are reclaimed even if users forget to callshutdown()
, then you must arrange that unused threads eventually die, by setting appropriate keep-alive times, using a lower bound of zero core threads and/or settingallowCoreThreadTimeOut(boolean)
.
这段话说的是:一个没有被引用的线程池,并且没有存活线程的话,将会被自动 shutdown。那也就是说,如果线程池有存活线程的话,即使线程池对象以及被引用,也不会自动释放线程。线程将会一直存在。
3.结论
Java 线程与系统线程相对应,系统线程是系统的关键资源。如果无节制的创建线程,将很快消耗完系统资源。线程池的正确使用模式是长期存在,用于为某种特定任务场景提供多线程执行支持。比如 web 容器处理请求的线程池。不应该为了临时创建用来执行一次性的短期任务。即使是特殊场景,需要临时创建线程池,那么也应该在任务完成后显式的调用 shutdown() 关闭线程池。
- 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。