作者:京东零售 肖朋伟

一、前言

开发过程中,多线程的应用场景可谓十分广泛,可以充分利用服务器资源,提高程序处理速度。我们通常也会使用池化技术,去避免频繁创建和销毁线程。

本篇旨在基于编码规范、工作中积累的研发经验等,整理在多线程开发的过程中需要注意的部分,比如不考虑线程池参数、线程安全、死锁等问题,将会存在潜在极大的风险。并且对其进行根因分析,避免每天踩一坑,坑坑不一样。

二、多线程并发场景有哪些坑?

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/

https://blog.csdn.net/whzhaochao/article/details/126032116

https://www.kuangstudy.com/bbs/1407940516238090242

JAVA多线程并发编程-避坑指南的更多相关文章

  1. Java 多线程并发编程一览笔录

    Java 多线程并发编程一览笔录 知识体系图: 1.线程是什么? 线程是进程中独立运行的子任务. 2.创建线程的方式 方式一:将类声明为 Thread 的子类.该子类应重写 Thread 类的 run ...

  2. 【收藏】Java多线程/并发编程大合集

    (一).[Java并发编程]并发编程大合集-兰亭风雨    [Java并发编程]实现多线程的两种方法    [Java并发编程]线程的中断    [Java并发编程]正确挂起.恢复.终止线程    [ ...

  3. java多线程并发编程与CPU时钟分配小议

    我们先来研究下JAVA的多线程的并发编程和CPU时钟振荡的关系吧 老规矩,先科普 我们的操作系统在DOS以前都是单任务的 什么是单任务呢?就是一次只能做一件事 你复制文件的时候,就不能重命名了 那么现 ...

  4. Java基础系列篇:JAVA多线程 并发编程

    一:为什么要用多线程: 我相信所有的东西都是以实际使用价值而去学习的,没有实际价值的学习,学了没用,没用就不会学的好. 多线程也是一样,以前学习java并没有觉得多线程有多了不起,不用多线程我一样可以 ...

  5. Java 多线程并发编程

    导读 创作不易,禁止转载! 并发编程简介 发展历程 早起计算机,从头到尾执行一个程序,这样就严重造成资源的浪费.然后操作系统就出现了,计算机能运行多个程序,不同的程序在不同的单独的进程中运行,一个进程 ...

  6. Java多线程并发编程/锁的理解

    一.前言 最近项目遇到多线程并发的情景(并发抢单&恢复库存并行),代码在正常情况下运行没有什么问题,在高并发压测下会出现:库存超发/总库存与sku库存对不上等各种问题. 在运用了 限流/加锁等 ...

  7. java多线程并发编程

    Executor框架 Executor框架是指java 5中引入的一系列并发库中与executor相关的一些功能类,其中包括线程池,Executor,Executors,ExecutorService ...

  8. Java多线程并发编程一览笔录

    线程是什么? 线程是进程中独立运行的子任务. 创建线程的方式 方式一:将类声明为 Thread 的子类.该子类应重写 Thread 类的 run 方法 方式二:声明实现 Runnable 接口的类.该 ...

  9. Java 多线程并发编程面试笔录一览

    知识体系图: 1.线程是什么? 线程是进程中独立运行的子任务. 2.创建线程的方式 方式一:将类声明为 Thread 的子类.该子类应重写 Thread 类的 run 方法 方式二:声明实现 Runn ...

  10. java多线程 并发 编程

    转自:http://www.cnblogs.com/luxiaoxun/p/3870265.html 一.多线程的优缺点 多线程的优点: 1)资源利用率更好 2)程序设计在某些情况下更简单 3)程序响 ...

随机推荐

  1. JVM面试必问:G1垃圾回收器

    摘要:G1垃圾回收器是一款主要面向服务端应用的垃圾收集器. 本文分享自华为云社区<JVM面试高频考点:由浅入深带你了解G1垃圾回收器!!!>,原文作者:Code皮皮虾 . G1垃圾回收器介 ...

  2. 火山引擎A/B测试私有化实践

    更多技术交流.求职机会.试用福利,欢迎关注字节跳动数据平台微信公众号,回复[1]进入官方交流群 作为一款面向 ToB 市场的产品--火山引擎 A/B 测试(DataTester)为了满足客户对数据安全 ...

  3. Xml Entity 实体互转(JAXB)

    感觉比 xStream 操作起来复杂些 Xml Entity 实体互转(XStream).但学习成本低些,不需要引用第三方依赖包 需要注意的是 实体中如果加了 getXX 需要在上面加上 @XmlTr ...

  4. Mysql--binlog日志

    一.简介 binlog日志也称二进制日志,记录了所有的DDL和DML( 除了数据查询语句 )语句,以事件形式记录,还包含语句所执行的消耗的时间,MySQL的二进制日志是事务安全型的. 一般来说开启二进 ...

  5. 使用 std::string_view 提升字符串处理性能

    C++标准库提供了一个非常优秀的字符串处理类std::string,我们可以通过该类完成各种字符串操作.但是std::string有一个缺点,它的很多操作都是针对字符串实体,存在不必要的内存拷贝的代码 ...

  6. 解决Github中使用Octotree时,出现 Error: API limit exceeded 报错 或者 Error: Connection error报错的问题(详细操作)

    对于科研工作者来说,Github 是不可多得的利器,那么Octotree 插件的使用将会让用户在使用 Github 时拥有更好的体验,提高学习工作的效率.但是笔者在使用的过程中遇到以下这样的问题,下面 ...

  7. CodeForces - 651A Joysticks ( 不难 但有坑 )

    正式更换编译器为: VS Code 如何配置环境:click here 代码格式化工具:clang-format A. Joysticks 题目连接: http://www.codeforces.co ...

  8. 涂色游戏Flood-it!(IDA star算法) - HDU 4127

    做题之前,可以先到下面这个网站玩一会游戏: https://unixpapa.com/floodit/?sz=14&nc=6 游戏开发里面,比较常用的一个搜索算法是寻路算法,寻路算法里面用的最 ...

  9. JVM自定义类加载器在代码扩展性的实践

    一.背景 名单管理系统是手机上各个模块将需要管控的应用配置到文件中,然后下发到手机上进行应用管控的系统,比如各个应用的耗电量管控:各个模块的管控应用文件考虑到安全问题,有自己的不同的加密方式,按照以往 ...

  10. 浏览器,navicat,IDEA--快捷键

    mysql快捷键:ctrl+r 运行查询窗口的sql语句ctrl+shift+r 只运行选中的sql语句ctrl+q 打开一个新的查询窗口ctrl+w 关闭一个查询窗口ctrl+/ 注释sql语句 c ...