进程
什么是进程
- 狭义定义:进程是正在运行的程序的实例。
- 广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
- 进程的概念主要有两点:
- 进程是一个实体(程序段、相关的数据段、PCB):每一个进程都有它自己的地址空间,一般情况下,包括文本区域 $text\ region$、数据区域 $data\ region$ 和堆栈 $stack\ region$。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
- 进程是一个“执行中的程序”:程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。进程由创建而产生,由调度而执行,由撤销而消亡。
《计算机操作系统》第三版对进程的定义是:
- 进程是程序的一次执行。
- 进程是一个程序及其数据在处理机上顺序执行时所发生的活动。
- 进程是程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位。
进程的状态
三态模型
- 就绪($Ready$)状态
当进程已分配到除 $CPU$ 以外的所有必要资源后,只要再获得 $CPU$,便可立即执行,进程这时的状态称为就绪状态。在一个系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列。
- 执行状态
进程已获得 $CPU$,其程序正在执行。在单处理机系统中,只有一个进程处于执行状态;在多处理机系统中,则有多个进程处于执行状态。
- 阻塞状态
正在执行的进程由于发生某事件而暂时无法继续执行时,便放弃处理机而处于暂停状态,亦即进程的执行受到阻塞,把这种暂停状态称为阻塞状态,有时也称为等待状态或封锁状态。致使进程阻塞的典型事件有:请求$I/O$,申请缓冲空间等。通常将这种处于阻塞状态的进程也排成一个队列。有的系统则根据阻塞原因的不同而把处于阻塞状态的进程排成多个队列。
五态模型
在不少系统中进程只有上述三种状态,但在另一些系统中,又增加了一些新状态,最重要的是挂起状态。引入挂起状态的原因有:
- 终端用户的请求。当终端用户在自己的程序运行期间发现有可疑问题时,希望暂时使自己的程序静止下来。
- 父进程请求。有时父进程希望挂起自己的某个子进程,以便考查和修改该子进程,或者协调各子进程间的活动。
- 负荷调节的需要。当实时系统中的工作负荷较重,已可能影响到对实时任务的控制时,可由系统把一些不重要的进程挂起,以保证系统能正常运行。
- 操作系统的需要。操作系统有时希望挂起某些进程,以便检查运行中的资源使用情况或进行记账。
在目前实际的系统中,为了管理的需要,还存在着两种比较常见的进程状态,即创建状态和终止状态。
- 创建状态
创建一个进程一般要通过两个步骤:首先,为一个新进程创建 $PCB$,并填写必要的管理信息;其次,把该进程转入就绪状态并插入就绪队列之中。
- 终止状态
进程的终止也要通过两个步骤:首先等待操作系统进行善后处理,然后将其 $PCB$ 清零,并将 $PCB$ 空间返还系统。
增加了创建状态和终止状态后,具有挂起状态的进程状态及转换图。
进程同步方式
- 信号量机制
- 整型信号量
- 记录型信号量
- $AND$ 型信号量
- 信号量集
- 管程机制
管程由四部分组成:① 管程的名称;② 局部于管程内部的共享数据结构说明;③ 对该数据结构进行操作的一组过程;④ 对局部于管程内部的共享数据设置初始值的语句。
进程通信方式
- 管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
- 有名管道(Names Pipes) : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循先进先出(first in first out)。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
- 信号(Signal) :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;
- 消息队列(Message Queuing) :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。
- 信号量(Semaphores) :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
- 共享内存(Shared memory) :使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
- 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
进程调度算法
- 先到先服务(FCFS)调度算法 : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
- 短作业优先(SJF)的调度算法 : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
- 时间片轮转调度算法 : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法,又称 RR(Round robin)调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
- 多级反馈队列调度算法 :前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成。,因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法。
- 优先级调度 : 为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。
线程
进程与线程的区别
进程是一个独立的运行环境,而线程是在进程中执行的一个任务。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如I/O):
- 进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
- 进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
- 进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。
另外一个重要区别是,进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即 $CPU$ 分配时间的单位 。
《深入理解Java虚拟机》第三版中提到,一种名为纤程的轻量级线程正在逐渐成熟。
进程与线程的关系
- $Java$ 对操作系统提供的功能进行封装,包括进程与线程。
- 运行一个程序会产生一个进程,一个进程至少包含一个线程。
- 每个进程对应一个 $JVM$ 实例,多个线程共享 $JVM$ 里的堆。
- $Java$ 采用单线程编程模型,程序会自动创建主线程。
- 主线程可以创建子线程,原则上要晚于子线程完成执行。
线程同步的方式
为使系统中的多线程能有条不紊地运行,在系统中必须提供用于实现线程间同步和通信的机制。为了支持不同频率的交互操作和不同程度的并行性,在多线程 $OS$ 中通常提供多种同步机制,如互斥锁、条件变量、计数信号量以及多读、单写锁等。
- 互斥锁($mutex$)
互斥锁可以有开锁(unlock
)和关锁(lock
)两种状态。相应地,可用两条命令(函数)对互斥锁进行操作。其中的关锁lock
操作用于将mutex
关上,开锁操作unlock
则用于打开mutex
。
当一个线程需要读/写一个共享数据段时,线程首先应为该数据段所设置的mutex
执行关锁命令。
- 命令首先判别
mutex
的状态,如果它已处于关锁状态,则试图访问该数据段的线程将被阻塞;而如果mutex
是处于开锁状态,则将mutex
关上后便去读/写该数据段。 - 在线程完成对数据的读/写后,必须再发出开锁命令将
mutex
打开,同时还须将阻塞在该互斥锁上的一个线程唤醒,其它的线程仍被阻塞在等待mutex
打开的队列上。
条件变量
每一个条件变量通常都与一个互斥锁一起使用,亦即,在创建一个互斥锁时便联系着一个条件变量。单纯的互斥锁用于短期锁定,主要是用来保证对临界区的互斥进入。而条件变量则用于线程的长期等待,直至所等待的资源成为可用的资源。信号量机制
就是信号量机制。
线程创建
- 继承
Thread
类- 重写
Thread::run
; - 调用
Thread::start
。
- 重写
- 实现
Runnable
接口,无返回值。- 获取
Runnable
接口的实现类,作为参数,创建Thread
; - 执行
Thread::start
。
- 获取
- 实现
Callable
接口,结合FutureTask
使用,有返回值。- 以
Callable
的实现类为参数,创建FutureTask
; - 将
FutureTask
作为Thread
的参数,创建Thread
; - 通过
Thread::start
启动线程; - 通过
FutureTask::get
阻塞获取线程返回值。
- 以
- 利用线程池
Executor
- 创建
Callable
或Runnable
任务,提交到线程池; - 通过返回的
Future::get
获取返回结果。
- 创建
线程状态
- $NEW$:新建,创建后尚未启动。
- $RUNNABLE$:运行,包含 $Running$ 和 $Ready$
- $BLOCKED$:阻塞,等待获取排它锁
- $WAITING$ :等待,不会被分配 CPU 时间,需要显示被唤醒
- $TIMED-WAITING$ :限期等待,在一定时间后会由系统自动唤醒
- $TERMINATED$: 终止,已终止线程的状态,线程已执行结束。
start() 与 run()
start()
: 通过start()
方法来启动的新线程,处于就绪状态,并没有运行,一旦得到 $CPU$ 时间片,就开始执行相应线程的run()
方法,这里方法run()
称为线程体,它包含了要执行的这个线程的内容,run()
方法运行结束,此线程随即终止。start()
不能被重复调用。用start()
方法来启动线程,真正实现了多线程运行,即无需等待某个线程的run()
方法体代码执行完毕就直接继续执行下面的代码,即进行了线程切换。run()
:run()
就和普通的成员方法一样,可以被重复调用。如果直接调用run()
方法,并不会启动新线程!程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run()
方法体执行完毕后才可继续执行下面的代码,这样就没有达到多线程的目的。- 总结:调用
start()
方法会创建一个新的子线程并启动,run()
方法只是Thread
的一个方法调用,还是在原线程里运行。
如何给run()
方法传参
构造函数传参、成员变量传参、回调函数传参。
sleep() 与 wait()
sleep()
是Thread
类的方法,wait()
是Object
类的方法。sleep()
方法可以在任何地方使用;wait()
方法只能在synchronized
方法或synchronized
块中使用。Thread.sleep()
: 只会让出$CPU$,不会导致锁行为的改变;Object.wait()
: 不仅让出 $CPU$,还会释放已经占有的同步资源锁。
notify() 与 notifyAll()
锁池($Entry\ List$):假设线程 $A$ 已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个
synchronized
方法(或者synchronized
块),由于这些线程在进入对象的synchronized
方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程 $A$ 拥有,所以这些线程就进入了该对象的锁池中。等待池($Wait\ Set$): 假设一个线程 $A$ 调用了某个对象的
wait()
方法,线程 $A$ 就会释放该对象的锁(因为wait()
方法必须出现在synchronized
中,这样自然在执行wait()
方法之前线程 $A$ 就已经拥有了该对象的锁),同时线程 $A$ 就进入到了该对象的等待池中,进入到等待池中的线程不会去竞争该对象的锁。如果另外的一个线程调用了相同对象的notifyAll()
方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的notify()
方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池。notify()
: 只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会。notifyAll()
: 会让所有处于等待池中的线程全部进入锁池去竞争获取锁的机会。
yield()
当调用 Thread.yield()
方法时,会给线程调度器一个当前线程愿意让出 $CPU$ 使用的暗示,但是线程调度器可能会忽略这个暗示。
如何实现处理线程的返回值
- 主线程等待
- 使用
Thread::join
方法阻塞当前线程以等待子线程处理完毕 - 通过
Callable
接口实现:通过FutureTask<>
或者Executor
获取
如何中断线程
- 调用
interrupt()
,通知线程应该中断了- 如果线程处于被阻塞状态,那么线程将立即退出被阻塞状态,并抛出一个
InterruptedException
异常。 - 如果线程处于正常活动状态,那么会将该线程的中断标志设置为
true
,被设置中断标志的线程将继续正常运行,不受影响。
- 如果线程处于被阻塞状态,那么线程将立即退出被阻塞状态,并抛出一个
线程池
为什么要使用线程池
线程池提供了一种限制和管理资源(包括执行一个任务), 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度:当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池的使用其实非常广泛,比如MySQL
和Tomcat
为客户端连接准备的线程池。
线程池集合
上图中有三个重要的 Executor
接口
Executor
:运行新任务的简单接口,将任务提交和任务执行细节解耦。ExecutorService
:具备管理执行器和任务生命周期的方法,提交任务机制更完善。ScheduledExecutorService
:支持Future
和定期执行任务
线程池参数
1 | public ThreadPoolExecutor(int corePoolSize, |
阻塞队列
BlockingQueue workQueue:阻塞队列,维护着等待执行的Runnable
任务对象。
常用的几个阻塞队列:
LinkedBlockingQueue:链式阻塞队列,底层数据结构是链表,默认大小是
Integer.MAX_VALUE
,也可以指定大小。ArrayBlockingQueue:数组阻塞队列,底层数据结构是数组,需要指定队列的大小。
SynchronousQueue:同步队列,内部容量为0,每个put操作必须等待一个take操作,反之亦然。
DelayQueue:延迟队列,该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素 。
线程工厂
创建线程的工厂 ,用于批量创建线程,统一在创建线程时设置一些参数,如是否守护线程、线程的优先级等。如果不指定,会新建一个默认的线程工厂。
1 | static class DefaultThreadFactory implements ThreadFactory { |
饱和策略
线程池饱和时的拒绝策略
AbortPolicy
: 直接抛出异常,这是默认策略CallerRunsPolicy
: 用调用者所在的线程来执行任务DiscardOldestPolicy
: 丢弃队列中最靠前的任务,并执行当前任务DiscardPolicy
: 直接丢弃任务- 实现
RejectedExecutionHandler
接口的自定义Handler
线程池工作流程
处理任务的核心方法是execute()
1 | // JDK 1.8 |
新任务提交 execute
执行后的判断
- 如果运行的线程少于
corePoolSize
,则创建新线程来处理任务,即使线程池中的其它线程是空闲的。 - 当
workQueue
未满,线程池中的线程数量大于等于corePoolSize
且小于maximumPoolSize
,将任务加到workQueue
中进行等待,等待空闲coreThread
来处理任务。 - 当
workQueue
已满,并且正在运行的线程数量大于等于corePoolSize
并且小于maximumPoolSize
,才创建新的线程去处理任务。 - 如果运行的线程数量大于等于
maximumPoolSize
,这时如果workQueue
已经满了,则通过Handler
所制定的策略来处理任务。
线程复用
当一个线程被创建的时候会被指定一个任务,当执行完这个任务之后线程会自动销毁。但是线程池可以复用线程,也就是线程在执行完任务后不被销毁,继续执行其他的任务。那么,线程池是如何做到线程复用的呢?
其实,ThreadPoolExecutor
在创建线程时,会将线程封装成工作线程worker
,并放入工作线程组中,然后这个工作线程worker
反复从阻塞队列中取任务执行。
瞥一眼工作线程Worker
1 | private final class Worker extends AbstractQueuedSynchronizer implements Runnable { |
线程池的状态
RUNNING
: 线程池刚创建后的状态,能接受新提交的任务,并且也能处理阻塞队列中的任务。SHUTDOWN
: 调用shutdown()
方法后,不再接受新提交的任务,但可以处理存量任务。STOP
: 调用shutdownNow()
方法后,不再接受新提交的任务,也不再处理存量任务。中断所有线程,阻塞队列中没有执行完的任务全部丢弃。TIDYING
: 所有的任务都已终止,ctl
记录的任务数量为 $0$,接下来会执行terminated()
进入$TERMINATED$ 状态。TERMINATED
:terminated()
方法执行完后进入该状态。
线程池的大小如何选定
- $CPU$ 密集型:线程数 $=$ 核数或者核数 $ + \ 1$
- $IO$ 密集型:线程数 $=$ $CPU$核数 $\times$ ($1 +$ 平均等待时间/平均工作时间)
常见的线程池
Executors
类提供了几个静态方法用来创建线程池,但是在实际项目中并不推荐直接使用,而是采用手动创建的方式。这样能够提醒创建者更加明确实际任务,避免资源耗尽的风险。
newSingleThreadExecutor()
1 | public static ExecutorService newSingleThreadExecutor() { |
有且仅有一个核心线程(corePoolSize == maximumPoolSize == 1
),使用了LinkedBlockingQueue
(容量很大),所以,不会创建非核心线程,所有任务按照先来先执行的顺序执行。如果这个唯一的线程不空闲,那么新来的任务会存储在任务队列里等待执行。
newCachedThreadPool()
1 | public static ExecutorService newCachedThreadPool() { |
运行流程:
- 将任务提交到线程池;
- 因为
corePoolSize = 0
,不必创建核心线程,maximumPoolSize
为Integer.MAX_VALUE
,即2147483647
; - 尝试将任务添加到
SynchronousQueue
队列; - 如果入队成功,等待被空闲线程拉取并执行,如果没有空闲线程,那么创建一个非核心线程,然后从队列中拉去任务并执行;
- 如果队列中已有任务在等待,入队操作将被阻塞。
当需要执行很多短时间的任务时,CacheThreadPool
的线程复用率比较高, 会显著地提高性能。而且线程 $60s$ 后会回收,意味着即使没有任务进来,CacheThreadPool
并不会占用很多资源。
newFixedThreadPool()
1 | public static ExecutorService newFixedThreadPool(int nThreads) { |
corePoolSize == maximumPoolSize
,都为传入参数nThreads
。所以只能创建核心线程,不能创建非核心线程。因为LinkedBlockingQueue
的默认大小为Integer.MAX_VALUE
。所以如果核心线程空闲,则交给核心线程处理,否则入队等待。等有核心线程空闲时进行处理。
与CachedThreadPool
的区别:
corePoolSize == maximumPoolSize
,只创建核心线程;而CachedThreadPool
的corePoolSize == 0
,所以只会创建非核心线程。getTask()
方法获取任务时:线程会阻塞在LinkedBlockingQueue.take()
,线程不会被回收;CachedThreadPool
会在 $60s$ 后被回收。- 由于线程不会被回收,会一直卡在阻塞,所以没有任务的情况下,
FixedThreadPool
占用资源更多。 - 都几乎不会触发拒绝策略,但是原理不同。
FixedThreadPool
是因为阻塞队列可以很大,故几乎不会触发拒绝策略;CachedThreadPool
是因为线程池很大,几乎不会导致线程数量大于最大线程数,故几乎不会触发拒绝策略。
newScheduledThreadPool()
创建一个定长线程池,支持定时及周期性任务执行。
1 | public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { |
newWorkStealingPool()
1 | public static ExecutorService newWorkStealingPool(int parallelism) { |
- 内部会构建
ForkJoinPool
,利用work-stealing
算法,并行地处理任务,不保证处理顺序。 Fork/Join
框架:把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大任务结果的框架。
扩展阅读:
- 深入理解 Java 线程池:ThreadPoolExecutor
- 《计算机操作系统》 第三版 汤子瀛