lukaliou123 / lukaliou123.github.io

lukaliou123在2022年的面试用知识点总结
Other
5 stars 0 forks source link

Java并发篇--线程池 #8

Open lukaliou123 opened 2 years ago

lukaliou123 commented 2 years ago

1. 什么是线程池?

Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理地使用线程池能够带来许多好处 1.降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 2.提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。 3.提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。但是,要做到合理利用

2.线程池作用

1.线程池是为突然大量爆发的线程设计的,通过有限的几个固定线程为大量的操作服务,减少了创建和销毁线程所需的时间,从而提高效率 2.如果一个线程所需要执行的时间非常长的话,就没必要用线程池,我们还不能控制线程池中线程的开始、挂起和中止

指定

lukaliou123 commented 2 years ago

3.什么是ThreadPoolExecutor?

ThreadPoolExecutor就是线程池 ThreadPoolExecutor其实也是JAVA的一个类,我们一般通过Executors工厂类的方法,通过传入不同的参数,就可以构造出适用于不同应用场景下的ThreadPoolExecutor(线程池) 构造参数图: image

构造参数参数介绍: corePoolSize 核心线程数量 maximumPoolSize 最大线程数量 keepAliveTime 线程保持时间,N个时间单位 unit 时间单位(比如秒,分) workQueue 工作队列 用于保存等待执行的任务的

阻塞队列,常见的有如下几种:

ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。 1.容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列):FixedThreadPool 和 SingleThreadExector 。由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。

2.SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。

3.DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

threadFactory(线程工厂):创建新线程的工厂,可以通过这个参数自定义如何创建线程,比如你可以自定义线程的名称,一般也不用写,有个默认的。

handler(拒绝策略):

当队列和最大线程池都满了之后,如何处理新进来的任务。常见的有四种策略:AbortPolicy、DiscardPolicy、DiscardOldestPolicy和CallerRunsPolicy。

如果在创建ThreadPoolExecutor的时候没有显式指定拒绝策略,那么默认的拒绝策略是AbortPolicy

AbortPolicy的策略是直接抛出一个RejectedExecutionException异常,来告诉你线程池已经无法处理更多的任务了。这个策略相对较为“激进”,但也很直观,让你立刻知道发生了什么问题。 1685450332740 线程池结构 image

lukaliou123 commented 2 years ago

4.什么是Executors?

Executors框架实现的就是线程池的功能。 Executors工厂类中提供的newCachedThreadPool, newFixedThreadPool, newScheduledThreadPool,newSingleThreaExecutor等方法其实也只是ThreadPoolExecutor的构造函数参数不同而已。通过传入不同的参数,就可以构造出适用于不同应用场景下的线程池。 image

lukaliou123 commented 2 years ago

5.线程池四种创建方式

1.newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。 2.newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待 3.newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行 4.newSingleThreadExecutor创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行

6.四种构建线程池的特点和区别

1.newCachedThreadPool 特点:它可以创建一个可缓存线程池,如果当前线程池的长的都超过了处理的需要时,他可以灵活的回收空闲的线程,当需要增加时,它可以灵活的添加新的线程,而不会对池的的长度做任何限制 缺点:它虽然可以无限新建线程,但是容易造成堆外内存溢出 总结:线程池为无限大,当执行第二个任务时第一个任务已经完成,会复用执行第一个任务的线程,而不用每次新建线程 image

2.newFixedThreadPool 特点:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。定长线程池的大小最好根据系统资源进行设置。 缺点: 线程数量是固定的,但是阻塞队列是无界队列,如果有很多请求挤压,阻塞队列越来越长,容易导致OOM(超出内存空间) 总结:请求的挤压一定要和分配的线程池大小匹配,定线程池的大小最好根据系统资源进行设置 image

3.newScheduledThreadPool 特点:创建一个固定长度的线程池,而且支持定时的以及周期性的任务执行,类似于Timer 缺点:由于所有人都是有一个线程来调度,因此所有任务都是串行执行,同一个时间只能由一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务(比如:一个任务出错,以后的任务都无法继续) image

4.newSingleThreadExecutor 特点:创建一个单线程化的线程池,它只会唯一的工作线程来执行任务,如果这个线程因为异常结束,那么会有一个新的线程来替代它,他必须保证前一项任务执行完毕才能执行后一项。保证所有任务按照指定顺序执行 缺点:单线程的 总结:保证所有任务按照指定顺序执行,如果这个唯一的线程因为因为这个异常结束,那么会有一个新的线程来替代它 image

补充:为什么不推荐使用这四种原生的线程池?

都会导致OOM

1.newFixedThreadPool 这种方式创建的线程池由于核心线程数和最大线程数相同,所以线程池中线程的数量是固定的,并且没有限制队列大小所以多余的任务均会被放到队列中排队,在资源有限时容易出现内存溢出。用的是 无界的 LinkedBlockingQueue 1685448842280

2.SingleThreadPool 这种方式创建的线程池是单线程线程池,核心线程数和最大线程数都是1,多余的任务都将会被放到缓冲队列中去,所以在资源优先的情况下容易出现内存溢出。使用的无界的延迟阻塞队列DelayedWorkQueue 1685448910224

3.CachedThreadPool 这种方式创建的线程池核心线程数为0,并且使用了SynchronousQueue队列,这个队列不存储元素,也就是任务直接会直接通过创建非核心线程来执行,核心线程数为Integer.MAX_VALUE,可以任务能无限创建队列,因此在资源优先的情况下容易发生内存溢出。 image

总结: 如果通过ThreadPoolExecutor的构造器去创建,我们就可以根据实际需求控制线程池需要的任何参数,避免发生OOM异常。

lukaliou123 commented 2 years ago

7.线程池都有哪些状态?

  1. RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务
  2. SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务
  3. STOP:不接受新的任务提交,不在处理等待队列中的任务,中断正在执行任务的线程
  4. TIDYING:所有的任务都销毁了,workCount为0,线程池的转台在转换为TIDYING状态时,会执行钩子方法terminated()
  5. TERMINATED:terminated()方法结束后,线程池的状态就会变成这个

8.线程池的执行原理?

image 1.判断线程池里的核心线程是否都在执行任务,如果不是(核心线程空闲或者还有核心线程没有被创建)则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则进入下个流程。 2.线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程 3.判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务

补充:

1.执行任务时,如果线程池中的线程数量小于corePoolSize,即使池中有空闲的线程数,也会创建新的线程来执行任务

2.线程池中的线程数量等于corePoolSize,并且缓冲队列未满时,则任务被放入缓冲队列中

3.线程池中的线程数量大于等于corePoolSize,并且缓冲队列已满,同时线程数量小于maximumPoolSize,则会创建新的线程来执行任务。

4.线程池中的线程数量已满时,则执行拒绝策略处理这些任务image

lukaliou123 commented 2 years ago

9.线程池中 submit() 和 execute() 方法有什么区别

相同:都可以开启线程执行池中的任务: 不同:

  1. 接收参数:execute()只能执行runnable类型的任务。Submint()可以执行runnable和callable类型的任务
  2. 返回值:submit()方法可以返回持有返回对象的线程,返回的对象是future对象,execute()当void
  3. 异常处理:submit()方便Exception处理
lukaliou123 commented 1 year ago

10.线程池中核心线程数的设置根据什么来?

核心线程数的设置一般会基于你的任务类型,是CPU密集型任务还是IO密集型任务

1.CPU密集型任务:这种类型的任务需要大量的计算,也就是CPU的使用率非常高。对于CPU密集型任务,线程池的大小最好设置为N+1,N是CPU的核数。这样可以让CPU的使用率最大化,同时避免了线程切换带来的开销。

CPU密集型的例子1.图像和视频处理:像图像和视频处理这样的任务需要大量的计算。比如,你要对一张图片应用一个滤镜,或者要对一段视频进行编码,这都需要大量的计算。 2.数据分析:数据分析常常需要对大量的数据进行计算。比如,你要计算一组数据的平均值,或者你要找出一组数据中的最大值。 3.机器学习训练:机器学习模型的训练常常需要大量的计算。比如,你要训练一个深度学习模型,这需要对大量的数据进行计算。

2.IO密集型任务:与CPU密集型任务相反,IO密集型任务需要大量的IO,即输入输出操作。这种任务并不是一直在执行任务,可能会有阻塞的情况,比如等待硬盘读写、网络传输等。在这种情况下,可以设置更大的线程池,因为线程并不是一直在执行。*线程池的大小一般设置为2N+1,最大可以设置为2N(IO耗时/CPU耗时)**,N是CPU的核数。

IO密集型的例子 1.文件操作:比如,你需要读写大量的文件,这都需要大量的I/O操作。 2.网络数据传输:网络数据的发送和接收都是典型的I/O操作。比如,你的任务是从网络上下载大量的数据,或者你的任务是在网络上发布大量的数据。 3.数据库操作:数据库的读写操作都是典型的I/O操作。比如,你的任务是向数据库中插入大量的数据,或者你的任务是从数据库中查询大量的数据。

IO密集型任务和CPU密集型任务的一种严格区分:

线程数更严谨的计算的方法应该是: 最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)), 其中 WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)。 线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。

我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例。CPU 密集型任务的 WT/ST 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。

lukaliou123 commented 1 year ago

11.线程池的参数设置依据

1.存活时间(KeepAliveTime):存活时间是指超过核心线程数的空闲线程在终止前等待新任务的最长时间。一般情况下,如果应用程序的负载非常高,那么我们可以设置一个较小的存活时间,这样可以更快地释放不需要的线程,减少资源消耗。反之,如果应用程序的负载较低,我们可以设置一个较大的存活时间,这样可以减少线程的频繁创建和销毁。

2.最大线程数(MaximumPoolSize):最大线程数是线程池中允许的最大线程数。这个值取决于你的系统资源(比如CPU和内存)和你的应用程序的需求。一般来说,你应该根据系统的CPU核数和负载情况来设定最大线程数。比如,如果你的系统有4个CPU核,那么你可能会设定最大线程数为8。

3.拒绝策略(RejectedExecutionHandler):当线程池和工作队列都满了的时候,新来的任务该如何处理?这就是拒绝策略要处理的问题。Java提供了四种拒绝策略:AbortPolicy(默认,直接抛出异常)、DiscardPolicy(默默丢弃,不抛出异常)、DiscardOldestPolicy(丢弃队列最前面的任务,然后尝试重新提交新任务)和CallerRunsPolicy(直接由提交任务的线程执行)。你可以根据你的应用程序的特性来选择合适的拒绝策略。

4.线程工厂(ThreadFactory):线程工厂主要用于创建新的线程。默认的线程工厂会创建一个普通的非守护线程,并且没有设置线程的名称、优先级和UncaughtExceptionHandler。如果你需要创建一些特殊的线程(比如设置线程的名称或优先级),你可以自定义线程工厂。

5.工作队列(BlockingQueue):工作队列用于存放等待执行的任务。你可以根据你的应用程序的特性来选择合适的工作队列。比如,如果你的应用程序的任务有明显的先进先出的特性,你可以使用LinkedBlockingQueue;如果你的应用程序需要执行优先级任务,你可以使用PriorityBlockingQueue。

lukaliou123 commented 1 year ago

12.ThreadLocal

12.1.ThreadLocal 有什么用?

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?

JDK 中自带的ThreadLocal类正是为了解决这样的问题。 ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据

如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

再举个简单的例子:两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。

12.2.ThreadLocal 原理了解吗?

Thread类源代码入手。 1691310905224 从上面Thread类 源代码可以看出Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set或get方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()、set()方法

ThreadLocal类的set()方法 1691310990942 通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对1691311181503 比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。

ThreadLocal 数据结构如下图所示: image

ThreadLocalMap是ThreadLocal的静态内部 image

12.3.ThreadLocal 内存泄露问题是怎么导致的?

ThreadLocalMap 中使用的 key 为 ThreadLocal弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法 1691311639364

lukaliou123 commented 1 year ago

13.Future

Future 类是异步思想的典型运用,主要### 用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。

这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能取消任务;判断任务是否被取消; 判断任务是否已经执行完成; 获取任务执行结果1691315661453 简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果

13.2.Callable 和 Future 有什么关系?

我们可以通过 FutureTask 来理解 Callable 和 Future 之间的关系。 FutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。 1691315910720 FutureTask 不光实现了 Future接口,还实现了Runnable 接口,因此可以作为任务直接被线程执行。

image FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象。 1691316107971 FutureTask相当于对Callable 进行了封装,管理着任务执行的情况,存储了 Callable 的 call 方法的任务执行结果

13.3.CompletableFuture 类有什么用?

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用

Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

下面我们来简单看看 CompletableFuture 类的定义。 可以看到,CompletableFuture 同时实现了 Future 和 CompletionStage 接口。 image

CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。CompletionStage 接口中的方法比较多,CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。

CompletableFuture 的优越性

由于CompletableFuture有异步计算的阶段,因此可以在返回结果前就进行操作,而不像future那样堵塞线程(轮循)直到结果返回。

CompletableFuture提供了非常灵活的方式来处理异步任务的结果。你可以在任务完成后,链式地添加各种处理步骤,比如thenApply是对结果进行某种变换,而thenAccept则是对结果进行消费

相比之下,传统的Future功能就比较简单和受限。它主要只提供了获取结果(可能导致当前线程阻塞)或检查任务是否完成的功能,但它没有提供对结果进行后续处理的链式操作。

例子: 1691412046204 1691412063884

上面的代码先使用CompletableFuture.supplyAsync来创建一个异步任务,计算5的平方。然后使用thenApply来对结果进行转换,将结果加上10。最后使用thenAccept来消费结果,将其打印到控制台。

运行这个代码,你应该会看到输出“Final result: 35”

CompletableFuture 的原理

它是基于异步编程模型实现的,而不是通过阻塞或自旋来等待结果。

CompletableFuture可以与执行器(Executor)结合使用,从而可以控制任务在哪个线程池中执行。通过使用CompletableFuture.supplyAsync等方法,你可以指定任务的执行方式和执行的线程池。这样的设计模式使得CompletableFuture更加灵活和高效。

当CompletableFuture的任务执行完成时,它会自动触发与之关联的后续处理动作,这些后续动作也可以异步地执行。这一切都是非阻塞的,所以可以说是AIO(Asynchronous I/O)的方式。

这种设计允许你更灵活地控制任务的执行流程,并且可以更高效地利用系统资源。不需要等待一个任务完成才开始下一个任务,你可以将多个任务组合在一起,它们可以并行或者按照指定的依赖顺序执行。