Java Web应用调优线程池:没你想的那么复杂
off999 2025-03-07 23:00 14 浏览 0 评论
“
不论你是否关注,Java Web应用都或多或少的使用了线程池来处理请求。线程池的实现细节可能会被忽视,但是有关于线程池的使用和调优迟早是需要了解的。本文由浅入深,介绍了Java线程池的使用,以及正确配置线程池的方法。所谓老司机带路,带你轻松上道。
最简单的单线程
我们先从基础开始。无论使用哪种应用服务器或者框架(如Tomcat、Jetty等),他们都有类似的基础实现。Web服务的基础是套接字(socket),套接字负责监听端口,等待TCP连接,并接受TCP连接。一旦TCP连接被接受,即可从新创建的TCP连接中读取和发送数据。
为了能够理解上述流程,我们不直接使用任何应用服务器,而是从零开始构建一个简单的Web服务。该服务是大部分应用服务器的缩影。一个简单的单线程Web服务大概是这样的:
ServerSocket listener = new ServerSocket(8080);
try {
while (true) {
Socket socket = listener.accept;
try {
handleRequest(socket);
} catch (IOException e) {
e.printStackTrace;
}
}
} finally {
listener.close;
}
上述代码创建了一个服务端套接字(ServerSocket),监听8080端口,然后循环检查这个套接字,查看是否有新的连接。一旦有新的连接被接受,这个套接字会被传入handleRequest方法。这个方法会将数据流解析成HTTP请求,进行响应,并写入响应数据。在这个简单的示例中,handleRequest方法仅仅实现数据流的读入,返回一个简单的响应数据。在通常实现中,该方法还会复杂的多,比如从数据库读取数据等。
final static String response =
“HTTP/1.0 200 OK\r\n” +
“Content-type: text/plain\r\n” +
“\r\n” +
“Hello World\r\n”;
public static void handleRequest(Socket socket) throws IOException {
// Read the input stream, and return “200 OK”
try {
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream));
log.info(in.readLine);
OutputStream out = socket.getOutputStream;
out.write(response.getBytes(StandardCharsets.UTF_8));
} finally {
socket.close;
由于只有一个线程来处理请求,每个请求都必须等待前一个请求处理完成之后才能够被响应。假设一个请求响应时间为100毫秒,那么这个服务器的每秒响应数(tps)只有10。
更进一步,多线程提升
虽然handleRequest方法可能阻塞在IO上,但是CPU仍然可以处理更多的请求。但是在单线程情况下,这是无法做到的。因此,可以通过创建多线程的方式,来提升服务器的并行处理能力。
public static class HandleRequestRunnable implements Runnable {
final Socket socket;
public HandleRequestRunnable(Socket socket) {
this.socket = socket;
}
public void run {
new Thread(new HandleRequestRunnable(socket)).start;
这里,accept方法仍然在主线程中调用,但是一旦TCP连接建立之后,将会创建一个新的线程来处理新的请求,既在新的线程中执行前文中的handleRequest方法。
通过创建新的线程,主线程可以继续接受新的TCP连接,且这些信求可以并行的处理。这个方式称为“每个请求一个线程(thread per request)”。当然,还有其他方式来提高处理性能,例如NGINX和Node.js使用的异步事件驱动模型,但是它们不使用线程池,因此不在本文的讨论范围。
在每个请求一个线程实现中,创建一个线程(和后续的销毁)开销是非常昂贵的,因为JVM和操作系统都需要分配资源。另外,上面的实现还有一个问题,即创建的线程数是不可控的,这将可能导致系统资源被迅速耗尽。
ERROR!资源耗尽
每个线程都需要一定的栈内存空间。在最近的64位JVM中,默认的栈大小是1024KB。如果服务器收到大量请求,或者handleRequest方法执行很慢,服务器可能因为创建了大量线程而崩溃。例如有1000个并行的请求,创建出来的1000个线程需要使用1GB的JVM内存作为线程栈空间。另外,每个线程代码执行过程中创建的对象,还可能会在堆上创建对象。这样的情况恶化下去,将会超出JVM堆内存,并产生大量的垃圾回收操作,最终引发内存溢出(OutOfMemoryErrors)。
这些线程不仅仅会消耗内存,它们还会使用其他有限的资源,例如文件句柄、数据库连接等。不可控的创建线程,还可能引发其他类型的错误和崩溃。因此,避免资源耗尽的一个重要方式,就是避免不可控的数据结构。
顺便说下,由于线程栈大小引发的内存问题,可以通过-Xss开关来调整栈大小。缩小线程栈大小之后,可以减少每个线程的开销,但是可能会引发栈溢出(StackOverflowErrors)。对于一般应用程序而言,默认的1024KB过于富裕,调小为256KB或者512KB可能更为合适。Java允许的最小值是160KB。
解决方案:线程池
为了避免持续创建新线程,可以通过使用简单的线程池来限定线程池的上限。线程池会管理所有线程,如果线程数还没有达到上限,线程池会创建线程到上限,且尽可能复用空闲的线程。
ServerSocket listener = new ServerSocket(8080);
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit( new HandleRequestRunnable(socket) );
在这个示例中,没有直接创建线程,而是使用了ExecutorService。它将需要执行的任务(需要实现Runnables接口)提交到线程池,使用线程池中的线程执行代码。示例中,使用线程数量为4的固定大小线程池来处理所有请求。这限制了处理请求的线程数量,也限制了资源的使用。
除了通过newFixedThreadPool方法创建固定大小线程池,Executors类还提供了newCachedThreadPool方法。复用线程池还是有可能导致不可控的线程数,但是它会尽可能使用之前已经创建的空闲线程。通常该类型线程池适合使用在不会被外部资源阻塞的短任务上。
策略:工作队列
使用了固定大小线程池之后,如果所有的线程都繁忙,再新来一个请求将会发生什么呢?ThreadPoolExecutor使用一个队列来保存等待处理的请求,固定大小线程池默认使用无限制的链表。注意,这又可能引起资源耗尽问题,但只要线程处理的速度大于队列增长的速度就不会发生。然后前面示例中,每个排队的请求都会持有套接字,在一些操作系统中,这将会消耗文件句柄。由于操作系统会限制进程打开的文件句柄数,因此最好限制下工作队列的大小。
public static ExecutorService newBoundedFixedThreadPool(int nThreads, int capacity) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue
new ThreadPoolExecutor.DiscardPolicy);
}
public static void boundedThreadPoolServerSocket throws IOException {
ServerSocket listener = new ServerSocket(8080);
ExecutorService executor = newBoundedFixedThreadPool(4, 16);
这里我们没有直接使用
Executors.newFixedThreadPool方法来创建线程池,而是自己构建了ThreadPoolExecutor对象,并将工作队列长度限制为16个元素。
如果所有的线程都繁忙,新的任务将会填充到队列中,由于队列限制了大小为16个元素,如果超过这个限制,就需要由构造ThreadPoolExecutor对象时的最后一个参数来处理了。示例中,使用了抛弃策略(DiscardPolicy),即当队列到达上限时,将抛弃新来的任务。初次之外,还有中止策略(AbortPolicy)和调用者执行策略(CallerRunsPolicy)。前者将抛出一个异常,而后者会再调用者线程中执行任务。
对于Web应用来说,最优的默认策略应该是抛弃或者中止策略,并返回一个错误给客户端(如HTTP 503错误)。当然也可以通过增加工作队列长度的方式,避免抛弃客户端请求,但是用户请求一般不愿意进行长时间的等待,且这样会更多的消耗服务器资源。工作队列的用途,不是无限制的响应客户端请求,而是平滑突发暴增的请求。通常情况下,工作队列应该是空的。
提升性能:线程数调优
前面的示例展示了如何创建和使用线程池,但是,使用线程池的核心问题在于应该使用多少线程。首先,我们要确保达到线程上限时,不会引起资源耗尽。这里的资源包括内存(堆和栈)、打开文件句柄数量、TCP连接数、远程数据库连接数和其他有限的资源。特别的,如果线程任务是计算密集型的,CPU核心数量也是资源限制之一,一般情况下线程数量不要超过CPU核心数量。
由于线程数的选定依赖于应用程序的类型,可能需要经过大量性能测试之后,才能得出最优的结果。当然,也可以通过增加资源数的方式,来提升应用程序的性能。例如,修改JVM堆内存大小,或者修改操作系统的文件句柄上限等。然后,这些调整最终还是会触及理论上限。
利特尔法则
利特尔法则描述了在稳定系统中,三个变量之间的关系。
其中L表示平均请求数量,λ表示请求的频率,W表示响应请求的平均时间。举例来说,如果每秒请求数为10次,每个请求处理时间为1秒,那么在任何时刻都有10个请求正在被处理。回到我们的话题,就是需要使用10个线程来进行处理。如果单个请求的处理时间翻倍,那么处理的线程数也要翻倍,变成20个。
理解了处理时间对于请求处理效率的影响之后,我们会发现,通常理论上限可能不是线程池大小的最佳值。线程池上限还需要参考任务处理时间。
假设JVM可以并行处理1000个任务,如果每个请求处理时间不超过30秒,那么在最坏情况下,每秒最多只能处理33.3个请求。然而,如果每个请求只需要500毫秒,那么应用程序每秒可以处理2000个请求。
拆分线程池
在微服务或者面向服务架构(SOA)中,通常需要访问多个后端服务。如果其中一个服务性能下降,可能会引起线程池线程耗尽,从而影响对其他服务的请求。
应对后端服务失效的有效办法是隔离每个服务所使用的线程池。在这种模式下,仍然有一个分派的线程池,将任务分派到不同的后端请求线程池中。该线程池可能因为一个缓慢的后端而没有负载,而将负担转移到了请求缓慢后端的线程池中。
另外,多线程池模式还需要避免死锁问题。如果每个线程都阻塞在等待未被处理请求的结果上时,就会发生死锁。因此,多线程池模式下,需要了解每个线程池执行的任务和它们之间的依赖,这样可以尽可能避免死锁问题。
总结
即使没有在应用程序中直接使用线程池,它们也很有可能在应用程序中被应用服务器或者框架间接使用。Tomcat、JBoss、Undertow、Dropwizard等框架,都提供了调优线程池(servlet执行使用的线程池)的选项。
好了,老司机就说到这里。希望现在你可以对线程池有了一定的了解和兴趣,通过了解应用的需求,组合最大线程数和平均响应时间,可以得出一个合适的线程池配置。
小Q说
“团战里的新手总是被骂猪队友,为什么?就是因为新手没人带着练。有老司机带路,总好过一个人苦苦摸索。而InfoQ,就是一列免费班车、满载着热忱服务的老司机。每天早上八点,准时到站,等你。”
本文系InfoQ原创首发,未经授权谢绝转载。
InfoQ原创翻译编辑&技术编辑招募中,简历快砸来:editors@cn.infoq.com
相关推荐
- Python钩子函数实现事件驱动系统(created钩子函数)
-
钩子函数(HookFunction)是现代软件开发中一个重要的设计模式,它允许开发者在特定事件发生时自动执行预定义的代码。在Python生态系统中,钩子函数广泛应用于框架开发、插件系统、事件处理和中...
- Python函数(python函数题库及答案)
-
定义和基本内容def函数名(传入参数):函数体return返回值注意:参数、返回值如果不需要,可以省略。函数必须先定义后使用。参数之间使用逗号进行分割,传入的时候,按照顺序传入...
- Python技能:Pathlib面向对象操作路径,比os.path更现代!
-
在Python编程中,文件和目录的操作是日常中不可或缺的一部分。虽然,这么久以来,钢铁老豆也还是习惯性地使用os、shutil模块的函数式API,这两个模块虽然功能强大,但在某些情况下还是显得笨重,不...
- 使用Python实现智能物流系统优化与路径规划
-
阅读文章前辛苦您点下“关注”,方便讨论和分享,为了回馈您的支持,我将每日更新优质内容。在现代物流系统中,优化运输路径和提高配送效率是至关重要的。本文将介绍如何使用Python实现智能物流系统的优化与路...
- Python if 语句的系统化学习路径(python里的if语句案例)
-
以下是针对Pythonif语句的系统化学习路径,从零基础到灵活应用分为4个阶段,包含具体练习项目和避坑指南:一、基础认知阶段(1-2天)目标:理解条件判断的逻辑本质核心语法结构if条件:...
- [Python] FastAPI基础:Path路径参数用法解析与实例
-
查询query参数(上一篇)路径path参数(本篇)请求体body参数(下一篇)请求头header参数本篇项目目录结构:1.路径参数路径参数是URL地址的一部分,是必填的。路径参...
- Python小案例55- os模块执行文件路径
-
在Python中,我们可以使用os模块来执行文件路径操作。os模块提供了许多函数,用于处理文件和目录路径。获取当前工作目录(CurrentWorkingDirectory,CWD):使用os....
- python:os.path - 常用路径操作模块
-
应该是所有程序都需要用到的路径操作,不废话,直接开始以下是常用总结,当你想做路径相关时,首先应该想到的是这个模块,并知道这个模块有哪些主要功能,获取、分割、拼接、判断、获取文件属性。1、路径获取2、路...
- 原来如此:Python居然有6种模块路径搜索方式
-
点赞、收藏、加关注,下次找我不迷路当我们使用import语句导入模块时,Python是怎么找到这些模块的呢?今天我就带大家深入了解Python的6种模块路径搜索方式。一、Python模块...
- 每天10分钟,python进阶(25)(python进阶视频)
-
首先明确学习目标,今天的目标是继续python中实例开发项目--飞机大战今天任务进行面向对象版的飞机大战开发--游戏代码整编目标:完善整串代码,提供完整游戏代码历时25天,首先要看成品,坚持才有收获i...
- python 打地鼠小游戏(打地鼠python程序设计说明)
-
给大家分享一段AI自动生成的代码(在这个游戏中,玩家需要在有限时间内打中尽可能多的出现在地图上的地鼠),由于我现在用的这个电脑没有安装sublime或pycharm等工具,所以还没有测试,有兴趣的朋友...
- python线程之十:线程 threading 最终总结
-
小伙伴们,到今天threading模块彻底讲完。现在全面总结threading模块1、threading模块有自己的方法详细点击【threading模块的方法】threading模块:较低级...
- Python信号处理实战:使用signal模块响应系统事件
-
信号是操作系统用来通知进程发生了某个事件的一种异步通信方式。在Python中,标准库的signal模块提供了处理这些系统信号的机制。信号通常由外部事件触发,例如用户按下Ctrl+C、子进程终止或系统资...
- Python多线程:让程序 “多线作战” 的秘密武器
-
一、什么是多线程?在日常生活中,我们可以一边听音乐一边浏览新闻,这就是“多任务处理”。在Python编程里,多线程同样允许程序同时执行多个任务,从而提升程序的执行效率和响应速度。不过,Python...
- 用python写游戏之200行代码写个数字华容道
-
今天来分析一个益智游戏,数字华容道。当初对这个游戏颇有印象还是在最强大脑节目上面,何猷君以几十秒就完成了这个游戏。前几天写2048的时候,又想起了这个游戏,想着来研究一下。游戏玩法用尽量少的步数,尽量...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- python计时 (73)
- python安装路径 (56)
- python类型转换 (93)
- python自定义函数 (53)
- python进度条 (67)
- python吧 (67)
- python字典遍历 (54)
- python的for循环 (65)
- python格式化字符串 (61)
- python静态方法 (57)
- python串口编程 (60)
- python读取文件夹下所有文件 (59)
- java调用python脚本 (56)
- python操作mysql数据库 (66)
- python字典增加键值对 (53)
- python获取列表的长度 (64)
- python接口 (63)
- python调用函数 (57)
- python人脸识别 (54)
- python多态 (60)
- python匿名函数 (59)
- python打印九九乘法表 (65)
- python赋值 (62)
- python异常 (69)
- python元祖 (57)