Tomcat对线程池的扩展-源码分析
Tomcat 扩展线程池的背景
JDK中ThreadPoolExecutor 执行步骤
在 Java 的并发编程中,ThreadPoolExecutor
类是线程池的核心实现类,它提供了灵活且强大的线程管理功能。要深入理解线程池,首先需要剖析ThreadPoolExecutor
的构造函数及其工作原理。
ThreadPoolExecutor
的工作原理可以概括为以下几个步骤:
1.当线程池创建后,会先创建核心线程并等待任务到来。
2.当有新任务提交时,首先判断当前线程池中的线程数是否小于核心线程数。如果小于,会立即创建新的线程来执行该任务。
3.如果当前线程数已经达到或超过核心线程数,任务会被放入任务队列中等待执行。
4.如果任务队列已满,且当前线程数小于最大线程数,线程池会创建新的非核心线程来执行任务。
5.如果任务队列已满,且当前线程数已经达到最大线程数,此时再提交任务,就会触发拒绝策略,根据设置的拒绝策略来处理新任务。
6.当线程池中的线程空闲时间超过keepAliveTime
(前提是线程数大于核心线程数),多余的空闲线程会被回收,直到线程数减少到核心线程数。
JDK 线程池的局限性
从上面的介绍,我们可以看到JDK 原生 ThreadPoolExecutor
的任务处理策略为 “核心线程→任务队列→非核心线程”,这在高并发 I/O 场景中存在明显不足。
问题场景:当系统面临突发流量时,JDK 线程池会先填满队列(如 LinkedBlockingQueue
无界队列可能导致 OOM),而非优先利用最大线程数处理请求。例如:
核心线程数设置为 10,最大线程数为 100,队列长度为 1000。
当瞬间涌入 1000 个请求时,前 10 个请求由核心线程处理,接下来 990 个进入队列,因队列未满所以未创建非核心线程 —— 此时队列中的990个请求堆积在队列中导致严重延迟。
所以tomcat为了解决这个问题,修改任务处理策略为“核心线程→非核心线程→任务队列”,这样的话当系统涌入大量请求时,可以立即创建最大线程数的线程去处理请求,如果最大线程数仍然处理不过来才会进入队列。
Tomcat 线程池扩展的实现细节
关键类与接口分析
在 Tomcat 对线程池的扩展中,StandardThreadExecutor
类扮演着至关重要的角色,它是 Tomcat 线程池实现的核心类之一。StandardThreadExecutor
类实现了org.apache.catalina.Executor
接口,该接口继承自java.util.concurrent.Executor
,这使得StandardThreadExecutor
具备了执行任务的基本能力,同时还融入了 Tomcat 的生命周期管理机制,使其能够更好地与 Tomcat 容器的整体架构相融合。
StandardThreadExecutor
类包含了一系列关键属性,这些属性共同决定了线程池的行为和性能:
线程优先级(threadPriority):默认值为Thread.NORM_PRIORITY
,即 5。线程优先级决定了线程在竞争 CPU 资源时的相对优先级,优先级较高的线程有更大的机会获得 CPU 时间片来执行任务。在一个包含多种任务类型的 Web 应用中,如同时存在实时数据处理任务和普通页面渲染任务,可以将实时数据处理任务所在线程的优先级适当提高,以确保其能够及时响应,而普通页面渲染任务的线程优先级则保持默认,避免高优先级线程过多占用资源导致低优先级线程饥饿。
守护线程标志(daemon):默认值为true
,表示线程池中的线程为守护线程。守护线程的特点是当 Java 虚拟机中所有的非守护线程都结束时,守护线程会自动结束,不会阻止虚拟机的关闭。在 Tomcat 中,将线程设置为守护线程可以确保在 Tomcat 容器关闭时,线程池中的线程能够自动退出,不会残留影响系统的正常关闭。
线程名称前缀(namePrefix):默认值为"tomcat - exec -"
。线程名称前缀用于为线程池中的线程命名,通过设置统一的前缀,可以方便在日志记录和调试过程中快速识别出这些线程属于 Tomcat 线程池,便于追踪和分析线程的执行情况。
最大线程数(maxThreads):默认值为 200,它限制了线程池中允许存在的最大线程数量。在高并发场景下,合理设置最大线程数至关重要。如果设置过小,当大量请求涌入时,可能会导致请求无法及时处理,出现请求排队等待甚至超时的情况;如果设置过大,过多的线程会增加系统的上下文切换开销,消耗大量的系统资源,反而降低系统性能。
最小空闲线程数(minSpareThreads):默认值为 25,它表示线程池中始终保持存活的最小线程数量,即使这些线程处于空闲状态也不会被销毁。这些最小空闲线程可以随时响应新的任务请求,减少了线程创建的时间开销,提高了系统的响应速度。在一个电商应用中,在业务低谷期也可能会有少量用户访问,保持一定数量的最小空闲线程可以快速处理这些请求,提升用户体验。
最大空闲时间(maxIdleTime):默认值为 60000 毫秒(即 60 秒),当线程池中的线程空闲时间超过这个值时,且线程数大于最小空闲线程数,多余的空闲线程会被回收。通过设置合理的最大空闲时间,可以在系统负载较低时及时释放不再使用的线程资源,避免资源浪费。
最大队列大小(maxQueueSize):默认值为Integer.MAX_VALUE
,表示任务队列的最大容量。当线程池中的线程都在忙碌,且任务队列未满时,新提交的任务会被放入任务队列中等待执行。在实际应用中,需要根据系统的负载和任务特点来合理设置队列大小,如果队列过小,可能会导致任务被拒绝;如果队列过大,可能会导致任务在队列中长时间等待,影响系统的响应及时性。
线程重生延迟时间(threadRenewalDelay):默认值为org.apache.tomcat.util.threads.Constants.DEFAULT_THREAD_RENEWAL_DELAY
,它用于设置在上下文停止后,线程池中线程更新的延迟时间。在 Tomcat 的一些场景中,当上下文发生变化时,可能需要对线程池中的线程进行更新,设置这个延迟时间可以避免同时更新所有线程,减少系统的瞬时压力。
任务队列(taskqueue):类型为TaskQueue
,是LinkedBlockingQueue
的子类,用于存储等待执行的任务。TaskQueue
对offer
方法进行了重写,以实现与 Tomcat 线程池独特的任务处理逻辑相匹配,这部分内容将在后续详细分析。
核心线程池实例(executor):类型为ThreadPoolExecutor
,负责管理线程的创建和任务的调度。它是 Tomcat 线程池的核心组件,通过对其参数的配置和方法的调用,实现了线程池的各种功能。
任务队列的定制
Tomcat 定义了TaskQueue
类,它继承自LinkedBlockingQueue
,是 Tomcat 线程池扩展的重要组成部分。TaskQueue
对LinkedBlockingQueue
的offer
方法进行了重写,这一重写是理解 Tomcat 线程池独特行为的关键。
TaskQueue
的offer
方法实现如下:
@Override
public boolean offer(Runnable o) {
//we can't do any checks
// 如果所属线程池为空,直接调用父类offer方法
if (parent==null) {
return super.offer(o);
}
// 如果当前线程数达到最大线程数,直接调用父类offer方法将任务入队
if (parent.getPoolSize() == parent.getMaximumPoolSize()) {
return super.offer(o);
}
// 如果已提交任务数小于等于当前线程数,说明有空闲线程,直接调用父类offer方法入队
if (parent.getSubmittedCount()<=(parent.getPoolSize())) {
return super.offer(o);
}
// 如果当前线程数小于最大线程数,返回false,尝试创建新线程
if (parent.getPoolSize()<parent.getMaximumPoolSize()) {
return false;
}
// 其他情况,调用父类offer方法将任务入队
return super.offer(o);
}
在上述代码中,parent
指向所属的ThreadPoolExecutor
实例。当offer
方法被调用时,首先判断parent
是否为空,如果为空则直接调用父类的offer
方法。然后判断当前线程池中的线程数是否达到最大线程数,如果达到,则直接将任务放入队列,因为此时无法再创建新线程,只能依靠队列来存储任务。接着判断已提交任务数是否小于等于当前线程数,如果是,说明当前线程池中有空闲线程,直接将任务放入队列,这些空闲线程可以立即处理新任务。如果当前线程数小于最大线程数,offer
方法返回false
,这会促使线程池尝试创建新的线程来执行任务,而不是将任务放入队列等待。只有在其他条件都不满足的情况下,才会将任务放入队列。
这种定制化的offer
方法逻辑,使得 Tomcat 线程池在处理任务时,优先创建新线程来处理任务,而不是将任务放入队列等待,从而更适合 I/O 密集型任务的处理。在 I/O 密集型任务中,线程大部分时间处于等待 I/O 操作完成的阻塞状态,如果任务都被放入队列等待,会导致线程资源得不到充分利用,而 Tomcat 通过这种方式,能够更快速地响应任务请求,提高系统的并发处理能力。
线程池执行逻辑重写
Tomcat 线程池对execute
方法进行了重写,以实现其独特的任务处理逻辑和重试策略。在 Tomcat 的ThreadPoolExecutor
类中,execute
方法的重写逻辑如下:
public void execute(Runnable command, long timeout, TimeUnit unit) {
// 提交任务计数加1
submittedCount.incrementAndGet();
try {
// 提交任务
executeInternal(command);
// 当任务被拒绝时
} catch (RejectedExecutionException rx) {
// 如果任务队列是TaskQueue类型
if (getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue) getQueue();
try {
// 尝试将任务强制放入队列
if (!queue.force(command, timeout, unit)) {
// 如果放入失败,提交任务计数减1,并抛出异常
submittedCount.decrementAndGet();
throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull"));
}
} catch (InterruptedException x) {
// 如果在放入队列过程中发生中断,提交任务计数减1,并抛出异常
submittedCount.decrementAndGet();
throw new RejectedExecutionException(x);
}
} else {
// 如果任务队列不是TaskQueue类型,提交任务计数减1,并抛出异常
submittedCount.decrementAndGet();
throw rx;
}
}
}
当调用execute
方法提交任务时,首先会将submittedCount
(记录已提交但尚未完成的任务数的原子变量)增加 1,表示有新任务提交。然后尝试调用父类的execute
方法来执行任务。如果父类的execute
方法抛出RejectedExecutionException
异常,说明任务提交失败,此时会判断任务队列是否为TaskQueue
类型。如果是TaskQueue
类型,会尝试调用queue.force(command, timeout, unit)
方法将任务强制放入队列,这里的force
方法实际上是调用了super.offer(command, timeout, unit)
,即调用父类LinkedBlockingQueue
的带超时时间的offer
方法,尝试在指定的时间内将任务放入队列。如果放入失败,说明队列已满,此时将submittedCount
减 1,并抛出RejectedExecutionException
异常。如果在放入队列的过程中发生InterruptedException
异常,同样将submittedCount
减 1,并抛出RejectedExecutionException
异常。如果任务队列不是TaskQueue
类型,则直接将submittedCount
减 1,并抛出捕获到的RejectedExecutionException
异常。
这种重写的execute
方法实现了一种重试策略,当任务被拒绝时,会尝试将任务放入队列,而不是立即执行拒绝策略,这在一定程度上提高了任务处理的成功率,使得 Tomcat 线程池在面对高并发请求时,能够更加灵活地处理任务,减少任务丢失的可能性,增强了系统的稳定性和可靠性。
优化建议
参数优化策略
最大线程数(maxThreads):应根据服务器的硬件配置和实际业务负载来合理设置。如果设置过小,在高并发情况下,可能会导致请求无法及时处理,出现大量请求排队等待甚至超时的情况;如果设置过大,过多的线程会增加系统的上下文切换开销,消耗大量的系统资源,反而降低系统性能。一般来说,可以通过性能测试逐步调整该参数,找到一个平衡点。例如,在一个拥有 8 核心 CPU 和 16GB 内存的服务器上,对于中等并发量的 Web 应用,可以先将最大线程数设置为 200,然后根据实际运行情况和性能指标进行调整。
最小空闲线程数(minSpareThreads):它表示线程池中始终保持存活的最小线程数量。设置合适的最小空闲线程数可以减少线程创建的时间开销,提高系统的响应速度。如果设置过小,在请求量突然增加时,可能会因为需要临时创建线程而导致响应延迟;如果设置过大,会浪费系统资源。对于大多数 Web 应用,建议根据平均请求量来设置该参数,一般可以设置为 20 - 50 之间。例如,对于一个平均每秒处理 50 个请求的应用,可以将最小空闲线程数设置为 30,以确保在请求量波动时,系统能够快速响应。
最大空闲时间(maxIdleTime):当线程池中的线程空闲时间超过这个值时,且线程数大于最小空闲线程数,多余的空闲线程会被回收。合理设置最大空闲时间可以在系统负载较低时及时释放不再使用的线程资源,避免资源浪费。一般可以将其设置为 60 - 120 秒之间。例如,在一个业务量有明显高峰和低谷的电商应用中,在低谷期,将最大空闲时间设置为 90 秒,可以有效地回收闲置线程,节省系统资源。
最大队列大小(maxQueueSize):它决定了任务队列的最大容量。如果设置过小,当线程池中的线程都在忙碌时,新提交的任务可能会因为队列已满而被拒绝;如果设置过大,可能会导致任务在队列中长时间等待,影响系统的响应及时性。在实际应用中,需要根据业务场景和请求处理时间来设置该参数。对于一些对响应时间要求较高的实时应用,建议将队列大小设置为较小的值,如 100 - 200;对于一些允许一定延迟的批量处理任务,可以适当增大队列大小,如 500 - 1000。例如,在一个实时聊天应用中,将最大队列大小设置为 150,确保新的聊天消息能够及时被处理,避免消息积压;而在一个批量数据处理任务中,将队列大小设置为 800,允许一定数量的任务在队列中等待处理,同时不会因为队列过大而导致任务处理延迟过长。
通过合理调整这些参数,可以使 Tomcat 线程池更好地适应不同的业务场景和负载情况,从而显著提升 Web 应用的性能和稳定性。
总结
通过对 Tomcat 线程池扩展的深入剖析,我们全面了解了其实现细节、性能优势以及在高并发场景下的重要作用。Tomcat 线程池通过对TaskQueue
的定制,改变了任务入队逻辑,优先创建新线程来处理任务,从而更适合 I/O 密集型任务的处理。同时,重写execute
方法实现的重试策略,有效提高了任务处理的成功率,增强了系统的稳定性。