这个题目我感觉很多大哥大姐和我一样,虽然夹在众位大哥大姐中跟着一块喊着“多线程与高并发”的口号,但是这里面其实包含的东西并不像名字里面这么少。现在就开始咱们的旅程吧。
特此感谢,低编程并发(微信公众号这位老师),以及B站的狂神说老师,课和文章都挺好,大家可以去看看。还有其他大牛们,本人的笔记离不开各位老师的引导。
开唠
首先,咱们为啥要用多线程呢?:单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率+多核时代主要是为了提高 CPU 利用率【多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销
】,具体见下:
main 线程和多个其他线程同时运行
线程相比进程能减少开销
体现在:
线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们
;线程的终止时间比进程快
,因为线程释放的资源相比进程少很多;同一个进程内的线程切换比进程切换快【线程间的切换和调度的成本远远小于进程】
,因为线程具有相同的地址空间(虚拟内存共享),这意味着 同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表
。而对于 进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的
;
多进程并发
每个已经执行了start()而且还没有结束的java.lang.Thread类的实例就代表一个线程
,可以点一点,看看这篇线程启动的几种方式)可以比作是轻量级的进程(线程是进程中的一个实体,是进程的一个执行路径,线程本身不会独立存在
),是程序执行的最小单位,线程间的切换和调度的成本远远小于进程(比如说,进程搞来了一个单位的资源来分配调度,分配调度好了之后,线程兜里揣着这些资源去执行任务
)线程的(运行时数据区域)内存区域分布一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持
。另外,LWP 只能由内核管理并像普通进程一样被调度。LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。多个线程可以同时运行,也就是并行
(同一时刻多个线程可以使用自己的CPU一块执行,而并发【并发:线程们轮流使用CPU的做法称作并发,concurrent】是多个线程有一定的时间间隔的在执行,只是切换的很快肉眼看不到而已,相当于是OS中的任务调度器将CPU的时间片分给不同的线程轮流切换使用,微观上这些线程们是串行执行的,只是咱们人肉眼看不到间隔,宏观上感觉是并行的
),这减少了线程上下文切换的开销
。每个CPU同一时刻只能被一个线程使用
(为了能让咱们用户感觉多个线程是在同时执行的,提高用户体验嘛),CPU资源的分配采用了时间片轮转的策略(也就是给每个线程分配一个时间片,每个线程只在自己对应的时间片内占用CPU并执行任务,当前线程使用完自己对应的CPU时间片后当前这个线程自己就会处于就绪状态并让出CPU给其他线程,这也就是线程的上下文切换)
。【线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。】
线程在执行过程中会有自己的运行条件和状态(也称上下文)
,比如程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。】
那为什么要用高并发呢:
然后,得慢慢请出咱们多高这部戏的主角,先是“打手”(打手来源见JVM_Part3中扫垃圾时,打手咋跑出来了)
咱们OS在分配资源时:由于线程的引入就可以把一个进程的资源调度分配和执行调度分开:
提到这两个重要的点,可能大家和我一样会有疑问,为啥要这样分呢。(我之前关于JVM的部分中也有提到,大家可以去翻翻)我感觉就一句话,一个进程中生活着很多个线程,线程们是真正占用CPU资源去运行或者说执行进程中的各个任务呢
。比如说 咱们打开了一个应用程序比如QQ,相当于咱们现在开启了一个进程,那么QQ里面咱们是不是存在很多小的任务比如聊天呀听音乐呀种个菜呀发个邮件呀等等(当然可能不是这么分的,大家理解意思就好),整个QQ应用程序中的多个小任务都得生活在进程中的多个线程来执行。而OS中一般是CPU来执行任务的。所以,精准定位,把CPU分配给线程不就刚好了,那么这么多线程不就可以带着自己趁手的兵器(方天画戟)就打怪闯关了。此时,咱们的打手也找到自己的称手的兵器的,可以开打了。
一个进程中生活着很多个线程,线程们是真正占用CPU资源去运行或者说执行进程中的各个任务呢
。而CPU一般是使用时间片轮转的方式让进程中的多个线程们轮询占用的(当前线程CPU时间片用完后要让出CPU,等下次轮到自己时再执行)在 Linux 内核中,进程和线程都是用 task_struct 结构体表示的
,区别在于:
线程的 task_struct 结构体里部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以 Linux 中的线程也被称为轻量级进程
,因为线程的 task_struct 相比进程的 task_struct 承载的资源比较少,因此以轻得名。
该task_struct结构体里有一个指向文件描述符数组的成员指针
。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向内核中所有打开的文件的列表,也就是说内核可以通过文件描述符找到对应打开的文件。Linux 内核里的调度器,调度的对象就是 task_struct【这个数据结构统称为任务】
对于普通任务来说,公平性最重要
,在 Linux 里面,实现了一个基于 CFS 的调度算法,也就是完全公平调度(Completely Fair Scheduling):让分配给每个任务的 CPU 时间是一样,于是它为每个任务安排一个虚拟运行时间 vruntime,如果一个任务在运行,其运行的越久,该任务的 vruntime 自然就会越大,而没有被运行的任务,vruntime 是不会变化的。那么,在 CFS 算法调度的时候【CFS 调度吗,它是会优先选择 vruntime 少的任务进行调度,所以高权重的任务就会被优先调度了,于是高权重的获得的实际运行时间自然就多了。】,会优先选择 vruntime 少的任务,以保证每个任务的公平性
。多任务的数量基本都是远超 CPU 核心数量,因此这时候就需要排队
。事实上,每个 CPU 都有自己的运行队列(Run Queue, rq),用于描述在此 CPU 上所运行的所有进程,其队列包含三个运行队列,Deadline 运行队列 dl_rq、实时任务运行队列 rt_rq 和 CFS 运行队列 csf_rq,其中 csf_rq 是用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务
。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 值越低,任务的优先级就越高。】老规矩,图这道菜咱们是不能少的。
那既然进程提供了闯关的场景,让各个打手大显身手,施展自己的武艺。那么打手们玩的兵器、用的功夫肯定大不相同,所以,上菜。
屏幕前的观众小胡和敏小言(观众来源可以看看前面JVM的双亲委派机制那里JVM的双亲委派机制)看不下去了。说:你上的这道菜不全呀,你看菜里面有打手状态、分类啥的,你给咱上上来看看呀。
掰急呀,这就来。
打手出生->打手带着配备的兵器和学到的手艺去闯关做任务(打手做任务过程中会遇到三个挫折,幸亏菩萨给了三根毫毛才能顺利活到最后)->打手完成任务退出圈子。那咱们具体看看这个过程到底是怎样…(彩蛋,window+R,输入jconsole可以打开java线程的图形化监视界面,监视线程们的运行状态)
在java中是区分不开运行状态和可运行状态的
)【生活状态其实是别人眼中的不同一个变量不同的值而已,细思极恐有木有…】//一个枚举类
public enum State{
NEW,//初始状态,线程被创建出来但没有被调用 start()
RUNNABLE,//运行状态,线程被调用了 start()等待运行的状态
BLOCKED,//阻塞状态,需要等待锁释放
WAITING,//等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)
TIMED_WAITING,//超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待
TERMINATED;//终止状态,表示该线程已经运行完毕
}
new实例化出一个 Thread后线程进入了新建状态
; 调用start() 会执行线程的相应准备工作【当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行;】,然后自动执行 run() 方法的内容,(调用 start() 方法【 调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态
】,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了【可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态
】。)这是真正的多线程工作【为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法】现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了
。直接执行 run() 方法
,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它
,所以这并不是多线程工作。
就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中
;只是实例化出来还没有调用执行start方法执行线程
。或者说 当我们用 new Thread() 新建一个线程时,如果线程没有开始运行 start() 方法,所以也没有开始执行 run() 方法里面的代码,那么此时它的状态就是 New
。)调用start方法,做好被CPU调度的准备,但是不是说调用start方法后就会立即被执行
)【[运行状态]指获取了CPU时间片的正在运行中的线程状态。当CPU时间片用完,会从[运行状态]转换至[可运行状态],会导致线程的上下文切换
】
(由于 BIO导致的线程阻塞,在Java里无法区分,仍认为是可运行)
Java 中的 Runable 状态对应操作系统线程状态中的两种状态,分别是 Running 和 Ready
,也就是说,Java 中处于 Runnable 状态的线程有可能正在执行,也有可能没有正在执行,正在等待被分配 CPU 资源
。所以,如果一个正在运行的线程是 Runnable 状态,当它运行到任务的一半时,执行该线程的 CPU 被调度去做其他事情,导致该线程暂时不运行,它的状态依然不变,还是 Runnable,因为它有可能随时被调度回来继续执行任务。Java 中阻塞状态它包括三种状态
,分别是 Blocked(被阻塞)、Waiting(等待)、Timed Waiting(计时等待)
,这三种状态统称为阻塞状态
从 Runnable 状态进入 Blocked 状态只有一种可能,就是进入 synchronized 保护的代码时没有抢到 monitor 锁
Blocked 与 Waiting 的区别是 Blocked 在等待其他线程释放 monitor 锁,而 Waiting 则是在等待某个条件,比如 join 的线程执行完毕,或者是 notify()/notifyAll()
。需要依靠其他线程的通知
才能够返回到运行状态。线程进入 Waiting 状态有三种可能性
。如果线程在获取这种锁时没有抢到该锁就会进入 Waiting 状态,因为本质上它执行了 LockSupport.park() 方法
,所以会进入 Waiting 状态。同样,Object.wait() 和 Thread.join() 也会让线程进入 Waiting 状态。因为唤醒 Waiting 线程的线程如果调用 notify() 或 notifyAll(),要求必须首先持有该 monitor 锁,所以处于 Waiting 状态的线程被唤醒时拿不到该锁,就会进入 Blocked 状态,直到执行了 notify()/notifyAll() 的唤醒它的线程执行完毕并释放 monitor 锁,才可能轮到它去抢夺这把锁,如果它能抢到,就会从 Blocked 状态回到 Runnable 状态
。打手阻塞的故事:这里面主要就是打手闯关的时候关口都是一夫当关万夫莫开,所以每次只有一个打手可以去打,其余的只能在门外先排个队等着。
队伍中的第二个打手受不了了,肿么肥事嘛,这么长时间咋还没过去。里面的正在占着关卡闯关的打手大声向外边喊着,别站着急呀,我这不是占着还没弄完呢嘛,我没弄完你其他人就不能进来哦。
况且,我闯完,下一个也是人家坐在贴着同步队列标志的、那个离门最近的那个椅子上的那个哥们进来执行呀,你这还在队伍中第二个位置呢你急啥。
这个挑事的对手灰溜溜的转头走了,坐到了离门比较远的那排椅子上的第一个位置上。
正在里面闯关的打手心想,我容易嘛我,进了关卡后谁知道还有三个里屋(代表线程的三种阻塞状态(当线程因为某种原因放弃 CPU 使用权后,即让出了 CPU 时间片,暂时就会停止运行,直到线程进入可运行状态(Runnable),才有机会再次获得 CPU 时间片转入 RUNNING 状态。一般来讲,从Java层面讲阻塞的情况可以分为如下三种(因为从OS层面讲只有五种状态,而且阻塞状态也只有一种)
:))呢,谁知道进哪个呀?不管了,乱敲一个门试试吧。
暂时放弃对CPU的使用权,停止执行
,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种
:
执行wait()方法
,使本线程进入到等待阻塞状态(RUNNING 状态的线程执行 Object.wait() 方法后,JVM 会将线程放入等待序列(waitting queue))他敲了一个门上贴着synchronized的里屋,里面有人喊了一身,别敲了有人…
这个打手又敲了第二个里屋的门,里面的人说:
Object.wait()
Thread.join()
我必须实现下面三件事才能开这间门,然后,等你出来,我再进去。
里面飞出来俩字…嗯呐
你们这都是啥规矩呀,真奇怪。
打手实现受不了了,就说,不行,你今天必须给我说一下你这XXX.wait()到底发生了什么,凭什么你执行一下这个就表示你占了这块一亩三分地呢?
里面那个又继续说,嗯呐。第三个屋是这样的,给你再看幅这第三间屋的设计原理图你就懂了。
打手:你上面那个第二个还没展开哟,你别想蒙我
嗯呐:这就来这就来
打手说,你们这也太复杂了吧,那你们是不是和我一样,有生有终(线程死亡的三种方式)呢?
嗯呐:那肯定呀,上菜
嗯呐:这样吧,看你在外边等了半天了,给你点吃的,你先享用,我们三个里屋你可能得得好一会了,唠唠其他的。
因此为了保证run()执行的顺序性,我们肯定需要一个信号量来让线程知道在任意时刻能不能执行逻辑代码。另外,因为三个线程是独立的,这个信号量的变化肯定需要对其他线程透明,因此volatile关键字也是必须要的。
。//Java中创建一个守护线程:
public static void main(String[] args){
Thread daemonThread = new Thread(new Runnable(){
@Override
public void run(){
......
}
});
//设置为守护线程只需要设置线程的daemon参数为true就行
daemonThread.setDaemon(true);
daemonThread.start();
}
JVM启动时会调用main函数,启动一个main函数就相当于启动了一个JVM的进程,而main函数所在的线程就是这个JVM的进程中的一个线程
打手边吃边说,给你们夫妻俩唠个小故事,之前我们打手之间发生过一次互夺兵器案件(你看上我的兵器了,我又觉得你的兵器好用:死锁(多个线程由于互相等待对方持有的资源而同时被阻塞,它们中的一个或者全部都在等待某个资源被释放从而导致谁都没法执行。由于线程被无限期地阻塞,因此程序不可能正常终止。 )),我差点没命了( 什么是线程死锁?如何避免死锁?)—线程死锁的补充篇
小胡和敏小言:快聊聊
互斥条件
:该资源任意一个时刻只由一个线程占用(其他线程此时还想请求获取该资源,必须等待占有资源的线程释放该资源才能得到该资源)请求与保持条件
:指一个线程自己已经持有了至少一个资源,但是自己吃着碗子的看着锅里的,又想请求获取新的资源,但此时这个新资源已经被其他线程占有,所以这个请求与保持条件限定此时当前线程阻塞不能硬抢别人的
(并不能释放自己已经获取的至少一个的资源
)。不可剥夺条件(抢占)
:线程已获得的资源在末使用完之前不能被其他线程强行剥夺
,只有自己使用完毕后才释放资源。循环等待条件
:指的是发生死锁时,肯定是若干进程之间形成一种头尾相接的循环等待资源关系。(每个进程都等待它前一个进程所持有的资源,即线程集合{T0,T1,T2,…,Tn}中 T0 正在等待一 个T1占用的资源,T1正在等待T2占用的资源,……Tn 在等待己被 T0占用的资源。)破坏产生死锁的四个条件中的其中一个【目前只有请求与保持条件和环路等待条件是可以被破坏的】
就可以了)
一次性申请线程所有需要的资源
----破坏请求与保持条件。主动释放它占有的资源
(进程回滚+死锁检测)。----破坏不剥夺条件按序申请资源来预防
。按某一顺序申请资源,释放资源则反序释放----破坏循环等待条件和请求与保持条件指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作。通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。【按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件】
在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态
。
小胡和敏小言:啊,完了,这就完了,还有没有呀?
打手,估计线程就到这了,下篇就该进程了…翻了个身睡了过去
小胡:梦话?
敏小言:估计是,都翻B面了都。老公,那咱们也去吃饭吧,吃完来再看。
走喽…
巨人的肩膀:
低编程并发(微信公众号这位老师)
B站的狂神说老师
Java并发编程之美
Java19 带来了一个 Java 开发者垂涎已久的新特性—— 虚拟线程。在 Java19 中,之前我们常用的线程叫做平台线程(platform thread),与系统内核线程仍然是一一对应的。其中大量(M)的虚拟线程在较小数量(N)的平台线程(与操作系统线程一一对应)上运行(M:N调度)。多个虚拟线程会被 JVM 调度到某一个平台线程上执行,一个平台线程同时只会执行一个虚拟线程