面试官再问你线程池,把这篇文章丢给他!

310 阅读14分钟

先赞后看,养成习惯 🌹
欢迎微信关注 Java编程之道
每天进步一点点,沉淀技术分享知识。

记一次真实蚂蚁金服面试经历,这是鄙人在暑期找实习阶段遇到的社会主义爆锤!!!那年我还只是个懵懂的少年...

今天分享给需要秋(春)招面试的你们,看你们能抗住几个问题。你要都抗住了...

万字长文!!!一定要耐住看!看完血赚!

线程池十二鞭

面试官:你了解多线程吗?线程池呢?

答:多线程技术主要解决处理器单元内多个线程执行的问题,它可以显著减少处理器单元的闲置时间,增加处理器单元的吞吐能力。同时也可以快速响应前端,将耗时任务交给线程去执行,提高前端用户的交互体验。

线程池是存放有一组线程的一个容器。线程池是为突然大量爆发的线程设计的,通过有限的几个固定线程为大量的操作服务,减少了创建和销毁线程所需的时间,从而提高效率。合理的使用线程池可以降低资源消耗,提高响应速度,提高线程的可管理性。

内心os:你看老弟我多稳!

面试官: 平时有用过线程池吗?

答:用过,曾设计线程池用于处理查询数据生产Excle文件并发送文件中心的任务。提高了系统的吞吐能力和响应速度。

`内心os:问题不大,都是唠家常!``

面试官:JDK提供了哪些默认的线程池实现吗,大概有什么区别呢?记得多少说多少

答:newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

// 无限大小线程池 jvm自动回收
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
	final int temp = i;
	newCachedThreadPool.execute(new Runnable() {
		@Override
		public void run() {
			try {
				Thread.sleep(100);
			} catch (Exception e) {
				// TODO: handle exception
			}
			System.out.println(Thread.currentThread().getName() + ",i:" + temp);
		}
	});
}

总结: 线程池为无限大,当执行第二个任务时第一个任务已经完成,会复用执行第一个任务的线程,而不用每次新建线程。

  • newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
ExecutorService newFixedThreadPool = Executors.newFixedThreadPool(5);
	for (int i = 0; i < 10; i++) {
	final int temp = i;
	newFixedThreadPool.execute(new Runnable() {
		@Override
		public void run() {
			System.out.println(Thread.currentThread().getId() + ",i:" + temp);
		}
	});
}

总结:因为线程池大小为5,每个任务输出index其余任务会在队列种等待

  • newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
ScheduledExecutorService newScheduledThreadPool = Executors.newScheduledThreadPool(5);
for (int i = 0; i < 10; i++) {
	final int temp = i;
	newScheduledThreadPool.schedule(new Runnable() {
		public void run() {
			System.out.println("i:" + temp);
		}
	}, 3, TimeUnit.SECONDS);
}

总结:表示延迟3秒执行。

  • newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();
for (int i = 0; i < 10; i++) {
	final int index = i;
	newSingleThreadExecutor.execute(new Runnable() {
		@Override
		public void run() {
			System.out.println("index:" + index);
			try {
				Thread.sleep(200);
			} catch (Exception e) {
				// TODO: handle exception
			}
		}
	});
}

注意: 结果依次输出,相当于顺序执行各个任务。

其实我当时回答的并没有现在这么全,主要还是记不起来名字了。

内心os:完了!没说全!会不会拜拜了?

面试官:阿里巴巴的java开发手册你看过吗?感觉写的怎么样?平时自己有按这个规范开发吗?

答:看过呀!阿里出品必属精品!自己是按照规范来开发,现在基本上成了自己的代码习惯了,在IDEA里面也安装了代码规范校验插件。

内心os:峰回路转,重回正轨,先舔一下。

面试官:哦?看过啊!你知道为什么阿里不让使用默认的线程池实现方式吗?会出现OOME?

答:阿里规范里面强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

问题:

  • 1) FixedThreadPool 和 SingleThreadPool:

允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

  • 2) CachedThreadPool:

允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

出现OOME我觉得主要是因为以上创建线程池的方式没有限制队列大小,如果某一时间大量异步任务涌入必会使用大量线程来执行,极可能导致OOME,JVM中线程栈也有一个默认大小,没有限制的创建线程必定会占用大量资源同时GC可能并没有即时触发。

内心os:应该...说的靠谱吧!

面试官:那来介绍一下自定义线程池的几个常用参数呗?

答:

  • corePoolSize: 线程池的核心池大小,换句更精炼的话:corePoolSize表示允许线程池中允许同时运行的最大线程数。

  • maximumPoolSize: 线程池允许的最大线程数,他表示最大能创建多少个线程maximumPoolSize肯定是大于等于corePoolSize。

  • keepAliveTime: 表示线程没有任务时最多保持多久然后停止。默认情况下,只有线程池中线程数大于corePoolSize 时,keepAliveTime才会起作用。换句话说,当线程池中的线程数大于corePoolSize,并且一个线程空闲时间达到了keepAliveTime,那么才会shutdown。

  • unit: 参数keepAliveTime的时间单位。

  • workQueue 新任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务。jdk中提供了四种工作队列:

    • ArrayBlockingQueue基于数组的有界阻塞队列,按FIFO排序。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。

    • LinkedBlockingQuene基于链表的无界阻塞队列(其实最大容量为Interger.MAX),按照FIFO排序。由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而不会去创建新线程直到maxPoolSize,因此使用该工作队列时,参数maxPoolSize其实是不起作用的。

    • SynchronousQuene一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略。

    • PriorityBlockingQueue具有优先级的无界阻塞队列,优先级通过参数Comparator实现。

  • threadFactory 创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等

  • handler 当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制执行拒绝策略

    • CallerRunsPolicy该策略下,在调用者线程中直接执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务。
    • AbortPolicy该策略下,直接丢弃任务,并抛出RejectedExecutionException异常。
    • DiscardPolicy该策略下,直接丢弃任务,什么都不做。
    • DiscardOldestPolicy该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列

内心os:都答上了,牛逼。问题不大。

面试官:简单说一下线程池的执行流程吧!

答:用户提交任务后会执行一下流程

  • 如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;
  • 如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;
  • 如果队列已经满了,则在总线程数不大于maximumPoolSize的前提下,则创建新的线程
  • 如果当前线程池中的线程数目达到maximumPoolSize,则会采取任务拒绝策略进行处理;
  • 如果线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。
    在这里插入图片描述

内心os:还好看过源码写过博客,捞的一。

面试官:你这个几个参数的值是怎么得来的呀?算出来的?怎么算出来的?

答:我所知道的是IO密集型核心线程数是CPU数*2,计算密集型核心线程数是CPU数+1。最大线程数是(最大任务数-队列容量)/每个线程每秒处理能力 = 最大线程数。队列大小一般为(核心线程数/每个任务耗时时间)x 系统允许容忍的最大响应时间。

关于线程池最有大小还有一个公式:线程数=CPU数xCPU利用率x(1+等待时间/计算时间)。

内心os:求放过!

面试官:线程池里面的任务是IO密集型的还是计算密集型的呢?

答:我认为计算密集型就是计算、逻辑判断量非常大而且集中的类型,因为主要占用cpu资源所以又叫cpu密集型。IO密集型就是磁盘的读取数据和输出数据非常大的时候就是属于IO密集型。

内心os:应该靠谱!应该快结束了吧

面试官:那线程池创建的时候内部有多少可用的线程?啥时候才真正有活跃的线程呢?

答:0个。当调用prestartAllCoreThreads方法时,或者线程任务提交的时候才会有真正的线程。我大概跟你聊一下内部的细节吧。巴拉巴拉巴拉....

ThreadPoolExecutor源码

  /**
     * Set containing all worker threads in pool. Accessed only when
     * holding mainLock.
     */
    private final HashSet<Worker> workers = new HashSet<Worker>();

从注释可以看到这个HashSet是存放所有的工作线程的容器,也就是线程池最核心的容器。我们可以就问题看看这个workers是在哪里进行Put操作的。我们看到ThreadPoolExecutor的构造函数中并没有对workers进行添加操作。只是对于变量进行了一个赋值操作,也就是说在ThreadPoolExecutor被new出来后workers容器里面是空的!也就是说初始线程为0

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

那什么时候才创建了线程放在线程池中?我们知道提交任务无非两种方式execute和submit,那么我们从这里入手看看到底是怎么回事。

  public <T> Future<T> submit(Runnable task, T result) {
        if (task == null) throw new NullPointerException();
        RunnableFuture<T> ftask = newTaskFor(task, result);
        execute(ftask);
        return ftask;
    }

可以看到submit提交的任务最终都是走到了execute方法中。

  public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
         /*
         * Proceed in 3 steps:
         *
         * 1. If fewer than corePoolSize threads are running, try to
         * start a new thread with the given command as its first
         * task.  The call to addWorker atomically checks runState and
         * workerCount, and so prevents false alarms that would add
         * threads when it shouldn't, by returning false.
         *
         * 2. If a task can be successfully queued, then we still need
         * to double-check whether we should have added a thread
         * (because existing ones died since last checking) or that
         * the pool shut down since entry into this method. So we
         * recheck state and if necessary roll back the enqueuing if
         * stopped, or start a new thread if there are none.
         *
         * 3. If we cannot queue task, then we try to add a new
         * thread.  If it fails, we know we are shut down or saturated
         * and so reject the task.
         */
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
    }

还是注释大法好,简单翻译一下

  • 如果运行的线程少于corePoolSize,尝试以给定的命令作为第一个启动新线程
  • 如果一个任务可以成功地排队,那么我们仍然需要再次检查是否应该添加线程,还是应该在进入此方法后关闭池。
  • 如果不能对任务进行排队,则尝试添加一个新线程。

打个断点看看?

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        Thread1 thread1 = new Thread1();
        thread1.setName("线程一");
        Thread2 thread2 = new Thread2();
        thread2.setName("线程二");
        executorService.submit(thread1);
        executorService.submit(thread2);
        executorService.shutdown();
    }

我们分别在submit和newFixedThreadPool中打入断点

可以看到在线程池的构造方法执行结束后真正存放线程的set为0。

当代码执行到submit后进去到execute方法,才往容器中存放了一个工作线程。

内心os:还好看过源码,顶得住!

面试官:最后再问一个问题,一个线程池中的线程异常了,那么线程池会怎么处理这个线程?

答:首先线程池针对不同的提交方式会抛出堆栈异常如,execute方法会抛出异常submit不会。其次出现异常不会影响其他线程任务的执行,最后该异常线程会被清理,线程池会重新添加一个新线程作为补充!我简单的说一下源码,阿巴阿巴阿巴...

就此简单分析分析

写个代码测试一下先

第一个结论得到证实:execute方法会抛出异常submit不会。我们之道submit方法执行时,返回结果封装在future中,针对这种情况我们可future.get()方法进行异常捕获栈异常。我们来看看源码到底是为什么!

submit提交任务

runWorker方法

该方法是调取works中的任务来执行的地方。在submit调用执行的时候我们的代码执行到了下图位置

线程中的打印任务已经执行,讲道理出现异常了应该被catch了。

可以看到task被封装成了FutureTask

但至此并没有异常出来。那我们进入run方法看看。

嗯?异常被单独处理了? setException(ex)咋处理的干啥了都。

再往下执行就基本上没东西了。卧槽?!那我们get一下试试?看看这个异常被处理的细节。 当我们调用get方法的时候state的值为3大于COMPLETING对应的2于是进入到report(s)方法。

在report方法中抛出了一个新的异常。由此可见我们在使用submit方法需要处理异常的时候需要对get方法进行异常的捕获!

execute提交任务

出现异常后立即执行了异常的捕获,同时继续往下执行到 processWorkerExit(w, completedAbruptly)方法。

注释上可见该方法会移除异常线程,并创建一个新的线程去替换他。

面试官:好!基础不错!我们接下来聊聊微服务相关的,你了解分布式事务吗...

那年夏天我没死在多线程,我死在了微服务!那时候我真没学啊!我是真不会啊!能不能给我Offer先,后面在学嘛。厚着脸皮跟面试官聊了分布式存储,他说他不懂......卒!

关于线程池的面试点,常见的也就是这些了,你要都掌握了,然后老老实实的去看一下源码。这个时候能难住你的就只有货真价实的线程池/JVM调优了。

祝你好运!奥给力?力奥给?奥地利?奥力给!

更多精彩好文尽在:Java编程之道 🎁
欢迎各位好友前去关注!🌹