面试官:Java线程池了解?如果你还回答不好,那还不赶快收藏!

601 阅读9分钟

写在前面

本文将根据面试中常被问到的 Java线程池 展开抽丝剥茧的解析,这个问题可以说是百分之百会在Java程序员面试中被问到,因为在工作中这个需求实在是太普遍了。Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。

本文成文的思路将根据面试中问答的流程展开,读者完全可以将本文展开的知识点作为回答此问题的常规套路,如果你掌握本文锁列出的知识点,那么就因这一个问题就可以让面试官对你刮目相看。

线程池的作用

在被问到,你是否了解线程池时,这个毫无疑问,肯定都了解,那么从哪开始说呢? 必须是使用线程的好处,也就是能解决什么问题。

先说出为什么需要使用线程池,也就是背景:

因为创建一个线程,却需要调用操作系统内核的 API,然后操作系统要为线程分配一系列的资源,这个成本就很高了,所以线程是一个重量级的对象,应该避免频繁创建和销毁,所以要使用线程池

然后再说出线程池如果解决上面的问题,这里列举额有三个优点:

  1. 降低资源消耗;
  2. 提高响应速度;
  3. 提高线程的可管理性;

线程池的实现原理

回答完使用线程池的必要性,接着就是重头戏,线程池的实现原理。

开门见山,先介绍线程池的实现类 ThreadPoolExecutor 是如何使用的,包含哪些参数,含义是什么?

ThreadPoolExecutor(int corePoolSize,
                   int maximumPoolSize,
                   long keepAliveTime,
                   TimeUnit unit,
                   BlockingQueue<Runnable> workQueue,
                   ThreadFactory threadFactory,
                   RejectedExecutionHandler handler)

要创建一个线程池,构造函数是比较复杂的,一共包含7个参数,具体每个参数的含义如下:

  1. corePoolSize: 线程池的基本大小,当线程池中线程的数量没有达到corePoolSize大小时,每提交一个任务到线程池就会创建一个线程来执行任务,即使其他线程空闲也会创建,直到数据等于线程池基本大小,就不再继续创建,而是在下面的逻辑。需要说明的是如果调用了线程池的 prestartAllCoreThreads() 方法,线程池会提前创建并启动所有基本线程。

  2. maximumPoolSize:表示线程池最大可以创建的线程数,当提交的任务特别多时,corePoolSize大小的数量搞不定就得额外加了,但是也不能无限加,只能加到maximumPoolSize 大小。当任务减少不需要这么多线程的时候就会减少,直到corePoolSize大小。

  3. keepAliveTime & unit:这两个参数是表示线程的最大空闲时间和时间单位的,也就是说当线程池中的线程增长到maximumPoolSize大小后,任务减少,线程在空闲keepAliveTime 时间后,如果数量大于corePoolSize就会销毁。

  4. workQueue:工作阻塞队列,表示当线程池中基本大小的线程池都处于运行状态,那么再提交任务就会放到声明的workQueue队列中,可以选择以下几个队列:

    • ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。
    • LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool() 使用了这个队列。
    • SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
    • PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
  5. threadFactory:通过这个参数你可以自定义如何创建线程,例如你可以给线程指定一个有意义的名字。

  6. handler:饱和策略,当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法处理新任务时抛出异常。在JDK 1.5中Java线程池框架提供了以下4种策略。

    • AbortPolicy:直接抛出异常,会 throws RejectedExecutionException。
    • CallerRunsPolicy:调用者所在线程自己来运行任务。
    • 丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
    • DiscardPolicy:不处理,丢弃掉。

我们再结合一张图给看看Java线程池的设计实现原理,帮你将上面线程池创建所需的参数串起来:

也就是说当线程池ThreadPoolExecutor执行execute或者submit方法时线程池的处理情况是这样的:

  1. 如果当前线程池中的线程少于 corePoolSize 则直接创建新线程来执行任务,注意这一步需要获取全局锁;
  2. 如果线程池中运行的线程大于等于 corePoolSize ,则将任务加入 workQueue,即阻塞队列。
  3. 如果阻塞队列也满了,任务无法加入,但是当前线程数小于 maximunPoolSize 最大线程数,则创建一个线程来执行任务,这一步骤需要获取全局锁;
  4. 如果当前线程数量已经等于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方法来中断线程,但是这快仅仅是调用中断方法,并不意味着线程就会终止,前提是线程执行的任务可以响应中断,要不然可能永远无法终止,对 线程中断不熟悉的小伙伴可以看看 七哥这篇踩坑指南: 一个线程中断引发Bug的“爆肝”排查经历

但上面这两个方法还是存在一定差异的,即shutdownNow方法调用后,首先将线程池的状态设为 STOP,然后调用线程池中所有线程的interrupt方法(包含正在运行的线程),并且返回队列中等待执行任务的列表。而shutdown方法只是将线程状态设为SHUTDOWN状态,然后调用线程池中空闲线程的interrupt方法。

线程池的参数如何配置

当你回答了上面所有线程池的知识点后,一般情况下已经差不多了,不过大厂的面试官还可能会问,既然你知道了原理,那么平时使用的时候,这些参数都是如何配置的呢?

遇到这个问题你的思路得清晰,这个数字肯定不是拍脑袋决定的,不要急于给出数字,而是从场景展开:

要想合理的使用线程池,那么就要首先分析任务特性,是CPU密集型还是IO密集型。

  1. CPU密集型的任务,应该配置尽可能少的线程数,一般配置 CPU个数+1个线程的线程池;
  2. IO密集型任务,因为并不是一直在执行任务,则应分配尽可能多的线程,一般配置2*CPU个数 的线程数量;

如果获取机器的CPU个数,我们可以使用Runtime.getRuntime().availableProcessors();

如果需要处理的任务是有优先级的,则可以使用PriorityBlockingQueue这个阻塞队列作为工作队列,优先级高的先执行。

值得一提的是,使用线程池建议使用有界队列,因为能增加系统的稳定性,我们之前就因为使用了 Executors.newFixedThreadPool() 创建的线程池,其默认使用了无界的 LinkedBlockingQueue 导致在数据库异常时,任务积压,线上频繁FGC,最终内存爆了,整个服务不可用。后来改为有界的工作队列后,就会不断抛出任务抛弃异常,便于监控发现并且不会导致整个服务不可用,只是线程任务异常。

总结

本文基于面试场景,对于Java线程池展开了解析,相信你如果能将本文的内容做到了然于心,以后面试碰到Java线程池这个问题就再也不会垂头丧气,而且胸有成竹。

关注我

公众号:七哥爱编程