java 线程池使用指南
专业在线打字练习网站-巧手打字通,只输出有价值的知识。
一 前言
线程池作为初学者常感困惑的一个领域,本次“巧手打字通课堂”将深入剖析其中几个最为普遍的误区。为了更清晰地阐述这些知识点,让我们以一个具体定义的线程池为例来展开说明。如下:
二 线程池创建时机的误解
问题:如果往线程池提交 120 个任务(假设提交的过程中没有任务执行完成退出的情况),正常情况下会有多少个活跃线程,队列里有多少个任务?
解答这个问题的关键在于深入理解线程池底层的运作机制。具体而言,核心线程数、最大线程数以及它们与任务队列之间的协同工作过程,可以通过参考下图的详细说明来获得更清晰的认知:
建议:在处理前台流量密集的业务网关系统时,一个优化的策略是将核心线程数与最大线程数设置为相等值。这一举措旨在避免当系统接近线程扩展的阈值时,因频繁地创建和销毁线程池而导致的服务响应波动,即所谓的“服务响应毛刺”。这种做法背后的逻辑与 JVM 中建议将-Xms(初始堆内存大小)和-Xmx(最大堆内存大小)参数配置为相等值相类似,都是为了减少因资源动态调整带来的性能波动,确保系统稳定运行。
三 线程数越多越好吗?
1. 线程的数量并非越多越好
具体原因可以归结为以下几点:
每个线程的创建都会消耗系统的内存资源。根据 JVM 规范,默认情况下,每个线程的栈大小被限制在约 1MB(这一值可通过 JVM 启动参数-Xss 进行调整)。因此,当线程数量过多时,会显著增加内存的消耗,影响系统资源的有效利用。
如果线程的创建与销毁所需的时间总和超过了实际执行任务的时间,那么创建额外的线程便显得毫无意义,反而会增加系统的负担。
过多的线程还可能导致操作系统频繁地进行线程上下文切换,这不仅会增加 CPU 的开销,还会减少 CPU 有效执行用户代码的时间,从而对系统性能产生不利影响。
2. 那设置多少线程数合适呢?
根据 Little's Law:一个系统请求数等于请求的到达率与平均每个单独请求花费的时间之乘积。
系统平均请求数,估算公式如下:
3. 举个例子:
当服务器拥有 8 核 CPU 时,若一个任务线程的 CPU 执行时间为 20 毫秒,而线程因等待(如网络 IO、磁盘 IO)所耗费的时间为 80 毫秒,理论上最佳线程数的计算方式为:(等待时间 + CPU 时间) / CPU 时间 * CPU 核数 = (80ms + 20ms) / 20ms * 8 = 40。这意味着,在不考虑其他系统负载和资源竞争的情况下,设置 40 个线程可能达到最佳性能。然而,这一结论仅基于理论计算,实际部署时需根据系统具体表现进行调整。
值得注意的是,一个复杂的系统中往往部署了多个线程池,它们之间会争夺 CPU、网络带宽、内存等宝贵资源。因此,最佳线程数的设定还需综合考虑系统整体的负载状况、资源利用率以及各任务的实际执行特性,通过性能测试来验证并优化。
四 线程池队列长度设置多少合适?
不当的线程池队列配置会引发严重后果,轻者导致任务执行延迟,用户无法及时获取结果;重者则可能因内存耗尽而引发 OOM(OutOfMemoryError)错误。为避免这些问题,以下是关于如何合理设置队列长度的几点建议:
明确指定队列大小:避免使用默认的最大值(如 Integer.MAX_VALUE),因为这可能导致无限制的内存占用,最终引发内存溢出。明确设定一个合理的队列长度限制是预防此类问题的关键。
基于实际场景调整队列大小:对于无严格运行时间限制的任务,虽然可以设置较大的队列以容纳更多任务,但应同时考虑系统稳定性及异常情况下的任务保护,比如系统重启可能导致任务丢失。因此,在增大队列时,需权衡任务持久性与系统安全。
面向 C 端用户的任务需精细计算队列大小:针对有严格响应时间要求的任务,如面向 C 端用户的服务,需根据任务执行速度和服务超时时间精确计算队列容量。例如,若核心线程数为 20,单任务执行时长为 500ms,服务承诺的响应超时时长为 2000ms,则队列大小可计算为 20*((2000/500)-1)=60。这样既能确保在超时前任务有机会被处理,又避免了队列过长导致的请求超时失效问题,从而保持服务响应的有效性和及时性。
五 丢弃策略也有坑
问题一:拒绝策略设置为 DiscardPolicy 或 DiscardOldestPolicy 与 Future 对象调用 get()方法的阻塞问题
在 Java 的并发编程中,线程池(如 ExecutorService)是一个强大的工具,用于管理一组并发执行的线程。然而,当线程池达到其最大容量时,新提交的任务需要被处理,这通常通过拒绝策略(RejectedExecutionHandler)来定义。DiscardPolicy 和 DiscardOldestPolicy 是两种常见的拒绝策略,它们分别代表直接丢弃新任务和丢弃队列中最旧的任务,而不进行任何形式的通知或处理。
问题剖析
DiscardPolicy:当线程池无法接受新任务时,此策略会静默地丢弃新提交的任务,不抛出异常也不返回任何错误。这意味着,如果你依赖于任务的执行结果,并且没有通过其他方式监控任务的提交状态,你可能会丢失重要任务而不自知。
DiscardOldestPolicy:与 DiscardPolicy 不同,这个策略会尝试通过丢弃队列中等待时间最长的任务来为新任务腾出空间。然而,同样地,它也不会对任务提交者提供任何反馈,除非你有额外的机制来追踪任务的执行状态。
Future 对象的 get()方法阻塞问题当使用上述任一拒绝策略,并且存在被拒绝的任务时,如果你尝试通过之前提交任务获得的 Future 对象调用 get()方法来获取结果,可能会遇到线程被无限期阻塞的情况。这是因为 get()方法会等待任务完成并返回其结果,但如果任务实际上从未被执行(因为被丢弃了),那么调用线程就会一直等待,除非设置了超时时间。
设置超时时间:在调用 Future.get()时,应该始终指定一个超时时间(如使用 get(long timeout, TimeUnit unit)),以防止线程无限期等待。
监控线程池状态:定期监控线程池的状态(如队列大小、活跃线程数等),以便在必要时采取措施,如调整线程池大小或优化任务处理逻辑。
使用其他拒绝策略:如果任务丢失是不可接受的,可以考虑使用 CallerRunsPolicy(在提交任务的线程中直接执行)或自定义的拒绝策略,这些策略可以提供更明确的反馈或处理逻辑。
问题二:Future 对象未调用 get()方法与任务异常的感知
当使用 ExecutorService.submit()提交任务时,该方法会返回一个 Future 对象,该对象代表了异步计算的结果。然而,如果任务执行过程中抛出了异常,并且你没有在任务内部捕获这些异常,也没有通过调用 Future.get()方法来获取结果,那么这些异常信息在线程池外部是无法被感知到的。
1. 问题详解:
异常丢失:如果任务中发生了异常且未被捕获,这个异常将会被封装在 ExecutionException 中,并在调用 Future.get()时抛出。但是,如果 get()方法从未被调用,那么这个异常就会默默地丢失,导致你无法得知任务执行失败的原因。
2. 建议与示例:
在任务中捕获异常:在任务内部使用 try-catch 块来捕获并处理可能发生的异常。这可以通过打印日志、发送告警等方式来实现,以便在任务失败时能够及时发现并处理。
调用 get()并指定超时时间:即使你在任务内部已经处理了异常,仍然建议调用 Future.get(long timeout, TimeUnit unit)来获取结果,并处理可能抛出的 ExecutionException,以确保所有异常情况都能被妥善处理。
六 谨防多业务的线程池共享
多条业务线共用单一的线程池资源,潜藏着多重隐患:
难以兼顾各业务线的独特需求,使得线程池的优化变得复杂而低效;
一旦某个业务的任务处理出现问题,其低下的效率或错误处理可能波及并影响其他业务线的任务执行效率与稳定性;
在问题排查阶段,由于线程池共享,难以直接通过线程池名称等常规手段迅速定位到具体业务线的问题所在。
因此,推荐采取线程池隔离策略,从设计之初就确保各条业务线的任务处理在独立的线程池环境中进行,以此保障它们之间互不干扰,各自稳定运行。
七 其他潜在风险
ThreadLocal 与线程池结合使用时的信息错乱:由于线程池中的线程会被复用,若这些线程内使用了 ThreadLocal 来存储数据,那么在线程被重新分配给不同任务时,可能会导致之前存储的信息被错误地访问或修改,进而引发数据错乱的问题。
业务线中父子线程池嵌套导致的阻塞:在复杂的业务逻辑中,若存在父子线程池相互嵌套使用的情况,可能会因为子线程池的阻塞或异常而影响到父线程池的正常运行,甚至导致整个业务流程在单点处发生阻塞,影响系统整体的性能和稳定性。
线程池资源闲置与浪费:在某些业务场景下,线程池被初始化后却并未得到充分利用,特别是在某些业务功能下线或调整时,这些闲置的线程池仍然占用着系统资源,造成不必要的资源浪费。
实时创建线程池导致的效率低下:在请求处理过程中实时创建线程池,不仅无法发挥线程复用的优势,还可能因为频繁地创建和销毁线程而增加系统的开销。建议将线程池定义为静态共享变量,在应用启动时或初始化阶段进行创建,以便在整个应用生命周期内复用,从而提高性能和资源利用率。
版权声明: 本文为 InfoQ 作者【巧手打字通】的原创文章。
原文链接:【http://xie.infoq.cn/article/ac6d36494be527a777e69d62b】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论