请简要描述线程与进程的关系,区别及优缺点?
-
本质区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
-
在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小
-
稳定性方面:进程中某个线程如果崩溃了,可能会导致整个进程都崩溃。而进程中的子进程崩溃,并不会影响其他进程。
-
内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源
-
包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程
协程为什么比线程快?
协程比线程快的主要原因有以下几点:
-
用户态切换:协程是在用户态下进行切换,不涉及内核态的上下文切换和系统调用,切换成本低,执行效率高。
-
轻量级:协程是由用户自己管理的,不需要操作系统进行调度和管理,占用的资源较少,创建和销毁的开销小。
-
高并发:协程可以在同一个线程内并发执行,避免了线程切换和同步的开销,提高了并发处理能力。
进程之间通信的方法
由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。
Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。
匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|
」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。
命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。
那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。
与信号量名字很相似的叫信号,它俩名字虽然相似,但功能一点儿都不一样。信号是异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL
和 SIGSTOP
,这是为了方便我们能在任何时候结束或停止某个进程。
前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
线程间通信知道哪些?
在Linux系统中,线程间通信的方式包括:
-
-
互斥锁(Mutex):线程可以使用互斥锁来保护共享资源,确保同时只有一个线程可以访问该资源。
-
条件变量:线程可以使用条件变量来等待特定条件的发生,以实现线程间的协调和通知。
-
信号量:线程可以使用信号量来控制对共享资源的访问,实现线程间的同步和互斥。
-
读写锁:允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。
-
什么是线程上下文切换?
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如程序计数器,虚拟机栈和本地方法栈等。
当出现如下情况的时候,会发生线程切换。
-
主动让出 CPU,比如调用了
sleep()
,wait()
等。 -
时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
-
调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
线程切换意味着需要保存当前线程的上下文(比如程序计数器,虚拟机栈和本地方法栈),留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。频繁上下文切换就会造成整体效率低下。
什么是线程死锁?如何避免死锁?
什么是死锁
死锁(Deadlock)描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。
四个必要条件
-
互斥
-
请求并保持
-
非抢占
-
循环等待
这四个条件是产生死锁的 必要条件 ,也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
产生死锁的四个必要条件是:互斥条件、持有并等待条件、不可剥夺条件、环路等待条件。
那么避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件。
那什么是资源有序分配法呢?
线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。
我们使用资源有序分配法的方式来修改前面发生死锁的代码,我们可以不改动线程 A 的代码。
我们先要清楚线程 A 获取资源的顺序,它是先获取互斥锁 A,然后获取互斥锁 B。
所以我们只需将线程 B 改成以相同顺序的获取资源,就可以打破死锁了。
sleep() 方法和 wait() 方法对比
共同点:两者都可以暂停线程的执行。
区别:
-
sleep()
方法没有释放锁(synchronized的锁),用于暂停执行任务。而wait()
方法释放了锁用于线程之间的交互,。 -
sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会自动苏醒。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。 -
sleep()
是Thread
类的静态本地方法(sleep()方法是让当前线程暂停执行,不涉及对象锁),wait()
则是Object
类的本地方法(wait()
是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁)。
为什么wait要包在同步块?
Java中的wait()
方法需要在同步块(synchronized block)中调用的原因是因为wait()
方法会释放对象的锁,而在同步块中可以确保线程在调用wait()
方法前持有对象的锁,从而避免多线程执行时的竞争和冲突。
具体原因如下:
-
线程安全:在同步块中调用
wait()
方法可以确保线程在调用wait()
前已经获取了对象的锁,避免多线程之间的竞争和数据不一致性问题。 -
对象监视器:
wait()
方法会释放对象的监视器(monitor),其他线程可以获取该对象的监视器并执行同步操作,确保线程之间的协作和同步。 -
唤醒机制:当调用
wait()
方法后,线程会进入等待状态,只有在其他线程调用notify()
或notifyAll()
方法唤醒该线程时,线程才会继续执行。在同步块中调用wait()
可以保证线程被正确唤醒。
说说线程的生命周期和状态?
源自《Java并发编程艺术》 java.lang.Thread.State枚举类中定义了六种线程的状态,可以调用线程Thread中的getState()方法获取当前线程的状态。
线程状态 | 解释 |
---|---|
NEW | 尚未启动的线程状态,即线程创建,还未调用start方法 |
RUNNABLE | 就绪状态(调用start,等待调度)+正在运行 |
BLOCKED | 线程在等待获取一个锁时进入该状态,是由于竞争锁资源引起的。 |
WAITING | 等待状态的线程正在等待另一线程执行特定的操作(如notify) |
TIMED_WAITING | 具有指定等待时间的等待状态 |
TERMINATED | 线程完成执行,终止状态 |
wait 状态下的线程如何进行恢复到 running 状态?
-
等待的线程被其他线程对象唤醒,
notify()
和notifyAll()
。 -
如果线程没有获取到锁则会直接进入 Waiting 状态,其实这种本质上它就是执行了 LockSupport.park() 方法进入了Waiting 状态,那么解锁的时候会执行
LockSupport.unpark(Thread)
,与上面park方法对应,给出许可证,解除等待状态。
notify 和 notifyAll 的区别?
同样是唤醒等待的线程,同样最多只有一个线程能获得锁,同样不能控制哪个线程获得锁。
区别在于:
-
notify:唤醒一个线程,其他线程依然处于wait的等待唤醒状态,如果被唤醒的线程结束时没调用notify,其他线程就永远没人去唤醒,只能等待超时,或者被中断
-
notifyAll:所有线程退出wait的状态,开始竞争锁,但只有一个线程能抢到,这个线程执行完后,其他线程又会有一个幸运儿脱颖而出得到锁
notify 选择哪个线程?
notify在源码的注释中说到notify选择唤醒的线程是任意的,但是依赖于具体实现的jvm。
notify源码
JVM有很多实现,比较流行的就是hotspot,hotspot对notofy()的实现并不是我们以为的随机唤醒,,而是“先进先出”的顺序唤醒。
如何停止一个线程的运行?
主要有这些方法:
-
异常法停止:线程调用interrupt()方法后,在线程的run方法中判断当前对象的interrupted()状态,如果是中断状态则抛出异常,达到中断线程的效果。
-
在沉睡中停止:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果
-
stop()暴力停止:线程调用stop()方法会被暴力停止,方法已弃用,该方法会有不好的后果:强制让线程停止有可能使一些请理性的工作得不到完成。
-
使用return停止线程:调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果。
调用 interrupt 是如何让线程抛出异常的?
每个线程都一个与之关联的布尔属性来表示其中断状态,中断状态的初始值为false,当一个线程被其它线程调用Thread.interrupt()
方法中断时,会根据实际情况做出响应。
-
如果该线程正在执行低级别的可中断方法(如
Thread.sleep()
、Thread.join()
或Object.wait()
),则会解除阻塞并抛出InterruptedException
异常。 -
否则
Thread.interrupt()
仅设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务。
如果是靠变量来停止线程,缺点是什么?
缺点是中断可能不够及时,循环判断时会到下一个循环才能判断出来。
虚拟内存是什么?有什么作用?
如果没有虚拟内存,程序读 写的地址是物理地址的话,可能会出现物理地址冲突的问题,比如,第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容。
为了解决这个问题,就引出了虚拟内存,操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址就行,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。
进程的中间层
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
最后,说下虚拟内存有什么作用?
-
第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
-
第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
软连接和硬连接有什么区别?
软连接实际上是一个指向目标文件的路径的符号链接,类似于Windows系统中的快捷方式,创建软连接不会占用目标文件的inode节点,只是简单地指向目标文件的路径。删除原始文件后,软连接仍然存在,但指向的目标文件失效,称为"悬空链接"。软链接可以跨文件系统创建软连接。
硬连接是指多个文件实际上指向同一个inode节点,即多个文件共享同一块数据块。创建硬连接会增加目标文件的链接计数,删除任何一个硬连接并不会影响其他硬连接指向的文件数据。只能在同一文件系统内创建硬连接。
内核态和用户态?
内核态和用户态是操作系统中的两种运行模式。它们的主要区别在于权限和可执行的操作:
-
内核态(Kernel Mode):在内核态下,CPU可以执行所有的指令和访问所有的硬件资源。这种模式下的操作具有更高的权限,主要用于操作系统内核的运行。
-
用户态(User Mode):在用户态下,CPU只能执行部分指令集,无法直接访问硬件资源。这种模式下的操作权限较低,主要用于运行用户程序。
内核态的底层操作主要包括:内存管理、进程管理、设备驱动程序控制、系统调用等。这些操作涉及到操作系统的核心功能,需要较高的权限来执行。
分为内核态和用户态的原因主要有以下几点:
-
安全性:通过对权限的划分,用户程序无法直接访问硬件资源,从而避免了恶意程序对系统资源的破坏。
-
稳定性:用户态程序出现问题时,不会影响到整个系统,避免了程序故障导致系统崩溃的风险。
-
隔离性:内核态和用户态的划分使得操作系统内核与用户程序之间有了明确的边界,有利于系统的模块化和维护。
内核态和用户态的划分有助于保证操作系统的安全性、稳定性和易维护性。
有哪些进程调度算法 ?
01 先来先服务调度算法
最简单的一个调度算法,就是非抢占式的先来先服务(*First Come First Serve, FCFS*)算法了。
FCFS 调度算法
顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
02 最短作业优先调度算法
最短作业优先(*Shortest Job First, SJF*)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
SJF 调度算法
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
03 高响应比优先调度算法
前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。
那么,高响应比优先 (*Highest Response Ratio Next, HRRN*)调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
img
从上面的公式,可以发现:
-
如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
-
如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
04 时间片轮转调度算法
最古老、最简单、最公平且使用最广的算法就是时间片轮转(*Round Robin, RR*)调度算法。
RR 调度算法
每个进程被分配一个时间段,称为时间片(*Quantum*),即允许该进程在该时间段中运行。
-
如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外一个进程;
-
如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
另外,时间片的长度就是一个很关键的点:
-
如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
-
如果设得太长又可能引起对短作业进程的响应时间变长。将
一般来说,时间片设为 20ms~50ms
通常是一个比较合理的折中值。
05 最高优先级调度算法
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(*Highest Priority First,HPF*)调度算法。
进程的优先级可以分为,静态优先级和动态优先级:
-
静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
-
动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
-
非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
-
抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
06 多级反馈队列调度算法
多级反馈队列(*Multilevel Feedback Queue*)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
顾名思义:
-
「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
-
「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
多级反馈队列
来看看,它是如何工作的:
-
设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
-
新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
-
当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也变更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
已知一个进程名,如何杀掉这个进程?
在Linux 操作系统,可以使用kill命令来杀死进程。
首先,使用ps命令查找进程的PID(进程ID),然后使用kill命令加上PID来终止进程。例如:
ps -ef | grep <进程名> // 查找进程的PID kill <PID> // 终止进程
fork创建子进程有哪些特点呢?
-
父子进程:fork调用后,会创建一个新的子进程,该子进程与父进程几乎完全相同,包括代码、数据和打开文件等。子进程从fork调用的位置开始执行,父进程和子进程在fork调用之后的代码处继续执行。
-
资源继承:子进程继承了父进程的大部分资源,包括打开的文件、文件描述符、信号处理器等。但是有些资源(如互斥锁和定时器)可能需要进行特殊处理,以避免竞争条件或资源泄漏。
-
内存:父进程和子进程拥有独立的虚拟内存空间,每个进程都有自己的内存映射表。子进程通过写时复制(copy-on-write)机制与父进程共享物理内存,只有在需要修改内存内容时才会进行复制。
-
父子关系:父进程可以通过fork的返回值判断是否为子进程。父进程的fork返回子进程的PID,而子进程的fork返回0。这样可以根据返回值的不同,在父子进程中执行不同的逻辑。