Java并发编程注意事项
程序中有效利用多线程,可以充分利用服务器的资源,提高程序的处理速度,但是线程并不能无限制创建,这样很容易会导致OOM
,所以一般会使用池化技术来避免线程的频繁创建和销毁。下面列举一些需要注意的事项。
正确创建线程池
规范来说,线程资源应该必须通过线程池提供,阿里Java开发手册中也强制要求“线程资源必须通过线程池提供,不允许在应用中自行显式创建线程”。但是线程池也不能无脑创建,否则也会带来一系列问题。
最常见创建线程池的方式,就是使用JDK提供的Executors
来创建,例如:newFixedThreadPool
,newCachedThreadPool
,newSingleThreadExecutor
等,这样虽然很方便,但是这种方式是不推荐的,甚至在阿里Java开发手册是强制禁止使用这种方式。原因如下:
FixedThreadPool
和SingleThreadPool
,允许的请求队列长度为Integer.MAX_VALUE
,可能会堆积大量的请求,从而导致OOM
。CachedThreadPool
和ScheduledThreadPool
,允许的创建线程数量为Integer.MAX_VALUE
,可能会创建大量的线程,从而导致OOM
。
其中通过Executor
框架关系如下图:
J.U.C
的三个接口:
Executor
:运行新任务的接口, 将任务提交和任务执行细节的解耦。ExecutorService
:具备管理执行器和任务生命周期的方法, 提交任务机制更完善(e.g.shutdown()
,isShutdown()
,submit()
…)。ScheduledExecutorService
:支持Future
和定期执行任务。
设置合适的线程池参数
上面说了,不建议使用 Executors
去创建的原因,推荐通过 ThreadPoolExecutor
的方式,这样的处理方式让写的人更加明确线程池的运行规则,规避资源耗尽的风险。相关创建参数如下:
参数 | 含义 | 描述 |
---|---|---|
corePoolSize | 核心线程数 | 线程池中要保留的线程数,即使它们处于空闲状态,除非设置了 allowCoreThreadTimeOut。 |
maximumPoolSize | 最大线程数 | 线程池中允许的最大线程数,大于0且需要大于核心线程数,否则抛IllegalArgumentException异常。 |
keepAliveTime | 保持存活时间 | 当线程数大于核心时,这是多余的空闲线程在终止前等待新任务的最长时间。 |
unit | 时间单位 | keepAliveTime 参数的时间单位。 |
workQueue | 任务队列 | 用于在执行任务之前保存任务的队列。此队列将仅保存 execute方法提交的 Runnable 任务。 |
threadFactory | 线程工厂 | 执行器创建新线程时要使用的工厂。 |
handler | 拒绝策略 | 当由于达到最大线程数和队列容量而导致执行受阻时使用的处理程序。 |
maximumPoolSize
当线程数大于corePoolSize
且workQueue
(有界队列)满时才会创建新线程去处理任务,直到大于maximumPoolSize
时就会执行拒绝策略。
handler
在设置任务队列时,需要考虑有界和无界队列,使用有界队列时,注意线程池满了后,被拒绝的任务如何处理。使用无界队列时,需要注意如果任务的提交速度大于线程池的处理速度,可能会导致OOM
。
对于拒绝策略,首先要明确的一点是“拒绝策略的执行线程是提交任务的线程,而不是子线程”。JDK 的 ThreadPoolExecutor
提供了4种拒绝策略:
内部类 | 拒绝策略 | 描述 |
---|---|---|
AbortPolicy | 中止策略 | 当触发拒绝策略时,直接抛出拒绝执行的 RejectedExecutionException 异常,中止策略的意思也就是打断当前执行流程。 |
CallerRunsPolicy | 调用者运行策略 | 当触发拒绝策略时,只要线程池没有关闭,就由提交任务的当前线程处理。 |
DiscardOldestPolicy | 弃老策略 | 如果线程池未关闭,就弹出队列头部的元素,然后尝试执行。 |
DiscardPolicy | 丢弃策略 | 直接静悄悄的丢弃这个任务,不触发任何动作。 |
对于自定义拒绝策略,不同场景应该选择相应的拒绝策略,以下是常见技术框架处理方式:
常见场景 | 描述 |
---|---|
Dubbo | Dubbo 中的线程拒绝策略 AbortPolicyWithReport,输出当前线程堆栈详情等。 |
JSF | JSF(JavaServer Faces) 的 BusinessPool 中 RejectedExecutionHandler 设置钩子函数,允许业务系统去扩展实现。 |
Netty | Netty 中的线程池拒绝策略 NewThreadRunsPolicy,创建一个新的线程去执行。 |
ActiveMQ | ActiveMq 中的线程池拒绝策略,通过队列的带有超时时间的 offer 方法,比如:executor.getQueue().offer(r, 60, TimeUnit.SECONDS); 要注意拒绝策略的执行线程是主线程,offer 函数也是会阻塞线程的,要慎用。 |
其他 | 可以进行 UMP 业务报警之后,再使用调用者执行、抛出异常等策略。 |
workQueue
workQueue
是一个阻塞队列来的,详情可以看Java的阻塞队列。
这就意味着,如果corePoolSize
不是0,核心线程就会一直阻塞从队列中获取任务,下面跑个例子验证,然后使用jstack
看线程的情况。
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 核心线程数,也叫做最小线程数,长期驻留的线程
20,// 最大线程数,如果 queue 满了,就会创建新线程,直到大于 maxSize
2L, // 空闲线程(超过核心数的线程), 在没有事情做的时候, 多久会被销毁
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingDeque<>(5), // 任务等待队列
Executors.defaultThreadFactory(), // 创建新线程的工厂类
new ThreadPoolExecutor.AbortPolicy() // 线程池饱和策略
);
for (int i = 0; i < 5; i++) {
executor.execute(() -> {
System.out.println(Thread.currentThread().getName() + "处理任务");
});
}
}
以上代码执行后,会发现程序不会退出,一直阻塞着。
使用jstack
查看线程信息,可以发现,有2个线程在一直阻塞(线程的状态可以看Java的线程),这2个其实就是核心线程,而这里创建线程使用的是默认线程工厂类并且创建时使用默认值,这就意味着创建的线程都是用户线程而不是守护线程(创建线程时可以通过setDaemon
方法进行,工厂的话可以在构造方法里面传入)。
想要不阻塞的话,可以把核心数改为0,在执行完所有任务后,并且到keepAliveTime
时间之后,所有线程都会被回收,这时候程序也会结束或者创建线程的工厂指定是守护线程。
局部线程池使用后销毁回收
线程会消耗宝贵的系统资源,比如内存等,所以是很不推荐使用局部线程池(未预先创建的线程池,用完就可以销毁,下次用时还会创建)的;但是如果某些特殊场景确实使用了局部线程池,那么应该在用完后,主动销毁。主动销毁线程池主要有两种方式:
方法 | 描述 |
---|---|
shutdown | “温柔”地关闭线程池。不接受新任务,但是在关闭前会将之前提交的任务处理完毕。 |
shutdownNow | “粗暴”地关闭线程池,也就是直接关闭线程池,通过 Thread#interrupt() 方法终止所有线程,不会等待之前提交的任务执行完毕。但是会返回队列中未处理的任务。 |
是否所有局部创建的线程池都需要主动销毁?为什么Dubbo
中的线程拒绝策略AbortPolicyWithReport
使用了 Executors.newSingleThreadExecutor()
,并且没有主动销毁动作?
从GC角度分析,JAVA 是通过可达性算法来来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots
没有任何引用链相连时,则证明此对象是不可用的。
对于线程池而言,在 ThreadPoolExecutor
类中具有非静态内部类 Worker
,用于表示当前线程池中的线程,因为非静态内部类对象具有外部包装类对象的引用,所以当存在线程 Worker 对象时,线程池不会被 GC 回收。也就是说,线程池没有引用,且线程池内没有存活线程时,才是可以被 GC 回收的。应注意的是线程池的核心线程默认是一直存活的,除非核心线程数为0
或者设置了allowCoreThreadTimeOut
允许核心消除空闲时销毁。
以下是Executors
创建的三种线程池比较:
线程池类型 | CachedThreadPool | FixedThreadPool | SingleThreadExecutor |
---|---|---|---|
核心线程数 | 0 | nThreads(用户设定) | 1 |
最大线程数 | Integer.MAX_VALUE | nThreads(用户设定) | 1 |
非核心线程存活时间 | 60s | 无非核心线程 | 无非核心线程 |
等待队列最大长度 | 0(这里用的是 SynchronousQueue ,一个不存储元素的神奇的阻塞队列)。 |
Integer.MAX_VALUE |
Integer.MAX_VALUE |
特点 | 提交任务优先复用空闲线程,没有空闲线程则创建新线程。 | 固定线程数,等待运行的任务均放入等待队列。 | 有且仅有一个线程在运行,等待运行任务放入等待队列,可保证任务运行顺序与提交顺序一致。 |
通过上面,就可以知道这三种类型线程池与GC的关系了。
CachedThreadPool
:没有核心线程,而且线程具有超时时间,可见在引用消失后,等待任务允许结束且所有线程空闲回收后,GC开始回收此线程池对象;FixedThreadPool
:线程数和最大线程数均 nThreads,并且在默认allowCoreThreadTimeOut
为false
情况下,核心线程即使空闲也不会被回收,故GC不会回收该线程池;SingleThreadExecutor
:创建时实际返回的是FinalizableDelegatedExecutorService
类的对象,该类重写了finalize()
函数执行线程池的销毁,该对象持有ThreadPoolExecutor
对象的引用,但ThreadPoolExecutor
对象并不引用FinalizableDelegatedExecutorService
对象,这使得在FinalizableDelegatedExecutorService
对象的外部引用消失后,GC 将会对其进行回收,触发finalize
函数,而该函数仅仅简单的调用shutdown
函数关闭线程,在所有当前的任务执行完成后,回收线程池中线程,所以 GC 可回收线程池对象。
private static class FinalizableDelegatedExecutorService
extends DelegatedExecutorService {
FinalizableDelegatedExecutorService(ExecutorService executor) {
super(executor);
}
@SuppressWarnings("deprecation")
protected void finalize() {
super.shutdown();
}
}
结论:CachedThreadPool 及 SingleThreadExecutor 的对象在不显式销毁时,且其对象引用消失的情况下,可以被 GC 回收;FixedThreadPool 对象在不显式销毁,且其对象引用消失的情况下不会被 GC 回收,会出现内存泄露。因此无论使用什么线程池,使用完毕后均调用 shutdown 是一个较为安全的编程习惯。
线程池启动后创建核心线程
默认情况下,即使是核心线程也只能在新任务到达时才创建和启动。对于ThreadPoolExecutor
线程池可以使用 prestartCoreThread
(启动一个核心线程)或prestartAllCoreThreads
(启动全部核心线程)方法来提前启动核心线程。
主线程设置合理等待时间
- 尽量避免使用
CompletableFuture.join()
,Future.get()
这类不带超时时间阻塞主线程操作。 - for 循环使用
future.get(long timeout, TimeUnit unit)
。使用此方法允许去设置超时时间,但是如果主线程串行获取的话,下一个future.get
方法的超时时间,应该是从第一个get()
结束后开始计算的,否则会导致超时时间不合理。
子线程执行超时时间
比如子线程调用 JSF/ HTTP 接口等,一定要检查超时时间配置是否合理。
主线程
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()
方法仅仅是改变一个标志位的值而已,和线程的状态并没有必然的联系。
并发执行线程安全的操作
多线程操作同一对象应考虑线程安全性。常见场景比如HashMap
应该换成ConcurrentHashMap
;StringBuilder
应该换成 StringBuffer
等。特别是使用Spring
框架时,默认注入都是单例对象,如果修改成员变量,一定要考虑线程安全问题。
变量在线程之间的传递
提交到线程池执行的异步任务,切换了线程,子线程在执行时,获取不到主线程变量中存储的信息,常见场景如下:
- 为了减少参数透传,可能存在
ThreadLocal
里面; - 客户的登录状态,
LoginContext
等信息,一般是线程变量。
如果要解决此问题,可以参考Transmittable-Thread-Local
中间件提供的解决方案。
并发会增大死锁的可能性
同一个容器的http请求本身就是多线程的。使用业务线程池的并发操作需要更加注意,因为更容易暴露出来“死锁”这个问题,而且有时候并不好排查。
比如 Mysql
事务隔离级别为RR
(默认隔离级别)时,间隙锁可能导致死锁问题。间隙锁是Innodb
在可重复读提交下为了解决幻读问题时引入的锁机制,在执行 update、delete、select … for update 等语句时,存在以下加间隙锁情况:
- 有索引,当更新的数据存在时,只会锁定当前记录;更新的不存在时,间隙锁会向左找第一个比当前索引值小的值,向右找第一个比当前索引值大的值(没有比当前索引值大的数据时,是 supremum pseudo-record,可以理解为锁到无穷大)。
- 无索引,全表扫描,如果更新的数据不存在,则会根据主键索引对所有间隙加锁。
当并发执行数据库事务(事务内先更新,后新增操作),当更新的数据不存在时,会加间隙锁,然后执行新增数据需要其他事务释放在此区间的间隙锁,则可能导致死锁产生;如果是全表扫描,问题可能更严重。
合理使用共享线程池
提交异步任务,不指定线程池,存在最主要问题是非核心业务占用线程资源,可能会导致核心业务受影响。因为公共线程池的最大线程数、队列大小、拒绝策略都比较保守,可能引发各种问题,常见场景如下:
CompleteFuture
提交异步任务,不指定线程池。CompleteFuture
的supplyAsync
等以*Async
为结尾的方法,会使用多线程异步执行。可以注意到的是,它也允许不携带多线程提交任务的执行线程池参数,而这时默认使用的ForkJoinPool.commonPool()
。ForkJoinPool
最适合的是计算密集型的任务,如果存在 I/O,线程间同步,sleep()
等会造成线程长时间阻塞的情况时,最好配合使用ManagedBlocker
。ForkJoinPool
默认线程数取决于parallelism
参数为:CPU 处理器核数-1,也允许通修改 Java 系统属性java.util.concurrent.ForkJoinPool.common.parallelism
进行自定义配置。JDK 8 引入的集合框架的并行流
Parallel Stream
。Parallel Stream
也是使用的ForkJoinPool.commonPool()
,但有一点区别是:Parallel Stream
的主线程 (提交任务的线程)是会去参与处理的;比如 8 核心的机器执行Parallel Stream
是有 8 个线程,而CompleteFuture
提交的任务只有 7 个线程处理。不建议使用是一致的。@Async
提交异步任务,不指定线程池。SpringBoot 2.1.9 之前版本使用
@Async
不指定Executor
会使用SimpleAsyncTaskExecutor
,该线程池默认来一个任务创建一个线程,若系统中不断的创建线程,最终会导致OOM
。SpringBoot 2.1.0 之后版本引入了TaskExecutionAutoConfiguration
,其使用ThreadPoolTaskExecutor
作为 默认Executor
,通过TaskExecutionProperties.Pool
可以看到其配置默认核心线程数:8,最大线程数:Integet.MAX_VALUE
,队列容量是:Integet.MAX_VALUE
,空闲线程保留时间:60s,线程池拒绝策略:AbortPolicy
。虽然可以通实现
AsyncConfigurer
接口等方式,自行配置线程池参数,但仍不建议使用公共线程池。
并发请求不能过载
最后,设置了合理的参数,也注意优化了各种场景问题,可以大胆使用多线程了,但是也一定要考虑对下游带来的影响,比如数据库请求并发量增长,占用JDBC
数据库连接、给依赖RPC
服务带来性能压力等,特别是持久层,一旦被打垮,影响的是整个系统。