大家好,我是真的不卷了的七哥。
Java 并发编程系列今天再来聊最后一篇:Java 线程池。
本文将根据面试中常被问到的 Java线程池 展开抽丝剥茧的解析,这个问题可以说是百分之百会在Java程序员面试中被问到,因为在工作中这个需求实在是太普遍了。
Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。
本文成文的思路将根据面试中问答的流程展开,读者完全可以将本文展开的知识点作为回答此问题的常规套路,如果你掌握本文所列出的知识点,那么就因这一个问题就可以让面试官对你刮目相看。
线程池的作用
在被问到,你是否了解线程池时,这个毫无疑问,肯定都了解,那么从哪开始说呢?必须是使用线程的好处,也就是能解决什么问题。
先说出为什么需要使用线程池,也就是背景:
因为创建一个线程,却需要调用操作系统内核的 API,然后操作系统要为线程分配一系列的资源,这个成本就很高了,所以线程是一个重量级的对象,应该避免频繁创建和销毁,所以要使用线程池
然后再说出线程池如果解决上面的问题,这里列举的有三个优点:
-
降低资源消耗; -
提高响应速度; -
提高线程的可管理性;
线程池的实现原理
回答完使用线程池的必要性,接着就是重头戏,线程池的实现原理。
开门见山,先介绍线程池的实现类 ThreadPoolExecutor 是如何使用的,包含哪些参数,含义是什么?
ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
要创建一个线程池,构造函数是比较复杂的,一共包含7个参数,具体每个参数的含义如下:
-
corePoolSize: 线程池的基本大小,当线程池中线程的数量没有达到corePoolSize大小时,每提交一个任务到线程池就会创建一个线程来执行任务,即使其他线程空闲也会创建,直到数据等于线程池基本大小,就不再继续创建。需要说明的是如果调用了线程池的
prestartAllCoreThreads()
方法,线程池会提前创建并启动所有基本线程。 -
maximumPoolSize:表示线程池最大可以创建的线程数,当提交的任务特别多时,corePoolSize大小的数量搞不定就得额外加了,但是也不能无限加,只能加到maximumPoolSize 大小。当任务减少不需要这么多线程的时候就会减少,直到corePoolSize大小。
-
keepAliveTime & unit:这两个参数是表示线程的最大空闲时间和时间单位的,也就是说当线程池中的线程增长到
maximumPoolSize
大小后,任务减少,线程在空闲keepAliveTime 时间后,如果数量大于corePoolSize
就会销毁。 -
workQueue:工作阻塞队列,表示当线程池中所有线程池都处于运行状态,那么再提交任务就会放到声明的workQueue队列中,可以选择以下几个队列:
-
ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。 -
LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法 Executors.newFixedThreadPool()
使用了这个队列。 -
SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工厂方法 Executors.newCachedThreadPool
使用了这个队列。 -
PriorityBlockingQueue:一个具有优先级的无限阻塞队列。 -
threadFactory:通过这个参数你可以自定义如何创建线程,例如你可以给线程指定一个有意义的名字。
-
handler:饱和策略,当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法处理新任务时抛出异常。在JDK 1.5中Java线程池框架提供了以下4种策略。
-
AbortPolicy:直接抛出异常,会 throws RejectedExecutionException
。 -
CallerRunsPolicy:调用者所在线程自己来运行任务。 -
DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。 -
DiscardPolicy:不处理,丢弃掉。
我们再结合一张图给看看Java线程池的设计实现原理,帮你将上面线程池创建所需的参数串起来:

也就是说当线程池ThreadPoolExecutor执行execute或者submit方法时线程池的处理情况是这样的:
-
如果当前线程池中的线程少于 corePoolSize 则直接创建新线程来执行任务,注意这一步需要获取全局锁; -
如果线程池中运行的线程大于等于 corePoolSize ,则将任务加入 workQueue,即阻塞队列。 -
如果阻塞队列也满了,任务无法加入,但是当前线程数小于 maximunPoolSize 最大线程数,则创建一个线程来执行任务,这一步骤需要获取全局锁; -
如果当前线程数量已经等于maximunPoolSize,这时提交的任务将会被拒绝,并且调用 RejectedExecutionHandler.rejectedExecution() 方法;
可以看出线程池是一个 生产者-消费者 模型。使用线程的一方是生产者,线程池本身是消费者。因为使用方是向线程池中丢任务(Runnable/Callable),而线程池本身消费这些提交的任务。
ThreadPoolExecutor 采取上述的实现原理,尽可能避免了在执行execute,submit方法时获取全局锁(性能瓶颈),因为只要提交的任务数达到 corePoolSize 时,几乎后面所有的 execute、submit 提交任务都是再走步骤2,无需获取锁,设计的是相当牛逼的。
如果向线程池提交任务
在回答了线程池的实现原理后,那么具体如何使用呢?你就可以回答这两种向线程池提交任务的方式,以及他们之间的区别和使用场景。
一共有两种方法提交任务:
-
execute() -
submit()
区别在于,execute 方法用于提交不需要返回值的任务,一方面无法判断任务是否执行成功,也无法获取线程的执行结果。
public void execute(Runnable command)
可以看到 execute接收的是一个 Runnable实例,并且这个方法是没有返回结果的。
那么你肯定会问,很多场景下,我们是需要获取任务的执行结果的。这种情况就可以使用 submit() 方法,ThreadPoolExecutor 提供了下面三个 submit 方法,方法签名如下:
// 提交Runnable任务
Future submit(Runnable task);
// 提交Callable任务
Future submit(Callable task);
// 提交Runnable任务及结果引用
Future submit(Runnable task, T result);
submit 方法都会返回 Future 对象,通过 future 对象的 future.isDone 方法我们就可以判断任务是否执行完成,以及通过 get() 和 get(timeout, unit) 获取任务的返回值。这两个get方法都会阻塞当前调用线程直到任务完成;
这里再多说一点,我们在平时使用Future阻塞获取任务结果时,可以使用 FutureTask 这个工具类,他同时实现了Runnable和Future接口,也就是说即可以作用任务传递给线程池,也可以用来获取子线程的执行结果。
示例如下:
// 创建FutureTask
FutureTask futureTask = new FutureTask<>(()->"这是返回结果" );
// 创建线程池
ExecutorService es = Executors.newCachedThreadPool();
// 提交FutureTask
es.submit(futureTask);
// 获取计算结果
String result = futureTask.get();
关闭线程池
线程池的关闭,我们可以使用线程池的 shutdown 和 shutdownNow 方法。它俩的原理都是遍历线程池中的线程然后逐个调用线程的interrupt方法来中断线程。但是这块仅仅是调用中断方法,并不意味着线程就会终止,前提是线程执行的任务可以响应中断,要不然可能永远无法终止。
但上面这两个方法还是存在一定差异的,即shutdownNow方法调用后,首先将线程池的状态设为 STOP,然后调用线程池中所有线程的interrupt方法(包含正在运行的线程),并且返回队列中等待执行任务的列表。而shutdown方法只是将线程状态设为SHUTDOWN状态,然后调用线程池中空闲线程的interrupt方法。
线程池的参数如何配置
当你回答了上面所有线程池的知识点后,一般情况下已经差不多了,不过大厂的面试官还可能会问,既然你知道了原理,那么平时使用的时候,这些参数都是如何配置的呢?
遇到这个问题你的思路得清晰,这个数字肯定不是拍脑袋决定的,不要急于给出数字,而是从场景展开:
要想合理的使用线程池,那么就要首先分析任务特性,是CPU密集型还是IO密集型。
-
CPU密集型的任务,应该配置尽可能少的线程数,一般配置 CPU个数+1个线程的线程池; -
IO密集型任务,因为并不是一直在执行任务,则应分配尽可能多的线程,一般配置2*CPU个数 的线程数量;
如果获取机器的CPU个数,我们可以使用Runtime.getRuntime().availableProcessors()
;
如果需要处理的任务是有优先级的,则可以使用PriorityBlockingQueue
这个阻塞队列作为工作队列,优先级高的先执行。
值得一提的是,使用线程池建议使用有界队列,因为能增加系统的稳定性,我们之前就因为使用了 Executors.newFixedThreadPool() 创建的线程池,其默认使用了无界的 LinkedBlockingQueue 导致在数据库异常时,任务积压,线上频繁FGC,最终内存爆了,整个服务不可用。后来改为有界的工作队列后,就会不断抛出任务抛弃异常,便于监控发现并且不会导致整个服务不可用,只是线程任务异常。
总结
本文基于面试场景,对于Java线程池展开了解析,相信你如果能将本文的内容做到了然于心,以后面试碰到Java线程池这个问题就再也不会垂头丧气,而且胸有成竹。
Java 并发编程相关的内容,暂时告一段落了,内容挺全的,工作或者面试当做八股文复习都挺不错的,求个点赞、在看😄。
原文始发于微信公众号(七哥聊编程):并发编程 13:Java线程池了解?
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/37092.html