这个题目我感觉很多大哥大姐和我一样,虽然夹在众位大哥大姐中跟着一块喊着“多线程与高并发”的口号,但是这里面其实包含的东西并不像名字里面这么少。现在就开始咱们的旅程吧。

特此感谢,低编程并发(微信公众号这位老师),以及B站的狂神说老师,课和文章都挺好,大家可以去看看。还有其他大牛们,本人的笔记离不开各位老师的引导。
开唠

1.首先,咱们为啥要用多线程呢?

单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率+多核时代主要是为了提高 CPU 利用率【多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销,具体见下:

  • 一个 Java 程序的运行是 main 线程和多个其他线程同时运行

    • 多核 CPU 时代意味着多个线程可以同时运行,也就是并行同一时刻多个线程可以使用自己的CPU一块执行而并发【并发:线程们轮流使用CPU的做法称作并发,concurrent】是多个线程有一定的时间间隔的在执行,只是切换的很快肉眼看不到而已,相当于是OS中的任务调度器将CPU的时间片分给不同的线程轮流切换使用,微观上这些线程们是串行执行的,只是咱们人肉眼看不到间隔,宏观上感觉是并行的),这减少了线程上下文切换的开销
    • 多线程编程中线程个数一般都大于CPU个数(线程一般很多嘛),而 每个CPU同一时刻只能被一个线程使用(为了能让咱们用户感觉多个线程是在同时执行的,提高用户体验嘛),CPU资源的分配采用了时间片轮转的策略(也就是给每个线程分配一个时间片,每个线程只在自己对应的时间片内占用CPU并执行任务,当前线程使用完自己对应的CPU时间片后当前这个线程自己就会处于就绪状态并让出CPU给其他线程,这也就是线程的上下文切换)。【线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。】
      • 那么就有个问题,让出 CPU 的线程等下次轮到自己占有 CPU 时如何知道自己之前运行到哪里了?—看这里线程如何知道切出去前自己运行到哪里
      • 线程的上下文切换时机:【线程在执行过程中会有自己的运行条件和状态(也称上下文),比如程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。】
        • 当前线程的CPU时间片使用完,当前线程处于就绪状态【时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。】
        • 被终止或结束运行
        • 当前线程被其他线程中断时
          • 主动让出 CPU,比如调用了 sleep(), wait() 等。
    • 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。多线程机制可以大大提高系统整体的并发能力以及性能,多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。
  • 线程相比进程能减少开销 体现在:
    • 多线程模型:
    • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们
    • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
    • 同一个进程内的线程切换比进程切换快【线程间的切换和调度的成本远远小于进程】,因为线程具有相同的地址空间(虚拟内存共享),这意味着 同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于 进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的
      • 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息;】
    • 同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了
  • 提前说好,并发不一定要依赖多线程,像PHP中很常见的 多进程并发
    • 多进程模型
    • 单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。宏观上好像是CPU同时在运行多个进程,但是其实实际上还是一个又一个进程以一定的时间间隔在运行。

2.三种线程的实现方式

  • 线程(其实,每个已经执行了start()而且还没有结束的java.lang.Thread类的实例就代表一个线程,可以点一点,看看这篇线程启动的几种方式)可以比作是轻量级的进程(线程是进程中的一个实体,是进程的一个执行路径线程本身不会独立存在),是程序执行的最小单位,线程间的切换和调度的成本远远小于进程(比如说,进程搞来了一个单位的资源来分配调度,分配调度好了之后,线程兜里揣着这些资源去执行任务)线程的(运行时数据区域)内存区域分布

    • 主要有三种线程的实现方式:

      • 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;

        • 用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
        • 用户线程的优缺点:
      • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程
        • 内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责
        • 内核级线程的优缺点:
      • 轻量级进程(LightWeight Process):在内核中来支持用户线程
        • 轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。另外,LWP 只能由内核管理并像普通进程一样被调度。LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。
    • 线程的运行空间来说,线程可以分为:
      • 用户级线程(user-level thread, ULT)

        • 它仅存在于用户级中,这种线程是不依赖于操作系统核心的。应用进程利用线程库来完成其创建和管理,速度比较快,操作系统内核无法感知用户级线程的存在。
      • 内核级线程(kernel-level, KLT)
        • 类线程依赖于内核,又称为内核支持的线程或轻量级进程。无论是在用户程序中的线程还是系统进程中的线程它们的创建、撤销和切换都由内核实现。比如英特尔i5-8250U是4核8线程,这里的线程就是内核级线程

3.那为什么要用高并发呢:

  • 充分利用计算机处理器的能力,你想想CPU多快呀,磁盘、网络通信或者数据库访问等相比之下就慢多了,所以你肯定不想把时间都浪费在等待上面吧
  • 现在的时代,对应用系统性能以及吞吐量要求越来越高,出现了处理海量数据和请求的要求,所以就催生了对高并发编程的迫切的要求
    • 现在HTTP下一个服务端同时对多个客户端提供服务这种并发应用场景很常见。咱们很多人同时上XX买东西,更有甚者进同一家店买同一个东西同一时间下单等,此时对于计算量相同的任务程序线程并发协调的越有条不紊效率自然越高。换句话说你要是线程之间经常切换,用户态内核态轮流干,频繁阻塞甚至死锁,这不卡死。

4.打手的故事

然后,得慢慢请出咱们多高这部戏的主角,先是“打手”(打手来源见JVM_Part3中扫垃圾时,打手咋跑出来了)
咱们OS在分配资源时:由于线程的引入就可以把一个进程的资源调度分配和执行调度分开:

  • 把(除CPU之外的资源)内存等资源分配给进程,比如内存地址、文件IO等

    • CPU中有PC、寄存器们等,关于CPU的结构以及具体的工作方式大家可以网上看看,知道CPU是干啥的玩意就行
  • OS把CPU分配给线程

提到这两个重要的点,可能大家和我一样会有疑问,为啥要这样分呢。(我之前关于JVM的部分中也有提到,大家可以去翻翻)我感觉就一句话,一个进程中生活着很多个线程,线程们是真正占用CPU资源去运行或者说执行进程中的各个任务呢。比如说 咱们打开了一个应用程序比如QQ,相当于咱们现在开启了一个进程,那么QQ里面咱们是不是存在很多小的任务比如聊天呀听音乐呀种个菜呀发个邮件呀等等(当然可能不是这么分的,大家理解意思就好),整个QQ应用程序中的多个小任务都得生活在进程中的多个线程来执行。而OS中一般是CPU来执行任务的。所以,精准定位,把CPU分配给线程不就刚好了,那么这么多线程不就可以带着自己趁手的兵器(方天画戟)就打怪闯关了。此时,咱们的打手也找到自己的称手的兵器的,可以开打了。

  • 一个进程中生活着很多个线程,线程们是真正占用CPU资源去运行或者说执行进程中的各个任务呢。而CPU一般是使用时间片轮转的方式让进程中的多个线程们轮询占用的(当前线程CPU时间片用完后要让出CPU,等下次轮到自己时再执行)
  • 在 Linux 内核中,进程和线程都是用 task_struct 结构体表示的,区别在于:
    • 线程的 task_struct 结构体里部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以 Linux 中的线程也被称为轻量级进程,因为线程的 task_struct 相比进程的 task_struct 承载的资源比较少,因此以轻得名。

      • 每一个进程都有一个数据结构 task_struct,该task_struct结构体里有一个指向文件描述符数组的成员指针该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向内核中所有打开的文件的列表,也就是说内核可以通过文件描述符找到对应打开的文件
    • 没有创建线程的进程,是只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样,它们对应到内核里都是 task_struct。Linux 内核里的调度器,调度的对象就是 task_struct【这个数据结构统称为任务】

      • 任务有优先级之分,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分为了这几种调度类:

        • 完全公平调度(Completely Fair Scheduling):我们平日里遇到的基本都是普通任务,对于普通任务来说,公平性最重要,在 Linux 里面,实现了一个基于 CFS 的调度算法,也就是完全公平调度(Completely Fair Scheduling):让分配给每个任务的 CPU 时间是一样,于是它为每个任务安排一个虚拟运行时间 vruntime,如果一个任务在运行,其运行的越久,该任务的 vruntime 自然就会越大,而没有被运行的任务,vruntime 是不会变化的。那么,在 CFS 算法调度的时候【CFS 调度吗,它是会优先选择 vruntime 少的任务进行调度,所以高权重的任务就会被优先调度了,于是高权重的获得的实际运行时间自然就多了。】,会优先选择 vruntime 少的任务,以保证每个任务的公平性
        • CPU 运行队列:一个系统通常都会运行着很多任务,多任务的数量基本都是远超 CPU 核心数量,因此这时候就需要排队。事实上,每个 CPU 都有自己的运行队列(Run Queue, rq),用于描述在此 CPU 上所运行的所有进程,其队列包含三个运行队列,Deadline 运行队列 dl_rq、实时任务运行队列 rt_rq 和 CFS 运行队列 csf_rq,其中 csf_rq 是用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务
        • 调整优先级:如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务,普通任务的调度类是 Fail,由 CFS 调度器来进行管理。CFS 调度器的目的是实现任务运行的公平性,也就是保障每个任务的运行的时间是差不多的。如果你想让某个普通任务有更多的执行时间,可以调整任务的 nice 值,从而让优先级高一些的任务执行更多时间。nice 的值能设置的范围是 -20~19, 值越低,表明优先级越高,因此 -20 是最高优先级,19 则是最低优先级,默认优先级是 0。【nice 值并不是表示优先级,而是表示优先级的修正数值,它与优先级(priority)的关系是这样的:priority(new) = priority(old) + nice。内核中,priority 的范围是 0~139,值越低,优先级越高,其中前面的 0~99 范围是提供给实时任务使用的,而 nice 值是映射到 100~139,这个范围是提供给普通任务用的,因此 nice 值调整的是普通任务的优先级。权重值与 nice 值的关系的,nice 值越低,权重值就越大,计算出来的 vruntime 就会越少,由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 nice 值越低,任务的优先级就越高。】

老规矩,图这道菜咱们是不能少的。

6.打手的生命状态(周期)

那既然进程提供了闯关的场景,让各个打手大显身手,施展自己的武艺。那么打手们玩的兵器、用的功夫肯定大不相同,所以,上菜。

屏幕前的观众小胡和敏小言(观众来源可以看看前面JVM的双亲委派机制那里JVM的双亲委派机制)看不下去了。说:你上的这道菜不全呀,你看菜里面有打手状态、分类啥的,你给咱上上来看看呀。
掰急呀,这就来。

打手出生->打手带着配备的兵器和学到的手艺去闯关做任务(打手做任务过程中会遇到三个挫折,幸亏菩萨给了三根毫毛才能顺利活到最后)->打手完成任务退出圈子。那咱们具体看看这个过程到底是怎样…(彩蛋,window+R,输入jconsole可以打开java线程的图形化监视界面,监视线程们的运行状态