java 线程池

218 阅读9分钟

Java 线程池

参考: blog.csdn.net/zhipengfang…

tech.meituan.com/2020/04/02/…

为什么需要线程池

线程的创建它会开辟本地方法栈、虚拟机栈、程序计数器等线程私有的内存,同时销毁的时候需要销毁以上3个区域,因此频繁的创建和消耗比较消耗系统资源;

当任务量远远大于线程可以处理的任务量的时候,并不能友好拒绝任务。

基于线程的以上两个缺点,为了解决这样的缺点,我们引入了线程池。

什么是线程池

就是使用池化技术来管理线程和使用线程的方式。类似于生活中的一个仓库,当我们需要某个工具的时候直接去仓库取就可以了,用完放回去,下次还能继续使用。而线程池就是放线程的一个“仓库”,需要的时候取,用完放回去,下次还能用,不需要在频繁的创建和销毁线程了。线程池中的任务队列可以很好地管理并执行任务。线程池是长生命周期的,启动后不会主动关闭。

线程池的优点

  1. 可以避免频繁的创建和消耗线程。

  2. 可以更好的管理线程的个数和资源的个数。

  3. 拥有更多的功能,比如线程池可以进行定时任务的执行。

  4. 线程池可以更友好的拒绝不能处理的任务。

线程池工作原则

线程池遵循以下工作原则:

  1. 如果当前线程池中的线程数量小于核心线程数,会直接创建新的线程来执行任务。
  2. 如果当前线程池中的线程数量大于等于核心线程数,但任务队列未满,任务会被放入队列中等待执行。
  3. 如果任务队列已满,但当前线程池中的线程数量小于最大线程数,会创建新的线程来执行任务。
  4. 如果当前线程池中的线程数量达到最大线程数,且任务队列已满,根据指定的拒绝策略来处理无法执行的任务。

线程池的各个参数详细介绍

  1. corePoolSize(核心线程数): corePoolSize表示线程池中的核心线程数量,即线程池中始终保持的活动线程数量。如果当前线程池中的线程数量小于核心线程数,会直接创建新的线程来执行任务。

  2. maximumPoolSize(最大线程数): maximumPoolSize表示线程池中允许的最大线程数量。如果当前线程池中的线程数量大于等于核心线程数,但任务队列未满,任务会被放入队列中等待执行。如果任务队列已满,但当前线程池中的线程数量小于最大线程数,会创建新的线程来执行任务。

  3. keepAliveTime(线程空闲时间): keepAliveTime表示线程空闲时的存活时间。如果线程空闲时间超过keepAliveTime,且线程池中的线程数量大于核心线程数,多余的线程会被销毁,以减少资源消耗。

  4. unit(时间单位): unit是keepAliveTime的时间单位,可以是秒、毫秒、微秒等。通过合理设置keepAliveTime和unit,可以控制线程空闲时间的精度。

  5. workQueue(任务队列): workQueue是用于存放待执行任务的队列。线程池中的线程会从任务队列中获取任务并执行。常用的任务队列有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue等。

  6. threadFactory(线程工厂): threadFactory用于创建新的线程对象。通过实现ThreadFactory接口,可以自定义线程的创建方式。例如,可以设置线程的名称、优先级等。

  7. RejectedExecutionHandler(拒绝策略): RejectedExecutionHandler用于处理线程池无法执行的任务。常见的拒绝策略有四种:

    1. ThreadPoolExecutor.AbortPolicy(默认策略): 当线程池无法执行任务时,会抛出RejectedExecutionException异常。
    2. ThreadPoolExecutor.CallerRunsPolicy: 当线程池无法执行任务时,会将任务返回给调用者来手动处理。
    3. ThreadPoolExecutor.DiscardPolicy: 当线程池无法执行任务时,会直接丢弃任务,不会抛出任何异常。
    4. ThreadPoolExecutor.DiscardOldestPolicy: 当线程池无法执行任务时,会丢弃最旧的任务,然后尝试再次执行当前任务。

ThreadPoolExecutor是如何运行

线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。

任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(1)直接申请线程执行该任务;(2)缓冲到队列中等待线程执行;(3)拒绝该任务。

线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。

Executors 的 四种线程池工具类

  1. FixedThreadPool: FixedThreadPool是一种固定大小的线程池,它的核心线程数和最大线程数是相等的,任务队列使用LinkedBlockingQueue。适用于负载较重且任务数量相对固定的场景,如电商商品批量上架。
  2. CachedThreadPool: CachedThreadPool是一种大小可变的线程池,它的核心线程数为0,最大线程数为Integer.MAX_VALUE,线程空闲时间为60秒,任务队列使用SynchronousQueue。适用于负载较轻且任务数量不确定的场景,如电商订单处理。
  3. SingleThreadPool: SingleThreadPool是一种只有一个线程的线程池,它的核心线程数和最大线程数都是1,任务队列使用LinkedBlockingQueue。适用于需要保证任务按顺序执行的场景,如电商订单状态更新。
  4. ScheduledThreadPool: ScheduledThreadPool是一种定时任务线程池,可以按照指定的时间间隔或延迟来执行任务。适用于需要定时执行任务的场景,比如定时任务调度,如电商秒杀活动定时开启。

为什么不建议用Executors?

不建议使用Executors的最重要的原因是:Executors提供的很多方法默认使用的都是无界的LinkedBlockingQueue(如下图),高负载情境下,无界队列很容易导致OOM,而OOM会导致所有请求都无法处理,这是致命问题。所以强烈建议使用有界队列。 注:LinkedBlockingQueue是有界队列,但是不设置大小的话,就默认为Integer.MAX_VALUE,相当于无界队列了。

Executors 返回的线程池对象的弊端:

  1. FixedThreadPool 和 SingleThreadPool:

允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

  1. CachedThreadPool 和 ScheduledThreadPool:

允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

通过 ThreadPoolExecutor 来创建线程池,那核心参数设置多少合适呢?

《Java 并发编程事件》这本书里介绍的一个线程数计算公式:

Ncpu = CPU 核数

Ucpu = 目标 CPU 利用率,0 <= Ucpu <= 1

W / C = 等待时间 / 计算时间的比例

要程序跑到 CPU 的目标利用率,需要的线程数为:

Nthreads = Ncpu * Ucpu * (1 + W / C)

这公式太偏理论化了,很难实际落地下来,首先很难获取准确的等待时间和计算时间。再着一个服务中会运行着很多线程,比如 Tomcat 有自己的线程池、Dubbo 有自己的线程池、GC 也有自己的后台线程,我们引入的各种框架、中间件都有可能有自己的工作线程,这些线程都会占用 CPU 资源,所以通过此公式计算出来的误差一定很大。

所以说怎么确定线程池大小呢?

其实没有固定答案,需要通过压测不断的动态调整线程池参数,观察 CPU 利用率、系统负载、GC、内存、RT、吞吐量 等各种综合指标数据,来找到一个相对比较合理的值。

所以不要再问设置多少线程合适了,这个问题没有标准答案,需要结合业务场景,设置一系列数据指标,排除可能的干扰因素,注意链路依赖(比如连接池限制、三方接口限流),然后通过不断动态调整线程数,测试找到一个相对合适的值。

线程池咋监控

5种告警规则。

  1. 线程池活跃度告警。活跃度 = activeCount / maximumPoolSize,当活跃度达到配置的阈值时,会进行事前告警。
  2. 队列容量告警。容量使用率 = queueSize / queueCapacity,当队列容量达到配置的阈值时,会进行事前告警。
  3. 拒绝策略告警。当触发拒绝策略时,会进行告警。
  4. 任务执行超时告警。重写 ThreadPoolExecutor 的 afterExecute() 和 beforeExecute(),根据当前时间和开始时间的差值算出任务执行时长,超过配置的阈值会触发告警。
  5. 任务排队超时告警。重写 ThreadPoolExecutor 的 beforeExecute(),记录提交任务时时间,根据当前时间和提交时间的差值算出任务排队时长,超过配置的阈值会触发告警

线程使用需要注意的

  1. OOM 问题。刚开始使用线程都是通过 Executors 创建的,前面说了,这种方式创建的线程池会有发生 OOM 的风险。不要使用Executors 创建线程
  2. 任务执行异常丢失问题。可以通过下述4种方式解决
  1. 在任务代码中增加 try、catch 异常处理
  2. 如果使用的 Future 方式,则可通过 Future 对象的 get 方法接收抛出的异常
  3. 为工作线程设置 setUncaughtExceptionHandler,在 uncaughtException 方法中处理异常
  4. 可以重写 afterExecute(Runnable r, Throwable t) 方法,拿到异常 t
  1. 共享线程池问题。整个服务共享一个全局线程池,导致任务相互影响,耗时长的任务占满资源,短耗时任务得不到执行。同时父子线程间会导致死锁的发生,导致 OOM

  2. 跟 ThreadLocal 配合使用,导致脏数据问题。我们知道 Tomcat 利用线程池来处理收到的请求,会复用线程,如果我们代码中用到了 ThreadLocal,在请求处理完后没有去 remove,那每个请求就有可能获取到之前请求遗留的脏值。

  3. ThreadLocal 在线程池场景下会失效,可以考虑用阿里开源的 Ttl 来解决

以上提到的线程池动态调参、通知告警在开源动态线程池项目 DynamicTp 中已经实现了,可以直接引入到自己项目中使用。

DynamicTp 是一个基于配置中心实现的轻量级动态线程池管理工具,主要功能可以总结为动态调参、通知报警、运行监控、三方包线程池管理等几大类。