各种名词区分
文章目录
一:进程、线程、纤程、协程、管程
1:进程
进程也就是平时所说的程序,比如在操作系统上运行一个谷歌浏览器,那么就代表着谷歌浏览器就是一个进程。
进程是操作系统中能够独立运行的个体,并且也作为资源分配的基本单位,由指令、数据、堆栈等结构组成。
安装好一个程序之后,在程序未曾运行之前也仅是一些文件存储在磁盘上,当启动程序时会向操作系统申请一定的资源,如CPU、存储空间和I/O设备等,OS为其分配资源后,会真正的出现在内存中成为一个抽象的概念:进程。
其实操作系统这个帝国之上,在运行时往往有着很多个进程存在,你可以把这些进程理解成一个个的工厂,根据各自的代码实现各司其职。
随着计算机硬件技术的不断进步,慢慢的CPU架构更多都是以多核的身份出现在市面上,所以对于程序而言,CPU利用率的要求会更高。
但是进程的调度开销是比较大的,并且在并发中切换过程效率也很低,所以为了更高效的调度和满足日益复杂的程序需求,最终发明了线程。
2:线程
在操作系统早期的时候其实并没有线程的概念,到了后来为了满足并发处理才推出的一种方案
线程作为程序执行的最小单位,一个进程中可以拥有多条线程,所有线程可以共享进程的内存区域】
线程通常在运行时也需要一组寄存器、内存、栈等资源的支撑。
现如今,程序之所以可以运行起来的根本原因就是因为内部一条条的线程在不断的执行对应的代码逻辑。
假设进程现在是一个工厂,那么线程就是工厂中一个个工位上的工人。
工厂之所以能够运转的根本原因就在于:内部每个工位上的工人都各司其职的处理自己分配到的工作。
多核CPU中,一个核心往往在同一时刻只能支持一个内核线程的运行
所以如果你的机器为八核CPU,那么理论上代表着同一时刻最多支持八条内核线程同时并发执行。
当然,现在也采用了超线程的技术,把一个物理芯片模拟成两个逻辑处理核心,让单个处理器都能使用线程级并行计算,进而兼容多线程操作系统和软件,减少了CPU的闲置时间,提高的CPU的运行效率。
比如四核八线程的CPU,在同一时刻也支持最大八条线程并发执行。
在OS中,程序一般不会去直接申请内核线程进行操作,而是去使用内核线程提供的一种名为LWP
的轻量级进程(Lightweight Process
)进行操作
这个LWP
也就是平时所谓的线程,也被成为用户级线程。
在如今的操作系统中,用户线程与内核线程主要存在三种模型:一对一模型、多对一模型以及多对多模型。
一对一模型
一对一模型是指一条用户线程对应着内核中的一条线程,而Java中采用的就是这种模型
一对一模型是真正意义上的并行执行,因为这种模型下,创建一条Java的Thread
线程是真正的在内核中创建并映射了一条内核线程的
执行过程中,一条线程不会因为另外一条线程的原因而发生阻塞等情况。
不过因为是直接映射内核线程的模式,所以数量会存在上限。
并且同一个核心中,多条线程的执行需要频繁的发生上下文切换以及内核态与用户态之间的切换,如果线程数量过多,切换过于频繁会导致线程执行效率下降。
多对一模型
多对一模型是指多条用户线程映射同一条内核线程的情况,对于用户线程而言,它们的执行都由用户态的代码完成切换
这种模式优点很明显,一方面可以节省内核态到用户态切换的开销,第二方面线程的数量不会受到内核线程的限制。
但是缺点也很明显,因为线程切换的工作是由用户态的代码完成的,所以如果当一条线程发生阻塞时,与该内核线程对应的其他用户线程也会一起陷入阻塞。
多对多模型
多对多模型就可以避免上面一对一和多对一模型带来的弊端,也就是多条用户线程映射多条内核线程
这样即可以避免一对一模型的切换效率问题和数量限制问题,也可以避免多对一的阻塞问题
3:协程
协程是一种基于线程之上,但又比线程更加轻量级的存在
这种由程序管理的轻量级线程也被称为用户空间线程,对于内核而言是不可见的。
正如同进程中存在多条线程一样,线程中也可以存在多个协程。
协程在运行时也有自己的寄存器、上下文和栈,协程的调度完全由用户控制
协程调度切换时,会将寄存器上下文和栈保存到分配的私有内存区域中,在切回来的时候,恢复先前保存的寄存器上下文和栈【现场恢复】
直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
前面把线程比作了工厂工位上的固定工人,那么协程更多的就可以理解为:工厂中固定工位上的不固定工人。
一个固定工位上允许有多个不同的工人,当轮到某个工人工作时,就把上一个工人的换下来,把这个要工作的工人换上去。
或者当前工人在工作时要上厕所,那么就会先把当前工作的工人撤下去,换另一个工人上来,等这个工人上完厕所回来了,会再恢复它的工作。
协程有些类似于线程的多对一模型。
4:纤程
纤程是微软组织为了帮助企业程序的更好移植到Windows系统,而在操做系统中增加的一个概念
由操作系统内核根据对应的调度算法进行控制,也是一种轻量级的线程。
纤程和协程的概念一致,都是线程的多对一模型,但有些地方会区分开来,但从协程的本质概念上来谈:纤程、绿色线程、微线程这些概念都属于协程的范围。
纤程和协程的区别在于:纤程是OS级别的实现,而协程是语言级别的实现,纤程被OS内核控制,协程对于内核而言不可见。
5:管程
管程(Monitors
)提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。
在Java中Synchronized关键字就是基于它实现的
6:总结
- 从实现级别上来看:进程、线程、纤程是OS级别的实现,而绿色线程、协程这些则是语言级别上的实现。
- 从调度方式上而言:进程、线程、绿色线程属于抢占式执行,而纤程、协程则属于合作式调度。
- 从包含关系上来说:一个OS中可以有多个进程,一个进程中可以有多条线程,而一条线程中则可以有多个协程、纤程、微线程等。
二:死锁、活锁、锁饥饿
1:死锁
死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待的现象
若无外力作用则不会解除等待状态,它们之间的执行都将无法继续下去
1.1:死锁产生需要四个条件
- 互斥条件:共享资源的排他性访问(互斥是基本前提)
- 不剥夺条件:访问时该共享资源不会被剥夺,而是需要共享资源的进程主动释放(不能蛮抢)
- 请求并保持:保持当前的资源而请求另外的资源(不放弃自己的)
- 循环等待条件:存在共享资源的循环等待链(A -> B -> C -> A)
只要系统或程序内发生死锁情况,那么这四个条件必然成立,只要上述中任意一条不符合,那么就不会发生死锁
1.2:系统资源的分类
永久性资源
永久性资源也被称为可重复性资源,即代表着一个资源可以被执行实体(线程/进程)重复性使用,它们不会因为执行实体的生命周期改变而发生变化。
比如所有的硬件资源就是典型的永久性资源,这些资源的数量是固定的,执行实体在运行时即不能创建,也不能销毁
要使用这些资源时必须要按照请求资源、使用资源、释放资源这样的顺序操作。
临时性资源
临时性资源也被称为消耗性资源,这些资源是由执行实体在运行过程中动态的创建和销毁的
如硬件中断信号、缓冲区内的消息、队列中的任务等
这些都属于临时性资源,通常是由一个执行实体创建出来之后,被另外的执行实体处理后销毁。
比如典型的一些消息中间件的使用,也就是生产者-消费者模型。
可抢占式资源
可抢占式资源也被称为可剥夺性资源,是指一个执行实体在获取到某个资源之后,该资源是有可能被其他实体或系统剥夺走的。
可剥夺性资源在程序中也比较常见,如:
- 进程级别:CPU、主内存等资源都属于可剥夺性资源,系统将这些资源分配给一个进程之后,系统是可以将这些资源剥夺后转交给其他进程使用的。
- 线程级别:比如Java中的
ForkJoin
框架中的任务,分配给一个线程的任务是有可能被其他线程窃取的。
可剥夺性资源还有很多,诸如上述过程中的一些类似的资源都可以被称为可剥夺性资源。
不可抢占式资源
同样,不可抢占式资源也被称为不可剥夺性资源,不可剥夺性是指把一个执行实体获取到资源之后,系统或程序不能强行收回,只能在实体使用完后自行释放。
例如:
- 进程级别:磁带机、打印机等资源,分配给进程之后只能由进程使用完后自行释放。
- 线程级别:锁资源就是典型的线程级别的不可剥夺性资源,当一条线程获取到锁资源后,其他线程不能剥夺该资源,只能由获取到锁的线程自行释放。
资源引发的死锁问题
在上述资源中,竞争临时性资源和不可剥夺性资源都可能引起死锁发生,也包括如果资源请求顺序不当也会诱发死锁问题
如两条并发线程同时执行,T1
持有资源M1
,线程T2
持有M2
,而T2
又在请求M1
,T1
又在请求M2
,两者都会因为所需资源被占用而阻塞,最终造成死锁。
也并非只有资源抢占会导致死锁出现,有时候没有发生资源抢占,就单纯的资源等待也会造成死锁场面
如服务A
在等待服务B
的信号,而服务B
恰巧也在等待服务A
的信号,结果也会导致双方之间无法继续向前推进执行。
1.3:死锁的分析和处理
package com.cui.commonboot.myjuc;
/**
* <p>
* 功能描述:dead lock test
* </p>
*
* @author cui haida
* @date 2024/01/04/15:33
*/
public class DeadLock implements Runnable {
public boolean flag = true;
// static field belong to class, all instance share
private static Object o1 = new Object();
private static Object o2 = new Object();
// constructive method
public DeadLock(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
// flag -> true
if (flag) {
synchronized (o1) {
System.out.println("线程:" + Thread.currentThread().getName() + "持有o1....");
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("线程:" + Thread.currentThread().getName() + "等待o2....");
synchronized (o2) {
System.out.println("true");
}
}
}
// flag -> false
if (!flag) {
synchronized (o2) {
System.out.println("线程:" + Thread.currentThread().getName() + "持有o2....");
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("线程:" + Thread.currentThread().getName() + "等待o1....");
synchronized (o1) {
System.out.println("false");
}
}
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new DeadLock(true),"T1");
Thread t2 = new Thread(new DeadLock(false),"T2");
t1.start();
t2.start();
}
}
处理死锁问题总的归纳来说可以从如下四个角度出发:
- 预防死锁:通过代码设计或更改配置来破坏掉死锁产生的四个条件其中之一,以此达到预防死锁的目的。
- 避免死锁:在资源分配的过程中,尽量保证资源请求的顺序性,防止推进顺序不当引起死锁问题产生。
- 检测死锁:允许系统在运行过程中发生死锁情况,但可设置检测机制及时检测死锁的发生,并采取适当措施加以清除。
- 解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱出来。
1.3.1:预防死锁
预防死锁的手段是通过破坏死锁产生的四个必要条件中的一个或多个,以此达到预防死锁的目的。
互斥条件的破坏 -> 互斥资源变成可以同时访问的资源
将只能互斥访问的资源变成可以同时访问的资源,将独占锁改为共享锁,不是所有的资源都可以共享
不剥夺/不可抢占条件的破坏 -> 代价太高,不行
请求新资源无法满足的时候必须释放已有的资源
由OS协助强制剥夺某一个进程持有的资源,实现复杂,代价高
请求并保持条件的破坏 -> 一次性申请所有的资源
进程一开始就一次性的申请自己所需要的所有的资源【但是这样会造成资源浪费和进程饥饿】
进行阶段性的请求和释放资源
循环等待条件的破坏 -> 请求和释放顺序性
对所有的现有资源进行排序,按照序号请求资源,请求时从低到高,释放时从高到低
此举大大的限制了新设备的增加和用户的编程
1.3.2:避免死锁
避免死锁是指系统或程序对于每个能满足的执行实体的资源请求进行动态检查,并且根据检查结果决定是否分配资源
如果分配后系统可能发生死锁,则不予分配,反之则给予资源分配,这是一种保证系统不进入死锁状态的动态策略。
避免死锁的常用算法
-
有序资源分配法:这种方式大多数被操作系统应用于进程资源分配。假设此时有两个进程P1、P2,进程P1需要请求资源顺序为R1、R2,而进程P2使用资源的顺序则为R2、R1。如果这个情况下两个进程并发执行,采用动态分配法的情况下是有一定几率发生死锁的,所以可以采用有序资源分配法,把资源分配的顺序改为如下情况,从而做到破坏环路条件,避免死锁发生。
- P1:R1,R2
- P2:R1,R2
-
银行家算法:银行家算法顾名思义是来源于银行的借贷业务,有限的本金要应多个客户的借贷周转,为了防止银行家资金无法周转而倒闭,对每一笔贷款,必须考察其借贷者是否能按期归还。在操作系统中研究资源分配策略时也有类似问题,系统中有限的资源要供多个进程使用,必须保证得到的资源的进程能在有限的时间内归还资源,以供其他进程使用资源,确保整个操作系统能够正常运转。如果资源分配不得到就会发生进程之间环状等待资源,则进程都无法继续执行下去,最终造成死锁现象。
OS实现:把一个进程需要的、已占有的资源情况记录在进程控制块中,假定进程控制块PCB其中“状态”有就绪态、等待态和完成态。当进程在处于等待态时,表示系统不能满足该进程当前的资源申请。“资源需求总量”表示进程在整个执行过程中总共要申请的资源量。显然,每个进程的资源需求总量不能超过系统拥有的资源总数,通过银行家算法进行资源分配可以避免死锁。
上述的两种算法更多情况下是操作系统层面对进程级别的资源分配算法,而在程序开发中又该如何编码才能尽量避免死锁呢?大概有如下两种方式:
- 顺序加锁
- 超时加锁
前者是保证锁资源的请求顺序性,防止请求顺序不当引起资源相互等待,最终造成死锁发生。
后者则是获取锁超时中断的意思,在JDK级别的锁,如ReetrantLock、Redisson
等,都支持该方式,也就是在指定时间内未获取到锁资源则放弃获取锁资源。
1.3.3:检测死锁
可以通过多种方式定位问题:
- 通过
jps+jstack
工具排查。 - 通过
jconsole
工具排查。 - 通过
jvisualvm
工具排查。 - 也可以通过其他一些第三方工具排查问题,但前面两种都是JDK自带的工具。
Jps + jstack
jconsole
选择线程之后检测死锁就可以获取死锁的位置信息
1.3.4:解除死锁
一般操作系统处理进程级别的死锁问题主要用三种方式:
- 资源剥夺法。挂起某些死锁进程,并剥夺它的资源,将这些资源分配给其他的死锁进程。但应当合理处置被挂起的进程,防止进程长时间挂起而得不到资源,一直处于资源匮乏的状态。
- 撤销进程法。强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。撤销的原则可以按进程优先级、进程重要性和撤销进程代价的高低进行。
- 进程回退法。让一个或多个进程回退到足以避免死锁发生的位置,进程回退时自己释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。
一般而言在Java程序中只能修改代码后重新上线程序,因为大部分的死锁都是由于代码编写不当导致的,所以将代码改善后重新部署即可。
2:活锁
活锁是指正在执行的线程或进程没有发生阻塞,但由于某些条件没有满足,导致反复重试-失败-重试-失败的过程。
与死锁最大的区别在于:活锁状态的线程或进程是一直处于运行状态的,在失败中不断重试,重试中不断失败,一直处于所谓的“活”态,不会停止。
而发生死锁的线程则是相互等待,双方之间的状态是不会发生改变的,处于所谓的“死”态。
死锁没有外力介入是无法自行解除的,而活锁状态有一定几率自行解除。
活锁状态是有可能自行解除的,但时间会久一点
在编写程序时,我们可以尽量避免活锁情况发生:
- 一方面可以在重试次数上加上限制
- 第二个方面也可以把重试的间隔时间加点随机数
- 第三个则是多线程协同式工作时则可以先在全局内约定好重试机制,尽量避免线程冲突发生。
3:锁饥饿
锁饥饿是指一条长时间等待的线程无法获取到锁资源或执行所需的资源,而后面来的新线程反而“插队”先获取了资源执行,最终导致这条长时间等待的线程出现饥饿。
ReetrantLock的非公平锁就有可能导致线程饥饿的情况出现,因为线程到来的先后顺序无法决定锁的获取
锁饥饿这种问题可以采用公平锁的方式解决,这样可以确保线程获取锁的顺序是按照请求锁的先后顺序进行的。
但实际开发过程中,从性能角度而言,非公平锁的性能会远远超出公平锁,非公平锁的吞吐量会比公平锁更高。
当然,如果你使用了多线程编程,但是在分配纤程组时没有合理的设置线程优先级,导致高优先级的线程一直吞噬低优先级的资源,导致低优先级的线程一直无法获取到资源执行,最终也会使低优先级的线程产生饥饿。