Java学习 - 进程,线程,协程

本文最后更新于 2024年12月5日 下午

进程(Process)

直观来看,在打开你的任务管理器时,出现的窗口上面就会有“进程”二字。

进程

由此可以看出很多东西:

  • 进程间相互独立(关闭一个进程时,其他进程没有受到影响)
    • 每个进程都分配了各自的内存空间(内存隔离),来避免干扰。
    • 一个进程无法直接访问,修改或调用另一个进程的内容。
    • 分布式系统中,如果两端之间进行了某种通信连接,那么在某一端崩溃时需要进行错误处理来避免连带另一端发生错误。

总的来说,进程就是一个程序的生命周期。

进程间如何相互通信?

即便是在同一台机器中,进程间也无法直接访问到对方的内存空间,而是需要发出请求并且获得回复。现在有很多种通信方式,举一个简单的例子就是使用TCP/IP连接。可以尝试自己写一个服务器和一个客户端,然后分别运行他们,让他们之间互相通信。这也就是一种进程间通信的方法。

现实场景中,也许更多地会用到管道(Pipe)、消息队列(Message Queue)、共享内存(Shared Memory)和信号量(Semaphore)等进程间通信的方式。

以管道为例,管道是一种半双工的通信方式,它可以在具有亲缘关系(如父子进程)的进程之间传递数据。一个进程向管道写入数据,另一个进程从管道读取数据,这个过程就像是两个房间之间通过一个管道来传递物品,而不是直接进入对方的房间(内存空间)拿东西。

共享内存机制虽然允许不同进程访问同一块物理内存区域,但进程并不能随意访问,需要通过操作系统提供的接口进行申请和管理。例如,在使用共享内存时,进程需要先请求操作系统分配共享内存段,然后通过映射等操作才能访问,并且在访问过程中还要考虑同步和互斥问题,以防止数据不一致。

线程(Thread)

假想一下,如果一个进程中只能从头到尾运行一个任务,而无法执行别的动作。也许可以想到,对于同一个系统,可以多创建一些进程,并让他们之间相互协作。但是,进程的创建往往伴随着新的内存空间的划分,进程之间的通信往往也是非常耗时,在面对一些需要频繁进行信息交换和修改,或有大量同时运行的系统时似乎会非常难用。因此,我们可以在同一个进程中使用多线程来解决这些问题。

然而,对于计算机来说,单核CPU每一次只能处理一个任务,因此我们看到的(单核CPU)并行任务实际上是极快的CPU处理速度和任务切换速度带来的假象。

既然多线程可以解决这些问题,那么可以看出,线程具备以下特点

  • 一个进程中可以有多个线程
  • 线程可以直接访问同一个内存空间(共享堆和方法区资源

此外,线程当然也会有属于自己的东西:

  • 程序计数器: 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指
  • 虚拟机栈 虚拟机栈用于存储线程执行方法时的栈帧。栈帧是一个内存区域,它包含了局部变量表、操作数栈、动态连接、方法出口等信息。当一个方法被调用时,就会创建一个对应的栈帧并压入虚拟机栈;当方法执行完成时,栈帧会出栈。
  • 本地方法栈: 本地方法栈与虚拟机栈非常相似,它主要用于为本地(Native)方法服务。本地方法是指用非 Java 语言(如 C 或 C++)编写的,通过 Java 本地接口(JNI)可以被 Java 程序调用的方法。本地方法栈为这些本地方法的执行提供了类似于虚拟机栈的支持,存储本地方法调用的相关信息,如本地方法的参数、局部变量等。

综上,线程是一个比进程更加轻量级的执行单位

线程的生命周期

  • NEW: 初始状态,线程被创建出来但没有被调用 start()

  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。

  • BLOCKED:阻塞状态,需要等待锁释放。

  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。

  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。

  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程生命周期

  • 任何的Waiting\texttt{Waiting}状态都可以在被其他线程的notify方法通知后进入Runnable\texttt{Runnable}状态。

  • Waiting\texttt{Waiting}Time_Waiting\texttt{Time\_Waiting}状态唯一的不同就是Time_Waiting\texttt{Time\_Waiting}多了一个时限,在Java中声明sleep(long millis)wait(long millis)时会进入该状态,并且在超时时进入Runnable\texttt{Runnable}状态。

  • Blocked\texttt{Blocked}状态是指在线程进入到了被锁定的方法块中时,由于没有持有对应的锁导致的阻塞状态。

综上可见,线程的运行经常会伴随着大量的状态切换。在同一个CPU在某一时刻只能运行一个线程的情况下,CPU也需要灵活地切换当前的状态来知道自己在执行哪一个线程的什么内容。但这里会出现一个问题:CPU如何知道这个线程任务在上一次暂停时运行到哪里了?

上下文切换

在单核 CPU 中,多线程或多进程表现出的并行效果是通过时间片轮转(分时复用)机制实现的。操作系统会为每个线程或进程分配一个时间片,例如每个时间片为 10 - 20 毫秒。

当一个线程的时间片用完后,CPU 会暂停该线程的执行,保存其上下文(包括程序计数器、寄存器等状态),然后切换到下一个线程执行。由于 CPU 切换任务的速度非常快,在人的感知或者宏观层面上,就好像这些线程或进程是在同时运行。

因此,可以想一下有哪些情况会导致一个线程停止:

  • 线程调用了wait()sleep()方法
  • 线程的时间片用完了
  • 线程被阻塞
  • 线程崩溃中止

前三个会需要程序存储线程当前的运行状态,造成上下文切换。而最后一个不需要。

多线程问题

多线程通常是并发任务,在访问和修改公共数据时也许会因为CPU调度问题出现无法预料的同步问题,这便是造成线程不安全问题的主要原因。因此,我们经常会讨论到线程安全问题,并且提出各种解决方案。

先说一下基本概念:

  • 线程安全: 一个数据,不论有多少线程同时对其进行访问和修改,都能够保证这个数据的正确性和一致性。
  • 线程不安全: 一个数据,在多线程环境下,多个线程同时对其进行访问和修改时,可能导致了数据错误,丢失和不符合预期的结果。

一个线程不安全的简单例子就是:对于一个数据a = 1,有两个线程,线程A要读取这个数据,另一个线程B要修改这个数据为2。假设程序中我是先运行的线程A,后运行的线程B。那么我的预期就应该是A中会获取到a = 1,B会将a修改。 但是由于出现了某些问题,线程A在准备读取数据前,线程B先完成了修改,那么线程A便获取到了a = 2。这不符合预期,也就造成了现成不安全问题。

如何解决这个问题?实际上可以很轻松地想到,这类操作一次只让一个线程执行不就好了吗?

这个解法,基本上就是我们最常见的synchronized或者是锁了。但是现实应用当中,这个解法也会带来一些问题,比如占用资源,运行效率低,死锁问题等等。因此,我们也需要想出一些别的解决方法来处理这类问题。

线程池

每一个线程的创建的销毁都伴随着一定的资源消耗,对于有大量并发的场景来说,频繁地创建和销毁线程会对系统带来极大的负担,一个个独立的线程也难以管理。因此,可以考虑使用线程池来解决这个问题。

线程池的好处

  • 方便管理
  • 节省资源
  • 提高响应速度

实际上,基本所有的好处要么就是因为线程池本身可以被理解为一个管理多个线程的类,符合面向对象的编程思想;要么就是省去了大量且频繁地创建和销毁流程。

线程池的创建

Java中常见的创建线程池的方法为:

  • ThreadPoolExecutor()
  • Executor框架的Executors工具
  • 自定义线程池

前两个比较常用,想要折磨一下自己或者想要充分了解线程池的一些内部细节和可能面对的问题则可以用考虑自己从头自定义线程池。

不过要注意,Executor框架中一些常用的Executors工具并不会保证程序不会出现OOM(Out-Of-Memory 内存耗尽)问题,因为它们虽然维持了一个固定量的核心线程池,但是在超过了这个核心线程池上限之后,也并不是说一定就不会再创建新线程,而是会做一些别的事情。这需要从线程池创建的流程中讲起。

线程池创建流程和潜在问题

以下为ThreadPoolExecutor的构造函数和相关参数[1]

1
2
3
4
5
6
7
8
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {...}

线程池包含了以下关键参数:

  • 核心线程数
  • 等待队列最大长度
  • 最大线程数
  • 拒绝策略

线程创建流程:

线程创建流程

在某一个核心线程执行完毕进入空闲状态时,会检查等待队列中是否有任务,如果有,则为这个任务分配一个线程并执行。

当一个非核心线程执行完毕后,会空闲一段keepAliveTime时间。如果在这段时间中一直没有新的任务请求非核心线程,则会被销毁。

拒绝策略

  1. ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。

  2. ThreadPoolExecutor.CallerRunsPolicy: 直接创建一个线程并执行新任务。也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。

  3. ThreadPoolExecutor.DiscardPolicy:不管了,摆了,直接丢掉

  4. ThreadPoolExecutor.DiscardOldestPolicy:也是摆了,但是是丢掉最早没有执行的任务请求

可以看出,1,3,4 都会维持一个最大线程量,而2则不会维持这个最大线程,所以也许会为程序带来一些延迟或者无法预料的问题。

协程(Coroutine)

Coroutine这个词挺有意思,routine可以是一个程序,co一般代表“合作”或者“多”的意思,两者加起来就成了协程。

协程(Coroutine)是一种比线程更加轻量级的用户态执行单元。它可以在一个线程内实现类似多任务的调度,允许在函数执行过程中暂停,然后在需要的时候恢复执行。

跟线程比起来,线程的切换会涉及到用户态和内核态的切换,开销相对较大。协程则是在用户态中调度,有着极低的切换成本。

协程提供了一种类似于顺序编程的并发模型,使得开发者可以用同步的思维方式来编写异步代码。通过使用特定的关键字(如 Python 中的async/await),开发者可以很方便地在函数执行过程中暂停协程,等待某个异步操作完成后再继续执行。不难发现,由于协程是在一个线程下工作,不存在多线程同时访问资源的问题,也就不会出现同步问题(因为是线性的,顺序性的执行)。

既然协程对比线程有这么多的关键优势,为什么没有大量使用协程?

  • 协程在同一个线程下运行,在多核CPU环境的今天,无法充分发挥多核的优势。

  • 现有的系统大多数都是在多进程和多线程架构构建的,将整个系统转化为协程也许会需要将整个系统重写,成本高昂。

  • 不是所有的编程语言都原生支持协程。虽然一些现代语言(如 Python、Go 等)对协程有很好的支持,但还有许多其他语言可能没有内置的协程机制,或者支持得不够完善。在这些语言中使用协程可能需要引入第三方库,而这些库的质量、性能和兼容性可能参差不齐。

  • 即使在支持协程的语言中,一些现有的软件框架和库可能没有针对协程进行优化。

  • 协程的调试相对复杂,因为其执行流程是非线性的,可能会在多个地方暂停和恢复。目前,许多调试工具是基于传统的顺序编程或者多线程编程模型设计的,对于协程的调试支持有限。

总的来说,似乎就是生态不够完善。

引用


Java学习 - 进程,线程,协程
http://example.com/2024/12/04/Java学习/Java学习 - 进程,线程,协程/
作者
Clain Chen
发布于
2024年12月4日
许可协议