JUC并发编程线程池及相关面试题 详解

Posted 小颜-

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JUC并发编程线程池及相关面试题 详解相关的知识,希望对你有一定的参考价值。

【JUC并发编程】线程池及相关面试题 详解

参考资料:

第十二章 线程池原理 · 深入浅出Java多线程原理

两道面试题,深入线程池,连环17问

深入理解Java并发编程之线程池、工作原理、复用原理及源码分析

硬核干货:4W字从源码上分析JUC线程池ThreadPoolExecutor的实现原理

文章目录

1、什么是线程池?线程池有什么好处?

所谓线程池,通俗来讲,就是一个管理线程的池子。它可以容纳多个线程,其中的线程可以反复利用,省去了频繁创建线程对象的操作。

线程池的优点:

在 Java 并发编程框架中的线程池是运用场景最多的技术,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理地使用线程池能够带来至少以下4个好处。

第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗;

第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行;

第三:提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。并发数量过多,可能会导致资源消耗过多,从而造成服务器崩溃。(主要原因)

第四:提供更强大的功能,比如延时定时线程池;

2、有几种常见的线程池?

Executors 是一个Java中的工具类。提供工厂方法来创建不同类型的线程池。

核心概念:这四个线程池的本质都是ThreadPoolExecutor对象。

newFiexedThreadPool(int Threads):创建固定数目线程的线程池。

newCachedThreadPool():创建一个可缓存的线程池,调用execute 将重用以前构造的线程(如果线程可用)。如果没有可用的线程,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有 60 秒钟未被使用的线程。

newSingleThreadExecutor()创建一个单线程化的Executor。

newScheduledThreadPool(int corePoolSize)创建一个支持定时及周期性的任务执行的线程池,多数情况下可用来替代Timer类。

3、但是为什么我说不建议大家使用这个类来创建线程池呢?

我提到的是『不建议』,但是在阿里巴巴Java开发手册中也明确指出,而且用的词是『不允许』使用Executors创建线程池。

Executors存在什么问题

在阿里巴巴Java开发手册中提到,使用Executors创建线程池可能会导致OOM(OutOfMemory ,内存溢出),但是并没有说明为什么,那么接下来我们就来看一下到底为什么不允许使用Executors?

我们先来一个简单的例子,模拟一下使用Executors导致OOM的情况。

/**
 * @author 刘宇浩
 */
public class ExecutorsDemo 
    private static ExecutorService executor = Executors.newFixedThreadPool(15);
    public static void main(String[] args) 
        for (int i = 0; i < Integer.MAX_VALUE; i++) 
            executor.execute(new SubThread());
        
    


class SubThread implements Runnable 
    @Override
    public void run() 
        try 
            Thread.sleep(10000);
         catch (InterruptedException e) 
            //do nothing
        
    

通过指定JVM参数:-Xmx8m -Xms8m 运行以上代码,会抛出OOM:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
    at java.util.concurrent.LinkedBlockingQueue.offer(LinkedBlockingQueue.java:416)
    at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1371)
    at com.hollis.ExecutorsDemo.main(ExecutorsDemo.java:16)

以上代码指出,ExecutorsDemo.java的第16行,就是代码中的executor.execute(new SubThread());

Executors为什么存在缺陷

其实,在上面的报错信息中,我们是可以看出蛛丝马迹的,在以上的代码中其实已经说了,真正的导致OOM的其实是LinkedBlockingQueue.offer方法。

如果翻看代码的话,也可以发现,其实底层确实是通过LinkedBlockingQueue实现的:

public static ExecutorService newFixedThreadPool(int nThreads) 
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());

如果对Java中的阻塞队列有所了解的话,看到这里或许就能够明白原因了。

Java中的BlockingQueue主要有两种实现,分别是ArrayBlockingQueueLinkedBlockingQueue

ArrayBlockingQueue是一个用数组实现的有界阻塞队列,必须设置容量。

LinkedBlockingQueue是一个用链表实现的有界阻塞队列,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE

这里的问题就出在:**不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。**也就是说,如果我们不设置LinkedBlockingQueue的容量的话,其默认容量将会是Integer.MAX_VALUE

newFixedThreadPool中创建LinkedBlockingQueue时,并未指定容量。此时,LinkedBlockingQueue就是一个无边界队列,对于一个无边界队列来说,是可以不断的向队列中加入任务的,这种情况下就有可能因为任务过多而导致内存溢出问题。

上面提到的问题主要体现在newFixedThreadPoolnewSingleThreadExecutor两个工厂方法上,并不是说newCachedThreadPoolnewScheduledThreadPool这两个方法就安全了,这两种方式创建的最大线程数可能是Integer.MAX_VALUE,而创建这么多线程,必然就有可能导致OOM。

创建线程池的正确姿势

避免使用Executors创建线程池,主要是避免使用其中的默认实现,那么我们可以自己直接调用ThreadPoolExecutor的构造函数来自己创建线程池。在创建的同时,给BlockQueue指定容量就可以了。

private static ExecutorService executor = new ThreadPoolExecutor(10, 10,
        60L, TimeUnit.SECONDS,
        new ArrayBlockingQueue(10));

这种情况下,一旦提交的线程数超过当前可用线程数时,就会抛出java.util.concurrent.RejectedExecutionException,这是因为当前线程池使用的队列是有边界队列,队列已经满了便无法继续处理新的请求。但是异常(Exception)总比发生错误(Error)要好。

4、线程池的主要参数有哪些?

主要参数就是下面这几个:

  • corePoolSize:线程池中的核心线程数,包括空闲线程,也就是核心线程数的大小;
  • maximumPoolSize:线程池中允许的最多的线程数,也就是说线程池中的线程数是不可能超过该值的;
  • keepAliveTime:当线程池中的线程数大于 corePoolSize 的时候,在超过指定的时间之后就会将多出 corePoolSize 的的空闲的线程从线程池中删除;
  • unit:keepAliveTime 参数的单位(常用的秒为单位);
  • workQueue:用于保存任务的队列,此队列仅保持由 executor 方法提交的任务 Runnable 任务;
  • threadFactory:线程池工厂,他主要是为了给线程起一个标识。也就是为线程起一个具有意义的名称;
  • handler:拒绝策略

5、线程池的工作流程?

当向线程池提交一个任务之后,线程池是如何处理这个任务的呢?下面就先来看一下它的主要处理流程。

当使用者将一个任务提交到线程池以后,线程池是这么执行的:

①首先判断核心的线程数是否已满,如果没有满,那么就去创建一个线程去执行该任务;否则请看下一步

②如果线程池的核心线程数已满,那么就继续判断任务队列是否已满,如果没满,那么就将任务放到任务队列中;否则请看下一步

③如果任务队列已满,那么就判断线程池是否已满,如果没满,那么就创建线程去执行该任务;否则请看下一步;

④如果线程池已满,那么就根据拒绝策略来做出相应的处理;

看到这里,我们再来画一张图来总结和概括下线程池的执行示意图:

6、线程池的拒绝策略有哪些?

线程池有四种默认的拒绝策略,分别为:

  1. AbortPolicy:这是线程池默认的拒绝策略,在任务不能再提交的时候,抛出异常,及时反馈程序运行状态。如果是比较关键的业务,推荐使用此拒绝策略,这样子在系统不能承载更大的并发量的时候,能够及时的通过异常发现;
  2. DiscardPolicy:丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃。这玩意不建议使用;
  3. DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。这玩意不建议使用;
  4. CallerRunsPolicy:如果任务添加失败,那么主线程就会自己调用执行器中的 executor 方法来执行该任务。这玩意不建议使用;

也就是说关于线程池的拒绝策略,最好使用默认的。这样能够及时发现异常。如果上面的都不能满足你的需求,你也可以自定义拒绝策略,只需要实现 RejectedExecutionHandler 接口即可

public class CustomRejection implements RejectedExecutionHandler 
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) 
        System.out.println("你自己想怎么处理就怎么处理");
    

7、线程池有哪几种工作队列?

workQueue 有多种选择,在 JDK 中一共提供了 7 中阻塞对列,分别为:

  1. ArrayBlockingQueue : 一个由数组结构组成的有界阻塞队列。 此队列按照先进先出(FIFO)的原则对元素进行排序。默认情况下不保证访问者公平地访问队列 ,所谓公平访问队列是指阻塞的线程,可按照阻塞的先后顺序访问队列。非公平性是对先等待的线程是不公平的,当队列可用时,阻塞的线程都可以竞争访问队列的资格。
  2. LinkedBlockingQueue : 一个由链表结构组成的有界阻塞队列。 此队列的默认和最大长度为Integer.MAX_VALUE。 此队列按照先进先出的原则对元素进行排序。
  3. PriorityBlockingQueue : 一个支持优先级排序的无界阻塞队列。 (虽然此队列逻辑上是无界的,但是资源被耗尽时试图执行 add 操作也将失败,导致 OutOfMemoryError)
  4. DelayQueue: 一个使用优先级队列实现的无界阻塞队列。 元素的一个无界阻塞队列,只有在延迟期满时才能从中提取元素
  5. SynchronousQueue: 一个不存储元素的阻塞队列。 一种阻塞队列,其中每个插入操作必须等待另一个线程的对应移除操作 ,反之亦然。(SynchronousQueue 该队列不保存元素)
  6. LinkedTransferQueue: 一个由链表结构组成的无界阻塞队列。 相对于其他阻塞队列LinkedTransferQueue多了tryTransfer和transfer方法。
  7. LinkedBlockingDeque: 一个由链表结构组成的双向阻塞队列。 是一个由链表结构组成的双向阻塞队列

在以上的7个队列中,线程池中常用的是ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue

队列中的常用的方法如下:

类型方法含义特点
抛异常add添加一个元素如果队列满,抛出异常 IllegalStateException
抛异常remove返回并删除队列的头节点如果队列空,抛出异常 NoSuchElementException
抛异常element返回队列头节点如果队列空,抛出异常 NoSuchElementException
不抛异常,但是不阻塞offer添加一个元素添加成功,返回 true,添加失败,返回 false
不抛异常,但是不阻塞poll返回并删除队列的头节点如果队列空,返回 null
不抛异常,但是不阻塞peek返回队列头节点如果队列空,返回 null
阻塞put添加一个元素如果队列满,阻塞
阻塞take返回并删除队列的头节点如果队列空,阻塞

8、如何合理设置线程池的核心线程数?

线程池数量的确定一直是困扰着程序员的一个难题,大部分程序员在设定线程池大小的时候就是随心而定。

很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换成本。不清楚什么是上下文切换的话,可以看我下面的介绍。

上下文切换:

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

类比于现实世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。

如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的! CPU 根本没有得到充分利用。

但是,如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

在实际的开发中,我们需要根据任务的性质(IO是否频繁?)来决定我们创建的核心的线程数的大小,实际上可以从以下的一个角度来分析:

  • 任务的性质:CPU密集型任务、IO密集型任务和混合型任务;
  • 任务的优先级:高、中和低;
  • 任务的执行时间:长、中和短;
  • 任务的依赖性:是否依赖其他系统资源,如数据库连接;

性质不同的任务可以用不同规模的线程池分开处理。分为CPU密集型和IO密集型

CPU密集型任务应配置尽可能小的线程,如配置 Ncpu+1个线程的线程池。(可以通过Runtime.getRuntime().availableProcessors()来获取CPU物理核数)

IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如 2*Ncpu

优先级不同的任务可以使用优先级队列 PriorityBlockingQueue来处理。它可以让优先级高的任务先执行(注意:如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行

执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行。依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越长,则 CPU 空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。

建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点。方式因为提交的任务过多而导致 OOM;

9、线程池优化了解吗?

1)用ThreadPoolExecutor自定义线程池,看线程是的用途,如果任务量不大,可以用无界队列,如果任务量非常大,要用有界队列,防止OOM
2)如果任务量很大,还要求每个任务都处理成功,要对提交的任务进行阻塞提交,重写拒绝机制,改为阻塞提交。保证不抛弃一个任务
3)最大线程数一般设为2N+1最好,N是CPU核数
4)核心线程数,看应用,如果是任务,一天跑一次,设置为0,合适,因为跑完就停掉了,如果是常用线程池,看任务量,是保留一个核心还是几个核心线程数
5)如果要获取任务执行结果,用CompletionService,但是注意,获取任务的结果的要重新开一个线程获取,如果在主线程获取,就要等任务都提交后才获取,就会阻塞大量任务结果,队列过大OOM,所以最好异步开个线程获取结果。

10、如何关闭线程池?

其实,如果优雅的关闭线程池是一个令人头疼的问题,线程开启是简单的,但是想要停止却不是那么容易的。通常而言, 大部分程序员都是使用 jdk 提供的两个方法来关闭线程池,他们分别是:shutdownshutdownNow

通过调用线程池的 shutdownshutdownNow 方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程(PS:中断,仅仅是给线程打上一个标记,并不是代表这个线程停止了,如果线程不响应中断,那么这个标记将毫无作用),所以无法响应中断的任务可能永远无法终止。

但是它们存在一定的区别,shutdownNow首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而 shutdown 只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。

只要调用了这两个关闭方法中的任意一个,isShutdown 方法就会返回 true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回 true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用 shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用 shutdownNow 方法。

这里推荐使用稳妥的 shutdownNow 来关闭线程池,至于更优雅的方式可以参考**并发编程设计模式中的两阶段终止模式。

11、你能设计实现一个线程池吗(BAT容易问到,小公司不会)?

12、线程池中的各个状态分别代表什么含义?状态之间是怎么流转的?

线程池目前有5个状态:

RUNNING:接受新任务并处理排队的任务。

SHUTDOWN:不接受新任务,但处理排队的任务。

STOP:不接受新任务,不处理排队的任务,并中断正在进行的任务。

TIDYING:所有任务都已终止,workerCount 为零,线程转换到 TIDYING 状态将运行 terminated() 钩子方法。

TERMINATED:terminated() 已完成。

13、核心线程怎么实现一直存活?

阻塞队列方法有四种形式,它们以不同的方式处理操作,如下表。

核心线程在获取任务时,通过阻塞队列的 take() 方法实现的一直阻塞(存活)。

14、非核心线程如何实现在 keepAliveTime 后死亡?

原理同上,也是利用阻塞队列的方法,在获取任务时通过阻塞队列的 poll(time,unit) 方法实现的在延迟死亡。

15、非核心线程能成为核心线程吗?

虽然我们一直讲着核心线程和非核心线程,但是其实线程池内部是不区分核心线程和非核心线程的。只是根据当前线程池的工作线程数来进行调整,因此看起来像是有核心线程于非核心线程。

16、使用new Thread();这种方式去进行显式创建线程会带来什么后果?

1. OOM: 如果当前方法突遇高并发情况,假设此时来了1000个请求,而按传统的网络模型是BIO,此时服务器会开1000个线程来处理这1000个请求(不考虑WEB容器的最大线程数配置),当1000个请求执行时又会发现此方法中存在new Thread();创建线程,此时每个执行请求的线程又会创建一个线程,此时就会出现1000*2=2000个线程的情况出现,而在一个程序中创建线程是需要向JVM申请内存分配的,但是此时大量线程在同一瞬间向JVM申请分配内存,此时会很容易造成内存溢出(OOM)的情况发生。

2. 资源开销与耗时: Java对象的生命周期大致包括三个阶段:对象的创建,对象的使用,对象的清除。因此,对象的生命周期长度可用如下的表达式表示:Object = O1 + O2 +O3。其中O1表示对象的创建时间,O2表示对象的使用时间,而O3则表示其清除(垃圾回收)时间。由此,我们可以看出,只有O2是真正有效的时间,而O1、O3则是对象本身的开销。当我们去创建一个线程时也是一样,因为线程在Java中其实也是一个Thread类的实例,所以对于线程而言,其实它的创建(申请内存分配、JVM向OS提交线程映射进程申请、OS真实线程映射)和销毁对资源是开销非常大的并且非常耗时的。

3. 不可管理性: 对于new Thread();的显示创建出来的线程是无法管理的,一旦CPU调度成功,此线程的可管理性几乎为零。

17、此时线程数小于核心线程数,并且线程都处于空闲状态,现提交一个任务,是新起一个线程还是给之前创建的线程?

李老是这样说的:If fewer than corePoolSize threads are running, try to start a new thread with the given command as its first task.

我觉得把 threads are running 去了,更合理一些,此时线程池会新起一个线程来执行这个新任务,不管老线程是否空闲。

18、如何理解核心线程的 ?

从上一个问题可以看出,线程池虽说默认是懒创建线程,但是它实际是想要快速拥有核心线程数的线程。核心线程指的是线程池承载日常任务的中坚力量,也就是说本质上线程池是需要这么些数量的线程来处理任务的,所以在懒中又急着创建它。

而最大线程数其实是为了应付突发状况。

举个装修的例子,正常情况下施工队只要 5 个人去干活,这 5 人其实就是核心线程,但是由于工头接的活太多了,导致 5 个人在约定工期内干不完,所以工头又去找了 2 个人来一起干,所以 5 是核心线程数,7 是最大线程数。

平时就是 5 个人干活,特别忙的时候就找 7 个,等闲下来就会把多余的 2 个辞了。

看到这里你可能会觉得核心线程在线程池里面会有特殊标记?

并没有,不论是核心还是非核心线程,在线程池里面都是一视同仁,当淘汰的时候不会管是哪些线程,反正留下核心线程数个线程即可。

19、线程池有几种状态?

注解说的很明白,我再翻译一下:

  • RUNNING:能接受新任务,并处理阻塞队列中的任务
  • SHUTDOWN:不接受新任务,但是可以处理阻塞队列中的任务
  • STOP:不接受新任务,并且不处理阻塞队列中的任务,并且还打断正在运行任务的线程,就是直接撂担子不干了!
  • TIDYING:所有任务都终止,并且工作线程也为0,处于关闭之前的状态
  • TERMINATED:已关闭。

20、为什么要把任务先放在任务队列里面,而不是把线程先拉满到最大线程数?

我说下我的个人理解。

其实经过上面的分析可以得知,线程池本意只是让核心数量的线程工作着,不论是 core 的取名,还是 keepalive 的设定,所以你可以直接把 core 的数量设为你想要线程池工作的线程数,而任务队列起到一个缓冲的作用。最大线程数这个参数更像是无奈之举,在最坏的情况下做最后的努力,去新建线程去帮助消化任务。

所以我个人觉得没有为什么,就是这样设计的,并且这样的设定挺合理。

当然如果你想要扯一扯 CPU 密集和 I/O 密集,那可以扯一扯。

原生版线程池的实现可以认为是偏向 CPU 密集的,也就是当任务过多的时候不是先去创建更多的线程,而是先缓存任务,让核心线程去消化,从上面的分析我们可以知道,当处理 CPU 密集型任务的时,线程太多反而会由于线程频繁切换的开销而得不偿失,所以优先堆积任务而不是创建新的线程。

而像 Tomcat 这种业务场景,大部分情况下是需要大量 I/O 处理的情况就做了一些定制,修改了原生线程池的实现,使得在队列没满的时候,可以创建线程至最大线程数。

21、原生线程池的核心线程一定伴随着任务慢慢创建的吗?

并不是,线程池提供了两个方法:

  • prestartCoreThread:启动一个核心线程
  • prestartAllCoreThreads :启动所有核心线程

不要小看这个预创建方法,预热很重要,不然刚重启的一些服务有时是顶不住瞬时请求的,就立马崩了,所以有预热线程、缓存等等操作。

22、线程池如何动态修改核心线程数和最大线程数?

其实之所以会有这样的需求是因为线程数是真的不好配置。

你可能会在网上或者书上看到很多配置公式,比如:

  • CPU 密集型的话,核心线程数设置为 CPU核数+1
  • I/O 密集型的话,核心线程数设置为 2*CPU核数

比如:

线程数=CPU核数 *(1+线程等待时间 / 线程时间运行时间)

这个比上面的更贴合与业务,还有一些理想的公式就不列了。就这个公式而言,这个线程等待时间就很难测,拿 Tomcat 线程池为例,每个请求的等待时间能知道?不同的请求不同的业务,就算相同的业务,不同的用户数据量也不同,等待时间也不同。

所以说线程数真的很难通过一个公式一劳永逸,线程数的设定是一个迭代的过程,需要压测适时调整,以上的公式做个初始值开始调试是 ok 的。

再者,流量的突发性也是无法判断的,举个例子 1 秒内一共有 1000 个请求量,但是如果这 1000 个请求量都是在第一毫秒内瞬时进来的呢?

这就很需要线程池的动态性,也是这个上面这个面试题的需求来源。

原生的线程池核心我们大致都过了一遍,不过这几个方法一直没提到,先来看看这几个方法:

我就不一一翻译了,大致可以看出线程池其实已经给予方法暴露出内部的一些状态,例如正在执行的线程数、已完成的任务数、队列中的任务数等等。

当然你可以想要更多的数据监控都简单的,像 Tomcat 那种继承线程池之后自己加呗,动态调整的第一步监控就这样搞定了!定时拉取这些数据,然后搞个看板,再结合邮件、短信、钉钉等报警方式,我们可以很容易的监控线程池的状态!

接着就是动态修改线程池配置了。

可以看到线程池已经提供了诸多修改方法来更改线程池的配置,所以李老都已经考虑到啦!

同样,也可以继承线程池增加一些方法来修改,看具体的业务场景了。同样搞个页面,然后给予负责人员配置修改即可。

所以原生线程池已经提供修改配置的方法,也对外暴露出线程池内部执行情况,所以只要我们实时监控情况,调用对应的 set 方法,即可动态修改线程池对应配置。

23、如果要让你设计一个线程池,你要怎么设计?

这种设计类问题还是一样,先说下理解,表明你是知道这个东西的用处和原理的,然后开始 BB。基本上就是按照现有的设计来说,再添加一些个人见解。

线程池讲白了就是存储线程的一个容器,池内保存之前建立过的线程来重复执行任务,减少创建和销毁线程的开销,提高任务的响应速度,并便于线程的管理。

我个人觉得如果要设计一个线程池的话得考虑池内工作线程的管理、任务编排执行、线程池超负荷处理方案、监控。

初始化线程数、核心线程数、最大线程池都暴露出来可配置,包括超过核心线程数的线程空闲消亡配置。

任务的存储结构可配置,可以是无界队列也可以是有界队列,也可以根据配置分多个队列来分配不同优先级的任务,也可以采用 stealing 的机制来提高线程的利用率。

再提供配置来表明此线程池是 IO 密集还是 CPU 密集型来改变任务的执行策略。

超负荷的方案可以有多种,包括丢弃任务、拒绝任务并抛出异常、丢弃最旧的任务或自定义等等。

线程池埋好点暴露出用于监控的接口,如已处理任务数、待处理任务数、正在运行的线程数、拒绝的任务数等等信息。

说,再添加一些个人见解。

线程池讲白了就是存储线程的一个容器,池内保存之前建立过的线程来重复执行任务,减少创建和销毁线程的开销,提高任务的响应速度,并便于线程的管理。

我个人觉得如果要设计一个线程池的话得考虑池内工作线程的管理、任务编排执行、线程池超负荷处理方案、监控。

初始化线程数、核心线程数、最大线程池都暴露出来可配置,包括超过核心线程数的线程空闲消亡配置。

任务的存储结构可配置,可以是无界队列也可以是有界队列,也可以根据配置分多个队列来分配不同优先级的任务,也可以采用 stealing 的机制来提高线程的利用率。

再提供配置来表明此线程池是 IO 密集还是 CPU 密集型来改变任务的执行策略。

超负荷的方案可以有多种,包括丢弃任务、拒绝任务并抛出异常、丢弃最旧的任务或自定义等等。

线程池埋好点暴露出用于监控的接口,如已处理任务数、待处理任务数、正在运行的线程数、拒绝的任务数等等信息。

我觉得基本上这样答就差不多了,等着面试官的追问就好。

Java并发编程面试题——JUC专题

文章目录

一、AQS高频问题

1.1 AQS是什么?

AQS是JUC下大量工具的基础类,很多工具都基于AQS实现的,比如lock锁,CountDownLatch,Semaphore,线程池等等都用到了AQS。

AQS中有一个核心属性state,还有一个双向链表以及一个单向链表。其中state是基于volatile修饰,再基于CAS修改,可以保证原子,可见,有序三大特性。单向链表是内部类ConditionObject对标synchronized中的等待池,当lock在线程持有锁时,执行await方法,会将线程封装为Node对象,扔到Condition单向链表中,等待唤醒。如果线程唤醒了,就将Condition中的Node扔到AQS的双向链表等待获取锁。

1.2 唤醒线程时,AQS为什么从后往前遍历?

当持有资源的线程执行完成后,需要在AQS的双向链表拿出来一个,如果head的next节点取消了

如果在唤醒线程时,head节点的next是第一个要被唤醒的,如果head的next节点取消了,会出现节点丢失问题。

如下图,当一个新的Node添加到链表时有3个步骤,当第三个步骤还未完成时,如果从head开始就找不到需要被唤醒的节点了。

1.3 AQS为什么用双向链表,(为啥不用单向链表)?

因为AQS中,存在取消节点的操作,如果使用双向链表只需要两步

  • 需要将prev节点的next指针,指向next节点。
  • 需要将next节点的prev指针,指向prev节点。

但是如果是单向链表,需要遍历整个单向链表才能完成的上述的操作。比较浪费资源。

1.4 AQS为什么要有一个虚拟的head节点

每个Node都会有一些状态,这个状态不单单针对自己,还针对后续节点

  • 1:当前节点取消了。
  • 0:默认状态,啥事没有。
  • -1:当前节点的后继节点,挂起了。
  • -2:代表当前节点在Condition队列中(await将线程挂起了)
  • -3:代表当前是共享锁,唤醒时,后续节点依然需要被唤醒。

但是一个节点无法同时保存当前节点状态和后继节点状态,有一个哨兵节点,更方便操作。

1.5 ReentrantLock的底层实现原理

ReentrantLock是基于AQS实现的。

  1. 在线程基于ReentrantLock加锁时,需要基于CAS去修改state属性,如果能从0改为1,代表获取锁资源成功
  2. 如果CAS失败了,添加到AQS的双向链表中排队(可能会挂起线程),等待获取锁。
  3. 持有锁的线程,如果执行了condition的await方法,线程会封装为Node添加到Condition的单向链表中,等待被唤醒并且重新竞争锁资源

1.6 ReentrantLock的公平锁和非公平锁的区别

公平锁和非公平中的lock方法和tryAcquire方法的实现有点不同,其他都一样

  • 非公平锁
    • lock:直接尝试将state从 0 改为 1,如果成功,拿锁直接走,如果失败了,执行tryAcquire。
    • tryAcquire:如果当前没有线程持有锁资源,直接再次尝试将state从0 改为 1 如果成功,拿锁直接走。
  • 公平锁
    • lock:直接执行tryAcquire。
    • tryAcquire:如果当前没有线程持有锁资源,先看一下,有排队的么。如果没有排队的,直接尝试将state从 0 改为 1。如果有排队的并且第一名,直接尝试将state从 0 改为 1。

如果都没拿到锁,公平锁和非公平锁的后续逻辑是一样的,加入到AQS双向链表中排队。

1.7 ReentrantReadWriteLock如何实现的读写锁

如果一个操作写少读多,还用互斥锁的话,性能太低,因为读读不存在并发问题。读写锁可以解决该问题。

ReentrantReadWriteLock也是基于AQS实现的一个读写锁,但是锁资源用state标识。如何基于一个int来标识两个锁信息,有写锁,有读锁,怎么做的?

一个int,占了32个bit位。在写锁获取锁时,基于CAS修改state的低16位的值。在读锁获取锁时,基于CAS修改state的高16位的值。

写锁的重入,基于state低16直接标识,因为写锁是互斥的。读锁的重入,无法基于state的高16位去标识,因为读锁是共享的,可以多个线程同时持有。所以读锁的重入用的是ThreadLocal来表示,同时也会对state的高16为进行追加。

二、阻塞队列高频问题

2.1 说下你熟悉的阻塞队列?

ArrayBlockingQueue:底层基于数组实现,记得new的时候设置好边界。

LinkedBlockingQueue:底层基于链表实现的,可以认为是无界队列,但是可以设置长度。

PriorityBlockingQueue:底层是基于数组实现的二叉堆,可以认为是无界队列,因为数组会扩容。

ArrayBlockingQueue,LinkedBlockingQueue是ThreadPoolExecutor线程池最常用的两个阻塞队列。

2.2 虚假唤醒是什么?

虚假唤醒在阻塞队列的源码中就有体现。

比如消费者1在消费数据时,会先判断队列是否有元素,如果元素个数为0,消费者1会await挂起。此处判断元素为0的位置,如果用if循环会导致出现一个问题。

  1. 如果生产者添加了一个数据,会唤醒消费者1并去拿锁资源。
  2. 此时如果来了消费者2抢到了锁资源并带走了数据的话,消费者1再次拿到锁资源时,无法从队列获取到任何元素,出现虚假唤醒问题。

解决方案,将判断元素个数的位置,设置为while判断。

三、线程池高频问题

3.1 线程池的7个参数

核心线程数,最大线程数,最大空闲时间,时间单位,阻塞队列,线程工厂,拒绝策略

3.2 线程池的状态有什么,如何记录的?

线程池有5个状态:RUNINING、SHUTDOWN、STOP、TIDYING、TERMINATED

线程池的状态是在ctl属性中记录的。本质就是int类型

3.3 线程池常见的拒绝策略

  • AbortPolicy:丢弃任务并抛异常(默认)

  • CallerRunsPolicy:当前线程执行

  • DiscardPolicy:丢弃任务直接不要

  • DiscardOldestPolicy:丢弃等待队列中最旧的任务,并执行当前任务

一般情况下,线程池自带的无法满足业务时,自定义一个线程池的拒绝策略,实现下面的接口即可。

3.4 线程池执行流程

核心线程不是new完就构建的,是懒加载的机制,添加任务才会构建核心线程

3.5 线程池为什么添加空任务的非核心线程


避免线程池出现队列有任务,但是没有工作线程处理。

当核心线程数是0个,任务进来后会到阻塞队列,但是没有工作线程,此时空任务的非核心线程就可以处理该任务。

##3.6 在没任务时,线程池中的工作线程在干嘛?

  • 如果是核心线程,默认情况下,会在阻塞队列的位置执行take方法,直到拿到任务为止。

  • 如果是非核心线程,默认情况下,会在阻塞队列的位置执行poll方法,等待最大空闲时间,如果没任务,删除线程,如果有活,那就正常干。

3.7 工作线程出现异常会导致什么问题?

首先出现异常的工作线程不会影响到其他的工作线程。

  • 如果任务是execute方法执行的,工作线程会将异常抛出。
  • 如果任务是submit方法执行的futureTask,工作线程会将异常捕获并保存到FutureTask里,可以基于futureTask的get得到异常信息。
  • 最后线程结束。

3.8 工作线程继承AQS的目的是什么?

工作线程的本质,就是Worker对象。继承AQS跟shutdown和shutdownNow有关系。

  • 如果是shutdown,会中断空闲的工作线程,基于Worker实现的AQS中的state的值来判断能否中断工作线程。如果工作线程的state是0,代表空闲,可以中断,如果是1,代表正在干活。

  • 如果是shutdownNow,直接强制中断所有工作线程

3.9 核心参数怎么设置?

线程池的目的是为了减少线程频繁创建/销毁带来的资源消耗,充分发挥CPU的资源,提升整个系统的性能。不同业务的线程池参考的方式也不一样。

  • 如果是CPU密集的任务,一般也就是CPU内核数 + 1的核心线程数。这样足以充分发挥CPU性能。
  • 如果是IO密集的任务,因为IO的程度不一样,有的是1s,有的是1ms,有的是1分钟,所以IO密集的任务在用线程池处理时,一定要通过压测的方式,观察CPU资源的占用情况,来决定核心线程数。一般发挥CPU性能到70~80足矣。

所以线程池的参数设置需要通过压测以及多次调整才能得出具体的。

四、CountDownLatch,Semaphore的高频问题

4.1 CountDownLatch是啥?有啥用?底层咋实现的?

CountDownLatch本质其实就是一个计数器。在多线程并行处理业务时,需要等待其他线程处理完再做后续的合并等操作时,可以使用CountDownLatch做计数,等到其他线程出现完之后,主线程就会被唤醒。实现过程如下:

  1. CountDownLatch本身就是基于AQS实现的。new CountDownLatch时,直接指定好具体的数值,这个数值会复制给state属性。
  2. 当子线程处理完任务后,执行countDown方法,内部就是直接给state - 1。
  3. 当state减为0之后,执行await挂起的线程,就会被唤醒。
import java.util.concurrent.CountDownLatch;

public class CountDownLatchTest 
    public static void main(String[] args) throws InterruptedException 
        CountDownLatch count = new CountDownLatch(3);
        for (int i = 0; i < 3; i++) 
            int finalI = i;
            new Thread(() -> 
                try 
                    Thread.sleep(1000);
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
                System.out.println("线程" + finalI + "执行中");
                count.countDown();
            ).start();
        

        count.await();

        System.out.println("所有线程都执行完成了");
    


4.2 Semaphore是啥?有啥用?底层咋实现的?

信号量,就是一个可以用于做限流功能的工具类。比如要求当前服务最多3个线程同时干活,将信号量设置为3。每个任务提交前都需要获取一个信号量,获取到就去干活,干完了,归还信号量。实现过程如下:

  1. 信号量也是基于AQS实现的,构建信号量时,指定信号量资源数,这个数值会复制给state属性。
  2. 获取信号量时,执行acquire方法,内部就是直接给state - 1。当state为0时,新来的任务会因获取不到信号量而等待。
  3. 当任务执行完成后,执行release方法,释放信号量。
import java.util.concurrent.Semaphore;

public class SemaphoreTest 
    public static void main(String[] args) 
        Semaphore semaphore = new Semaphore(3);
        for (int i = 0; i < 3; i++) 
            int finalI = i;
            new Thread(() -> 
                try 
                    semaphore.acquire();
                    System.out.println("线程" + finalI + "执行中");
                    Thread.sleep(5000);
                 catch (InterruptedException e) 
                    e.printStackTrace();
                 finally 
                    semaphore.release();
                

            ).start();
        

        new Thread(() -> 
            try 
                long begin = System.currentTimeMillis();
                semaphore.acquire();
                long end = System.currentTimeMillis();
                System.out.println("限流了" + (end - begin) + "ms");
             catch (InterruptedException e) 
                e.printStackTrace();
             finally 
                semaphore.release();
            
        ).start();
    


4.3 main线程结束,程序会停止嘛?

  • 如果main线程结束,但是还有用户线程在执行,不会结束!
  • 如果main线程结束,剩下的都是守护线程,结束!

五、CopyOnWriteArrayList的高频问题

5.1 CopyOnWriteArrayList是如何保证线程安全的?有什么缺点吗?

CopyOnWriteArrayList写数据时,是基于ReentrantLock保证原子性的。写数据时,会复制一个副本写入,写入成功后,才会写入到CopyOnWriteArrayList中的数组,保证读数据时,不要出现数据不一致的问题。

缺点就是:如果数据量比较大时,每次写入数据,都需要复制一个副本,对空间的占用太大了。如果数据量比较大,不推荐使用CopyOnWriteArrayList。

适合写操作要求保证原子性,读操作保证并发,并且数据量不大的场景。

六、ConcurrentHashMap(JDK1.8)的高频问题

6.1 HashMap为啥线程不安全?

  1. JDK1.7里有环(扩容时)。

  2. 并发添加数据时会覆盖,数据可能丢失。

  3. 在记录元素个数和HashMap写的次数时,记录不准确。

  4. 数据迁移,扩容,也可能会丢失数据。

6.2 ConcurrentHashMap如何保证线程安全的?

  1. 尾插
  2. 写入数组时,基于CAS保证安全;插入链表或红黑树时,基于synchronized保证安全。
  3. 这里ConcurrentHashMap是采用LongAdder实现的技术,底层还是CAS。
  4. ConcurrentHashMap扩容时,基于CAS保证数据迁移不出现并发问题。

6.3 ConcurrentHashMap构建好,数组就创建出来了吗?如果不是,如何保证初始化数组的线程安全?

ConcurrentHashMap是懒加载的机制,而且大多数的框架组件都是懒加载的~

基于CAS来保证初始化线程安全的,这里不但涉及到了CAS去修改sizeCtl的变量去控制线程初始化数据的原子性,同时还使用了DCL,外层判断数组未初始化,中间基于CAS修改sizeCtl,内层再做数组未初始化判断。

6.4 为什么负载因子是0.75,为什么链表长度到8转为红黑树?

负载因子是0.75从两个方面去解释。为啥不是0.5,为啥不是1?

0.5:如果负载因子是0.5,数据添加一半就开始扩容了

  • 优点:hash碰撞少,查询效率高。
  • 缺点:扩容太频繁,而且空间利用率低。

1:如果负载因子是1,数据添加到数组长度才开始扩容

  • 优点:扩容不频繁,空间利用率可以的。
  • 缺点:hash冲突会特别频繁,数据挂到链表上,影响查询效率,甚至链表过长生成红黑树,导致写入的效率也收到影响。

0.75就可以说是一个居中的选择,两个方面都兼顾了。

再就是泊松分布,在负载因子是0.75时,根据泊松分布得出,链表长度达到8的概率是非常低的,源码中的标识是0.00000006,生成红黑树的概率特别低。虽然ConcurrentHashMap引入了红黑树,但是红黑树对于写入的维护成本更高,能不用就不用,HashMap源码的注释也描述了,要尽可能规避红黑树。

6.5put操作太频繁的场景,会造成扩容时期put的阻塞 ?

一般情况下不会造成阻塞。

  • 如果在put操作时,发现当前索引位置并没有数据时,正常把数据落到老数组上。

  • 如果put操作时,发现当前位置数据已经被迁移到了新数组,这时无法正常插入,去帮助扩容,快速结束扩容操作,并且重新选择索引位置查询

6.6 ConcurrentHashMap何时扩容,扩容的流程是什么?

  • ConcurrentHashMap中的元素个数,达到了负载因子计算的阈值,那么直接扩容
  • 当调用putAll方法,查询大量数据时,也可能会造成直接扩容的操作,大量数据是如果插入的数据大于下次扩容的阈值,直接扩容,然后再插入
  • 数组长度小于64,并且链表长度大于等于8时,会触发扩容

6.7 ConcurrentHashMap得计数器如何实现的?

这里是基于LongAdder的机制实现,但是并没有直接用LongAdder的引用,而是根据LongAdder的原理写了一个相似度在80%以上的代码,直接使用。

LongAdder使用CAS添加,保证原子性,其次基于分段锁,保证并发性。

6.8 ConcurrentHashMap的读操作会阻塞嘛?

无论查哪,都不阻塞。

  • 查询数组:查看元素是否在数组,在就直接返回。
  • 查询链表:在链表next,next查询即可。
  • 扩容时:如果当前索引位置是-1,代表当前位置数据全部都迁移到了新数组,直接去新数组查询,不管有没有扩容完。
  • 查询红黑树:转换红黑树时,不但有一个红黑树,还会保留一个双向链表,此时会查询双向链表,不让读线程阻塞。

以上是关于JUC并发编程线程池及相关面试题 详解的主要内容,如果未能解决你的问题,请参考以下文章

这本Java高并发核心编程让我迈进字节跳动,月薪30k确实不错!

Java基础学习多线程实战面试题形式

多线程JUC并发篇常见面试详解

Java 面试题 —— 零度 Java 面试题系列

多线程进阶=>JUC并发编程

Java面试题