重温Java基础(二)之Java线程池最全详解

1. 引言

在当今高度并发的软件开发环境中,有效地管理线程是确保程序性能和稳定性的关键因素之一。Java线程池作为一种强大的并发工具,不仅能够提高任务执行的效率,还能有效地控制系统资源的使用。
本文将深入探讨Java线程池的原理、参数配置、自定义以及实际应用。通过理解这些关键概念,开发者将能够更好地应对不同的并发场景,优化程序的执行效率。
首先,我们将介绍线程池的基本概念,解释它在并发编程中的作用和优势。随后,我们将深入研究Java线程池的工作原理,剖析其在任务提交、执行和线程管理方面的内部机制。

2. Java线程池的基础概念

在并发编程中,线程池是一种重要的设计模式,它能够有效地管理和复用线程,提高程序的性能和资源利用率。Java线程池作为Java并发包(java.util.concurrent)的一部分,为开发者提供了方便、高效的多线程处理方式。同时在阿里巴巴开发规范中,强制要使用线程池去提供线程,不允许在代码中显示的创建线程。

2.1 什么是线程池?

线程池是由一组线程组成的线程队列,它们在程序启动时就被创建并一直存在。这些线程可被用来执行提交到线程池的各种任务,从而避免为每个任务都创建新线程。这种机制能够降低线程创建和销毁的开销,提高系统性能。

2.2 线程池的工作原理

线程池的工作原理基于任务队列和线程管理机制。当任务被提交到线程池时,线程池会选择合适的线程来执行任务。如果核心线程数未达到上限,新任务可能会导致新线程的创建。如果核心线程已满,任务将被放入任务队列等待执行。当任务队列也已满,而同时线程数未达到最大线程数,新的任务将创建临时线程来执行。

2.3 线程池的优势

使用线程池的优势主要体现在以下几个方面:
减少资源消耗: 线程的创建和销毁是有开销的,线程池通过复用线程,减少了这些开销。
提高响应速度: 由于线程池中的线程一直存在,可以更迅速地响应任务的到来。
避免系统过载: 控制线程数量,防止系统因过多线程而过载。

3. Java线程池的工作原理

Java线程池的工作原理涉及线程的创建、任务的提交与执行,以及对线程的管理。深入理解这些机制对于优化并发程序至关重要。

3.1 线程池的创建与初始化

在程序启动时,线程池被创建并初始化。这一过程包括设置线程池的基本参数,如核心线程数、最大线程数、任务队列等。核心线程数是线程池中一直存活的线程数量,而最大线程数则是线程池允许创建的最大线程数量。例如创建一个固定核心线程数的线程:

ExecutorService executorService = Executors.newFixedThreadPool(corePoolSize);

其中参数corePoolSize即为核心线程数

3.2 任务的提交与执行

任务提交到线程池后,线程池会根据一定的策略选择线程来执行任务。首先,线程池会检查核心线程是否已满,如果未满,新的任务可能会导致新线程的创建。如果核心线程已满,任务将被放入任务队列。
在Java线程池中,任务的提交与执行有两个主要的方法:submitexecute。这两种方法有一些区别,主要体现在返回值、异常处理和任务包装上。

3.2.1 submit方法

submit方法用于提交实现了Callable接口的任务,它可以返回一个Future对象,通过该对象可以获取任务执行的结果,取消任务等。submit方法还可以接受实现了Runnable接口的任务,但它无法获取任务的执行结果。submit方法在ExecutorService中定义的,并定义了三种重载方式:

<T> Future<T> submit(Callable<T> task);

<T> Future<T> submit(Runnable task, T result);

Future<?> submit(Runnable task);

具体使用如下:

class MyCallable implements Callable<Integer>{  
	@Override  
	public Integer call() throws Exception {  
	return null;  
	}  
}

// 提交callable任务,可以拿到返回值
Future<Integer> future1 = executorService.submit(new MyCallable());

class MyRunnable implements Runnable {  
	@Override  
	public void run() {  
	  
	}  
}
Future<Void> future2 = executorService.submit(new MyRunnable(), null);  
Future<?> future3 = executorService.submit(new MyRunnable());

主要特点:

  • 返回一个Future对象,可通过Futureget()方法可以获取到线程执行的返回值,get()方法是同步的,执行get()方法时,如果线程还没执行完,会同步等待,直到线程执行完成。
  • 可以接受CallableRunnable类型的任务。
  • 执行RunnableCallable的任务时,run()/call()方法没显式抛出异常。

3.2.2 execute方法

execute方法用于提交实现了Runnable接口的任务,它没有返回值,因此无法获取任务的执行结果。如果任务执行过程中抛出了异常,线程池会捕获并记录该异常,但无法通过execute方法获知。execute方法是在线程池的顶级接口Executor中定义的,而且只有这一个接口。

public interface Executor {  
	void execute(Runnable command);  
}

使用:

executorService.execute(() -> {  
	// 具体业务逻辑  
});

主要特点:

  • 没有返回值,无法获取任务的执行结果。
  • 只能接受Runnable类型的任务。

总的来说,submit方法更加灵活,适用于更多场景,而execute方法更加简单,适用于只关心任务执行而不需要获取结果的场景。在实际应用中,根据具体需求选择合适的方法。如果需要获取任务的执行结果、取消任务等,建议使用submit方法。只是执行任务而不关心返回值,可以使用execute方法。

4. 线程池的参数以及配置

Java线程池的性能和行为可以通过一系列参数进行调整,以满足不同的并发需求。ThreadPoolExecutor中提供的构造器如下:

ThreadPoolExecutor.png

4.1 七大参数

4.1 核心线程数(Core Pool Size)

核心线程数是线程池中一直存活的线程数量(即使它们处于空闲状态)。这些线程用于执行提交到线程池的任务。通过合理设置核心线程数,可以在系统负载增加时迅速响应任务。

4.2 最大线程数(Maximum Pool Size)

最大线程数定义了线程池中允许创建的最大线程数量。当核心线程都在执行任务,而新任务仍然到来时,线程池会创建新线程,直到达到最大线程数。超过最大线程数的任务会被拒绝。

4.3 线程存活时间(Keep Alive Time)

线程存活时间指的是非核心线程在空闲状态下的最大存活时间。当线程池中线程数量超过核心线程数时,空闲的非核心线程在经过一定时间后会被终止,从而释放系统资源。

4.4 TimeUnit

keepAliveTime的单位(ms、s...)

4.5 工作队列(Work Queue)

工作队列用于存放等待执行的任务。不同类型的队列对线程池的行为有重要影响,例如有界队列和无界队列。有界队列在任务数达到上限时会触发拒绝策略。

4.6 ThreadFactory

线程池中生成线程的工厂。默认使用默认工厂Executors.defaultThreadFactory()。但是实际使用时建议使用Guava的ThreadFactory自定义线程的名字,方便排查线程问题(阿里开发规范中也建议这么做)。如下:

ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("mythread-pool-").build();
4.7 拒绝策略(Rejected Execution Policy)

拒绝策略定义了当工作队列满,并且当前工作的线程数等于最大线程数时,后续再提交的任务如何处理。例如,可以选择抛弃任务、抛出异常或在调用线程中直接执行。Java线程池提供了几种常见的拒绝策略:

  • AbortPolicy(默认策略):
    直接抛出RejectedExecutionException,阻止系统继续接受新任务,保持原有状态。
new ThreadPoolExecutor.AbortPolicy();
  • CallerRunsPolicy:
    将任务返回给调用者,由调用线程直接执行。
new ThreadPoolExecutor.CallerRunsPolicy();
  • DiscardPolicy:
    直接丢弃无法处理的任务,不抛出异常。
new ThreadPoolExecutor.DiscardPolicy();
  • DiscardOldestPolicy:
    当任务被拒绝添加时,会抛弃任务队列中最旧的任务也就是最先加入队列的,再把这个新任务添加进去。
new ThreadPoolExecutor.DiscardOldestPolicy();

4.2 线程池提交任务执行流程

4.2.1 执行流程

线程池执行流程.png

4.2.2 实例讲解

某银行柜台,共有5个窗口(Maximum Pool Size),平时常开2个窗口办理业务(Core Pool Size),银行大厅摆了5个椅子(Work Queue)供客户等待。银行规定当常开的窗口都在办理业务,并且大厅椅子上都坐满了客户,那么另外3个不常开的窗口也要打开办理业务。如果这3个窗口也都全部在办理业务,后面继续来银行办理业务的客户银行将拒绝办理。如果某个员工空闲下并且超过了5(Keep Alive Time)秒钟(TimeUnit)那么他就可以关闭窗口去休息。但是必须保留2个常开的窗口。
我们先按照上述流程创建一个线程池:

// 推荐使用Guava的ThreadFactory构建ThreadFactory,自定义线程名称 方便后续排查问题  
ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("mythread-pool-").build();  
// 定义号线程  
ExecutorService executorService = new ThreadPoolExecutor(  
		// 核心线程数,即2个常开窗口  
		2,  
		// 最大的线程数,银行所有的窗口  
		5,  
		// 空闲时间  
		5,  
		TimeUnit.SECONDS,  
		// 工作队列  
		new LinkedBlockingQueue<>(5),  
		// 线程工厂  
		threadFactory,  
		// 拒绝策略  
		new ThreadPoolExecutor.AbortPolicy()  
);

(1)初始状态下,只有2个窗口为2个客户办理业务。

ThreadPool_演示1.png

(2)在客户1,客户2办理业务或者说常开窗口一直都有客户在办理业务,此时陆续有客户进来,需要在银行大厅的椅子上等待。

ThreadPool_图解2.png

(3)直到大厅的椅子都坐满。

ThreadPool_图解5.png

(4)此时如果在所有的窗口都在办理业务,大厅椅子坐满,如果再来一个客户,将开启3/4/5的窗口

ThreadPool_图解7.png

(5)此时如果在所有的窗口都在办理业务,大厅椅子坐满,还从外面再来2个客户办理业务,那么就需要把剩下的窗口都要打开去办理业务。

ThreadPool_图解8.png

(6)此时如果再来1个客户,就会按照线程池定义的拒绝策略去执行,比如我们设置策略为:AbortPolicy,就会抛出异常。

ThreadPool_图解9.png

4.3 线程池参数配置

线程池的配置参数在实际应用中需要根据具体的业务场景和性能需求进行巧妙调整。这就好比在日常生活中,如果有一个任务需要三人协同完成,但却有六人前来参与,就会造成三人的资源浪费;反之,若只安排两人协作,可能会超负荷而不切实际。因此,在线程池参数配置时,过小或过大都会带来问题。

当线程池数量设置过小时,面对大量同时到达的任务或请求,可能导致这些任务在任务队列中排队等待执行。甚至在任务队列达到最大容量时,无法处理额外的任务,或者导致任务队列积压,有可能引发内存溢出(OOM)问题。这明显是一个问题,因为CPU资源无法得到充分利用。

相反,若线程数量设置过大,大量线程可能会同时争夺CPU资源,导致频繁的上下文切换,从而增加线程的执行时间,影响整体执行效率。因此,在线程池配置中需要平衡线程数量,以满足高并发场景下的任务处理需求,同时避免不必要的资源争夺和上下文切换,以保障系统的稳定性和性能。

并没有一个通用的标准来设置参数,因此需要结合实际实战经验、业务需求以及服务器资源的状况,灵活而合理地进行参数配置。最终,合适的配置才是最为优越的选择。

当然也有一个简单而广泛适用的公式,可以用于确定线程池中的线程数:

  1. CPU 密集型任务(N+1):
    • 对于消耗主要是CPU资源的任务,可以将线程数设置为N(CPU核心数)+1。额外的一个线程用于防止线程偶发的缺页中断或其他原因导致的任务暂停,防止空闲时间的浪费。一旦任务暂停,多出来的一个线程可以充分利用CPU的空闲时间。
  2. I/O 密集型任务(2N):
    • 对于主要涉及I/O交互的任务,系统会在大部分时间内处理I/O,而在线程处理I/O的时间段内不会占用CPU。因此,在I/O密集型任务中,可以配置更多的线程,具体计算方法是2N。

那我们如何判断任务是CPU密集型还是IO密集型呢?简而言之,CPU密集型任务主要利用CPU计算能力,例如对内存中大量数据进行排序。而IO密集型任务涉及网络读取、文件读取等,其特点是CPU计算耗费的时间相对较少,大部分时间花在等待IO操作完成上。

但是我们在实际的业务中会发现,我们一个服务器上可能跑多种类型的业务,不太好判断到底是CPU密集任务还是IO密集型。我们可以根据监控服务线程池资源利用情况结合业务场景动态配制合理参数。这里我们就不得不提一下美团的线程池参数动态化配置:Java线程池实现原理及其在美团业务中的实践 - 美团技术团队

5. 线程池的使用

日常开发中我们可以通过Executors去创建线程池,例如:
(1)newFixedThreadPool()

ExecutorService executorService1 = Executors.newFixedThreadPool(2);

创建固定线程数的线程池,核心线程数等于最大线程数,此时keepAliveTime失效 。但是他的工作队列的长度为Integer.MAX_VALUE。可能会导致堆积大量的请求,导致OOM。

(2)newSingleThreadExecutor()

ExecutorService executorService2 = Executors.newSingleThreadExecutor();

创建单线程的线程池,即核心线程数等于最大线程数均等于1,keepAliveTime失效 。但是他的工作队列的长度为Integer.MAX_VALUE。可能会导致堆积大量的请求,导致OOM。

(3)newCachedThreadPool()

ExecutorService executorService3 = Executors.newCachedThreadPool();

创建一个核心线程数等于0,并且允许创建的最大线程数等于Integer.MAX。keepAliveTime为60秒。可能会造成创建大量的线程,从而导致OOM。

(4)newScheduledThreadPool()

ExecutorService executorService4 = Executors.newScheduledThreadPool(2);

创建一个允许最大线程数等于Integer.MAX,但是他使用的阻塞工作队列是DelayedWorkQueueDelayedWorkQueue的核心数据结构是二叉最小堆的优先队列,队列满时会自动扩容。所以最大线程数没有意义,线程池中永远会保持至多有核心线程数个工作线程正在运行。

注意: 以上创建线程池的方法,可以做自己Demo使用,不应该用在项目中。在阿里巴巴代码规范中,不支持使用这种方式去创建,支持手动创建线程池。

ThreadPool_阿里巴巴开发规范.png

6.总结

Java线程池是多线程编程中的重要工具,能够有效管理和复用线程,提高系统性能和资源利用率。本文深入探讨了线程池的基础概念、工作原理、参数配置、自定义以及使用示例,并强调了注意事项。
通过了解线程池的工作原理,开发者可以更好地配置线程池以适应不同的并发需求。自定义线程池则使得线程池更灵活地适应特定业务场景。在实际应用中,要谨慎选择线程池类型、合理配置参数、注意任务的生命周期和线程安全等问题,以确保系统的稳定性和性能。

参考文献

1、Java线程池实现原理及其在美团业务中的实践 - 美团技术团队 (meituan.com)
2、《Java并发编程实战》

本文已收录于我的个人博客:码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

posted @ 2024-01-31 15:51  码农Academy  阅读(202)  评论(0编辑  收藏  举报