线程池底层说明

课程要点

  • 深入系统底层剖析为什么需要线程池
  • 线程池实现原理剖析
  • 线程池执行流程源码解读
  • 线程池是如何重用线程的
  • 如何合理配置线程池的大小

1、深入系统底层剖析为什么需要线程池

Java线程理解

线程是调度CPU的最小单元,也叫轻量级进程LWP(Light Weight Process)

线程模型分类:

  • 用户级线程(User-level Thread, 简称ULT)
    • 用户程序实现,不依赖操作系统核心,应用提供创建、同步、调度和管理线程的函数来控制用户线程。不需要用户态/内核态切换,速度快。内核对ULT无感知,线程阻塞则进程(包括它的所有线程)阻塞。
    • 没有调度CPU的权限。
  • 内核级线程(Kernel-Level Thread,简称KLT)
    • 系统内核管理线程(KLT),内核保存线程的状态和上下文信息,线程阻塞不会引起进程阻塞。在多处理器系统上,多线程在多处理器上并行运行。线程的创建、调度和管理由内核完成,效率比ULT要慢,比进程操作快。
    • 只有内核级线程才有调度CPU的权限。

用户空间与内核空间

操作系统分为 内核空间 和 用户空间。我们的线程运行在用户空间就是用户级线程,运行在内核空间就是内核级线程。用户空间和内核空间之间是隔离的,是为了系统的安全性和稳定性

只有内核级线程才能调度CPU。在内核空间可以执行一切命令调用一切资源。用户空间只能进行简单计算不能调用系统资源。用户线程需要通过操作系统提供的交互接口去调用,向内核发出指令去操作CPU。

 程序创建的线程没有操作cpu的权限,它依托于主线程。比如PS程序,它创建3个用户线程,这三个线程不能直接调用cpu,它是依托于我们的主进程去调用cpu,这三个线程是在一条线上运行,一个阻塞就都要阻塞。而内核级线程都能调用系统资源,它具备并发能力。

JVM使用的线程模型是 “内核级线程”

下面试验例子去验证下,

写一个程序,创建300个线程, 运行程序后,去观察任务管理器中的线程数,会发现线程数变多了。

 

Java线程与系统内核线程

Java线程创建是依赖于系统内核,通过JVM调用系统库创建内核线程,内核线程与Java-Thread是1:1的映射关系。 

 JVM可以创建大量线程。本是使我们在JVM进程中创建了线程栈空间,栈空间里面会有一些栈贞指令。真正的线程要通过库调度器去调用内核空间去创建内核级线程。创建内核级线程后,才具有竞争CPU使用权限。用户线程和内核线程他们是1对1的关系。

用户态内核态上下文切换

CPU是通过时间片分配算法来循环执行任务的。

 java线程是依赖内核级线程来执行任务,这样就会有用户态和内核态的切换。

线程通过争抢CPU的时间片来执行任务。上图,线程1拿到CPU时间片执行任务,还没执行完时间片到了,那么要进行线程1到线程2的切换。这时候需要把线程1执行任务的上线文信息先保存起来。上下文信息就是在寄存器和缓存中的指令、程序指针、中间数据。我们需要把这些上下文信息保存起来,存在内核空间的TSS任务状态段中。、

线程t2执行完成,要切到t1继续未完成的任务,那么我们需要从内核空间的Tss任务状态段中把t1当时的上线文信息继续加载到寄存器和缓存中从而继续执行任务。

这就涉及到用户态和内核态的切换,不仅仅是任务执行过程中,在线程创建、销毁时都涉及到用户态和内核态切换。

如果是高并发的场景,比如618电商秒杀,会有大量请求过来,如果也有大量线程去对接每个任务,会有大量的线程的创建和销毁,其实一个任务执行时间不长,我们有大量时间花在了线程的创建和销毁,会有大量的用户态和内核态的切换,从而造成系统崩溃。

如果我们有一个线程池,对线程进行缓存并重复利用,就能大大减少资源消耗。

线程池的意义

线程是稀缺资源,它的创建与销毁是比较重且消耗资源的操作。而Java线程依赖于内核线程,创建线程需要进行操作系统状态切换,为了避免资源过度消耗需要设法重用线程执行多个任务。线程池就是一个线程缓存,负责对线程进行统一分配、调优与监控。

线程池优势:

  • 重用存在的线程,减少线程创建,消亡的开销,提高性能。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

二、线程池实现原理剖析

Executor框架

Excutor接口

ExcutorService是子接口,主要定义了线程池的一些行为。

 Executors是工具类。

线程池的创建方式有 5 种

  1. newCachedThreadPool(), 根据任务情况创建需要的线程说明,任务并发多线程就多,一池N线程,
  2. newFixedThreadPool(), 不管多少任务,线程数量固定的。
  3. newScheduledThreadPool()
  4. newSingleThreadExecutor(), 一池一线程
  5. newWorkStealingPool(), JDK1.8之后出来的,这是任务窃取线程池。有多个线程任务队列,有的线程没任务是饥饿状态,就回去别的地方拿任务。

这类线程池比较坑,fix、single他们的任务队列都是无限队列,无限制的堆积任务就会导致OOM。

阿里的程序规约,也规定了不能通过Executors创建线程池,必须手动创建,其实内部也是通过 new ThreadPoolExecutor()创建。

线程池的几个重要参数   

一共有七大参数

corePoolSize: 

  • 核心线程池
  •  举例: corePoolSize如果值为2,类似银行网点有固定2个窗口开放。

workQueue:

  • 阻塞队列。比如设置队列5个大小。
  • 类似银行窗口处理不过来,大家阻塞排队的等待侯课区。

maximumPoolSize:

  • 最大线程数,其包含了核心线程数。
  • workQueue队列满了, 核心线程数不够用,,那么要开启的临时工作线程,如果corePoolSize为2, maximumPoolSize为3,那么我们最多还能创建1个临时的工作线程。         

KeepAliveTime:

  • 最大空闲时间
  • 当高峰期过了,要撤掉临时窗口,对于这个临时线程,会保留的时间

unit:  

  • 时间单位

threadFactory:

  • 创建线程的线程工厂。

handler:

  • 拒绝策略,当核心线程处理不过来,队列也满了,临时线程也忙不过来,这时候还有任务过来就会触发拒绝策略。JDK本身提供了4个拒绝策略,我们自己也能实现。
    • AbortPolicy,默认拒绝策略,拒绝任务,然后抛出异常。
    • DiscardPolicy: 不执行任务也不抛出异常,什么也不错
    • DiscardOldest: 尝试丢弃队列最前面的任务,再继续判断。
    • CallerRunsPolicy: 当队列满了,让调用者自己去干。
  • 也可以自己实现andler,比如可以记录日志后续处理。

如下图,我们自己创建的线程池,最多同时可以处理8个任务,3在线程处理,5个在队列中。

 原理解析

任务提交过来,是由线程池的executor方法接受任务,它接受的是实现了Runable和Callable接口的任务。实际的任务是run方法里面的具体执行内容。

如果来了两个任务,就创建核心线程去执行这两个任务,如果这时候还来任务,就把任务放到队列里面。如果队列里面超过了队列大小(比如5个),那么还有1个任务过来,就创建临时线程(比如配置1个)去执行任务。它先从任务队列里面拿。这时候如果还有任务过来,就会触发拒绝策略。

 上面图就是整个线程池的工作原理,具体就要看源码了。

线程池的生命状态

线程池五种状态

  1. Runing: 能接收新任务,以及处理已经添加的任务。
  2. Shutdown: 不接受新任务,可以处理已经添加的任务。
  3. Stop:不接受新任务,不处理已经添加的任务,并且中断正在处理的任务。
  4. Tidying: 所有的任务已经终止,ctl记录的任务数量为 0 (ctl负责记录线程池的运行状态与活动线程数)
  5. Terminated:线程池彻底终止,则线程池转化为terminated状态。

由Tidying切换到Terminated之前会执行一个钩子函数terminated(),在结束线程池之前你需要执行什么业务逻辑处理,需要自己实现。 

三、线程池执行流程源码解读

execute(Runnable command).

看源码,从执行方法开始看。execute(Runnable command).

线程池运行过程中可能变化的量,状态的变化?

  • 活动线程数
  • 线程池状态

java的线程池怎么保障自己的绝对安全支持并发呢? 它把活动线程数和线程状态都存到一个Integer类型的变量中,高3位的记录线程池的生命状态,低29位记录任务数量。 后续计算就是位运算,处理是原子的,且效率高。

 如果当前线程数小于核心数,则创建线程执行任务。

如果上面不成立,就继续下面代码,线程数大于等于核心数,新来的任务就加入队列中。

判断线程池是running状态,并且加入任务加入队列成功,里面在double check(双重验证),不是正常运行,就移除任务。如果活动数是0,就创建活动线程执行任务。 

最下面,两种请求,一个是线程池不是running状态,或者是running但是队列满了,就去看创建空闲线程,如果能创建空闲工作线程,就去支持任务,如果不能,就出发拒绝策略。  

addWorker(command, true)

runWorker(Worker)

里面会创建一个while循环,不断从队列里面拿任务(调用task.run)。 这个runWorker里面是不断调用拿到task的真正的run方法。runWorker一直从队列里面拿,如果队列空了,这个线程就可以进行缓存不销毁,方式是调用方法进入阻塞状态,直到队列不为空。

四、如何合理配置线程池的大小

与cpu核数、任务类型(io密集型、cpu密集型)等有关。

如果是cpu密集型,如果cpu核数是n, 就设置线程数 n + 1. 为什么加1呢,方式某些线程因为缺页终端等,导致cpu空闲。 

如果是io密集型,就设置为线程数2n。

在实际工作中,我们的工作任务情况不同,就要考虑线程的等待时间,(wait-time  + st)/ st  * N, N就是cpu核数, st就是线程平均的cpu运行时间。 加入每个线程平均运行时间0.5秒,io等待时间1.5秒, cpu核数是8,那就要设置线程数32个。

实际情况还需要压测。

学习视频 90分钟搞懂Java线程池原理,才有底气去面试大厂!_哔哩哔哩_bilibili

猜你喜欢

转载自blog.csdn.net/songtaiwu/article/details/125304812