vlambda博客
学习文章列表

由于不知道Java线程池的bug,某程序员叕被祭天

我们会使用各种池化技术缓存 创建性能开销较大的 对象,比如线程池、连接池、内存池。
它们的原理都是预先创建一些对象入池,使用时直接取出,用完归还以复用,还会通过策略调整池中缓存对象的数量,实现动态伸缩性。

由于线程的创建比较昂贵,短平快的任务一般考虑使用线程池处理,而非直接创建线程。

手动声明线程池

JDK的Executors工具类定义了很多便捷的方法可以快速创建线程池。

但是阿里有话说:
由于不知道Java线程池的bug,某程序员叕被祭天
我们来看他说的弊端案例真的这么严重吗?

newFixedThreadPool 可能 OOM

我们写一段测试代码,来初始化一个单线程的FixedThreadPool,循环1亿次向线程池提交任务,每个任务都会创建一个比较大的字符串然后休眠一小时:

由于不知道Java线程池的bug,某程序员叕被祭天

执行程序后不久,日志中就出现了如下OOM:

Exception in thread "http-nio-45678-ClientPoller" 
java.lang.OutOfMemoryError: GC overhead limit exceeded
  • 1

  • 2

由于不知道Java线程池的bug,某程序员叕被祭天

  • newFixedThreadPool线程池的工作队列直接new了一个LinkedBlockingQueue
    由于不知道Java线程池的bug,某程序员叕被祭天

  • 但其默认构造器是一个Integer.MAX_VALUE长度的队列,所以很快就队列满了
    由于不知道Java线程池的bug,某程序员叕被祭天

虽然使用newFixedThreadPool可以固定工作线程数量,但任务队列几乎无界。如果任务较多且执行较慢,队列就会快速积压,内存不够就很容易导致OOM。

newCachedThreadPool导致OOM

[11:30:30.487] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.OutOfMemoryError: unable to create new native thread] with root cause
java.lang.OutOfMemoryError: unable to create new native thread
  • 1

  • 2

日志可见OOM是因为无法创建线程,newCachedThreadPool这种线程池的最大线程数是Integer.MAX_VALUE,可认为无上限,而其工作队列SynchronousQueue是一个没有存储空间的阻塞队列。
所以只要有请求到来,就必须找到一条工作线程处理,若当前无空闲线程就再创建一个新的。

由于我们的任务需1小时才能执行完成,大量任务进来后会创建大量的线程。我们知道线程是需要分配一定的内存空间作为线程栈的,比如1MB,因此无限创建线程必然会导致OOM:

public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
  • 1

  • 2

  • 3

  • 4

开发同学其实面试时都知道这俩线程池原理,只是抱有侥幸,觉得只是使用线程池做了轻量任务,不会造成队列积压或开启大量线程。

案例

用户注册后,我们调用一个外部服务去发送短信,发送短信接口正常时可在100ms内响应,TPS 100的注册量,CachedThreadPool能稳定在占用10个左右线程的情况下满足需求。在某个时间点,外部短信服务不可用了,我们调用这个服务的超时又特别长, 比如1分钟,1分钟可能就进来了6000用户,产生6000个发送短信的任务,需要6000个线程,没多久就因为无法创建线程导致了OOM。

所以阿里也不建议使用Executors提供的两种方便线程池创建方式:

  • 需根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数

  • 任何时候都应为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量CPU、线程执行出现异常等问题时,往往会抓取线程栈。此时,有意义的线程名称,就可以方便定位问题。

除手动声明线程池外,推荐用些监控手段观察线程池状态。线程池这个组件往往会表现得任劳任怨、默默无闻,除非出现拒绝策略,否则压力再大都不会抛异常。若能提前观察到线程池队列的积压或线程数量的快速膨胀,往往可提早发现并解决问题。

线程池线程管理

  • 如下方法实现最简陋的监控
    由于不知道Java线程池的bug,某程序员叕被祭天

自定义个线程池,借助Jodd类库的ThreadFactoryBuilder方法来构造一个线程工厂,实现线程池线程的自定义命名。

然后,我们写一段测试代码来观察线程池管理线程的策略。测试代码的逻辑为,每次间隔1秒向线程池提交任务,循环20次,每个任务需要10秒才能执行完成,代码如下:

  • 发现提交失败的记录,日志就像这样
    由于不知道Java线程池的bug,某程序员叕被祭天

线程池默认行为

  • 不会初始化corePoolSize个线程,有任务来了才创建工作线程

  • 核心线程满后不会立即扩容线程池,而是把任务堆积到工作队列

  • 工作队列满后扩容线程池,直至线程数达到maximumPoolSize

  • 若队列已满且达最大线程后,还有任务来按拒绝策略处理

  • 当线程数大于核心线程数时,线程等待keepAliveTime后还是无任务需要处理,收缩线程到核心线程数

了解这个策略,有助于我们根据实际的容量规划需求,为线程池设置合适的初始化参数。也可通过一些手段来改变这些默认工作行为,比如:

  • 声明线程池后立即调用prestartAllCoreThreads方法,来启动所有核心线程

  • 传true给allowCoreThreadTimeOut,让线程池在空闲时同样回收核心线程

Java线程池是先用工作队列来存放来不及处理的任务,满后再扩容线程池。当工作队列设置很大时(那个默认工具类),最大线程数这个参数就没啥意义了,因为队列很难满或到满时可能已OOM,更没机会去扩容线程池了。

是否能让线程池优先开启更多线程,而把队列当成后续方案?
比如案例的任务执行得很慢,需要10s,若线程池可优先扩容到5个最大线程,那么这些任务最终都可以完成,而不会因为线程池扩容过晚导致慢任务来不及处理。

实现思路

实现基本就如下两个难题:

  • 线程池在工作队列满了无法入队的情况下会扩容线程池,那是否可重写队列的offer,人为制造该队列已满的假象?

  • Hack了队列,在达到最大线程后势必会触发拒绝策略,那么能否实现一个自定义的拒绝策略处理程序,这个时候再把任务真正插入队列?

Tomcat其实已经实现了类似的“弹性”线程池。
务必确认清楚线程池本身是不是复用的
某项目生产环境偶尔报警线程数过多,超过2000个,收到报警后查看监控发现,瞬时线程数比较多但过一会儿又会降下来,线程数抖动很厉害,而应用的访问量变化不大。

为定位问题,在线程数较高时抓取线程栈,发现内存中有1000多个自定义线程池。一般来说,线程池肯定是复用的,有5个以内的线程池都可认为正常,但1000多个线程池肯定不正常。

在项目代码也没看到声明线程池,搜索execute关键字后定位到,原来是业务代码调用了一个类库来获得线程池,类似如下:
调用ThreadPoolHelper的getThreadPool方法来获得线程池,然后提交数个任务到线程池处理,看不出什么异常。
由于不知道Java线程池的bug,某程序员叕被祭天
但getThreadPool方法居然是每次都使用Executors.newCachedThreadPool来创建一个线程池。
由于不知道Java线程池的bug,某程序员叕被祭天
newCachedThreadPool会在需要时创建必要多的线程,业务代码的一次业务操作会向线程池提交多个慢任务,这样执行一次业务操作就会开启多个线程。如果业务操作并发量较大的话,的确有可能一下子开启几千个线程。

那为什么我们能在监控中看到线程数量会下降,而不OOM?
newCachedThreadPool的核心线程数是0,而keepAliveTime是60s,即60s后所有的线程都可回收。

修复

使用静态字段存放线程池的引用,返回线程池的代码直接返回这个静态字段即可。
由于不知道Java线程池的bug,某程序员叕被祭天

考虑线程池的混用

线程池的意义在于复用,那这是不是意味着程序应该始终使用一个线程池?
不是。要根据任务优先级指定线程池的核心参数,包括线程数、回收策略和任务队列。

案例

业务代码使用线程池异步处理一些内存中的数据,但监控发现处理得很慢,整个处理过程都是内存中的计算不涉及I/O操作,也需要数s处理时间,应用程序CPU占用也不是很高。
最终排查发现业务代码使用的线程池,还被一个后台文件批处理任务用了。
由于不知道Java线程池的bug,某程序员叕被祭天

模拟一下文件批处理,在程序启动后通过一个线程开启死循环逻辑,不断向线程池提交任务,任务的逻辑是向一个文件中写入大量的数据:


  • 1

可以想象到,这个线程池中的2个线程任务是相当重的。通过printStats方法打印出的日志,我们观察下线程池的负担:
由于不知道Java线程池的bug,某程序员叕被祭天

线程池的2个线程始终处活跃状态,队列也基本满。因为开启了CallerRunsPolicy拒绝处理策略,所以当线程满队列满,任务会在提交任务的线程或调用execute方法的线程执行,也就是说不能认为提交到线程池的任务就一定是异步处理的。
若使用CallerRunsPolicy,有可能异步任务变同步执行。从日志的第四行也可以看到这点。这也是这个拒绝策略比较特别的原因。

不知道写代码的同学为什么设置这个策略,或许是测试时发现线程池因为任务处理不过来出现了异常,而又不希望线程池丢弃任务,所以最终选择了这样的拒绝策略。不管怎样,这些日志足以说明线程池饱和了。
业务代码复用这样的线程池来做内存计算就难搞了。

  • 向线程池提交一个简单任务
    由于不知道Java线程池的bug,某程序员叕被祭天

  • 简单压测TPS为85,性能差
    由于不知道Java线程池的bug,某程序员叕被祭天

问题没这么简单。原来执行IO任务的线程池使用CallerRunsPolicy,所以直接使用该线程池进行异步计算,当线程池饱和的时候,计算任务会在执行Web请求的Tomcat线程执行,这时就会进一步影响到其他同步处理的线程,甚至造成整个应用程序崩溃。

修正

使用独立的线程池来做这样的“计算任务”。
模拟代码执行的是休眠操作,并不属于CPU绑定的操作,更类似I/O绑定的操作,若线程池线程数设置太小会限制吞吐能力:

  • 使用单独的线程池改造代码后再来测试一下性能,TPS提高到1683

可见盲目复用线程池混用线程的问题在于,别人定义的线程池属性不一定适合你的任务,混用会相互干扰。
就好比,我们往往会用虚拟化技术来实现资源的隔离,而不是让所有应用程序都直接使用物理机。

线程池混用:Java 8的parallel stream

可方便并行处理集合中的元素,共享同一ForkJoinPool,默认并行度是CPU核数-1。对于CPU绑定的任务,使用这样的配置较合适,但若集合操作涉及同步IO操作的话(比如数据库操作、外部服务调用等),建议自定义一个ForkJoinPool(或普通线程池)。

参考

  • 《阿里巴巴Java开发手册》