揭秘 Spring Boot 的心脏:Tomcat 线程池工作原理与调优

63 阅读6分钟

当你使用 spring-boot-starter-web 启动一个 Spring Boot 应用时,你有没有想过,那个在控制台一闪而过的 "Tomcat started on port(s): 8080" 到底意味着什么?

Spring Boot 究竟是如何“凭空”启动一个 Web 服务器的?它又是如何处理成百上千的并发请求的?答案就藏在 内嵌式 Tomcat (Embedded Tomcat) 及其核心的 线程池 里。

1. Tomcat 是什么?它在 Spring Boot 里干什么?

简单来说,Tomcat 是一个 Web 服务器Servlet 容器

在 Spring Boot 诞生之前,我们开发 Web 应用(比如 JSP/Servlet)需要先在服务器上安装一个 Tomcat,然后把我们的项目(打成 .war 包)部署到 Tomcat 的 webapps 目录下去运行。

Spring Boot 彻底改变了这一点。

  • 内嵌 (Embedded): Spring Boot 把 Tomcat “塞” 进了你的项目里(作为一个 jar 包依赖)。
  • 启动: 当你运行 Spring Boot 的 main 方法时,Spring Boot 会在内部自动配置并启动这个内嵌的 Tomcat。
  • 职责: 它的核心职责就一个——处理 HTTP 请求

它的工作流程是:

  1. 监听指定的端口(如 8080)。
  2. 接收来自客户端(浏览器、App等)的 HTTP 请求。
  3. 将请求交给 Spring Boot 的核心处理器 DispatcherServlet
  4. 等待 Spring Boot 处理完业务逻辑。
  5. 接收 Spring Boot 返回的响应,并将其打包成 HTTP 响应,回传给客户端。

2. Tomcat 线程池:它和 Java 的 ThreadPoolExecutor 是一回事吗?

这是个非常关键的问题。答案是:不完全是。

Tomcat 的线程池并不是我们平时用 new ThreadPoolExecutor(...) 创建的那个标准 JUC 线程池,尽管它们都实现了 JUC 的 Executor 接口。

Tomcat 内部有一个定制的线程池实现(例如 org.apache.tomcat.util.threads.ThreadPoolExecutor),它针对 Web 服务的场景(大量、短时间的请求)做了专门优化。

核心区别:Tomcat 线程池的工作策略

标准 JUC 线程池(java.util.concurrent):

  1. 请求来了,如果核心线程(corePoolSize) 没满,就创建新线程。
  2. 如果核心线程满了,就把请求放进阻塞队列(BlockingQueue)
  3. 如果阻塞队列也满了,就看最大线程(maximumPoolSize) 是否已满。
  4. 如果没满,就创建线程(救急线程)来处理。
  5. 如果最大线程也满了,就执行拒绝策略。

它的策略是:优先用满核心线程 -> 其次填满队列 -> 最后才扩容到最大线程。

Tomcat 线程池(默认策略):

  1. 请求来了,看当前线程数是否达到最大线程(max-threads)
  2. 如果没有达到最大线程数,立即创建新线程来处理这个请求。
  3. 如果已经达到了最大线程数,就把请求放进任务队列(TaskQueue)
  4. 如果任务队列也满了,就拒绝请求(通常是返回“连接拒绝”或超时)。

它的策略是:优先扩容到最大线程 -> 其次才使用队列。

为什么 Tomcat 要这么设计?

对于 Web 服务器来说,响应延迟是致命的。标准 JUC 线程池的“先入队”策略会导致请求在队列中等待,增加了响应时间。

Tomcat 的策略更“激进”:它假设 Web 请求都是短平快的,宁愿(在资源允许内)多创建几个线程来立即处理请求,也不愿意让请求在队列里排队。

3. 核心参数配置与 "说法"

在 Spring Boot 的 application.propertiesapplication.yml 中,我们可以通过 server.tomcat.threads 来设置这些核心参数。

# 1. 最大工作线程数 (默认 200)
server.tomcat.threads.max=200

# 2. 最小备用线程数 (默认 10)
server.tomcat.threads.min-spare=10

# 3. 最大连接排队数 (默认 100)
server.tomcat.accept-count=100

# 4. 最大连接数 (默认 8192)
server.tomcat.max-connections=8192

这些参数设置有什么“说法”吗?当然有!调优的本质就是理解它们。

(1) server.tomcat.threads.max (最大线程数)

  • 这是什么? 这是 Tomcat 线程池的“天花板”,决定了你的应用同一时刻最多能处理多少个请求。默认值是 200。

  • 有什么说法? 这是最核心、最需要调优的参数。

    • 设置太小: 并发量一上来,线程池瞬间占满,新请求全部进入 accept-count 队列排队,导致响应变慢,甚至超时。
    • 设置太大: 线程本身会占用内存(JVM 栈内存,默认 1MB 左右)。2000 个线程就可能吃掉 2G 内存。同时,大量线程在 CPU 间上下文切换(Context Switching) 的开销会非常大,反而导致性能下降。
  • 调优建议:

    • CPU 密集型应用(例如,大量计算、加密):线程数不宜过高,通常设为 CPU 核心数 * 2 左右,因为 CPU 一直在忙,线程再多也得排队等 CPU。
    • I/O 密集型应用(例如,大量访问数据库、调用外部 API):线程数可以设高一些(如 200、400 甚至更高)。因为线程在等待 I/O(等数据库返回数据)时,CPU 是空闲的,可以切换去处理其他线程的请求。

(2) server.tomcat.threads.min-spare (最小备用线程)

  • 这是什么? 即使没有请求,Tomcat 也会保持这么多“空闲”线程“待命”。默认值是 10。

  • 有什么说法?

    • 这是 Tomcat 的“预热”线程。当突发流量("毛刺")进来时,这些备用线程可以立即顶上去,而不需要经历“创建新线程”的开销。
    • 如果你的应用经常面临突发流量,可以适当调高此值(例如 20 或 50),用少量资源换取更好的瞬时响应能力。

(3) server.tomcat.accept-count (连接排队数)

  • 这是什么? 当所有工作线程(max-threads)都在忙时,新来的连接请求会进入这个队列排队。默认值是 100。

  • 有什么说法?

    • 这是“最后一道防线”。它是一个 TCP 层的 backlog 队列
    • 如果 max-threads 满了,accept-count 队列也满了,那么新来的请求会直接被拒绝(客户端会收到 "Connection refused")。
    • 这个值是你应用处理能力的“缓冲垫”。如果你的应用能很快处理完请求(即 max-threads 很快能释放出来),这个值小一点没关系。如果处理慢,你又不想立即拒绝请求,可以适当调大它,但这也会导致排队请求的平均响应时间变长。

(4) server.tomcat.max-connections (最大连接数)

  • 这是什么? Tomcat 在任何时候允许接收和保持的总连接数。默认 8192。

  • 有什么说法?

    • 不要和 max-threads 搞混。Connection (连接) ≠ Thread (线程)
    • HTTP/1.1 默认有 Keep-Alive 机制,一个连接处理完请求后不会马上关闭,而是会“保持存活”一段时间,等待下一个请求。
    • max-connections 管理的就是这些“正在处理的连接” + “保持存活的连接”。
    • 这个值必须大于等于 max-threads。通常保持默认值 8192 就足够了,它主要用来防止(如 C10K)恶意连接耗尽服务器资源。

4. 总结

  1. Spring Boot 内嵌了 Tomcat 作为默认的 Web 服务器,负责处理 HTTP 请求
  2. Tomcat 的线程池是为 Web 场景定制的,它不是标准的 JUC ThreadPoolExecutor
  3. Tomcat 的策略是优先创建线程到 max-threads,以保证低延迟;而标准 JUC 线程池是优先填满 core-threadsqueue
  4. 调优的核心是 max-threads(处理能力)和 accept-count(缓冲能力),需要根据你的业务是 CPU 密集型还是 I/O 密集型来进行调整。

最后的建议: 不要在没有监控的情况下盲目调优。先使用 Spring Boot Actuator 或 Prometheus 监控 tomcat_threads_busy(繁忙线程)、tomcat_threads_current(当前线程)和 tomcat_connections_current(当前连接),找到瓶颈后再进行调整。