作者:京东零售 肖朋伟
一、前言
开发过程中,多线程的应用场景可谓十分广泛,可以充分利用服务器资源,提高程序处理速度。我们通常也会使用池化技术,去避免频繁创建和销毁线程。
本篇旨在基于编码规范、工作中积累的研发经验等,整理在多线程开发的过程中需要注意的部分,比如不考虑线程池参数、线程安全、死锁等问题,将会存在潜在极大的风险。并且对其进行根因分析,避免每天踩一坑,坑坑不一样。
二、多线程并发场景有哪些坑?
1、“不正确的创建”线程池
常规来说,线程资源必须通过线程池提供,不允许在应用中自行显式创建线程,京东 JAVA 代码规范也明确表示 “线程资源必须通过线程池提供,不允许在应用中自行显式创建线程”,但是创建线程池的方式也有很多种,不能滥用。
常见创建线程池方式如:通过 JDK 提供的 ThreadPoolExecutor、ScheduledThreadPoolExecutor 以及 JDK 7 开始引入的 ForkJoinPool 创建,还有更方便的 Executors 类以及 spring 提供的 ThreadPoolTaskExecutor 等。其关系如下图:
Executors 的 newFixedThreadPool、newScheduledThreadPool、newSingleThreadExecutor、newCachedThreadPool 方法在底层都是用的 ThreadPoolExecutor 实现的。虽然更加方便,但也增加了风险,如果不清楚其相关实现,在特定场景可能导致很严重的问题,所以开发规范中,会严格禁用此类用法。它们的弊端如下:
(1)FixedThreadPool 和 SingleThreadPool 允许的请求任务队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
(2)CachedThreadPool 和 ScheduledThreadPool 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
2、线程池“参数设置不合理”
上面提到了 任务队列 和 最大线程数的重要性,下面通过 ThreadPoolExecutor 介绍线程池其他核心参数,都应该根据场景合理配置,详细如下:
注意在设置任务队列时,需要考虑有界和*队列,使用有界队列时,注意线程池满了后,被拒绝的任务如何处理。使用*队列时,需要注意如果任务的提交速度大于线程池的处理速度,可能会导致内存溢出。
对于拒绝策略,首先要明确的一点是“拒绝策略的执行线程是提交任务的线程,而不是子线程”。JDK 的 ThreadPoolExecutor 提供了 4 种拒绝策略:
对于自定义拒绝策略,不同场景应选择相应的拒绝策略,抛开应用场景讲技术会显得苍白,大家可以参考常见技术框架的处理方式:
相关拓展:
相信大多数京东开发者都遇到过,JSF 依赖服务触发拒绝策略的现象,即抛出线程拒绝异常。这是当 Provider的 业务线程池满了,无可用线程池的时候,会返回一个异常给 Consumer,告知 Consumer 该 Provider 线程池已耗尽。如图:
当然这种异常场景,根本原因并非线程池配置不合理,应该关注服务提供方性能瓶颈,关于线程池的配置,其实没用一个统一或者可推荐的配置可以套用。对于 JSF 业务线程池,默认使用的是伸缩无队列线程池,其也提供了配置方式。
3、局部线程池“使用后不销毁回收”
线程会消耗宝贵的系统资源,比如内存等,所以是很不推荐使用局部线程池(未预先创建的线程池,用完就可以销毁,下次用时还会创建)的;但是如果某些特殊场景确实使用了局部线程池,那么应该在用完后,主动销毁。主动销毁线程池主要有两种方式:
为了更深入的理解两个问题:
(1)到底是否所有局部创建的线程池都需要主动销毁?
(2)为什么 Dubbo 中的线程拒绝策略 AbortPolicyWithReport 使用了 Executors.newSingleThreadExecutor(),并且没有主动销毁动作?
我们需要从 GC 角度进行分析。要知道对象什么时候死亡,我们需要先知道 JVM 的 GC 是如何判断对象是可以回收的。JAVA 是通过可达性算法来来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。
对于线程池而言,在 ThreadPoolExecutor 类中具有非静态内部类 Worker,用于表示当前线程池中的线程,因为非静态内部类对象具有外部包装类对象的引用,所以当存在线程 Worker 对象时,线程池不会被 GC 回收。也就是说,线程池没有引用,且线程池内没有存活线程时,才是可以被 GC 回收的。应注意的是线程池的核心线程默认是一直存活的,除非核心线程数为 0 或者设置了 allowCoreThreadTimeOut 允许核心消除空闲时销毁。
我们对 Executors 创建的三种线程池进行比较:
三种类型的线程池与 GC 关系:
(1)CachedThreadPool:没有核心线程,且线程具有超时时间,可见在其引用消失后,等待任务运行结束且所有线程空闲回收后,GC开始回收此线程池对象;
(2)FixedThreadPool:核心线程数及最大线程数均为 nThreads,并且在默认 allowCoreThreadTimeOut 为 false 的情况下,其引用消失后,核心线程即使空闲也不会被回收,故 GC 不会回收该线程池;
(3)SingleThreadExecutor:在创建时实际返回的是 FinalizableDelegatedExecutorService 类的对象,该类重新了 finalize() 函数执行线程池的销毁,该对象持有 ThreadPoolExecutor 对象的引用,但 ThreadPoolExecutor 对象并不引用 FinalizableDelegatedExecutorService 对象,这使得在 FinalizableDelegatedExecutorService 对象的外部引用消失后,GC 将会对其进行回收,触发 finalize 函数,而该函数仅仅简单的调用 shutdown 函数关闭线程,在所有当前的任务执行完成后,回收线程池中线程,则 GC 可回收线程池对象
所以结论是:CachedThreadPool 及 SingleThreadExecutor 的对象在不显式销毁时,且其对象引用消失的情况下,可以被 GC 回收;FixedThreadPool 对象在不显式销毁,且其对象引用消失的情况下不会被 GC 回收,会出现内存泄露。因此无论使用什么线程池,使用完毕后均调用 shutdown 是一个较为安全的编程习惯。
4、线程池处理“刚启动时效率低”
默认情况下,即使是核心线程也只能在新任务到达时才创建和启动。对于 ThreadPoolExecutor 线程池可以使用 prestartCoreThread(启动一个核心线程)或 prestartAllCoreThreads(启动全部核心线程)方法来提前启动核心线程。
5、“不合理的使用共享线程池”
提交异步任务,不指定线程池,存在最主要问题是非核心业务占用线程资源,可能会导致核心业务收影响。因为公共线程池的最大线程数、队列大小、拒绝策略都比较保守,可能引发各种问题,常见场景如下:
(1)CompleteFuture 提交异步任务,不指定线程池。
CompleteFuture 的 supplyAsync 等以 *Async 为结尾的方法,会使用多线程异步执行。可以注意到的是,它也允许我们不携带多线程提交任务的执行线程池参数,这个时候是默认使用的 ForkJoinPool.commonPool()。
ForkJoinPool 最适合的是计算密集型的任务,如果存在 I/O,线程间同步,sleep() 等会造成线程长时间阻塞的情况时,最好配合使用 ManagedBlocker。ForkJoinPool 默认线程数取决于 parallelism 参数为:CPU 处理器核数-1,也允许通修改 Java 系统属性 "java.util.concurrent.ForkJoinPool.common.parallelism" 进行自定义配置。
(2)JDK 8 引入的集合框架的并行流 Parallel Stream。
Parallel Stream 也是使用的 ForkJoinPool.commonPool(),但有一点区别是:Parallel Stream 的主线程 (提交任务的线程)是会去参与处理的;比如 8 核心的机器执行 Parallel Stream 是有 8 个线程,而 CompleteFuture 提交的任务只有 7 个线程处理。不建议使用是一致的。
(3)@Async 提交异步任务,不指定线程池。
SpringBoot 2.1.9 之前版本使用 @Async 不指定 Executor 会使用 SimpleAsyncTaskExecutor,该线程池默认来一个任务创建一个线程,若系统中不断的创建线程,最终会导致系统占用内存过高,引发 OutOfMemoryErro r错误。SpringBoot 2.1.0 之后版本引入了 TaskExecutionAutoConfiguration,其使用 ThreadPoolTaskExecutor 作为 默认 Executor,通过 TaskExecutionProperties.Pool 可以看到其配置默认核心线程数:8,最大线程数:Integet.MAX_VALUE,队列容量是:Integet.MAX_VALUE,空闲线程保留时间:60s,线程池拒绝策略:AbortPolicy。
虽然可以通实现 AsyncConfigurer 接口等方式,自行配置线程池参数,但仍不建议使用公共线程池。
6、主线程“等待时间不合理”
(1)应尽量避免使用 CompletableFuture.join(),Future.get() 这类不带有超时时间的阻塞主线程操作。
(2)for 循环使用 future.get(long timeout, TimeUnit unit)。此方法允许我们去设置超时时间,但是如果主线程串行获取的话,下一个 future.get 方法的超时时间,是从第一个 get() 结束后开始计算的,所以会导致超时时间不合理。
7、提交任务“不考虑子线程超时时间”
(1)主线程 Future.get 虽然超时,但是子线程依然在执行?
比如当通过 ExecutorService 提交一个 Callable 任务的时候,会返回一个 Future 对象,Future 的 get(long timeout, TimeUnit unit) 方法时,如果出现超时,则会抛出 java.util.concurrent.TimeoutException;但是,此时 Future 实例所在的线程并没有中断执行,只是主线程不等待了,也就是当前线程的 status 依然是 NEW 值为 0 的状态,所以当大量超时,可能就会将线程池打满。
提到中断子线程,会想到 future.cancel(true) 。那么我们真的可以中断子线程吗?首先 Java 无法直接其他线程的,如果非要实现此功能,也只能通过 interrupt 设置标志位,子线程执行到中间环节去查看标志位,识别到中断后做后续处理。理解一个关键点,interrupt() 方法仅仅是改变一个标志位的值而已,和线程的状态并没有必然的联系。
(2)子线程的任务都应该有一个合理的超时时间。
比如子线程调用 JSF/ HTTP 接口等,一定要检查超时时间配置是否合理。
8、并发执行“线程不安全”操作
多线程操作同一对象应考虑线程安全性。常见场景比如 HashMap 应该换成 ConcurrentHashMap;StringBuilder 应该换成 StringBuffer 等。
9、“不考虑线程变量”的传递
提交到线程池执行的异步任务,切换了线程,子线程在执行时,获取不到主线程变量中存储的信息,常见场景如下:
(1)类似 BU 等,为了减少参数透传,可能存在了 ThreadLocal 里面;
(2)客户的登录状态,LoginContext 等信息,一般是线程变量;
如果解决此问题,可以参考 Transmittable-Thread-Local 中间件提供的解决方案等。
10、并发会“增大出现死锁的可能性”
多线程不只是程序中提交到线程池执行,比如打到同一容器的 http 请求本身就是多线程,任何多线程操作都有死锁风险。使用业务线程池的并发操作需要更加注意,因为更容易暴露出来“死锁”这个问题。
比如 Mysql 事务隔离级别为 RR 时,间隙锁可能导致死锁问题。间隙锁是 Innodb 在可重复读提交下为了解决幻读问题时引入的锁机制,在执行 update、delete、select ... for update 等语句时,存在以下加间隙锁情况:
(1)有索引,当更新的数据存在时,只会锁定当前记录;更新的不存在时,间隙锁会向左找第一个比当前索引值小的值,向右找第一个比当前索引值大的值(没有比当前索引值大的数据时,是 supremum pseudo-record,可以理解为锁到无穷大)。
(2)无索引,全表扫描,如果更新的数据不存在,则会根据主键索引对所有间隙加锁。
当并发执行数据库事务(事务内先更新,后新增操作),当更新的数据不存在时,会加间隙锁,然后执行新增数据需要其他事务释放在此区间的间隙锁,则可能导致死锁产生;如果是全表扫描,问题可能更严重。
11、“不考虑请求过载”
最后,我们设置了合理的参数,也注意优化了各种场景问题,终于可以大胆使用多线程了。也一定要考虑对下游带来的影响,比如数据库请求并发量增长,占用 JDBC 数据库连接、给依赖 RPC 服务带来性能压力等。
参考文章链接:
•http://www.kailing.pub/article/index/arcid/255.html
•https://blog.csdn.net/sinat_15946141/article/details/107951917
•https://segmentfault.com/a/1190000016461183?utm_source=tag-newest
•https://blog.csdn.net/v123411739/article/details/106609583/