文章目录
- 52. 什么是进程?
- 53. 并发和并行有什么区别?
- 54. 进程的状态有哪几种?以及进程状态的变迁有哪些?
- 55. 什么是挂起状态?
- 56. PCB是什么?每个PCB具有哪些信息?每个PCB是如何组织的呢?
- 57. 进程的控制
- 58. CPU的上下文切换
- 59. 进程上下文切换
- 60. 发生进程上下文切换的常见场景有哪些?
- 61. 什么是线程?线程的优缺点有哪些?
- 62. 比较线程和进程
- 63. 线程上下文切换的是什么?
- 64. 线程的实现方式有哪几种?
- 65. 用户线程如何理解?有什么优缺点?
- 66. 内核线程如何理解?有什么优缺点?
- 67. 轻量级进程如何理解?有什么优缺点?
- 68. 进程调度的时机
- 69. 进程的调度原则
- 70. 进程间有哪些通信方式?
- 71. 针对 TCP 协议通信的 socket 编程模型
- 72. 针对 UDP 协议通信的 socket 编程模型
- 73. 针对本地进程间通信的 socket 编程模型
- 74. 什么是互斥和同步?
- 75. 如何实现多进程/线程的互斥与同步?
- 76. 生产者—消费者模型(待解答)
- 77. 哲学家就餐问题(待解答)
- 78. 读者—写者问题(待解答)
- 79. 什么是死锁?产生死锁需要满足哪些条件?如何避免死锁问题的产生?
- 80. 常见的锁有哪些?它们的特点与应用场景是什么?
- 81. 一个进程最多可以创建多少个线程?
- 82. 线程崩溃,进程一定会崩溃吗?
52. 什么是进程?
我们编写的代码只是存放在硬盘上的静态文件,通过编译生成二进制的可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为进程(Process)
。
53. 并发和并行有什么区别?
在多个进程运行的过程中,在同一时刻只能有一个进程运行时,就是并发;在同一时刻有多个进程同时运行,就是并行。
54. 进程的状态有哪几种?以及进程状态的变迁有哪些?
进程有5种基本状态:
- 运行态:该状态的进程占用CPU;
- 就绪态:该状态的进程可运行,由于其他进程处于运行状态而暂时处于排队等待运行;
- 阻塞态:该状态的进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
- 创建态:进程正在被创建时的状态;
- 终止态:进程正在从系统中消失时的状态。
进程的状态变迁:
- NULL -> 创建状态:一个新进程被创建时的第一个状态;
- 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
- 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
- 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
- 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
- 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
- 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;
55. 什么是挂起状态?
在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。那么,当进程没有占用实际的物理内存空间的情况,这种状态就是挂起状态。
挂起状态可以分为两种:
- 阻塞挂起状态:进程在硬盘并等待某个事件的发生;
- 就绪挂起状态:进程在硬盘上,进入内存即可立即运行。
56. PCB是什么?每个PCB具有哪些信息?每个PCB是如何组织的呢?
- PCB也就是进程控制块(Process Control Block)。它是用来描述进程的数据结构。PCB是进程存在的唯一标识。
- PCB包含进程描述信息(进程标识符、用户标识符)、进程控制与管理信息(进程当前状态、进程优先级)、资源分配清单和CPU相关信息。
- PCB通常是通过链表进行组织的,把具有相同状态的进程链接在一起,组成就绪队列或阻塞队列。除了链表的组织方式,还有索引的方式。
57. 进程的控制
进程的创建、终止、阻塞、唤醒的过程就是所谓的进程的控制。
- 创建进程:
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所有的所有资源。
创建进程的过程如下:- 申请一个空白的PCB,并向PCB中填写一些控制和管理进程的信息,比如进程的唯一标识等;
- 为该进程分配运行时的资源,比如内存资源;
- 将PCB插入到就绪队列,等待被调度运行;
- 终止进程
进程可以有3种终止方式:正常结束、异常结束以及外界干预(信号kill掉)。
当子进程被终止时,由父进程对其的资源进行回收。而当父进程被终止时,该子进程就变成了孤儿进程,会被1号进程收养,在子进程结束后,由1号进程对它的资源进行回收。
终止进程的过程如下:- 查找需要终止的进程的PCB;
- 如果处于运行态,则立即终止该进程的运行,然后将CPU资源分配给其他进程;
- 如果该进程还有子进程,则将子进程交给1号进程接管;
- 将该进程所拥有的全部资源归还给操作系统;
- 将其从PCB所在队列中删除;
- 阻塞进程
当进程需要等待某一事件的完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由其他进程唤醒。
阻塞进程的过程如下:- 找到将要被阻塞的进程标识号所对于的PCB;
- 如果该进程处于运行态,则保护其现场,将其状态转换为阻塞态,停止运行;
- 将该PCB插入到阻塞队列中;
- 唤醒进程
只有当阻塞态的进程所等待的事件出现时,才由发现者进程用唤醒语句唤醒该进程。
唤醒进程的过程如下:- 在该进程的阻塞队列中找到相应进程的PCB;
- 将其从阻塞队列中移除,并将其状态转换为就绪态;
- 把该PCB插入到就绪队列中,等待调度程序的调度;
如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。
58. CPU的上下文切换
- CPU上下文是指CPU寄存器和程序计数器,它们是CPU运行任何任务前所必须依赖的环境。
- CPU上下文切换就是先把上一个任务的CPU上下文(CPU寄存器和程序计数器)信息保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的位置,运行新任务。
- 这里所谓的任务,主要就是进程、线程和中断。根据任务的不同,CPU上下文切换可分为进程上下文切换、线程上下文切换和中断上下文切换
59. 进程上下文切换
- 进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
- 进程的上下文切换不仅包括虚拟内存、栈、全局变量等用户空间的资源,还包括内核堆栈、寄存器等内核空间的资源。
- 通常,会把交换的信息保存在进程的PCB。
60. 发生进程上下文切换的常见场景有哪些?
- 当处于运行态的进程时间片耗尽时,进程就会从运行态转换为就绪态,系统从就绪队列选择另外一个进程运行;
- 当系统资源(比如内存)不足时,进程要等到资源满足后才可以运行,这时进程会被挂起,并由系统调度其他进程运行;
- 当进程通过sleep函数将自己挂起时,系统也会重新调度其他进程运行;
- 当有优先级更高的进程需要运行时,为了保证高优先级进程的运行,当前进程会被挂起,然后运行高优先级的进程;
- 当发生硬件中断时,CPU上的进程也会被中断挂起,转而执行内核中的中断处理程序;
61. 什么是线程?线程的优缺点有哪些?
- 线程是进程当中的一条执行流程。
- 同一个进程的多个线程之间可以共享代码段、数据段、打开的文件等资源,但是每个线程各自拥有一套独立的寄存器和栈,这样可以确保线程的控制流是相互独立的。
- 线程的优缺点:
- 线程的优点:一个进程中可以同时存在多个线程;各个线程之间可以并发运行;各个线程之间可以共享地址空间和文件等资源。
- 线程的缺点:针对C/C++语言,当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃。
62. 比较线程和进程
- 进程是资源(包括内存、打开的文件等)分配的基本单位,线程是 CPU 调度的基本单位;
- 进程拥有完整的资源平台,而线程只独享必不可少的资源,比如寄存器和栈;
- 线程同样具有就绪、阻塞、运行三种基本状态,同样具有状态之间的转换关系;
- 线程能减少并发执行的时间和空间开销;
- 线程使用有一定难度,需要处理数据一致性的问题。
线程相比进程能减少开销,主要体现在:
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 线程的终止时间比进程快,因为线程终止时,需要释放的资源比进程少很多;
- 同一个进程内的线程切换比进程切换快,因为线程共享虚拟地址空间,线程切换的过程中不需要切换页表;而进程都独自拥有自己的虚拟内存,进程切换时需要对页表进行切换,而页表的切换过程开销还是比较大的;
- 由于同一个进程的各线程间共享内存和文件资源,那么在线程之间传递数据的时候,不需要经过内核空间,这使得线程之间的数据交互的效率更高。
63. 线程上下文切换的是什么?
- 当两个线程不属于同一个进程时,线程上下文切换过程和进程上下文切换一样;
- 当两个线程属于同一个进程时,因为线程间共享虚拟内存,所以在线程切换时,虚拟内存这些资源就保存不动,只需要切换线程私有的资源、寄存器等不共享的数据。
64. 线程的实现方式有哪几种?
主要有 3 种线程的实现方式:
- 用户线程:在用户空间实现的线程,由用户态的线程库对线程进行管理;
- 内核线程:在内核中实现的线程,由内核对线程进行管理;
- 轻量级进程(LWP):在内核中支持的用户线程。
用户线程和内核线程的对应关系:多对一、一对一、多对多。
65. 用户线程如何理解?有什么优缺点?
用户线程是基于用户态的线程库来实现的,线程控制块(Thread Control Block,TCB)也是在线程库里实现的,对于操作系统而言是看不见这个TCB的,它只能看见整个进程的PCB。由用户级线程库函数完成线程的管理,包括线程的创建、终止、同步和调度等。
用户线程的优点:
- 每个进程都有私有的线程控制块列表,用来跟踪记录它各个线程的状态,TCB由用户级线程库函数来管理,可用于不支持线程技术的操作系统;
- 用户线程的切换由线程库函数来完成,无需用户态与内核态的切换,线程切换的速度特别快。
用户线程的缺点:
- 由于操作系统不参与线程的调度,如果一个线程发起系统调用后发生了阻塞,那么进程中的其他用户线程也都无法执行;
- 当一个线程开始运行后,除非该线程主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行;
- 由于时间片是分配给进程的,故与其他进程相比,多线程的进程执行时,每个线程得到的时间片较少,运行会比较慢。
66. 内核线程如何理解?有什么优缺点?
内核线程是由操作系统管理的,线程对应的TCB存在于操作系统中,这样线程的创建、终止和管理都是由操作系统负责。
内核线程的优点:
- 在一个进程中,如果每个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
- 时间片是分配给线程的,多线程的进程可获得更多的CPU运行时间。
内核线程的缺点:
- 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;
- 线程的创建、终止和切换都是通过系统调用的方式进行;
因此,内核线程对系统的开销比较大。
67. 轻量级进程如何理解?有什么优缺点?
轻量级进程(LWP)是内核支持的用户线程,一个进程可以有一个或多个LWP,每个LWP是跟内核线程一对一映射的,也就是说每个 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理,并向普通进程一样被调度。在大多数系统中,LWP 与普通进程的区别是它只有一个最小的执行上下文和调度程序所需的统计信息。
在 LWP 之上也是可以使用用户线程的,LWP 与用户线程的对应关系有以下三种:
- 1 : 1,一个 LWP 对应一个用户线程;
- 优点:实现并行,当一个LWP阻塞,不会影响其他用户线程;
- 缺点:每个用户线程对应一个内核线程,创建线程的开销较大。
- N : 1,一个LWP对应多个用户线程;
- 优点:多个用户线程的管理是在用户空间完成的,上下文切换发生在用户空间,线程切换的效率高;
- 缺点:如果一个用户线程阻塞,整个进程就会阻塞,无法充分利用 CPU 资源。
- M : N,多个LWP对应多个用户线程;
- 优点:大部分的线程上下文切换发生在用户空间,且多个线程可以充分利用CPU资源。
68. 进程调度的时机
在进程的生命周期中,当进程从就绪态转换为运行态或者从运行态转换为其他状态时,都会触发操作系统的调度。
- 从就绪态转换为运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
- 从运行态转换为阻塞态:当进程等待 I/O 事件的完成而阻塞时,操作系统必须选择另外一个进程运行;
- 从运行态转换为结束态:当进程结束后,操作系统会从就绪队列选择一个进程运行。
69. 进程的调度原则
- 要提高
CPU利用率
,当进程发生 I/O 事件时,调度程序需要从就绪队列中选择一个进程来运行; - 要提高
系统的吞吐量
,调度程序要权衡长任务和短任务进程的运行完成数量; - 要使得
周转时间
尽可能短,避免进程等待时间很长而运行时间很短的情况发生; - 要使得
等待时间
尽可能短,就绪队列中进行的等待时间也是调度程序所需要考虑的原则; - 对于交互式比较强的应用,
响应时间
越快越好,响应时间也是调度程序需要考虑的原则。
70. 进程间有哪些通信方式?
进程间通信的方式有匿名管道、命名管道、消息队列、共享内存、信号量、信号和Socket通信
。
(1)匿名管道
- 匿名管道没有名字,它是特殊的文件只存在于内存,不存在于文件系统中,shell命令中的
|
竖线就是匿名管道,通信的数据是无格式的流并且大小受限。通信的方式是单向的,数据只能在一个方向上传输,如果要双向通信,需要创建两个管道,并且匿名管道只能用于有关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。 - 匿名管道的创建需要通过系统调用:
int pipe(int fd[2])
,这里表示创建了一个匿名管道,并返回了两个文件描述符,一个是管道的读端文件描述符fd[0]
,另一个是管道的写端描述符fd[1]
。 - 所谓的管道,就是内核中的一块缓存。我们可以使用
fork
创建子进程,创建的子进程会复制父进程的文件描述符,这样父子进程就可以通过各自的fd
读写同一个管道文件实现进程间通信。
(2)命名管道
- 命名管道可以在不相关的进程间通信,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程可通过这个文件进行通信。
- 无论是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时也是从内核中读取,同时通信数据遵循先进先出的原则,不支持 lseek 之类的文件定位操作。
(3)消息队列
- 消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的消息链表,消息队列的消息体是用户自定义的数据类型。
- 消息队列的生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在。
- 消息队列的通信不及时,每次数据的读写都需要经过用户态与内核态之间的拷贝过程。
- 消息队列不适合传输比较大的数据,因为内核中的消息体的长度和消息队列总长度都有限制。
(4)共享内存
- 共享内存是直接分配一个共享空间,每个进程都可以直接访问,这样就可以解决消息队列通信中用户态和内核态之间数据拷贝带来的开销,不需要陷入内核态或者使用系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。
- 高效便捷的共享内存通信,却存在一个问题,如果多个进程同时修改同一个共享内存,很有可能会产生冲突,造成数据的错乱。
(5)信号量
- 信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
- 信号量表示资源的数量,控制信号量的方式有两个原子操作:
- 一个是
P
操作,这个操作会把信号量减1
。如果相减后信号量< 0
,则表明资源被占用,进程需阻塞等待;如果相减后信号量>= 0
,则表明还有资源可使用,进程可正常继续执行。 - 另一个是
V
操作,这个操作会把信号量加1
。如果相加后信号量<= 0
,则表明当前有进程被阻塞,于是会将其中一个阻塞的进程唤醒运行;如果相加后信号量> 0
,则表明当前没有进程被阻塞。
- 一个是
- P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作必须成对出现。
- 将信号量初始化为
1
,则代表 互斥信号量,它可以保证在任一时刻只有一个进程访问共享内存。 - 将信号量初始化为
0
,则代表 同步信号量,它可以保证进程有顺序的执行。
(6)信号
- 信号是进程间通信机制中唯一的异步通信机制,信号可以在应用进程与内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件。
- 信号事件的来源主要有硬件来源(如键入Ctrl + C)和软件来源(如 kill 命令)。
- 一旦有信号产生,进程有三种响应信号的方式:1、执行默认操作;2、捕捉信号;3、忽略信号。有两个信号是应用进程无法捕捉和忽略的,即
SIGKILL
和SIGSTOP
,这是为了我们能在任意时刻结束或停止某个进程。
(7)Socket 通信
- Socket 通信不仅可以使网络中不同主机上的进程间通信,还可以在同主机上进程间通信。
- 创建 Socket 的系统调用:
int socket(int domain, int type, int protocal)
三个参数分别代表:- domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;
- type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
- protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;
- 根据创建 socket 类型的不同,通信的方式也就不同:
- 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;
- 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
- 实现本地进程间通信: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;
71. 针对 TCP 协议通信的 socket 编程模型
- 服务端和客户端初始化
socket
,得到文件描述符; - 服务端调用
bind
,将绑定在 IP 地址和端口; - 服务端调用
listen
,进行监听; - 服务端调用
accept
,等待客户端连接; - 客户端调用
connect
,向服务器端的地址和端口发起连接请求; - 服务端
accept
返回用于传输的socket
的文件描述符; - 客户端调用
write
写入数据;服务端调用read
读取数据; - 客户端断开连接时,会调用
close
,那么服务端read
读取数据的时候,就会读取到了EOF
,待处理完数据后,服务端调用close
,表示连接关闭。
注意⚠️:服务端调用 accept
时,连接成功了会返回一个已完成连接的 socket
,后续用来传输数据。所以,监听的 socket
和真正用来传送数据的 socket
,是两个不同的 socket
,一个叫作监听socket
,一个叫作已完成连接socket
。
72. 针对 UDP 协议通信的 socket 编程模型
- 通信双方初始化
socket
,得到文件描述符; - 通信双方调用
bind
,将绑定在 IP 地址和端口; - 每次通信时,调用
sendto
和recvfrom
,都要传入目标主机的 IP 地址和端口。
73. 针对本地进程间通信的 socket 编程模型
- 对于本地字节流 socket,其 socket 类型是
AF_LOCAL
和SOCK_STREAM
。 - 对于本地数据报 socket,其 socket 类型是
AF_LOCAL
和SOCK_DGRAM
。 - 本地字节流 socket 和 本地数据报 socket 在
bind
的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。
74. 什么是互斥和同步?
- 临界区:临界区是访问共享资源的代码片段,一定不能让多个线程同时执行该代码片段。
- 互斥:保证一个线程在临界区执行时,其他线程应该被阻止进入临界区。互斥并不只是针对多线程,在多进程竞争共享资源时,同样采用互斥的方式来避免资源竞争造成的资源混乱。
- 同步:同步就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这个相互制约的等待与互通信息称为进程/线程同步。
75. 如何实现多进程/线程的互斥与同步?
实现多进程/线程互斥的方法:(1) 锁
:加锁、解锁操作;(2) 信号量
:P、V操作。
实现多进程/线程同步的方法:信号量
:P、V操作。
(1)锁
原子操作
:代码段要么全部执行,要么都不执行,不能出现执行到一半的中间状态。
根据锁的实现不同,可分为忙等待锁
和无忙等待锁
。
当线程获取不到锁时,线程就会一直处于 while 循环,不做任何事情,所以这种锁被称为忙等待锁
,也被称为自旋锁(spin lock)
。
在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃CPU使用权。
无等待锁
指当线程获取不到锁的时候,不用自旋,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。
(2)信号量
- 信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
- 信号量表示资源的数量,控制信号量的方式有两个原子操作:
- 一个是
P
操作,这个操作会把信号量减1
。如果相减后信号量< 0
,则表明资源被占用,进程需阻塞等待;如果相减后信号量>= 0
,则表明还有资源可使用,进程可正常继续执行。 - 另一个是
V
操作,这个操作会把信号量加1
。如果相加后信号量<= 0
,则表明当前有进程被阻塞,于是会将其中一个阻塞的进程唤醒运行;如果相加后信号量> 0
,则表明当前没有进程被阻塞。
- 一个是
- P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作必须成对出现。
- 将信号量初始化为
1
,则代表 互斥信号量,它可以保证在任一时刻只有一个进程访问共享内存。 - 将信号量初始化为
0
,则代表 同步信号量,它可以保证进程有顺序的执行。
76. 生产者—消费者模型(待解答)
77. 哲学家就餐问题(待解答)
78. 读者—写者问题(待解答)
79. 什么是死锁?产生死锁需要满足哪些条件?如何避免死锁问题的产生?
当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁
,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁
。
简单来说,死锁问题的产生是由于两个或者两个以上的线程并行执行的时候,争夺资源而互相等待造成的。
死锁只有同时满足
以下四个条件才会产生:
互斥条件
:指多个线程不能同时使用同一个资源;持有并等待条件
:线程在持有一部分资源的同时,等待其他资源的到来;不可剥夺条件
:线程持有的资源,在自己使用完之前不能被其他线程获取;环路等待条件
:在发生死锁时,多个线程获取资源多顺序构成了环形链路。
避免死锁问题产生的方法是只需要破坏产生死锁的其中一个条件即可,最常见的并且可行的方法是使用资源有序分配法,来破坏环路等待条件
。
80. 常见的锁有哪些?它们的特点与应用场景是什么?
常见的锁分为互斥锁、自旋锁、读写锁、乐观锁、悲观锁
。
(1)互斥锁与自旋锁
- 互斥锁和自旋锁是最底层的两种锁,很多高级的锁都是基于它们实现的,它们是各种锁的地基。
- 互斥锁加锁失败后,线程会释放CPU给其他线程;自旋锁加锁失败后,线程会忙等待,直到它拿到锁。
- 互斥锁是一种独占锁,对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。
- 互斥锁加锁失败后,会有两次线程上下文切换的成本:
- 当线程加锁失败后,内核会把线程的状态从运行状态设置为睡眠状态,然后把 CPU 切换给其他线程运行;
- 接着,当锁被释放时,之前睡眠状态的线程被其他线程唤醒,转换为就绪状态,然后内核会在合适的时机把 CPU 切换给该线程运行。
- 当两个线程属于同一个进程时,因为虚拟内存是共享的,所以在线程上下文切换时,虚拟内存这些资源保存不动,只需要切换线程的私有数据、寄存器等不共享的数据。
- 自旋锁是通过 CPU 提供的
CAS
函数(Compare And Swap),在用户态完成加上和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也会小一些。 - 一般加锁的过程包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行下一步;
- 第二步,将锁设置为当前线程持有;
- CAS 函数把这两个步骤合并成一条硬件级指令,形成原子指令。设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么
CAS(lock, 0, pid)
表示自旋锁的加锁操作,CAS(lock, pid, 0)
表示解锁操作。 - 自旋锁是最简单的一种锁,一直自旋,利用CPU周期,直到锁可用。在单核 CPU 上,需要 抢占式的调度器 (即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃CPU的控制权。
- 如果能确定被锁住的代码执行时间很短,就应该选用自旋锁,否则使用互斥锁。
(2)读写锁
- 读写锁适用于能明确区分读操作和写操作的场景,读写锁在 读多写少 的场景下,能发挥出优势。
- 写锁是独占锁,任意时刻只能有一个线程持有写锁,类似于互斥锁和自旋锁;而读锁是共享锁,多个线程能够同时持有读锁也不会破坏共享资源的数据,这大大提高了共享资源的访问效率。
- 根据实现不同,读写锁分为 读优先锁 和 写优先锁 。读优先锁期望读锁能够被更多的读线程持有,写线程可能会被饿死;写优先锁是优先服务写线程,读线程可能被饿死。
- 为了避免线程饥饿的问题,采用公平读写锁,用队列把获取锁的线程排队,不管是读线程还是写线程都按照先进先出的原则加锁,这样读线程仍然可以并发,也不会出现饥饿的现象。
(3)乐观锁与悲观锁
- 悲观锁比较悲观,它认为多线程同时修改共享资源的概率比较高,很容易出现冲突,所以访问共享资源之前,先要加锁。互斥锁、自旋锁、读写锁都属于悲观锁。
- 乐观锁比较乐观,它认为多线程同时修改共享资源的概率非常低,不容易出现冲突,所以乐观锁全程并没有加锁。它的工作方式是:先修改完共享资源,再验证这段时间内有没有冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
- 只有在冲突概率非常低,且加锁成本非常高的场景下,才考虑使用乐观锁。一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。
- 不管使用哪种锁,加锁的代码范围应尽可能的小,也就是加锁的粒度要小,这样执行速度就会比较快。
81. 一个进程最多可以创建多少个线程?
- 一个进程最多可以创建的线程数量跟两个东西有关:
进程的虚拟内存空间上限
,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间也就越大,那么虚拟内存就会占用的越多。系统参数限制
,有系统级别的参数限制整个系统的最大线程个数。
- 因此
- 对于
32
位的操作系统而言,用户态的虚拟空间只有3G,如果创建每个线程时分配10M大小的栈空间,那么一个进程最多只能创建300
个左右的线程。 - 对于
64
位的操作系统而言,用户态的虚拟空间大到有128T,理论上不会受虚拟内存大小的限制,但是会受到系统的参数或性能限制。
- 对于
82. 线程崩溃,进程一定会崩溃吗?
线程崩溃,进程不一定会崩溃。
一般来说,由于进程中的各个线程的虚拟地址空间时共享的,那么某个线程对地址的非法访问就会导致内存的不确定性,进而影响其他线程,因此线程崩溃会导致所属进程崩溃,但是,Java语言中的线程崩溃却不会造成进程崩溃。
在 Java 中由于非法访问内存而产生的常见错误是 StackoverflowError 和 NPE,JVM却没有崩溃的原因是虚拟机内部定义了信号处理函数,而在信号处理函数中对这两者做了额外的处理以让 JVM 不崩溃。如果 JVM 不对信号做额外的处理,最后会自己退出并产生 crash 文件,这个文件记录了虚拟机崩溃的重要原因。