背景
下面是 经过多天调查的结果,内存泄露的原因探明了,不再受每日泄露人工维护的困扰。
不过也给我们留下了一个新的疑问,我们服务中的线程池该如何管理呢。是大家共用一个线程池还是每个任务有自己的线程池比较好呢
一、过去两周xxljob应用在生产环境环境出现了"java.lang.OutOfMemoryError: unable to create new native thread"的情况。
出问题的pod就是xxljob的第一个pod,因为目前业务上有较多的任务都是在第一个pod上运行
为什么有多数的任务都在一个pod执行呢,主要是当前部分任务没有考虑多节点并发运行的情况以及没有实现分片运行的机制;
二、过去两周针对上述的问题进行了排查和分析,主要从以下几个方向去排查
查看prod的worker物理机对应的内存是否足够,通过free命令查看后得到结果如下
通过上述图片可以判断机器内存还够用,转向其他方向排查
查看操作系统的进程数限制 ulimit -a
通过这个图也排除了进程数限制的问题。
排除了物理机内存限制,操作系统进程数限制之后,有通过设置JVM参数 -Xss -Xmx -Xms来调节内存和栈大小,在早上开盘时段仍然有大量的错误。
昨天早上开盘前将pod数从4扩6,情况并未改善
通过skywalking查看xxljob的jvm数据
skywalking监控情况:
通过上图发现,在出问题的时候jvm线程数最高峰超过了1000.
通过网上资料执行命令“”cat /sys/fs/cgroup/pids/pids.max“, 结果显示是1024
结合xxljob的业务实际需要使用的thread数,高峰期应该是会超过1024的,因此初步怀疑是pids.max这个值设置过低导致的,希望可以将该参数值调高到8192,观察和验证一下情况。
三 特申请将prod的ocp pod的 /sys/fs/cgroup/pids/pids.max参数由原来的1024调成8192
思路
遇到的问题:
现在服务中大家每个任务都觉得自己需要并发处理,尽快的提高处理速度,所以每个人都自己维护了自己的线程池,这样服务中的线程池就有很多,启动的线程也有很多(比如上面就搞到了1k+),那么就产生了一个疑问,是不是大家共用一些线程池比较好呢。
大概的思路:
我能想到的思路大体有3个:
第1个思路:
就是上面提到的,大家共用一些线程池
第2个思路:
线程池大家还是自己用自己的,不过会提供一个线程池监控的平台,并且线程池参数可以动态配置
对线程中的任务做一些数据分析,这样哪些任务不需要线程池,哪些任务的线程数量可以减少就很清晰了,也 可以达到减少线程的目的
另外就是大家开发还像以前一样,不需要了解哪些用common线程池,哪些要用私有的,减少了很多心智的负担
第3个思路:
前面用过2年的go,go的协程用起来非常简单和方便,开发人员随意开启协程就可以了,使用起来比较简单,线程的调度用调度器实现,不需要开发人员管理。
这个问题就是java没有大规模使用的经验,开源框架也可能存在不可知的问题,引出新的复杂性
调研
共用线程池
1)独立的线城池之间互相不影响彼此的任务作业,更有利于保证本任务的独立性和完整性,更符合低耦合的设计思想
2)如果所有的场景共用一个线程池,可能会出现问题,比如有任务A、任务B、任务C 这三个任务场景共用一个线程池。当任务A请求量剧烈增加的时候就会导致任务B和任务C,没有可用的线程,可能出现迟迟获取不到资源的情况。比如任务A同时有3000个线程请求,此时就可能会导致 任务B和任务C分配不到资源或者分配到很少的线程资源。
注:
1.JDK自带的类使用了很多的线程池;
2.很多开源框架使用了大量的线程池;
3.自己的应用也会创建多个线程池;
4.多少个线程池,每个线程池提供多少线程,必须经过详细的测试
主要的观点就是每个任务还是用自己的线程池,这样可以避免开发人员错用的时候导致死锁或者饥饿等问题。
基于我们的开发情况,设计共用线程池需要开发人员富有经验的判断,经验不足或者判断失误可能导致更复杂严重的问题发生。
基于以上:建议还是各业务用自己的线程池,开发过程中尽量减少线程的使用。有时候1个线程就可以满足需求。
线程监控
发现2个开源框架:
我们需要的也就是下面3个:
-
代码零侵入,使用简单: 实现对运行中线程池参数的动态修改,实时生效
-
运行监控: 实时监控线程池的运行状态,触发设置的报警策略时报警,报警信息推送办公平台
-
通知告警: 定时采集线程池指标数据,配合像 Grafana 这种可视化监控平台做大盘监控
协程框架
看了下 java21正式版已经升级了virtualThread功能,暂时升级21可能比较复杂,这个可以作为长期方案考虑
看起来开发人员实现也比较简单(虽然没有go简单)
package main
import (
"fmt""time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world")
say("hello")
}
Java 实现:
public final class VirtualThreads {
static void say(String s) {
try {
for (int i = 0; i < 5; i++) {
Thread.sleep(Duration.ofMillis(100));
System.out.println(s);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws InterruptedException {
var worldThread = Thread.startVirtualThread(
() -> say("world")
);
say("hello");
// 等待虚拟线程结束
worldThread.join();
}
}
极速高效:掌握 Java 协程编程艺术 Java19 正式 GA!看虚拟线程如何大幅提高系统吞吐量 https://openjdk.org/jeps/425
https://howtodoinjava.com/java/multi-threading/virtual-threads/
https://mccue.dev/pages/5-2-22-go-concurrency-in-java
结论
理由:java动态线程池可以实现线程池的动态变更和数据监测,选择一个轻量的线程池框架,对代码的改动也很小,开发人员开发起来和以前也没什么不同。选哪个好呢:hippo4j&&dynamic-tp
框架 | 描述 | 备注 |
hippo4j | 这个后期可以迁移到控制台版本 | |
dynamic-tp | 这个可以直接打印到日志里,现在就可以用 |
最终选择 dynamic-tp,已经封装了com.chinaamc.core.config.ThreadPoolUtil.create()方法,后续创建线程池大家直接调用此方法,主要实现了:
1、线程监控
2、tradeId多线程传递
3、也可实现线程池的动态更新,可以在nacos配置
public class ThreadPoolUtil {
public static ThreadPoolExecutor create(String threadPoolName,
int corePoolSize,
int maxPoolSize,
int queueCapacity,
int keepAliveSeconds,
RejectedExecutionHandler handler) {
return ThreadPoolBuilder.newBuilder()
.threadPoolName(threadPoolName)
.threadFactory(threadPoolName)
.corePoolSize(corePoolSize)
.maximumPoolSize(maxPoolSize)
.workQueue(QueueTypeEnum.VARIABLE_LINKED_BLOCKING_QUEUE.getName(), queueCapacity)
.keepAliveTime(keepAliveSeconds)
.rejectedExecutionHandler(handler)
.taskWrapper(new TraceIdTaskWrapper())
.buildDynamic();
}
}
线上问题
上线后马上遇到1个线程问题,syncHistoryPriceJob任务执行一直不能结束,后面和股票相关的所有任务都不能结束,所有的股票相关任务都卡住了。
引入的dynamictp框架遇到线程池耗尽会打印日志:
2023-10-21 02:00:00.709 WARN [] [xxl-job, JobThread-170-1697824800148] org.dromara.dynamictp.core.aware.TaskRejectAware DynamicTp execute, thread pool is exhausted, tpName: fundex-quote-job-performance-stock-thread-pool, traceId: N/A, poolSize: 50 (active: 50, core: 20, max: 50, largest: 50), task: 1074 (completed: 0), queueCapacity: 1024, (currSize: 1024, remaining: 0) ,executorStatus: (isShutdown: false, isTerminated: false, isTerminating: false)
我们发现线程池中的队列已经耗尽,查看线程池配置我们发现拒绝策略是DiscardOldestPolicy
public ThreadPoolExecutor minuteChartsFundThreadPoolTaskExecutor() {
return ThreadPoolUtil.create(
stockThreadNamePrefix,
corePoolSize,
maxPoolSize,
queueCapacity,
keepAliveSeconds,
new ThreadPoolExecutor.DiscardOldestPolicy());
}
我们再看下任务主线程的代码逻辑
CountDownLatch countDownLatch = new CountDownLatch(securityCodesByType.size());
log.info("当前执行任务数量为{}", countDownLatch.toString());
ThreadPoolExecutor executor = getExecutorByjobParam(jobType);
if (Objects.isNull(executor)) {
log.error("参数错误,请输入正确的参数");
return;
}
String finalJobType = jobType;
Date now = new Date();
securityCodesByType.forEach(securityCode -> executor.execute(() -> {
try {
securityLatestQuoteService.syncPerformanceJob(securityCode,now);
} catch (Exception e) {
log.error("========================同步区间涨跌幅,{}数据定时任务失败, securityCode {}========================", JobDataTypeEnums.getValue(finalJobType), securityCode, e);
} finally {
countDownLatch.countDown();
}
}));
countDownLatch.await();
看到这里大家估计已经猜到了,当前任务task: 1074 (completed: 0), queueCapacity: 1024 加入总数量3000条,这里触发拒绝策略导致1074-1024个任务被抛弃,总数就不满3000,代码都卡在
这里可以看下这个2个文档,遇到相似的问题:
countDownLatch.await();
至于1024是因为新上线的ThreadPoolUtil的create方法指定队列长度未生效(已修复)
下一步处理
1、countDownLatch.await();需要设置过期时间,方式任务全部卡住
public boolean await(long timeout, TimeUnit unit)
2、现在线程池耗尽会打印错误日志,需根据日志优化线程池配置