使用线程池你需要注意这几点


使用线程池你需要注意这几点

文章插图
   在程序开发中,我们会用各种池化技术来缓存创建昂贵的对象,比如线程池、连接池、内存池等 。一般是预先创建一些对象放入池中,使用的时候直接取出使用,用完归还以便复用 。还会通过一定的策略调整池中缓存对象的数量,实现池的动态伸缩 。
由于线程的创建比较昂贵,随意、没有控制地创建大量线程会造成性能问题,因此,短平快的任务一般考虑使用线程池来处理,而不是直接创建线程 。
今天,我们就针对线程池这个话题展开讨论 。通过三个生产事故,来看看使用线程池应该注意些什么 。
线程池的声明需要手动进行JAVA 中的 Executors 类定义了一些快捷的工具方法,来帮助我们快速创建线程池 。不过,《阿里巴巴 Java 开发手册》中也提到,我们应该禁止使用这些方法来创建线程池,而应该手动通过 new ThreadPoolExecutor 来创建线程池 。这一条规则的背后,是大量血淋淋的生产事故 。最典型的就是 newFixedThreadPool 和 newCachedThreadPool 可能因为资源耗尽而导致 OOM 问题 。
首先,我们来看一下 newFixedThreadPool 为什么可能会出现 OOM 的问题 。
我们写一段测试代码,来初始化一个单线程的 FixedThreadPool,循环 1 亿次向线程池提交任务,每个任务都会创建一个比较大的字符串然后休眠一小时:
使用线程池你需要注意这几点

文章插图
   执行程序后不久,日志中就出现了如下 OOM:
使用线程池你需要注意这几点

文章插图
   翻看 newFixedThreadPool 方法的源码不难发现,线程池的工作队列直接 new 了一个 LinkedBlockingQueue,而默认构造方法的 LinkedBlockingQueue 是一个 Integer.MAX_VALUE 长度的队列,可以认为是无界的:
使用线程池你需要注意这几点

文章插图
   【使用线程池你需要注意这几点】虽然使用 newFixedThreadPool 可以把工作线程控制在固定的数量上,但任务队列是无界的 。如果任务较多并且执行较慢的话,队列可能会快速积压,撑爆内存导致 OOM 。
我们再把刚才的例子稍微改一下,改为使用 newCachedThreadPool 方法来获得线程池 。程序运行不久后,同样看到了如下 OOM 异常:
使用线程池你需要注意这几点

文章插图
   从日志中可以看到,这次 OOM 的原因是无法创建线程,翻看 newCachedThreadPool 的源码可以看到,这种线程池的最大线程数是 Integer.MAX_VALUE,可以认为是没有上限的,而其工作队列 SynchronousQueue 是一个没有存储空间的阻塞队列 。这意味着,只要有请求到来,就必须找到一条工作线程来处理,如果当前没有空闲的线程就再创建一条新的 。
由于我们的任务需要 1 小时才能执行完成,大量的任务进来后会创建大量的线程 。我们知道线程是需要分配一定的内存空间作为线程栈的,比如 1MB,因此无限制创建线程必然会导致 OOM:
使用线程池你需要注意这几点

文章插图
   其实,大部分 Java 开发同学知道这两种线程池的特性,只是抱有侥幸心理,觉得只是使用线程池做一些轻量级的任务,不可能造成队列积压或开启大量线程 。
但现实往往是残酷的 。我之前就遇到过这么一个事故:用户注册后,我们调用一个外部服务去发送短信,发送短信接口正常时可以在 100 毫秒内响应,TPS 100 的注册量,CachedThreadPool 能稳定在占用 10 个左右线程的情况下满足需求 。在某个时间点,外部短信服务不可用了,我们调用这个服务的超时又特别长,比如 1 分钟,1 分钟可能就进来了 6000 用户,产生 6000 个发送短信的任务,需要 6000 个线程,没多久就因为无法创建线程导致了 OOM,整个应用程序崩溃 。
因此,我同样不建议使用 Executors 提供的两种快捷的线程池,原因如下:
  1. 我们需要根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数 。
  2. 任何时候,都应该为自定义线程池指定有意义的名称,以方便排查问题 。当出现线程数量暴增、线程死锁、线程占用大量 CPU、线程执行出现异常等问题时,我们往往会抓取线程栈 。此时,有意义的线程名称,就可以方便我们定位问题 。


    推荐阅读