Loading... 在负责对线程池优化以及编写自定义拒绝策略时,顺带恶补了这一块的知识。 <!--more--> (以下大部分图片来源于网络) ## Tomcat架构 Tomcat作为一个**「Http 服务器 + Servlet 容器」**,其核心功能主要是: (1)处理 socket 连接,负责将网络字节流与 Request 和 Response 对象的转化; (2)加载和管理 Servlet,以及具体处理 Request 请求; 所以 Tomcat 设计了两个核心组件**连接器(Connector)**和**容器(Container)**。连接器负责对外交流,容器负责内部处理。   * `Server`: 对应的就是一个 Tomcat 实例。 * `Service`: 一个 Tomcat 实例默认一个 Service。 * `Connector`:一个 Service 可能多个 连接器,接受不同连接协议。 * `ProtocolHandler`: 主要处理网络连接和应用层协议,包含了两个重要部件 EndPoint 和 Processor。 * `EndPoint`: 负责网络通信,EndPoint是通信端点,即通信监听的接口,是具体的 Socket 接收和发送处理器,是对传输层的抽象,因此 EndPoint是用来实现 TCP/IP 协议数据读写的,提供字节流给Processor,本质调用操作系统的 socket 接口。 其对应的抽象实现类是 AbstractEndpoint。具体子类有NioEndpoint和 Nio2Endpoint等,不在这里多赘述。 * `Processor`: 负责应用层协议解析,用来实现 HTTP 协议。通过接收来自 EndPoint 的 Socket,读取字节流解析成 Tomcat Request 和 Response 对象。 * `Adapter`: 负责Tomcat Request/Response与ServletRequest/ServletResponse的转换,提供ServletRequest对象给Container容器。 * `Container`: 多个连接器对应一个容器,从父到子分别为:`Engine`、`Host`、`Context`和 `Wrapper`。 Wrapper 表示一个 Servlet ,Context 表示一个 Web 应用程序,而一个 Web 程序可能有多个Servlet;Host 表示一个虚拟主机,或者说一个站点,一个 Tomcat 可以配置多个站点(Host);一个站点( Host) 可以部署多个 Web 应用;Engine 代表 引擎,用于管理多个站点(Host),一个 Service只能有 一个 Engine。这也正是组合模式的体现。 总之,Tomcat利用**组合模式**,也就是模版方法模式,去管理每个组件。 ### 请求定位 Servlet 的过程 既然提到了我们熟悉的Servlet,那我们就理一理它的请求过程: 用户请求的URL通过Mapper 组件的功能定位到一个Servlet。Mapper组件里保存了 Web 应用的配置信息,其实就是容器组件与访问路径的映射关系。 当一个请求到来时,Mapper 组件通过解析请求 URL 里的域名和路径,再到自己保存的 Map 里去查找,就能定位到一个 Servlet。一个请求 URL 最后只会定位到一个 Wrapper容器,也就是一个 Servlet。  首先,HTTP 连接器监听访问的端口(默认8080),既然被Connector连接器监听到,也就是确定了一个Service(可以参考图一),结合上文提到的一个Service只有一个Container容器,也就相当于定位到了它的顶级容器Engine。接着,Mapper 组件就可以通过 URL 中的域名去查找相应的 Host 容器,找到后就可以根据 URL 的路径来匹配相应的 Web 应用的路径,再找到相应的 Context 容器。最后再根据注解类中配置的Servlet 映射路径来找到具体的 Wrapper 和 Servlet。 --- 除此之外,Tomcat中的Lifecycle 生命周期、热加载、类加载器也都值得一看,包括其中涉及到的责任链模式、模版方法模式、策略模式等等都很适合学习,但是因为时间问题都只是囫囵吞枣的过了一遍,这里就不写出来了。 ## Tomcat & Java线程池 Tomcat继承并重写了JAVA原生的java.util.concurrent.ThreadPoolExecutor,增加了一些更有效率的方法,并且定义了默认拒绝策略。 **Java线程池:** ```java public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { ...... } ``` * `corePoolSize`:核心线程数,保留在池中的线程数。即使它们空闲,除非设置了allowCoreThreadTimeOut,不然不会关闭。 * `maximumPoolSize`:队列满后池中允许的最大线程数。 * `keepAliveTime`:如果线程数大于核心数,多余的空闲线程的保持的最长时间会被销毁。 * `TimeUnit`:keepAliveTime 参数的时间单位。 当设置 allowCoreThreadTimeOut(true) 时,线程池中corePoolSize 范围内的线程空闲时间达到keepAliveTime 也将回收。 * `workQueue`:当线程数达到 corePoolSize 后,新增的任务就放到工作队列 workQueue 里,而线程池中的线程则努力地从 workQueue 里拉活来干,也就是调用 poll 方法来获取任务。 * `ThreadFactory`:创建线程的工厂,比如设置是否是后台线程、线程名等。 * `RejectedExecutionHandler`:拒绝策略,处理程序因为达到了线程界限和队列容量执行拒绝策略。也可以自定义拒绝策略,只要实现 RejectedExecutionHandler 即可。默认的拒绝策略:AbortPolicy 拒绝任务并抛出 RejectedExecutionException 异常。  **Tomcat线程池:** org.apache.catalina.core.StandardThreadExecutor: ```java protected void startInternal() throws LifecycleException { taskqueue = new TaskQueue(maxQueueSize); TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority()); executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf); executor.setThreadRenewalDelay(threadRenewalDelay); if (prestartminSpareThreads) { executor.prestartAllCoreThreads(); } taskqueue.setParent(executor); setState(LifecycleState.STARTING); } ``` 从构造方法上看就是稍微换了个名字而已,构造函数的话也有自定义拒绝策略的实现: ```java public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler); prestartAllCoreThreads(); } public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler); prestartAllCoreThreads(); } public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, new RejectHandler()); prestartAllCoreThreads(); } public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, new RejectHandler()); prestartAllCoreThreads(); } ``` 再看看StandardThreadExecutor底下的配置参数以及官方文档中的解释: * `daemon`:以守护进程或非守护进程状态运行线程,默认true。 * `executor`:ThreadPoolExecutor,该组件的执行器,默认null。 * `maxIdleTime`:最大空闲时间(毫秒),默认60000。 * `maxQueueSize`:在拒绝策略拒绝它们之前可以排队的最大个数,默认Integer.MAX_VALUE。 (但是找不到在哪里可以修改它) * `maxThreads`:最大线程数,默认200。 * `minSpareThreads`:最小(核心)线程数,默认25。 * `name`:线程池的名称。 * `namePrefix`:线程名默认前缀,默认"tomcat-exec-"。 * `prestartminSpareThreads`:当服务器启动时,是否要创建出最小空闲线程(核心线程)数量的线程,默认false。 * `threadPriority`:线程优先级,默认Thread.NORM_PRIORITY(5)。 * `threadRenewalDelay`:停止上下文后,池中的线程将被更新时间,默认DEFAULT_THREAD_RENEWAL_DELAY(1000L)。 (在这里并不存在acceptCount,只能从StandardThreadExecutor里用到的org.apache.tomcat.util.threads.ThreadPoolExecutor底下的execute()方法查看到,其command的socketWrapper就存在该字段) **Tomcat的特殊点:** 1. Tomcat 有自己的定制版任务队列和线程工厂,并且可以限制任务队列的长度,它的最大长度是maxQueueSize。(也就是说设置的AcceptCount可能没有改变队列的长度。) 2. Tomcat 对线程数也有限制,虽然TaskQueue是一个无界队列,但是设置了核心线程数(minSpareThreads)和最大线程池数(maxThreads)。 重新理一下两个的异同点: (1)前 corePoolSize 个任务时,来一个任务就创建一个新线程。 (2)还有任务提交,直接放到队列,队列满了,但是没有达到最大线程池数则创建临时线程救火。 (3.1)Java线程池:线程总线数达到 maximumPoolSize ,直接执行拒绝策略。 (3.2)Tomcat线程池:线程总线数达到 maximumPoolSize ,继续尝试把任务放到队列中。如果队列也满了,插入任务失败,才执行拒绝策略。 也就是说,最大的差别在于 Tomcat 在线程总数达到最大数时,不是立即执行拒绝策略,而是再尝试向任务队列添加任务,添加失败后再执行拒绝策略。代码如下: org.apache.tomcat.util.threads.ThreadPoolExecutor: ```java public void execute(Runnable command, long timeout, TimeUnit unit) { // 记录提交任务数 +1 submittedCount.incrementAndGet(); try { // 调用 java 原生线程池来执行任务,当原生抛出拒绝策略 super.execute(command); } catch (RejectedExecutionException rx) { //总线程数达到 maximumPoolSize,Java 原生会执行拒绝策略 if (super.getQueue() instanceof TaskQueue) { final TaskQueue queue = (TaskQueue)super.getQueue(); try { // 尝试把任务放入队列中 if (!queue.force(command, timeout, unit)) { submittedCount.decrementAndGet(); // 队列还是满的,插入失败则执行拒绝策略 throw new RejectedExecutionException("Queue capacity is full."); } } catch (InterruptedException x) { submittedCount.decrementAndGet(); throw new RejectedExecutionException(x); } } else { // 提交任务书 -1 submittedCount.decrementAndGet(); throw rx; } } } ``` org.apache.tomcat.util.threads.TaskQueue则是继承了LinkedBlockingQueue类,主要添加了两个方法force(Runnable o)和force(Runnable o, long timeout, TimeUnit unit),根据线程池状态决定是否调用队列的offer()方法,这里就直接贴出offer(o)的: ```java public boolean offer(Runnable o) { //we can't do any checks if (parent==null) return super.offer(o); //we are maxed out on threads, simply queue the object if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o); //we have idle threads, just add it to the queue if (parent.getSubmittedCount()<=(parent.getPoolSize())) return super.offer(o); //if we have less threads than maximum force creation of a new thread if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false; //if we reached here, we need to add it to the queue return super.offer(o); } ``` 当任务队列满时,父类的execute直接抛出RejectedExecutionException异常: ```java private static class RejectHandler implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, java.util.concurrent.ThreadPoolExecutor executor) { throw new RejectedExecutionException(); } } ``` 总的来说,Tomcat在原生的ThreadPoolExecutor的基础上,做了小范围的修改,部分提升了高并发下的性能,并减小了错误率。比较而言,Jetty自研的QTP与Java原生的线程池差别要大的多。 ## Tomcat连接池优化 **其实连接池和线程池是分不开的,两者的优化是互通的,这里区分也只是意思一下,看起来没那么乱而已** (这块涉及到NIO、BIO就暂时不表,重点在下面这几个参数) ### 主要参数 1、**`acceptCount`:最大等待数** ~~accept队列的长度;当accept队列中连接的个数达到acceptCount时,队列满,进来的请求一律被拒绝。默认值是100。~~ 这段说明其实是有歧义的,为什么是连接的个数?它会让人以为是建立连接时的请求等待队列。 官方文档的说明为:当所有可能的请求处理线程正在使用时,传入连接请求的最大队列长度。队列满时收到的任何请求都将被拒绝。acceptCount默认值为100。 详细的来说:当调用HTTP请求数达到tomcat的最大线程数时,还有新的HTTP请求到来,这时tomcat会将该请求放在等待队列中,这个acceptCount就是指能够接受的最大等待数,默认100。如果等待队列也被放满了,这个时候再来新的请求就会被tomcat拒绝 `connection refused`。 (在项目中测试却发现虽然该配置成功了,但是多余的请求扔一直等待执行) 2、**`maxConnections`:最大连接数** 这个参数是指在同一时间,tomcat能够接受的最大连接数。当Tomcat接收的连接数达到maxConnections时,Acceptor线程不会读取accept队列中的连接;这时accept队列中的线程会一直阻塞着,直到Tomcat接收的连接数小于maxConnections。如果设置为-1,则连接数不受限制。 默认值与连接器使用的协议有关:NIO的默认值是10000,APR/native的默认值是8192,而BIO的默认值为maxThreads(如果配置了Executor,则默认值是Executor的maxThreads)。 (注意:在windows下,APR/native的maxConnections值会自动调整为设置值以下最大的1024的整数倍;如设置为2000,则最大值实际是1024。) 当连接数达到最大值maxConnections后,系统会继续接收连接,但不会超过acceptCount的值。 3、**`maxThreads`:最大线程数** 请求处理线程的最大数量。默认值是200(Tomcat7和8都是的)。如果该Connector绑定了Executor,这个值会被忽略,因为该Connector将使用绑定的Executor,而不是内置的线程池来执行任务。 maxThreads规定的是最大的线程数目,并不是实际running的CPU数量;实际上,maxThreads的大小比CPU核心数量要大得多。这是因为,处理请求的线程真正用于计算的时间可能很少,大多数时间可能在阻塞,如等待数据库返回数据、等待硬盘读写数据等。因此,在某一时刻,只有少数的线程真正的在使用物理CPU,大多数线程都在等待;因此线程数远大于物理核心数才是合理的。 换句话说,Tomcat通过使用比CPU核心数量多得多的线程数,可以使CPU忙碌起来,大大提高CPU的利用率。 --- 通过此图应该能够加深理解:  ### 优化建议 (1)maxThreads数量应该远大于CPU核心数量;而且CPU核心数越大,maxThreads应该越大;应用中CPU越不密集(IO越密集),maxThreads应该越大,以便能够充分利用CPU。但是,增加线程是有成本的,更多的线程,不仅仅会带来更多的线程上下文切换成本,而且意味着带来更多的内存消耗。JVM中默认情况下在创建新线程时会分配大小为1M的线程栈,所以,更多的线程异味着需要更多的内存。 线程数的经验值为:1核2g内存为200,线程数经验值200;4核8g内存,线程数经验值800。 (2)maxConnections的设置与Tomcat的运行模式有关。如果tomcat使用的是BIO,那么maxConnections的值应该与maxThreads一致;如果tomcat使用的是NIO,那么类似于Tomcat的默认值,maxConnections值应该远大于maxThreads。 (3)通过前面的介绍可以知道,虽然tomcat同时可以处理的连接数目是maxConnections,但服务器中可以同时接收的连接数为maxConnections+acceptCount 。acceptCount的设置,与应用在连接过高情况下希望做出什么反应有关系。如果设置过大,后面进入的请求等待时间会很长;如果设置过小,后面进入的请求立马返回connection refused。 ## Tomcat线程池优化 ### 优化参考(评审中) > 场景1:数据统计相关服务: 数据统计相关服务需要做的最多的一件事,数据的聚合,可能来自大宝鉴业务域,其他业务域 比如 用户业务域,社区业务域本身实时性要求 ,并发性要求不高,但是报表统计涉及数据量会很大。问题更多的会出现在数据查询相关,rpc调用相关的问题。比如说查询超时等。 ```java server: tomcat: # 固定线程数 max-threads: 50 min-spare-threads: 50 spring: datasource: # 超时等待时间(毫秒) max-wait: 5000 ``` > 场景2:soa相关服务:并发 ,实时性要求很高 ,通常用于数据实时同步录入大宝鉴业务域,有可能依赖于阿里,数美等第3方平台并提供基础服务。业务通常不会很复杂,不会涉及复杂的查询 。对于复杂业务比如封号操作 我们可以通过服务解耦的方式来解决,涉及到慢soa 调用的我们都需要做优化。 ```java server: tomcat: # 固定线程数 max-threads: 200 min-spare-threads: 200 # accept队列的长度,队列满后会直接拒绝并返回connection refused accept-count: 5000 spring: datasource: # 超时等待时间(毫秒) max-wait: 3000 ``` > 场景3:定时任务相关服务:主要是大量的异步数据同步 ,定时检测。 会有数据的批量导入,这一块会有大量的线程,内存会有较大的影响。 ```java spring: task: # 任务调度线程池配置 scheduling: pool: size: 100 # 任务执行线程池配置 execution: pool: # 最大线程数 max-size: 200 # 核心线程数 core-size: 100 datasource: # 超时等待时间(毫秒) max-wait: 3000 ``` ### 参考公式 `线程池大小 = (线程 I/O 阻塞时间 + 线程 CPU 时间 )/ 线程 CPU 时间` 其中: `线程 I/O 阻塞时间 + 线程 CPU 时间 = 平均请求处理时间` ## 遇到的问题以及一些疑惑 **问题1:** 不知道怎么能让Tomcat线程池 `startInternal()`执行创建时,走的new ThreadPoolExecutor传入自定义的拒绝策略RejectHandler对象。(默认构造方法没有传次对象,会默认创建一个新的RejectHandler) 网上说只要重写RejectedExecutionHandler 即可。但网上都是在main里面new ThreadPoolExecutor,然后传入自定义的handler去检验,这样当然可以了,简直就是牛头不对马嘴。 **问题2:** 我的线程池大小配置以及队列都是生效的(通过prometheus可以查看两者配置存在),但是在项目测试中却没有走默认的拒绝策略,而是等待执行。 猜想可能是在java线程池和Tomcat线程池差异提到的拒绝策略执行流程的差异。 尝试通过重写ThreadPoolExecutor以及替换Tomcat的ExecutorService(获取不到WebServer)都无效,知道的可行方案可能也就是把整个Tomcat线程池的依赖提取出来改成传入自定义handler的方法了。 但是无论怎么看都是绝对不可能被采纳的。这也太蠢了叭。 个人偏向第一点,应该是队列没有满或者是整个项目另外配了策略导致满队列之后其他请求进入等待而不是被拒绝(比如请求连接的时长是mysql默认的十小时?导致一直保持连接等待请求响应?) 从源码可以看到:`taskqueue = new TaskQueue(maxQueueSize);`这个maxQueueSize跟AcceptCount又有什么关联呢。 通过学习发现(上文),acceptCount官方说明就是线程处理等待队列,按道理来说没错啊。 或许还可以通过内嵌Tomcat配置来解决: https://www.cnblogs.com/senlinyang/p/8526633.html --- **问题3:** 在线上遇到了Tomcat内置线程池在繁忙的时候拉满了200,空闲时线程还是无法自动销毁,设置了 `maxIdleTime`无效。 该问题在[Unable to configure maxIdleTime for Tomcat server worker threads](https://github.com/spring-projects/spring-boot/issues/16450)有提及,验证了以前配置不生效的观点,同时还证明了自定义线程池的可行性。但属实是暴力破解。 可以看看后续Tomcat官网有没有给出解决方案,或者采用 `undertow`作为内置Tomcat的替代方案(需验证指标能否被Prometheus监控拉取到)。 Last modification:August 21, 2022 © Allow specification reprint Like 0 喵ฅฅ
2 comments
tomcat最好把max thread和min thread设置成一样的,原理和堆内存配置一样。不然在瞬时流量打进去的时候,会创建大量的线程,导致cpu负载暴增加,而且其他正在执行的线程会出现卡顿。
本篇解析不是很深,而且还有遗留的bug待解决。
你们这几个偷学的,如果有问题和解决方案都可以直接在评论提出来。
(╯‵□′)╯︵┴─┴