java大厂面试题整理(五)线程及线程池相关知识点

先从java开启一个线程开始说。
首先常用的有四种方式:继承+两种实现+线程池获取。
其实我们之前大量的demo都是new Thread(()->{}).start();这个就是继承的方式。
这里重点说下两种实现:


开启线程方法

这里注意两种方式的区别:

  1. Runnable没有返回值,而Callable是有返回值的(返回值是传入的泛型类型)
  2. Runnable的run是不会抛异常的,而Callable中的call是会抛异常的
  3. 两者的需要实现的方法不一样。

Callable的实现方式:
正常我们用匿名内部类的方式:new Thread(()->{},"name")或者不需要这个name参数。但是这里其实()代表的是Runnable类型的参数。所以说Callable要如何去写呢?


Thread构造方法

那我们要怎么把Callable和Runnable挂上关系呢?如下图结构:


Runnable和Callable关联关系

所以如下图代码的实现:
    public static void main(String[] args) throws Exception {
        FutureTask futureTask = new FutureTask(()->{
            TimeUnit.SECONDS.sleep(2);
            return 1024;
            });
        new Thread(futureTask).start();
        System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
        System.out.println(futureTask.get());
        System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
    }

注意虽然我直接返回了1024.但是是因为是测试代码。正常来讲这个方法里的要处理业务逻辑的。可能执行N久。所以我这里睡了2秒来表示代码执行2s最终算出这个1024的结果。而futureTask.get()就是获取call方法的结果。注意这个方法是可以设置等待时长的。比如方法没执行完会一直阻塞在那里等结果。但是可以设置等xxx时间还没结果就不等了。
然后注意我这两个时间输出语句:意料之中的第一个直接打印了,而第二个等了2s获取到这个返回值打印的。如下运行截图:

不设置等待时长的运行截图

等待超时的运行截图

反正这个get方法设置时间的和不设置时间的都试过了。总而言之用法上很常规。类似阻塞队列。然后使用的时候有一些建议:
get方法放在最后。因为get是要等计算完成才能获取到,所以放在最后合计的时候获取可以节省时间。比如 同时四个线程在main线程中执行。
A-2s。B-2s。C-2s。D-2s。
如果我们在启动A线程紧接着去get,那么get本身还要等2s,然后get以后再去启动B。再紧接着去get。又等2s。最终这四个线程的结果合计要8s才能获取到。
但是如果我们先启动A,B,C,D.然后最后去getA,,B,C,D.这样只需要2s我们就能获取到这四个线程的合适了。
当然了,java中还有一点:如果同一个task,那么计算结果是可以共通的。如下代码:
两次用到了futureTask,但是只打印了一遍

如果想每个线程都计算一次,要写多个实例。

线程池

Java中线程池是通过Executor框架实现的。该框架中用到了Executor,Executors,ExecutorService,ThreadPoolExecutor等几个类。


java线程类结构

注意上文中有个Executors图中没有。但是这个类是一个工具类。我们可以回忆一下

  • 数组:Array,然后有个数组工具类Arrays。
  • 集合:Collection,然后有个Collections工具类。
  • 线程:Executor,然后有个Executors工具类。

你看这么一顺是不是发现合情又合理还好记?那这个就过了,说下一个知识点:
java中线程池有多种。其分别有不同的使用场景和作用。然后每次申请的时候注意不要new!!new是新建一个,我们不要用这种方式,而是应该用工具类申请一个。而池化技术一定一定要注意的就是关闭线程。甚至有时候关闭比启用还要重要!
当然了一些用的比较少的类行就不讲了。这里重点就讲几种:

  1. 初始化的时候就固定线程数的线程池:适合执行长期的任务,性能好很多
    这个就是在创建线程池的时候就设定好线程池的容量。创建方法如下:
ExecutorService threadPool = Executors.newFixedThreadPool(5);//初始化的时候就固定大小的线程池

这个参数的大小就是初始化线程数。然后我们可以测试使用一下(获取线程是submit,关闭线程是shutdown):


使用线程池

首先线程的个数最多只有5,所以说五个线程是没问题的。其次设定是10个客户办理业务,一共有五个窗口。一个常规思维就是一个窗口办理两个。但是实际上并不是。有的窗口办理了三个业务,有的只办理了一个。实际上分析可能是有个业务 办理时间长。所以办理的少。代码的角度来说:当线程池归还以后,下次从线程池获取线程的概率是一样的。不会因为这个刚用完所以让它歇歇。
刚刚说了初始化的时候就固定了5个线程,所以哪怕再多任务过来了,也只能有五个线程工作,我把for循环设置为100,1000,10000也都超出不了五个线程。

  1. 初始化的时候只有一个线程的线程池。适合一个任务一个任务执行的场景
//只有一个线程的线程池
ExecutorService threadPool = Executors.newSingleThreadExecutor();

这个其实名字就比较容易理解,单例模式嘛,就是单个线程。然后测试可以和刚刚的一样的demo,我们简单跑一下:


只有一个线程干活
  1. 可伸缩扩容的线程池。适用于执行很多短期异步的小程序或者负载较轻的服务器。
        //只有一个线程的线程池
        ExecutorService threadPool = Executors.newCachedThreadPool();

这个咋说呢,就是几个够用要几个线程。不限制大小。依然是上面的demo:


十个任务用了八个线程

for循环中睡1s,则一个线程就够用了

由此说,这个线程池是根据实际情况伸缩。当然了这个底层的调度就比较复杂了。我也不清楚。。

注意了上面三种创建方式,其底层源码都是用的一个方法:


三种形式的线程池调用了一个方法

三种形式的线程池调用了一个方法

而这个方法就是new ThreadPoolExecutor()。其中有五个参数。而这五个参数对应的意义很重要,要背下来!当然了我们看上去只有五个参数。但是其实底层的话是一共有七个参数的。下面我们一个个介绍。

线程池七大参数

我们之前看源码明明是五个参数,为什么这里说线程池的七大参数呢?直接看源码:


线程池源码

其实我们看是五个参数是因为没有看到底。继续往下走就会发现底层都是七个参数。下面我们一个个说这七个参数都是什么:

  • corePoolSize:线程池中的长驻核心线程数。
  • maximumPoolSize:线程池能够容纳同时执行的最大线程数。此值必须大于1.
  • keepAliveTime:多余的空闲线程的存活时间(当线程池中线程个数大于核心长驻数而小于最大线程数的时候会启效果。也就是线程空闲到设置的时间就会自动销毁,直到线程数等于corePoolSize)
  • unit:keepAliveTime的单位
  • workQueue:任务队列,被提交但尚未被执行的任务。
  • threadFactory:生成线程池中工作线程的线程工厂,用于创建线程,一般用默认的即可。
  • handler:拒绝策略,当队列满了并且工作线程大于等于线程池的最大线程数(maximumPoolSize)的时候如何来拒绝新的任务。

其实上面的就是七个参数的理论了。下面我们结合实际来讲讲:
比如线程池是一个银行。那么第一个corePoolSize:核心线程数。我们可以理解为银行总是有人值班的窗口。
而当核心线程数满了,都在工作以后,就会进入到队列中排队。也就是workQueue。
而maximumPoolSize是能容纳的最大线程数。比如说银行的常用窗口只有两个,然后都干着活,并且大厅排队的人也满了,这时候会把其余没有人值班的窗口也启用。但是前提是有窗口了才能安排人值班。总不能看人多了现去扒个窗口出来吧。所以我们可以理解为银行现存的窗口(不管有没有人值班)的个数就是能容纳的最大个数。
而这里keepAliveTime和unit是一对。是因为人多所以临时开启的窗口,但是假如所有人都办完了,现在没有客人了,这些本来不是长期,因为人多所以临时开启的窗口总不能也一直在这值班了。所以会有个机制:比如说半小时内还没人来,那么这个窗口就关了。里面的工作人员该干嘛干嘛去了。
ThreadFactory就是生成线程的工厂。一般都是默认的。换成银行的话我们可以理解为银行中的工作人员的来源。你去银行办业务但是你不用管给你办业务的服务人员是怎么来的,是分配来的还是转行来的还是潜规则进来的,这个和你都没啥关系。
handler是拒绝策略。很容易理解:一个小营业厅在人多了会排队,实在不行加值班窗口。但是不管怎么加还是不断有人来,挤都挤不进去了,这个时候只能拒绝不让客户进了。而这个具体的策略是有四种实现的。具体要怎么拒绝是可以酌情设置的。


submit底层也是execute

下面是线程池底层原理的语言叙述:

  1. 在创建了线程池后,等待提交过来的任务请求。

  2. 当调用execute()方法(submit底层也是调用execute方法)添加一个请求任务时,线程池会做如下判断:

    • 如果正在运行的线程数量小于corePoolSize,那么马上就创建线程运行这个任务。
    • 如果正在运行的线程数量大于等于corePoolSize,那么将回把这个任务放入队列
    • 如果这个时候队列也满了且正在运行的线程数还小于maximumPoolSize,那么还是要创建非核心线程立刻运行第一个等待的任务。并且可以把这个插入到阻塞队列了(注意阻塞队列是不能插队的!)
    • 如果队列满了,并且正在运行的线程数量大于等于maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
  3. 当一个线程完成任务后,它会从队列中获取下一个任务来执行。

  4. 当一个下次你哼无事可做超过一定的时间(keepAliveTime)时,线程池会判断:

    • 如果当前与性的线程池数量大于corePoolSize,那么这个线程会被销毁。
    • 线程池的所有任务执行完成后,最终会收缩到corePoolSize的大小。
线程池的拒绝策略

其实这个上面已经说到过了,但是这里再用文字表述下:
等待队列已经排满了,再也塞不下新的任务了,同事线程池中的max线程也达到了,无法继续为新任务服务。这时候我们就需要用拒绝策略机制合理的处理这个问题。
拒绝策略有四种:

  • AbortPolicy(默认):直接抛出RejectedExecutionException异常阻止系统正常运行。
  • CallerRunsPolicy:调用者运行。一种调节机制。该策略既不会抛弃任务,也不会抛出异常。而是将某些任务回退到调用者。从而降低新的任务容量。
  • DiscardOldestPolicy:抛弃队列中等待最久的任务。然后把当前任务加入到队列中尝试再次提交当前任务。
  • DiscardPolicy:直接丢弃任务,不予任何处理也不抛出异常。如果允许任务丢失,这是最好的一种方案。

在实际中,上面的三个线程池(单一的,可变的,固定长度的)我们一个都不用!而是一般都是自己写。因为默认的这三个:
FixedThreadPool和SingleThreadPool的允许请求队列的长度(可排队的长度)为int最大值。会导致OOM。
CachedThreadPool和ScheduledThreadPool允许创建的最大线程数为int最大值。也会导致OOM。
所以实际上我们要自己去自定义线程池。这七个参数我们也都知道意思了,随便写个就行了:

        ExecutorService threadPool = new ThreadPoolExecutor(2, //核心线程数
                5, //最大线程数
                2, //空闲线程超过多久销毁
                TimeUnit.SECONDS,//空闲线程销毁时限的单位
                new LinkedBlockingQueue(10), //阻塞队列。能排队的任务数:10
                Executors.defaultThreadFactory(),//默认的线程工厂
                new ThreadPoolExecutor.AbortPolicy());//拒绝策略。

下面测试一下拒绝策略:
我设置最大线程数5,最长队列10.也就是同时能容纳的最大数是15.然后我用十六个任务试一下:


第一个拒绝策略,抛出异常

而第二种的效果:


第二种拒绝策略,返回给了main线程

后两种就是单纯的扔任务还没提示,控制台没什么好看的,反正知道是扔任务,只不过扔的是不一样的任务就行了。

如何合理配置最大线程数

在实际中,这几个参数的设置是有一定的规则的。不是看心情来的。而设置的规则分两种:
CPU密集型和IO密集型。
CPU密集型:该任务需要大量大量的运算,而没有阻塞,CPU一直全速运行(CPU密集任务只有在真正的多核CPU上才可能通过多线程得到加速,而在单核CPU上无论开几个模拟的多线程都不可能得到加速。因为CPU的运算能力就那些)。
CPU密集型任务配置尽可能少的线程数量。一般公式CPU核数+1个线程的线程池。
IO密集型:由于IO密集型任务线程并不是一直在执行任务,所以应该配置尽可能多的线程。如CPU核数2*
IO密集型即该任务需要大量的IO,即大量的阻塞。
在单线程上运行IO密集型的任务会导致大量的CPU运算能力浪费在等待。所以在IO密集型任务中使用多线程可以大大的加速程序运行。即使在单核CPU上,这种加速主要就是利用被浪费掉的阻塞时间。公式:
CPU核数/(1-阻塞系数)。阻塞系数在0.8-0.9之间。
注:上面说的是最大线程数。核心线程数的话也结合实际吧。稍微少点问题也不大。

本篇笔记就记到这里,因为是分了几天整理的,所有可能有的写的比较乱。反正如果稍微帮到你了记得点个喜欢点个关注。也祝大家工作顺顺利利,生活健康!

你可能感兴趣的:(java大厂面试题整理(五)线程及线程池相关知识点)