一:什么是线程池?为什么要使用线程池?
是一个管理线程的池子,它可以容纳多个线程,其中的线程可以反复利用,省去了频繁创建线程对象的操作。
好处:(1)降低资源消耗,降低频繁创建、销毁线程带来的额外开销。
(2)降低使用复杂度。将任务的提交和执行进行解耦,我们只需要创建一个线程池,然后往里面提交任务就行,具体执行流程由线程池自己管理。
二:ThreadPoolExecutor 都有哪些核心参数?
corePoolSize 核心线程数目 (最多保留的线程数)
maximumPoolSize 最大线程数目
keepAliveTime 生存时间 - 针对救急线程
unit 时间单位 - 针对救急线程
workQueue 阻塞队列
threadFactory 线程工厂 - 可以为线程创建时起个好名字
handler 拒绝策略
在创建线程池后,等待提交过来的任务请求,当调用execute()方法添加任务时,线程池会做如下判断:
(1)当任务数没有超过 coreSize 时,新建一个线程来处理提交的任务;
(2)如果任务数超过 coreSize 时,加入任务队列暂存;
(3)如果任务队列满了且正在运行的线程数量小于最大线程数,那么创建一个非核心线程立刻运行这个任务;
(4)如果任务队列满了且正在运行的线程数量大于或等于最大线程数,线程池会执行拒绝策略;
上述执行流程是 JUC 标准线程池提供的执行流程,主要用在 CPU 密集型场景下。像 Tomcat,他们内部的线程池主要用来处理网络 IO 任务的,它利用 TaskQueue 的 offer() 方法修改了 JUC 线程池的执行流程,来支持 IO 密集型场景使用。
三:你刚也说到了 Worker 继承 AQS 实现了锁机制,那 ThreadPoolExecutor 都用到了哪些锁?为什么要用锁?
-
mainLock 锁:ThreadPoolExecutor 内部维护了 ReentrantLock 类型锁 mainLock, workers 变量用的 HashSet 是线程不安全的,largestPoolSize(这个字段是用来记录线程池中,曾经出现过的最大线程数)、completedTaskCount 也是没用 volatile 修饰,所以需要在锁的保护下进行访问。
(1)面试官:为什么不直接用个线程安全容器呢?
interruptIdleWorkers() 方法,用 mainLock 来实现串行化,避免中断风暴的风险,有了锁这个大前提后,也不需要并发安全的 Set 集合。
(2)面试官:怎么理解这个中断风暴呢?
就是如果不加锁,interruptIdleWorkers() 方法在多线程访问下就会发生这种情况。一个线程调用interruptIdleWorkers() 方法对 Worker 进行中断,此时该 Worker 出于中断中状态,此时又来一个线程去中断正在中断中的 Worker 线程,这就是所谓的中断风暴。
(3)面试官:那 largestPoolSize、completedTaskCount 变量加个 volatile 关键字修饰是不是就可以不用 mainLock 了?
假设 addWorkers 方法还没来得及修改 largestPoolSize 的值,就有线程调getLargestPoolSize 方法。由于没阻塞,获取到的值,只是那一瞬间的 largestPoolSize,不一定是addWorker 方法执行完成后的值,所以是为了保证这两个参数的准确性,在获取这两个值时,能保证获取到的一定是addWorkers方法执行完成后的值。 -
Worker 线程锁:该锁主要是用来维护运行中线程的中断状态。
面试官:这个维护运行中线程的中断状态怎么理解呢?
Worker 继承 AQS 主要就是为了实现了一把非重入锁,维护线程的中断状态,保证不能中断运行中的线程。
四:你在项目中是怎样使用线程池的?Executors 了解吗?
Executors 创建的线程池有发生 OOM 的风险,Executors.newFixedThreadPool 创建的线程池内部使用的是无界的队列,可能会堆积大量请求,导致 OOM。
在 Spring 环境中使用线程池,直接使用 JUC 原生 ThreadPoolExecutor 有个问题,Spring 容器关闭的时候可能任务队列里的任务还没处理完,有丢失任务的风险。所以使用 Spring 提供的ThreadPoolTaskExecutor,并且要按业务类型进行线程池隔离,任务执行参差不齐,避免共享一个线程池。
五:刚你说到了通过 ThreadPoolExecutor 来创建线程池,那核心参数设置多少合适呢?
- 如果是CPU密集型应用,则线程数设置为N+1,CPU利用率达到100%,那么线程数就是CPU核心数,+1是为了确保在线程暂停时有一个额外的线程来保持CPU周期工作。所以N+1确实是一个经验值。
- 如果是IO密集型应用,则线程数设置为2N+1,对于IO密集型应用,假定所有的操作时间几乎都是IO操作耗时,那么W/C的值就为1,那么对应的线程数确实为2N。
- 如果不知道是IO密集还是CPU密集呢
上述公式很难获取准确的等待时间和计算时间,需要通过压测不断的动态调整线程池参数,观察 CPU 利用率、系统负载、GC、内存、RT、吞吐量等各种综合指标数据,来找到一个相对比较合理的值。
六:execute() 提交任务和 submit() 提交任务有啥不同?
- execute() 无返回值,submit() 有返回值,会返回一个 FutureTask,然后可以调用 get() 方法阻塞获取返回值。
- execute只能提交Runnable类型的任务,submit既能提交Runnable类型任务也能提Callable类型任务。
- execute会直接抛出任务执行时的异常,submit会吃掉异常,但是调用Future.get()方法时,可以捕获到异常。
七:线程池拒绝策略有哪些?
AbortPolicy(默认):丢弃任务并抛出 RejectedExecutionException 异常。
DiscardPolicy:丢弃任务,但是不抛出异常。可以配合这种模式进行自定义的处理方式。
DiscardOldestPolicy:丢弃队列最早的未处理任务,然后重新提交被拒绝的任务。
CallerRunsPolicy:由调用线程处理该任务。
八:为什么不建议直接使用Spring的@Async
当我们没有自定义线程池的时候,@Async就会用SimpleAsyncTaskExecutor这个线程池,其实他并不是真的线程池,它是不会重用线程的,每次调用都会创建一个新的线程,也没有最大线程数设置。所以,我们应该自定义线程池来配合@Async使用,而不是直接就用默认的。
九:你在使用线程池的过程中遇到过哪些坑或者需要注意的地方?
- OOM 问题,刚开始使用线程都是通过 Executors 创建的,这种方式创建的线程池会有发生 OOM 的风险。
- 共享线程池问题。整个服务共享一个全局线程池,导致任务相互影响,耗时长的任务占满资源,耗时短的任务不到执行。同时如果任务之间存在父子关系,可能会导致死锁的发生,进而引发 OOM。
- 配合ThreadLocal 使用,导致脏数据问题。我们知道 Tomcat 利用线程池来处理收到的请求,会复用线程,如果我们代码中用到了 ThreadLocal,在请求处理完后没有去 remove,那每个请求就有可能获取到之前请求遗留的脏值。
- 需要自定义线程工厂指定线程名称,不然发生问题都不知道咋定位。
十:什么是压测,怎么做压测?
压测通过模拟用户请求,帮助我们发现系统的瓶颈以及评估系统的整体水位。以下是进行压测的一般步骤:
- 确定测试目标:具体是哪个接口,那个方法,哪种具体的case。以及这次压测我们要实现什么目的。
- 制定压测计划:确定压测的具体策略,包括测试的时间、持续多久、并发量要压到多少
- 创建环境并准备脚本:压测可以在测试环境也可以在生产环境,并且需要准备好压测数据及脚本。
- 执行压测:根据测试计划,执行压测并收集性能指标。
- 监控系统性能:在施压过程中,观察系统的整体情况。包括但不限于:a.RT、CPU利用率、Load、内存情况、GC次数、GC时长、网络I0情况、堆内存情况、线上报警情况等。
- 分析结果:对压测结果进行分析,确定系统的性能瓶颈和潜在问题
- 优化和再测试:根据分析结果,进行必要的优化和改进,并重新进行压测