进程、线程和协程
进程
程序由指令和数据组成,但程序要运行就要将指令加载进CPU以及数据加载进内存,并且在指令运行过程中可能还会用到磁盘、网络等设备。进程就是用来加载指令、管理内存和IO的。当一个程序被运行,从磁盘加载这个程序的代码至内存,就开启了一个进程。进程可以视为一个程序的实例,大部分程序可以同时运行多个实例进程,有的程序只能启动一个程序实例。
操作系统以进程为单位分配CPU时间片、内存等资源,进程是资源分配的最小单位。
进程之间可以通过以下几种方式进行通信:
- 管道(pipe)和有名管道(named pipe):管道(就是我们常用的“|”)可用于具有亲缘关系的父子进程间的通信,有名管道除了具有管道所具有的功能外,还允许无亲缘关系进程间的通信;
- 信号(signal):信号是在软件层次上对中断机制的一种模拟,是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果一致;
- 消息队列(message queue):消息队列是消息的链接表,克服了以上两种通信方式中中信号量有限的缺点,具有写权限的进程可以按照一定的规则向消息队列中添加新信息,具有读权限的进程可以从消息队列中读取消息;
- 共享内存(shared memory):是最有用的进程间通信方式,使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存数据的更新。这种方式需要依靠某种同步操作,例如互斥锁、信号量等;
- 套接字(socket):可用于网络中不同机器之间的进程间通信。
线程
线程是进程中的实体,一个进程可以拥有多个线程,一个线程必须有一个父进程。一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行。线程也被称为轻量级进程,是操作系统调度执行的最小单位。
同步互斥
线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。
线程互斥是指对于共享的进程系统资源,在各个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程使用,其他要使用该资源的线程必须等待,直到占用资源者释放资源。线程互斥可以看成是一种特殊的线程同步。
可以使用下面四种方法来控制线程的同步或互斥:
- 临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在一段时间内只允许一个线程访问的资源称为临界资源;
- 互斥量:可以协调共同对一个共享资源的单独访问;
- 信号量:可以控制一个具有有限数量的用户资源;
- 事件:用来通知线程有一些事件已发生,从而启动后继任务。
协程
协程(Coroutines)是一种基于线程之上但又比线程更加轻量级的存在,协程不是被操作系统内核管理,完全是由程序控制,仅是在用户态执行,对内核来说完全不可见。
协程相比于线程有以下优势:
- 线程的切换由操作系统调度,协程由用户自己调度,减少了上下文切换,提高了效率;
- 线程的默认大小是1M,协程更轻量,接近1K,因此可以在相同的内存中开启更多的协程;
- 协程不需要多线程的锁机制,因此只有一个线程,不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态,效率比多线程高很多。
协程适用于被阻塞的、需要大量并发的场景,不适用于大量计算的场景。
进程与线程的区别
- 进程是基本上相互独立的,线程存在于进程内,是进程的一个子集;
- 进程拥有共享的资源,如内存空间等,这些对进程内的线程是共享的;
- 进程间的通信较为复杂,其中同一台计算机的进程通信称为IPC(Inter-Process-Communication),不同计算机之间的进程通信,需要通过网络,并遵守共同的协议例如HTTP协议等;线程间的通信相对简单,因为它们共享进程内的内存,因此它们可以通过这块共享内存来进行通信;
- 线程更轻量,线程上下文切换成本一般比进程上下文切换成本低,但线程上下文切换也是重量级操作。
内核模式和用户模式
内核模式(Kernel Mode)
在内核模式下,执行代码可以完全且不受限制地访问底层硬件,可以执行任何CPU指令和引用任何内存地址。内核模式通常为操作系统的最低级别、最受信任的功能保留。内核模式下的崩溃是灾难性的,会让整个电脑崩溃。
用户模式(User Mode)
在用户模式下,执行代码不能直接访问硬件或引用内存,在此模式下运行的代码必须委托给系统API来访问硬件或内存。由于这种隔离提供的保护,用户模式下的崩溃总是可恢复的。我们电脑上的应用程序或代码大多都是在用户模式下运行。
用户模式和内核模式组件之间的通信可以表示如下图:
应用程序一般在以下几种情况下会从用户模式切换到内核模式:
- 系统调用:当应用程序执行一些包括执行文件操作、网络数据发送等在内的操作时,都必须通过系统调用进入内核态调用内核中的代码来完成操作;
- 异常事件:当发生某些预先不可知的异常时,就会切换到内核态,以执行相关的异常事件;
- 设备中断:在使用外围设备时,如果外围设备完成了用户请求,就会向CPU发送一个中断信号,此时CPU就会暂停执行原本要执行的下一条指令,转而去处理中断事件,此时就会从用户态切换到内核态。
CPU提供了Ring0~Ring3四种级别的运行模式,其中Ring3级别最低,Ring0级别最高,Linux使用Ring3级别运行用户态,Ring0作为内核态,Ring1和Ring2没有使用。Ring3不能访问Ring0的地址空间,包括代码和数据。
上下文切换
上下文切换是指CPU从一个进程或线程到另一个进程或线程的切换。其中上下文是指CPU寄存器和程序计数器在任何时间点的内容。寄存器是CPU内部的一小块速度非常快的内存,通过提供对常用值的快速访问来加快程序的执行。程序计数器是一种专门的寄存器,用于指示CPU在其指令序列中的位置,并保存正在执行的指令地址或下一条要执行的指令的地址,这取决于操作系统。寄存器存储的是数据,程序计数器存储的是指令地址,因此我们可以认为上下文存储的就是程序运行时数据和指令。
上下文切换可以更详细地描述为内核(操作系统核心)对CPU上的进程或线程执行以下活动:
- 暂停一个进程的处理,并将该进程的CPU状态(上下文)存储在内存中的某个地方;
- 从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它;
- 返回程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程的执行。
上下文切换只能在内核模式下发生。内核模式是CPU的特权模式,其中只有内核运行,并提供对所有内存位置和所有其他系统资源的访问。其他程序最初在用户模式下运行,但它们可以通过系统调用运行部分内核代码。
上下文切换是多任务操作系统的一个基本特性。在多任务操作系统中,多个进程似乎同时在一个CPU上执行,彼此之间互不干扰。这种并发的错觉是通过在快速连续发生的上下文切换(每秒数十次或数百次)来实现的。这些上下文切换发生的原因是进程自愿放弃它们在CPU中的时间,或是调度器在进程耗尽其CPU时间片时进行切换的结果。
上下文切换通过是计算密集型的,即计算量大。就CPU时间而言,上下文切换对系统来说是一个巨大的成本,实际上它可能是操作系统上成本最高的操作。因此,操作系统设计的一个主要焦点是尽可能地避免不必要地上下文切换。与其他操作系统相比,Linux的众多优势之一就是它的上下文切换和模式切换成本极低。
查看CPU上下文切换命令
vmstat
vmstat命令是Virstual Memory Statistics虚拟内存统计的缩写,可以用来监控CPU使用、进程状态、内存使用、虚拟内存使用、硬盘输入/输出状态等信息。该命令有一种使用格式如下:
vmstat 刷新延迟 刷新次数
例如我们执行vmstat 1 5,控制台会每隔1S打印一些信息,一共打印5次,打印结果如下:
其中各个字段与其对应的含义如下表:
信息类型 | 具体字段 | 含义 |
procs(进程信息) | r | 等待运行的进程数,数量越大表示系统越繁忙 |
b | 不可被唤醒的进程数,数量越大表示系统越繁忙 | |
memory(内存信息字段) | swpd | 虚拟内存的使用情况,单位为KB |
free | 空闲的内存容量,单位为KB | |
buff | 缓冲的内存容量,单位为KB | |
cache | 缓存的内存容量,单位为KB | |
swap(交换分区信息) | si | 从磁盘中交换到内存中数据的数量,单位为KB |
so | 从内存中交换到磁盘中数据的数量,单位为KB | |
io(磁盘读/写信息) | bi | 从块设备中读入的数据的总量,单位是块 |
bo | 写到块设备的数据的总量,单位是块 | |
system(系统信息) | in | 每秒被中断的进程次数 |
cs | 每秒进行的上下文切换次数 | |
cpu(CPU信息) | us | 非内核进程消耗CPU运算时间的百分比 |
sy | 内核进程消耗CPU运算时间的百分比 | |
id | 空闲CPU的百分比 | |
wa | 等待I/O所消耗的CPU百分比 | |
st | 被虚拟机所盗用的CPU百分比 |
pidstat
pidstat可以查看某一个进程或线程的上下文切换,选项和参数如下表:
选项 | 含义 |
-u | 默认参数显示各个进程的CPU统计信息 |
-r | 显示各个进程的内存使用情况 |
-d | 显示各个进程的IO使用情况 |
-w | 显示各个进程的上下文切换 |
-p | 执行PID |
例如我们要查看pid为30035的进程每2秒的上下文切换情况,可以使用pidstat -w -p 30035 2命令,结果如下图:
线程的生命周期
线程在不同的层面有不同的生命周期,主要可以分为操作系统层面和Java层面。
操作系统层面
操作系统层面的线程生命周期有五个状态,分别为:初始状态、可运行状态、运行状态、休眠状态和终止状态,这些状态之间的切换可以表示如下图:
- 初始状态:线程已经被创建但还不允许分配CPU执行。初始状态属于编程语言特有的,这里的被创建仅仅是在编程语言层面被创建,在操作系统层面真正的线程还没有创建;
- 可运行状态:线程可以分配CPU执行。在这种状态下,真正的操作系统已经被成功创建了,等待分配CPU即可执行;
- 运行状态:当有空闲的CPU时,操作系统会为其分配一个处于可运行状态的线程,被分配到CPU的线程就由可运行状态转换到了运行状态;
- 休眠状态:运行状态的线程如果调用了一个阻塞的API(例如以阻塞的方式读文件)或等待某个事件,线程的状态就会由运行状态转换到休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现,线程就会从休眠状态转换到可运行状态;
- 线程执行完活出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
在Java里把可运行状态和运行状态合并了,线程调度是由操作系统来处理的,因为JVM层面不需要关心这两个状态。
Java层面
Java层面线程的状态可以在Thread类中看到所有的六种状态,分别为:初始状态、运行状态、阻塞状态、等待状态、超时等待状态和终止状态,这些状态之间的切换可以表示如下图:
- 初始状态(NEW):创建一个Thread线程对象后,这个线程就处于初始状态;
- 运行状态(Runnable):当线程对象调用了start()方法后,线程就处于就绪状态,意味着线程可以执行,具体执行时机取决于操作系统对线程的调度。需要注意的是,不能对一个线程调用多次start()方法,且当线程执行完成后也不能用start将其唤醒;
- 阻塞状态(Blocked):线程的阻塞状态只会与synchronized有关,当线程等待进入synchronized方法或synchronized块时,该线程就会进入阻塞状态。当该线程取得锁后,就会从阻塞状态转换到运行状态(就绪状态);
- 等待状态(Waiting):当线程需要等待其他线程做出一些特定的动作(通知或中断)时就进入了等待状态;
- 超时等待状态(Timed_Waiting):与Waiting状态类似,只是Timed_Waiting在指定的时间会自行返回;
- 终止状态(Terminated):线程执行完毕后就进入了Terminated状态。
其中Blocked、Waiting和Timed_Waiting等同于操作系统层面线程的休眠状态,因此Java线程只要出于这三种状态之一,就永远不会有CPU使用权。
Java线程
线程可以分为内核级线程和用户级线程。
- 内核级线程(Kernel Level Thread,KLT):依赖于内核,即无论是用户进程中的线程还是系统进程中的线程,它们的创建、撤销和切换都需要内核来实现;
- 用户级线程(User Level Threa,ULT):操作系统内核不知道应用线程的存在。
Java线程属于内核级线程。
Thread.start()
我们都知道创建一个Thread线程对象后,需要执行start()方法才会创建一个线程,新的线程最终会执行Thread.run()方法,那么具体是如何实现的呢?
Thread#start()源码分析| ProcessOn免费在线作图,在线流程图,在线思维导图
线程调度机制
线程调度指的是系统为线程分配处理器使用权的过程,主要调度方式分为两种:协同式线程调度和抢占式线程调度。
协同式线程调度
线程执行时间由线程本身来控制,线程把自己的工作执行完后,需要主动通知系统切换到另一个线程上。优点是实现简单,且切换操作对线程本身是可见的,不会有线程同步问题。缺点是线程执行时间不可控,如果在执行的某个线程有问题,可能会一直阻塞。
抢占式线程调度
每个线程由系统来分配执行时间,线程的切换不由线程本身决定。线程的执行时间对系统来说是可控的,不会有因为一个线程导致整个进程阻塞的问题出现。
Java中的线程调度就是抢占式线程调度。如果希望系统可以为某些线程分配的时间多一些,另一些线程分配的时间少一些,可以通过设置优先级来完成。一共有10个级别的线程优先级,从1到10,数值越大表示优先级越高,默认的优先级级别是5。需要注意的是,优先级并不是绝对的,因为Java线程是通过映射到系统的原生线程来实现的,因此线程调度最终还是取决于操作系统,优先级只是或多或少的可以影响到一些。
Thread常用方法
sleep()
- 调用sleep()方法会让当前线程从Running状态进入Timed_Waiting状态,让出CPU资源,但不会释放对象锁;
- 其他线程可以调用interrupt()方法打断正在睡眠的线程,这时sleep()方法会抛出InterruptedException异常,并清除中断标志;
- 睡眠结束后的线程未必会立刻得到执行;
- sleep()方法传参为0时,其效果与yield()方法相同。
yield()
- yield()方法会释放CPU资源,让当前线程从Running进入Runnable状态,让优先级更高或与当前线程优先级相同的线程获得执行机会,但不会释放锁;
- 如果当前只有main线程在执行,调用yield()方法后,main线程会继续执行,因为没有比它优先级更高的线程;
- 具体的实现依赖于操作系统的任务调度器。
join()
当前程序需要等待调用join()方法的线程执行完成后才能继续执行,一般用于等待异步线程执行完结果之后才能继续运行的场景。
线程中断机制
stop()方法可以达到停止线程的目的,但此方式已经被JDK废弃了,原因是stop()方法太过于暴力,直接将线程终止,无论它处于何种状态。线程中断机制提供了比stop()方法更优雅的停止线程执行的方式。
线程中断机制是一种协作机制,即通过中断并不能直接终止一个线程,需要被中断的线程自己处理。被中断的线程拥有完全的自主权,既可以选择立即终止,也可以选择一段时间后再停止,也可以选择不停止。
Java API中为中断机制服务的方法有以下三个:
- interrupt():将线程的中断标志位设置为true,不会停止线程;
- isInterrupted():判断当前线程的中断标志位是否为true,不会清除标志位;
- Thread.interrupted():判断当前线程的中断标志位是否为true,并清除中断标志位,将其置为false。
例如在下面的代码中,在线程t中执行count++操作,启动线程t,随后将其中断标志位设置为ture,可以通过isIntertupted()方法来停止线程。
public class StopThread implements Runnable{
@Override
public void run() {
int count = 0;
while (!Thread.currentThread().isInterrupted() && count < 1000){
System.out.println("count = " + count++);
}
System.out.println("线程停止: stop thread");
}
public static void main(String[] args) {
Thread t = new Thread(new StopThread());
t.start();
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.interrupt();
}
}
执行结果如下图:
需要注意的是,使用中断机制时要注意某些情况下中断标志位会被清除。例如当线程中执行sleep()方法睡眠较长时间(几秒或更长)时,为该线程设置中断标志位为true,处于sleep状态的线程不会真的睡眠这么久,而是会感知到中断抛出sleep()方法抛出的InterruptedException异常,例如下面的程序。
public class ThreadInterruptTest {
static int i = 0;
public static void main(String[] args) {
System.out.println("begin");
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
i++;
System.out.println(i);
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//Thread.interrupted() 清除中断标志位
//Thread.currentThread().isInterrupted() 不会清除中断标志位
if (Thread.currentThread().isInterrupted() ) {
System.out.println("=========");
}
if(i==10){
break;
}
}
}
});
t1.start();
//不会停止线程t1,只会设置一个中断标志位 flag=true
t1.interrupt();
}
}
控制台打印结果:
可以看到没有“=====”被打印,因为中断标志位被sleep()方法清除了。由此可知,sleep()方法可以被中断,并抛出中断异常:sleep interrupted,清除中断标志位。与sleep()方法类似,wait()方法也可以被中断,抛出中断异常:InterruptedException,也会清除中断标志位。
线程间通信
Java线程之间可以通过volatile、等待唤醒机制、管道输入输出流和join()方法进行通信。
volatile
volatile有两大特性:可见性和有序性(禁止重排序),其中可见性也可以让线程之间进行通信。方式是将一共享变量使用volatile修饰,某一线程修改了这个共享变量,其他线程可以立即看到并做出动作,这就达到了线程间通信的目的。
等待唤醒机制
等待唤醒机制可以基于Object中的wait()方法和notify()方法或notifyAll()方法来实现,在一个线程内调用该线程锁对象的wait()方法,线程将进入等待队列等待直到被唤醒。
例如下面的代码中,第一个线程调用wait()方法进入等待队列,第二个线程睡眠2S后调用notify()方法将第一个线程唤醒并修改flag=false,第一个线程被唤醒后由于flag=false退出while循环,结束执行。
public class WaitDemo {
private static Object lock = new Object();
private static boolean flag = true;
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (lock){
while (flag){
try {
System.out.println("wait start .......");
//等待
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("wait end ....... ");
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
if (flag){
synchronized (lock){
if (flag){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//通知
lock.notifyAll();
System.out.println("notify .......");
flag = false;
}
}
}
}
}).start();
}
}
控制台打印结果:
但wait()和notify()方法实现的等待唤醒机制必须在synchronized方法或synchronized块中才能执行,并且当多个线程都进入等待队列时,notify()方法只能唤醒这多个线程中随机的一个。我们可以使用LockSupport类来替代wait()和notify()方法。
LockSupport是JDK中用来实现线程阻塞和唤醒的工具,线程调用park()方法则进入等待“许可”状态,调用unpark()方法则为指定的线程提供“许可”。它可以在任何场合使线程阻塞,可以指定任何线程进行唤醒,且唤醒和阻塞的顺序可以调换,即先为某个线程发放“许可”随后该线程知道park()方法仍不会阻塞。
LockSupport使用举例如下面程序。
public class LockSupportTest {
public static void main(String[] args) throws InterruptedException {
Thread parkThread = new Thread(new ParkThread());
parkThread.start();
Thread.sleep(2000);
//为指定线程parkThread提供“许可”
System.out.println("唤醒parkThread");
LockSupport.unpark(parkThread);
}
static class ParkThread implements Runnable{
@Override
public void run() {
System.out.println("ParkThread开始执行");
// 等待“许可”
LockSupport.park();
System.out.println("ParkThread执行完成");
}
}
}
控制台打印结果:
也可以先在main线程中为park线程发放“许可”,这样park线程不会阻塞,直接执行完成,例如下面的程序。
public class LockSupportTest {
public static void main(String[] args) throws InterruptedException {
Thread parkThread = new Thread(new ParkThread());
parkThread.start();
//为指定线程parkThread提供“许可”
System.out.println("唤醒parkThread");
LockSupport.unpark(parkThread);
}
static class ParkThread implements Runnable{
@SneakyThrows
@Override
public void run() {
System.out.println("ParkThread开始执行");
Thread.sleep(3000);
// 等待“许可”
LockSupport.park();
System.out.println("ParkThread执行完成");
}
}
}
控制台打印结果:
管道输入输出流
管道输入输出流与普通的文件输入输出流或网络输入输出流不同的地方在于,它主要用于线程之间的数据传输,且传输的媒介是内存。管道输入输出流主要包括以下四种实现:PipedOutputStream(字节输出流)、PipedInputStream(字节输入流)、PipedReader(字符输入流)和PipedWriter(字符输出流)。
join()
join()方法可以理解成是线程合并,当在一个线程中调用另一个线程的join方法时,当前线程阻塞等待被调用join()方法的线程执行完毕才能继续执行,所以join()方法的好处是能保证线程的执行顺序,但调用join()方法的线程和当前线程已经失去了并发的意义,虽然存在多个线程,但本质还是串行执行的。join()的实现是基于等待通知机制。