JAVA多线程并发编程-避坑指南

java,多线程,并发,编程,指南 · 浏览次数 : 138

小编点评

**1.线程池处理“刚启动时效率低”默认情况下,即使是核心线程只能在新任务到达时才创建和启动** **2.for循环使用 future.get(long timeout, TimeUnit unit)**允许我们去设置超时时间,但是如果主线程串行获取的话,下一个 future.get 方法的超时时间,是从第一个 get() 结束后开始计算的,所以会导致超时时间不合理 **3.提交任务“不考虑子线程超时时间”(1)主线程 Future.get 虽然超时,但是子线程依然在执行?** **4.主线程“等待时间不合理”(1)应尽量避免使用 CompletableFuture.join() ** **5.子线程的任务应该有一个合理的超时时间。** **6.使用业务线程池的并发操作需要更加注意,因为更容易暴露出来“死锁”这个问题** **7.注意对下游带来的影响,比如数据库请求并发量增长,占用 JDBC 数据库连接、给依赖 RPC 服务带来性能压力等** **8.并发会“增大出现死锁的可能性”多线程不只是程序中提交到线程池执行,比如打到同一容器的 http 请求本身就是多线程,任何多线程操作都有死锁风险** **9.“不考虑请求过载”最后,我们设置了合理的参数,也注意优化了各种场景问题,终于可以大胆使用多线程了** **10.“不考虑请求过载”最后,我们设置了合理的参数,也注意优化了各种场景问题,终于可以大胆使用多线程了** **11.建议考虑对下游带来的影响,比如数据库请求并发量增长,占用 JDBC 数据库连接、给依赖 RPC 服务带来性能压力等**

正文

作者:京东零售 肖朋伟

一、前言

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

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

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

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多线程并发编程-避坑指南相似的内容:

JAVA多线程并发编程-避坑指南

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

4.7 C++ Boost 多线程并发库

C++语言并没有对多线程与网络的良好支持,虽然新的C++标准加入了基本的`thread`库,但是对于并发编程的支持仍然很基础,Boost库提供了数个用于实现高并发与网络相关的开发库这让我们在开发跨平台并发网络应用时能够像Java等语言一样高效开发。thread库为C++增加了多线程处理能力,其主要提供了清晰的,互斥量,线程,条件变量等,可以很容易的实现多线程应用开发,而且该库是可跨平台的,并且支持

学了这么久的高并发编程,连Java中的并发原子类都不知道?

摘要:保证线程安全是 Java 并发编程必须要解决的重要问题,本文和大家聊聊Java中的并发原子类,看它如何确保多线程的数据一致性。 本文分享自华为云社区《学了这么久的高并发编程,连Java中的并发原子类都不知道?这也太Low了吧》,作者:冰 河。 今天我们一起来聊聊Java中的并发原子类。在 ja

Java并发Map的面试指南:线程安全数据结构的奥秘

简介 在计算机软件开发的世界里,多线程编程是一个重要且令人兴奋的领域。然而,与其引人入胜的潜力相伴而来的是复杂性和挑战,其中之一就是处理共享数据。当多个线程同时访问和修改共享数据时,很容易出现各种问题,如竞态条件和数据不一致性。 本文将探讨如何在Java中有效地应对这些挑战,介绍一种强大的工具——并

实战分析Java的异步编程,并通过CompletableFuture进行高效调优

一、写在开头 在我们一开始讲多线程的时候,提到过异步与同步的概念,这里面我们再回顾一下: 同步:调用方在调用某个方法后,等待被调用方返回结果;调用方在取得被调用方的返回值后,再继续运行。调用方顺序执行,同步等待被调用方的返回值,这就是阻塞式调用; 异步:调用方在调用某个方法后,直接返回,不需要等待被

当面试官问出“Unsafe”类时,我就知道这场面试废了,祖坟都能给你问出来!

一、写在开头 依稀记得多年以前的一场面试中,面试官从Java并发编程问到了锁,从锁问到了原子性,从原子性问到了Atomic类库(对着JUC包进行了刨根问底),从Atomic问到了CAS算法,紧接着又有追问到了底层的Unsafe类,当问到Unsafe类时,我就知道这场面试废了,这似乎把祖坟都能给问冒烟

24个写出漂亮代码的小技巧

这篇文章我会总结一些实用的有助于提高代码质量的建议,内容较多,建议收藏! 内容概览: 提取通用处理逻辑 注解、反射和动态代理是 Java 语言中的利器,使用得当的话,可以大大简化代码编写,并提高代码的可读性、可维护性和可扩展性。 我们可以利用 注解 + 反射 和 注解+动态代理 来提取类、类属性或者

ORA-01008:并非所有变量都已绑定-解决办法

近期批量处理数据,后台用JAVA编写,连接Oracle数据库,程序运行报ORA-01008问题。解决这个问题时遇见的坑较多,下面复盘现象、问题提出解决办法,希望能帮到遇见同类问题的你。 调试问题: 后台代码: /** * 插入操作的封装 * **/ public static void insert

从源码入手详解ReentrantLock,一个比synchronized更强大的可重入锁

写在开头 随手一翻,发现对于Java中并发多线程的学习已经发布了十几篇博客了,多线程 是Java基础中的重中之重!因此,可能还需要十几篇博客才能大致的讲完这部分的知识点,初学者对于这部分内容一定要多花心思,不可马虎!今天我们继续来学习一个重要知识点:ReentrantLock ReentrantLo

java中有哪些并发的List?只知道一种的就太逊了

java中有很多list,但是原生支持并发的并不多,我们在多线程的环境中如果想同时操作同一个list的时候,就涉及到了一个并发的过程,这时候我们就需要选择自带有并发属性的list,那么java中的并发list到底有哪些呢?今天要给大家介绍的是ArrayList、CopyOnWriteArrayLis