「建议心心」要就来15道多线程面试题一次爽到底(1.1w字用心整理)


本文是给「建议收藏」200MB大厂面试文档,整理总结2020年最强面试题库「CoreJava篇」写的答案,所有相关文章已经收录在码云仓库:https://gitee.com/bingqilinpeishenme/Java-interview

千上万水总是情,先赞后看行不行,奥力给

本文为多线程面试题答案的上篇:线程基本概念+线程池,锁+其他面试题会在下篇写出。

上篇情况:

  • 共有15道面试题
  • 图文并茂,概念+代码相互辅助
  • 1.1w余字儿,建议收藏方便以后查阅

1. 什么是进程?什么是线程?

进程(process)和线程(thread)是操作系统的基本概念,但是它们比较抽象,不容易掌握。
最近在阮一峰的博客上看到了一个解释,感觉非常的好,分享给小伙伴们。

  1. 计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行。

  2. 假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是,单个CPU一次只能运行一个任务

  3. 进程就好比工厂的车间,它代表CPU所能处理的单个任务。任一时刻,CPU总是运行一个进程,其他进程处于非运行状态。

  4. 一个车间里,可以有很多工人。他们协同完成一个任务

  5. 线程就好比车间里的工人。一个进程可以包括多个线程

进程

所谓进程就是运行在操作系统的一个任务,进程是计算机任务调度的一个单位,操作系统在启动一个程序的时候,会为其创建一个进程,JVM就是一个进程。进程与进程之间是相互隔离的,每个进程都有独立的内存空间。

计算机实现并发的原理是:CPU分时间片,交替执行,宏观并行,微观串行。同理,在进程的基础上分出更小的任务调度单元就是线程,我们所谓的多线程就是一个进程并发多个线程。

线程

在上面我们提到,一个进程可以并发出多个线程,而线程就是最小的任务执行单元,具体来说,一个程序顺序执行的流程就是一个线程,我们常见的main就是一个线程(主线程)。

线程的组成

想要拥有一个线程,有这样的一些不可或缺的部分,主要有:CPU时间片,数据存储空间,代码。
CPU时间片都是有操作系统进行分配的,数据存储空间就是我们常说的堆空间和栈空间,在线程之间,堆空间是多线程共享的,栈空间是互相独立的,这样做的好处不仅在于方便,也减少了很多资源的浪费。代码就不做过多解释了,没有代码搞个毛的多线程。

2. 什么是线程安全?

关于什么是线程安全,为什么会有线程安全的出现,以及为什么需要锁,我在三四年前写过一个小故事。

几个小概念
临界资源:当多线程访问同一个对象时, 这个对象叫做临界资源
原子操作:在临界资源中不可分割的操作叫原子操作
线程不安全:多线程同时访问同一个对象, 破坏了不可分割的操作, 就可能发生数据不一致

“弱肉强食”的线程世界

大家好,我叫王大锤,我的目标是当上CEO...额 不好意思拿错剧本了。大家好,我叫0x7575,是一个线程,我的线生理想是永远最快拿到CPU。

先给大家介绍一下线程世界,线程世界是一个弱肉强食的世界,资源永远稀缺,什么东西都要抢,这几个纳秒我有幸拿到CPU,对int a = 20进行一次加1操作,当我从内存中取出a,进行加1后就失去了CPU,休息结束之后准备写入内存的时候,我惊奇的发现:内存中的a这时候已经变成了22。

一定有线程趁我不在修改了数据,我左右为难,很多线程也都劝我不要写入,但是迫于指令,我只能把21写入内存覆盖掉不符合我的运算逻辑的22。

以上只是一个微小的事故,类似的事情在线程世界层出不穷,所以虽然我们每一个线程都尽职尽责,但是在人类看来我们是引起数据不安全的祸首。

这是何等的冤枉啊,线程世界一直都是竞争激烈的世界,尤其是对于一些共享变量,共享资源(临界资源),同时有多个线程进行争夺使用时再正常不过的事情了。除非消除共享的资源,但是这又是不可能的,于是事情就开始僵持了。

线程世界出现了一把锁

幸好还是又聪明人的,有人想到了一个解决问题的好方法。虽然不知道谁想到的注意,但是这个注意确实解决了一部分问题,解决的方案是加锁

你想要进行对一组加锁的代码进行操作吗?想的话就先去抢到锁,拿到锁之后就可以对被加锁的代码为所欲为了,倘若拿不到锁的话就只能在代码块门口等着,因为等的线程太多了,这还成为了一种社会现象(状态),该社会现象被命名为线程的阻塞。

听上去很简单,但是实际上加锁有很多详细的规定的,详情政府发布了《关于synchronzied使用的若干规定》以及后来发布的《关于Lock使用的若干规定》。

线程和线程之间是共享内存的,当多线程对共享内存进行操作的时候有几个问题是难以避免的,竞态条件(race condition)和内存可见性。
竞态条件:当多线程访问和操作同一对象的时候,最终结果和执行时序有关,正确性是不能够人为控制的,可能正确也可能不正确。(如上文例子)

上文中说到的加锁就是为了解决这个问题,常见的解决方案有:

  • 使用synchronized关键字
  • 使用显式锁(Lock)
  • 使用原子变量

内存可见性:关于内存可见性问题要先从内存和cpu的配合谈起,内存是一个硬件,执行速度比CPU慢几百倍,所以在计算机中,CPU在执行运算的时候,不会每次运算都和内存进行数据交互,而是先把一些数据写入CPU中的缓存区(寄存器和各级缓存),在结束之后写入内存。这个过程是及其快的,单线程下并没有任何问题。

但是在多线程下就出现了问题,一个线程对内存中的一个数据做出了修改,但是并没有及时写入内存(暂时存放在缓存中);这时候另一个线程对同样的数据进行修改的时候拿到的就是内存中还没有被修改的数据,也就是说一个线程对一个共享变量的修改,另一个线程不能马上看到,甚至永远看不到。

这就是内存的可见性问题。

解决这个问题的常见方法是:

  • 使用volatile关键字
  • 使用synchronized关键字或显式锁同步

3. 线程的状态有哪些?

一个线程在启动之后不会立马执行,而是处于就绪状态(Ready),就绪状态就是线程的状态的一种,处于这种状态的线程意味着一切准备就绪, 需要等待系统分配到时间片。为什么没有立马运行呢,因为同一时间只有一个线程能够拿到时间片运行,新线程启动的时候让它启动的线程(主线程)正在运行,只有等主线程结束,它才有机会拿到时间片运行。

线程的状态:初始状态(New),就绪状态(Ready),运行状态(Running)(特别说明:在语法的定义中,就绪状态和运行状态是一个状态Runable),等待状态(Waitering),终止状态(Terminated)

  1. 初始状态(New)
    1. 线程对象被创建出来,便是初始状态,这时候线程对象只是一个普通的对象,并不是一个线程
  2. Runable
    1. 就绪状态(Ready):执行start方法之后,进入就绪状态,等待被分配到时间片。
    2. 运行状态(Running):拿到CPU的线程开始执行。处于运行时间的线程并不是永久的持有CPU直到运行结束,很可能没有执行完毕时间片到期,就被收回CPU的使用权了,之后将会处于等待状态。
  3. 等待状态(Waiting)
    1. 等待状态分为有限期等待和无限期等待,所谓有限期等待是线程使用sleep方法主动进入休眠,有一定的时间限制,时间到期就重新进入就绪状态,再次等待被CPU选中。
    2. 而无限期等待就有些不同了,无限期并不是指永远的等待下去,而是指没有时间限制,可能等待一秒也可能很多秒。至于进入等待的原因也不尽相同,可能是因为CPU时间片到期,也可能是因为一个比较耗时的操作(数据库),或者主动的调用join方法。
  4. 阻塞状态(Blocked)
    1. 阻塞状态实际上是一种比较特殊的等待状态,处于其他等待状态的线程是在等着别的线程执行结束,等着拿CPU的使用权;而处于阻塞状态的线程等待的不仅仅是CPU的使用权,主要是锁标记,没有拿到锁标记,即便是CPU有空也没有办法执行。
  5. 终止线程(Terminated)
    1. 已经终止的线程会处于该种状态。

4. wait和sleep的区别

5. 等待和阻塞的区别

6. Java中创建线程的方式

  1. 继承Thread
  2. 实现Runnable接口
  3. 实现Callable接口,结合 FutureTask使用
  4. 利用该线程池
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;

public class NewThreadDemo {

    public static void main(String[] args) throws Exception {
        
        //第一种方式
        Thread t1 = new Thread(){
            @Override
            public void run() {
                System.out.println("第1种方式:new Thread 1");
            }
        };
        t1.start();
        
        TimeUnit.SECONDS.sleep(1);
        
        //第二种方式
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("第2种方式:new Thread 2");
            }
        });
        t2.start();

        TimeUnit.SECONDS.sleep(1);
        
        
        //第三种方式
        FutureTask<String> ft = new FutureTask<>(new Callable<String>() {
            @Override
            public String call() throws Exception {
                String result = "第3种方式:new Thread 3";
                return result;
            }
        });
        Thread t3 = new Thread(ft);
        t3.start();
        
        // 线程执行完,才会执行get(),所以FutureTask也可以用于闭锁
        String result = ft.get();
        System.out.println(result);
        
        TimeUnit.SECONDS.sleep(1);
        
         //第四种方式
        ExecutorService pool = Executors.newFixedThreadPool(5);

        Future<String> future = pool.submit(new Callable<String>(){
            @Override
            public String call() throws Exception {
                String result = "第4种方式:new Thread 4";
                return result;
            }
        });

        pool.shutdown();
        System.out.println(future.get());
    }
}

7. Callable和Runnable的区别?

	class c implements Callable<String>{
		@Override
		public String call() throws Exception {
			return null;
		}
	}
	
	class r implements Runnable{
		@Override
		public void run() {
		}
	}

相同点:

  1. 两者都是接口
  2. 两者都需要调用Thread.start启动线程

不同点:

  1. 如上面代码所示,callable的核心是call方法,允许返回值,runnable的核心是run方法,没有返回值
  2. call方法可以抛出异常,但是run方法不行
  3. 因为runnable是java1.1就有了,所以他不存在返回值,后期在java1.5进行了优化,就出现了callable,就有了返回值和抛异常
  4. callable和runnable都可以应用于executors。而thread类只支持runnable

8. 什么是线程池?有什么好处?

谈到线程池就会想到池化技术,其中最核心的思想就是把宝贵的资源放到一个池子中;每次使用都从里面获取,用完之后又放回池子供其他人使用,有点吃大锅饭的意思。

Java线程池有以下优点:

  1. 线程是稀缺资源,不能频繁的创建。
  2. 解耦作用;线程的创建于执行完全分开,方便维护。
  3. 应当将其放入一个池子中,可以给其他任务进行复用。

9. 创建线程池的方式

  1. 通过Executors类
  2. 通过ThreadPoolExecutor类

在Java中,我们可以通过Executors类创建线程池,常见的API有:

  1. Executors.newCachedThreadPool():无限线程池。
  2. Executors.newFixedThreadPool(nThreads):创建固定大小的线程池。
  3. Executors.newSingleThreadExecutor():创建单个线程的线程池。
  4. Executors.newScheduledThreadPool()
  5. Executors.newWorkStealingPool(int) java8新增,使用目前机器上可用的处理器作为它的并行级别

以上的这些创建线程池的方法,实际上JDK已经给我们写好的,可以拿来即用的。但是只要我们查看上述方法的源码就会发现:

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

以上方法实际上都是利用 ThreadPoolExecutor 类实现的。

所以第二种创建线程方式是自己通过 new ThreadPoolExecutor来进行创建。

10. Executors 有那么多创建线程池的方法,开发中用哪个比较好?

答案:一个都不用。

从《阿里巴巴Java开发手册》中可以看到

关于参数的详细解释见下一个问题。

11. 如何通过 ThreadPoolExecutor 自定义线程池?即线程池有哪些重要的参数?

在上一个问题中,我们提到了创建线程池要通过 new ThreadPoolExecutor 的方式,那么,如何创建呢?在创建的时候,又需要哪些参数呢?

我们直接看一下 ThreadPoolExecutor 的构造方法源码,如下:

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;
    }

密密麻麻都是参数,那么这些参数都什么呢?

大致的流程就是

  1. 创建线程池之后,有任务提交给线程池,会先由 核心线程执行
  2. 如果任务持续增加,corePoolSize用完并且任务队列满了,这个时候线程池会增加线程的数量,增大到最大线程数
  3. 这个时候如果任务继续增加,那么由于线程数量已经达到最大线程数,等待队列也已经满了,这个时候线程池实际上是没有能力执行新的任务的,就会采用拒绝策略
  4. 如果任务量下降,就会有很多线程是不需要的,无所事事,而只要这些线程空闲的时间超过空闲线程时间,就会被销毁,直到剩余线程数为corePoolSize。

通过以上参数可以就可以灵活的设置一个线程池了,示例代码如下:

/**
* 获取cpu核心数
*/
 private static int corePoolSize = Runtime.getRuntime().availableProcessors();

    /**
     * corePoolSize用于指定核心线程数量
     * maximumPoolSize指定最大线程数
     * keepAliveTime和TimeUnit指定线程空闲后的最大存活时间
     */
    public static ThreadPoolExecutor executor  = new ThreadPoolExecutor(corePoolSize, corePoolSize+1, 10l, TimeUnit.SECONDS,
            new LinkedBlockingQueue<Runnable>(1000));

12. 线程池底层工作原理?

关于线程池的工作原理和执行流程,通过两张图来进行展示

  1. 在创建了线程池后,等待提交过来的任务请求。
  2. 当调用execute()方法添加一个请求任务时,线程池会做如下判断:
    1. 如果正在运行的线程数量小于corePoolSize,那么马上创建马上创建线程运行这个任务。
    2. 如果正在运行的线程数量大于或等于corePoolSize,那么将这个任务放入队列
    3. 如果这个时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务。
    4. 如果队列满了且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会启动饱和拒绝策略来执行
  3. 当一个线程完成任务时,它会从队列中取下一个任务来执行。
  4. 当一个线程无事可做超过一定的时间(keepAlilveTime)时,线程池会判断:
    1. 如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉。
    2. 所以线程池的所有任务完成后它最终会收缩到corePoolSize的大小

13. 谈谈线程池的饱和策略,也叫做拒绝策略。

所谓饱和策略就是:当等待队列已经排满,再也发不下新的任务的时候,这时,线程池的最大线程数也到了最大值,意味着线程池没有能力继续执行新任务了,这个时候再有新任务提交到线程池,如何进行处理,就是饱和(拒绝)策略

14. 如何合理配置一个线程池

通常我们是需要根据这批任务执行的性质来确定的。

  • IO 密集型任务:由于线程并不是一直在运行,所以可以尽可能的多配置线程,比如 CPU 个数 * 2
    • IO密集型,即该任务需要大量的IO,即大量的阻塞。
    • 在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力浪费在等待。
    • 所以IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。
  • CPU 密集型任务(大量复杂的运算)应当分配较少的线程,比如 CPU 个数相当的大小。CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行。

当然这些都是经验值,最好的方式还是根据实际情况测试得出最佳配置。

15. 如何关闭线程池

关闭线程池的方法有两个:shutdown()/shutdownNow()

  • shutdown() 执行后停止接受新任务,会把队列的任务执行完毕。
  • shutdownNow() 也是停止接受新任务,但会中断所有的任务,将线程池状态变为 stop。

关闭线程池的代码:

long start = System.currentTimeMillis();
for (int i = 0; i <= 5; i++) {
    pool.execute(new Job());
}
pool.shutdown();
while (!pool.awaitTermination(1, TimeUnit.SECONDS)) {
    LOGGER.info("线程还在执行。。。");
}
long end = System.currentTimeMillis();
LOGGER.info("一共处理了【{}】", (end - start));

pool.awaitTermination(1, TimeUnit.SECONDS) 会每隔一秒钟检查一次是否执行完毕(状态为 TERMINATED),当从 while 循环退出时就表明线程池已经完全终止了。

参考资料:

  1. 进程和线程的一个简单解释
  2. Java—多线程基础
  3. Java—线程同步
  4. 创建线程的四种方式
  5. Callable和Runnable的区别
  6. 如何优雅的使用和理解线程池
  7. 《阿里巴巴Java开发手册》
  8. 深入理解线程池原理篇

欢迎关注本人公众号:鹿老师的Java笔记,将在长期更新Java技术图文教程和视频教程,Java学习经验,Java面试经验以及Java实战开发经验。

posted @ 2020-03-24 10:48  编程鹿  阅读(1089)  评论(0编辑  收藏  举报