6

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

 3 years ago
source link: https://blog.csdn.net/qq_33589510/article/details/109549716
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

手动声明线程池

JDK的Executors工具类定义了很多便捷的方法可以快速创建线程池。
watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzMzNTg5NTEw,size_1,color_FFFFFF,t_70#pic_center

但是阿里有话说:
watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzMzNTg5NTEw,size_16,color_FFFFFF,t_70#pic_center
我们来看他说的弊端案例真的这么严重吗?

newFixedThreadPool 可能 OOM

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

watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzMzNTg5NTEw,size_1,color_FFFFFF,t_70#pic_center

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

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

20201107180608685.png#pic_center

  • newFixedThreadPool线程池的工作队列直接new了一个LinkedBlockingQueue
    watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzMzNTg5NTEw,size_1,color_FFFFFF,t_70#pic_center
  • 但其默认构造器是一个Integer.MAX_VALUE长度的队列,所以很快就队列满了
    watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzMzNTg5NTEw,size_1,color_FFFFFF,t_70#pic_center

虽然使用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 

日志可见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>());

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

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

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

  • 需根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数
  • 任何时候都应为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量CPU、线程执行出现异常等问题时,往往会抓取线程栈。此时,有意义的线程名称,就可以方便定位问题。

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

线程池线程管理

  • 如下方法实现最简陋的监控

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

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

  • 发现提交失败的记录,日志就像这样

线程池默认行为

  • 不会初始化corePoolSize个线程,有任务来了才创建工作线程
  • 核心线程满后不会立即扩容线程池,而是把任务堆积到工作队列
  • 工作队列满后扩容线程池,直至线程数达到maximumPoolSize
  • 若队列已满且达最大线程后,还有任务来按拒绝策略处理
  • 当线程数大于核心线程数时,线程等待keepAliveTime后还是无任务需要处理,收缩线程到核心线程数

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

  • 声明线程池后立即调用prestartAllCoreThreads方法,来启动所有核心线程
  • 传true给allowCoreThreadTimeOut,让线程池在空闲时同样回收核心线程

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

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

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

  • 线程池在工作队列满了无法入队的情况下会扩容线程池,那是否可重写队列的offer,人为制造该队列已满的假象?
  • Hack了队列,在达到最大线程后势必会触发拒绝策略,那么能否实现一个自定义的拒绝策略处理程序,这个时候再把任务真正插入队列?

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

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

在项目代码也没看到声明线程池,搜索execute关键字后定位到,原来是业务代码调用了一个类库来获得线程池,类似如下:
调用ThreadPoolHelper的getThreadPool方法来获得线程池,然后提交数个任务到线程池处理,看不出什么异常。

但getThreadPool方法居然是每次都使用Executors.newCachedThreadPool来创建一个线程池。

newCachedThreadPool会在需要时创建必要多的线程,业务代码的一次业务操作会向线程池提交多个慢任务,这样执行一次业务操作就会开启多个线程。如果业务操作并发量较大的话,的确有可能一下子开启几千个线程。

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

使用静态字段存放线程池的引用,返回线程池的代码直接返回这个静态字段即可。

考虑线程池的混用

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

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

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

可以想象到,这个线程池中的2个线程任务是相当重的。通过printStats方法打印出的日志,我们观察下线程池的负担:

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

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

  • 向线程池提交一个简单任务
  • 简单压测TPS为85,性能差

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

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

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

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

线程池混用:Java 8的parallel stream

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

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK