前言
今天不学习,明天变垃圾
本文主要内容:多线程案例的【线程池】,线程池在日常开发中是非常重要的组件,一定要学会模拟实现!以及关注面试题!
一、线程池
-
池:可以提高效率,即用即取。
-
常见的【池】如
① String字符串常量池;
② mysql jdbc数据连接池(DataSource):在建立连接之后,同时也会保留一些之前的连接,后续再需要建立连接的时候直接从池子里面取一个已经连接好的就行了,省下了重新建立连接的过程。 -
线程的诞生是因为进程太重量了,进程太重量会导致创建/销毁进程比较低效(线程节省了内存资源的申请和释放的开销)。线程就是共享了同一个进程的内存资源,新的线程复用之前的资源就不必再重新申请了;但是如果线程创建的速率进一步的频繁了,此时线程创建和销毁的开销仍然是不能忽略的;此时就可以使用线程池来进一步的优化这里的速度了!
-
创建一个线程池,里面创建好了很多线程。当需要执行任务的时候就不需要再重新创建线程了,而是直接从池子里取一个现成的线程直接使用,用完之后并不是释放线程,而是直接还回到线程池中。
-
理解为啥从池子里取比创建新线程快?
答: 创建线程还是会申请点资源的,但是这个资源已经很少了,速度已经很快了,暂可以忽略不计。
【原因】创建线程是要在操作系统内核中完成的,涉及到用户态到内核态之间的切换操作!这个操作是存在一定的开销的。(Ps:加锁也涉及到 用户态到内核态之间的切换) -
计算机基本的软硬件结构:(从下到上)硬件、驱动、内核、系统调用、应用程序。
应用程序(相当于用户态)发起的一个创建线程的行为,线程本质上是PCB,是内核中的数据结构,则应用程序就需要通过系统调用进入操作系统内核中执行(也就是完成用户态到内核态的切换);内核完成PCB的创建,把PCB加入到调度队列中,然后再返回给应用程序(也就是完成内核态到用户态的切换)。 -
从线程池取线程、把线程放回线程池,这都是纯的用户态实现的逻辑; 而从系统这里创建线程,则是用户态和内核态共同完成的逻辑。
(注:创建进程也是通过内核完成的,也要经历用户态和内存态之间的切换过程)
用户态:每个进程都是自己执行自己的逻辑;
内核态:一个系统里只有这一份内核在执行逻辑,这个内核要给所有的进程都提供一些服务;即使有多个内核,但是一般而言还是用户态数会大于内核态数。
所以:一般来说,纯用户态速度更快,即:使用线程池是纯用户态操作,要比创建线程(要经历内核态)速度更快
二、Java标准库中的线程池
-
以下这种创建方式就称为【工厂模式】,newCacheThreadPool就是工厂方法。(也就是:在创建实例对象的时候没有new构造方法,而是调用另一个类的静态方法来实现对象的创建)
-
常见的设计模式主要介绍的是【单例模式】和【工厂模式】。
-
构造方法存在一定的局限性,为了绕开局限就引入了【工厂模式】。
① 创建实例最主要就是使用构造方法new,但是在new的过程中就需要调用构造方法,有时候希望类能够提供多种构造实例的方法,此时就需要重载构造方法来实现不同版本的对象创建,但是重载要求参数类型/参数个数不相同,就带来了一定的限制;
② 此时就通过使用普通方法来进行设置,直接通过方法名来进行区分。
举例:笛卡尔和极坐标 坐标(参数类型和个数都相同,无法构成方法重载,但是内部实现确实是不一样的,此时直接使用普通方法就行) -
线程池使用submit方法把任务提交到线程池中即可,线程池中就会有一些线程来完成这里的任务。
-
参考代码:标准库中的线程池
三、 线程池的模拟实现
-
【线程池的模拟实现】
一个线程池可以同时提交N个任务,对应的线程池中有M个线程来负责完成这N个任务 -
那么如何把N个任务分配给M个线程呢?
答: 生产者消费者模型。
① 先搞一个阻塞队列,每个被提交的任务都放到阻塞队列中;搞M个线程来取队列元素,如果队列空则M个线程就进行阻塞等待;但是如果队列不为空,每个线程都取一个任务,执行任务完成后再来取下一个…直到队列空,线程继续阻塞。
② 不能平均分:因为每个线程执行时间都是不一样的
③ 不用结束,因为无法判定啥时候会有新的线程过来;如果非要结束,那就单独写一个shutdown方法强制中断interrupt所有的工作线程 -
一般还是在服务器开发中比较常用到线程池,则此时线程池大部分是要持续工作的,所以一般不需要结束线程
-
Thread跑起来之后是不会被销毁的,这和普通对象是不一样的
// Thread在执行过程中是不会提前销毁的;但是Thread中的run执行完了就会销毁Thread了。 -
标准库里提供的ThreadPoolExecutor其实是更复杂一些的,尤其是构造方法,可以支持很多参数,可以支持很多选项,让我们创建出不同风格的线程池
(具体可以参考java帮助文档:java帮助文档 ) -
构造方法【常见面试题!!】
1) 查看ThreadPoolExecutor里的构造方法:java.util.concurrent(并发) -> ThreadPoolExecutor (线程池)
2) 此处只分析最后一个构造方法:
① corePoolSize:核心线程数
② maximumPoolSize:最大线程数
(任务数量是不太确定的,有时候任务多了,核心线程处理不过来,此时就需要更多的线程来帮助一起处理任务;当任务处理完之后,这些除了核心线程外的线程在一定时间的空闲之后就可以销毁了;但是核心线程即使空闲也不会销毁。
灵活调配这两个数值,可以做到既能够处理任务巅峰,又能够在空闲的时候节省资源。)
③ keepAliveTime:运行的额外线程空闲的最大时间,也就是空闲上限。
④ unit:时间的单位
⑤ workQueue:手动给线程池传入一个任务队列。其实在线程池中是有自己的队列的(如果不自己手动传入就会在线程池内部自己创建),但是有时候代码的业务逻辑中本身就有一个队列来保存这里的任务,此时如果把自己队列中的任务再拷贝到线程池内部就是画蛇添足了,直接就让线程池消费业务逻辑中已有的队列即可!
⑥ threadFactory:描述了线程是如何创建的。工厂对象就负责创建线程,程序员可以手动指定线程的创建策略。
⑦ RejectedExecutionHandler handler:【重点!常考!】线程池的拒绝策略。线程池的任务队列已经满了(工作线程忙不过来了),如果又添加了别的新任务,那该怎么办呢?
——这个拒绝策略对于实现“高并发”服务器也是非常有意义的。
以下就是标准库中提供的拒绝策略:
① AbortPolicy:中断策略,直接抛异常 handler(回调,处理方法)
② CallerRunsPolicy:调用者来执行,而不是被调用者来执行(按理来说是被调用者执行);如果调用者也不执行就丢弃该任务
③ DiscardOldestPolicy:丢弃最老的未处理请求
④ DiscardPolicy:直接丢弃最新的任务
(实际开发中,需要根据请求来决定使用哪种策略)
【面试官考察你对于ThreadPoolExecutor的理解,其实主要就是在考察拒绝策略】
- 延伸问题:线程池不是可以自定义线程数目吗,那么在实际开发中,线程池的数目如何确定?设定为几计较合适呢?2?4?…
—— 网上大部分说法是错误的。只要你具体说出一个数字都是错误的! 因为我们在这里是不可以确定出具体的个数的。
理由:① 主机的CPU的配置不确定;
② 程序的执行特点(也就是:代码里具体都干了啥?是CPU密集型的任务还是IO密集型的任务)也是不确定的。
执行特点:也就是代码里具体都干了啥?是CPU密集型的任务(做了大量的算术运算和逻辑运算)还是IO密集型的任务(做了大量的读写网卡/读写硬盘)
- 有些程序代码里既需要进行很多的CPU密集型任务,又需要很多的IO任务,则此时是很难量化该进程的两种任务的比例的。
1)如果任务100%是CPU密集型的话,线程说明最多也就是N,更大的话其实已经没意义了,因为此时CPU已经被占满了
2)如果线程只有10%是CPU密集型,其余90%都是在操作IO(不使用CPU),那么此时线程数目设置成10N也是没关系的,因此此时所有线程中只有10%是在使用CPU的。
- 工作中实际的处理方案就是进行验证,也就是针对进程做性能测试:分别给线程池设置成不同的数目,如0.5N,N,1.5N,2N…都试试,分别记录每种情况下该线程的一些核心性能指标和系统负载情况,最终选择一个你认为比较合适的配置。
【其实面试官考察的关键是如何设置线程池数目的方法(实验+压测)】
【线程池既是日常开发中非常常用的组件,又是面试中的高频问题,一定要重点掌握!!!】
-
多线程的案例:单例模式、阻塞队列、定时器、线程池都是日常开发中常用的多线程相关的基础组件,务必要重点掌握。
-
如果代码出现死锁,那就先使用jconsole查看线程的调用栈,明确死锁卡死在哪个位置,然后再进行进一步分析。
10.参考代码:Demo6线程池模拟实现
THINK
- 线程池的构造方法!【面试】
- 线程池标准库中的submit方法
- 线程池的模拟实现【自己写一遍】
- 【面试】线程池如何设置线程数目
- 【面试】对于ThreadPoolExecutor的理解(其实就是考察拒绝策略)
- 线程池进行任务分配给线程使用的是【生产者消费者模型】