1、线程池执行任务的过程
我们都知道,通过corePoolSize maxPoolSize BlockingQueue RejectedExecutionHandler等参数(默认AbortPolicy策略,即直接废弃任务)创建线程池后,大致的执行过程是:
1、客户端提交一个任务,比如通过threadPool.execute(task)时,首先会拿corePoolSize中的线程来执行任务
2、如果提交task的速度比较快且任务执行耗时较长,corePoolSize的线程处理不过来时,客户端提交的task就会进入到阻塞队列中,
3、如果阻塞队列满了,客户端如果继续提交任务则会创建maxPoolSize的线程来继续处理任务,
4、如果此时maxPoolSize仍旧处理不过来,则会触发拒绝策略,进行任务的特殊处理,或废弃,或抛出异常,或使用主线程来执行等等。具体执行策略根据具体场景来定义,也可以实现RejectedExecutionHandler接口自行实现拒绝策略。
当然了,线上环境比较推崇的还是尽量去避免触发拒绝策略,比如通过压测等手段进行tps、吞吐量的预估,然后去调优,或者增加机器保证利用率能达标前提下,得到一个比较优的参数,这样线上服务就可以尽量避免触发一些拒绝策略。
2、java线程池中的线程是如何实现复用的?
2.1简单的描述:
可以理解为每次提交任务,线程池会根据一个阈值,判断是否要创建线程,创建的线程会去执行任务task,任务执行完毕了,这时候这个线程不是像往常我们认知的那样,直接从RUNNABLE状态切换为TERMINATED状态(Thread有多种状态),而是通过一个死循环while loop的方式,会不断地从阻塞队列中尝试获取任务task并执行,如果此时阻塞队列中没有任务,那么当前线程就会阻塞在阻塞队列的一个Condition中(Condition condition = takeLock.newCondition() -> condition.await,当然了await前需要先获取到takeLock的锁,也就是需要先执行takeLock.lock() 后续要执行takeLock.unLock()),而阻塞队列的特性又是有任务进入到队列时,会调用condition.signal()方法尝试唤醒所有WAITING状态线程(和object.notifyAll()是类似作用),这时候,线程就可以执行任务了。通过这么一种死循环loop的方式+阻塞队列的方式,实现线程池中线程的复用
2.2详细源码解释:
1 一开始客户端提交任务,会直接执行addWorker操作
1-1 addWorker(command,true)分析
1-2 Worker类分析
1-3 Worker的runWorker()分析
1-4 getTask()方法分析:
上面我们知道了,通过while死循环结合getTask()方法,就实现了线程池中线程的复用,因为这些线程永远不会退出,不会变成TEMINATED状态(异常情况除外。)
到这里,可能大家会好奇:while死循环不会导致cpu一直空转占用cpu资源吗?这里其实就要引出阻塞队列这个点了,其实线程池中的线程执行完任务后,如果线程池的阻塞队列中(即创建线程池时的BlockingQueue这个参数)没有任务的话,那么当前线程会阻塞在队列中,具体来说是阻塞在同步队列队列头的的条件队列上,即ReentrantLock的Condition上,此时线程的状态是WAITING状态,后续生产者线程,即提交任务的线程提交任务到线程池的阻塞队列中时,会执行condition.signal方法唤醒阻塞的线程
具体原理分析详见博客:JAVA线程池阻塞队列详解
3、如果线程池中的线程执行任务过程中遇到了Exception,会发生什么?
源码分析:
从上面我们可以得知,线程池的每一个线程都会被包装成一个Worker对象,然后通过while死循环的方式,从firstTask或者不断从队列中获取task的形式来执行任务,而真正执行任务的操作,是调用了task.run()方法,如下图
而这个run()方法的调用,也就是执行客户端提交的任务时,是可能出线异常的,从上图也可以看到是做了try catch操作的,如果出现异常,那么completedAbruptly则为true,然后进入processWorkerExit方法针对以上情况,我们分析下processWorkerExit方法:
总结下来就是:线程池中的线程执行任务过程中遇到异常的话,异常会被捕捉到,该线程的Worker会被从workers列表中移除,最终被垃圾回收。并且线程池会重新创建一个非核心线程进行补足操作,该线程firstTask为null,因此会直接阻塞在getTask()方法中(阻塞队列中),该补足的线程因为是非核心线程的关系,如果在KeepAliveTime时间段内一直是IDEL状态没有执行任务的话,也会被回收掉。当然了,如果遭遇异常的是核心corepoolsize中的线程的话,那么客户端下一个任务提交的时候,就会立马创建一个核心线程执行任务。
线程池的参数该怎么设计?如何选择最佳参数?
这个一般没有准则,需要通过压测以及线上业务的具体情况才能够指导最佳线程数的设置。但是还是有一些最佳实践可以参考的。
一般而言,对于cpu密集型程序,比如说一些跑深度学习模型的cpu机器,线程池的数量就不宜设置过多,一般cpu的逻辑核心保持一致或者稍多为佳;而对于IO密集型的程序,比如说一般的web服务,基本线程都是阻塞在其他的服务调用上,比如dubbo服务的调用,访问数据库等等,这种情况下其实线程并没有一直占用cpu资源而是阻塞在了各种IO上,这时线程池中的线程可以设置的多一些,像一般4核 8核的机器,设置个100~200线程是完全没有问题的(机器配置和创建多少线程一般没太大关系,但是每个线程默认占1MB的栈内存,要注意避免因为这个原因导致OOM问题)
有些基础要了解,cpu有n个核,在同一时刻就只能同时跑n个线程,所以对于cpu密集型程序无脑的设置很多线程是没有意义的,反而会增加线程之间切换的成本,亲测对于排序rank服务,增加线程数后,超时问题会更加明显。机器的核数可以通过RunTime.getRunTime().availableProcessors(),这在偏cpu密集型机器上会常用到。
一些具体业务的例子:
负责的某个业务,每次用户请求都需要对近2000个物料使用深度模型进行排序得分(大致逻辑就是根据用户特征和物料特征进行匹配,越匹配分数越高),每次打分前需要查询近2000个物料的基础信息、tag信息等(原始数据都在redis中,基于性能和gc考虑,将redis数据缓存一份到java堆外缓存,采用ohc缓存框架),显然这里不可能单线程逐个查询,这个耗时是用户无法忍受的,所以每次用户请求都需要用到多线程去查询上述信息,并且程序本身是个C端程序的下游,并发访问量很大,但场景tps峰值达到5000以上,此时需要的线程其实是需要double 5000倍的。针对这种业务场景,有什么经验可以借鉴?
1、加机器 -.-
2、调整batchSize大小直到最优,上述每次请求要查询2000物料信息,可以考虑用100线程,每个线程查询20物料(即batchSize为20),也可以使用50线程,batchSize为40的方式,等等组合方式,一般需要通过压测或线上流量,结合具体的tps和机器各项指标情况找到最佳配比,找到程序耗时和cpu利用率的平衡点。
3、将cpu密集型程序和IO密集型程序进行拆分,比如特征的查询,上述物料基础信息的查询是偏向IO交互,而深度模型预测则是cpu密集型的,可以将这些不通类型的程序拆分到不同的服务中。
4、量大资源少情况下,要特别关注线程池队列的大小和拒绝策略,比如任务执行耗时稍长的话,就可能导致在队列中的任务一直得不到执行,从而导致超时情况或者任务被丢弃掉的情况(多任务执行一般通过countDownLatch.countDown() 结合countDownLatch.await(100ms)的方式来实现,或者通过
CompletablFuture.runAsync(()->{},threadPool) CompletableFuture.allOf(allFutures).get(100ms) 实现,两种方式达到的效果都是一样的:实现多线程在指定时间内执行多个任务,并综合最终结果 )
5、调整线程数如corePoolSize和maxPoolSzie,和任务执行的超时时间(参考4),找到机器cpu利用率和出错率的平衡点。
6、观察GC指标是否正常,young gc每分钟多少次,每次耗时多少,有没有fullgc都要关注(大公司一般都有监控平台)。GC会影响用户线程的执行,导致耗时增加很多,当然不同的的垃圾回收器在GC时对用户线程的影响不同,但是或多或少都有影响。
7、机器本身差异影响,这个是非常容易忽略的一个点。在C端程序中,往往都是几十上百台物理机或者容器,出线报错首先要先排除是个别机器才有报错的可能性。之前遇到过几次线上超时问题比较严重,达到了3%,从程序端和数据端都找不出原因,后来从日志中才发现报错全是集中在一个机器上,该物理机是年代比较久的机器,cpu性能很差、网卡也没有升级,所以有各种问题。
8、个别报错如果在业务承受范围内,是可以忽略的,因为cpu难免偶尔会有歇菜的情况。综合考虑成本和修复问题的收益,为了一个0.01%的问题花费较多精力去修复,往往是不可取的。
9、通过arthas工具,排查程序耗时问题。