进程、线程和协程

进程是操作系统进行资源分配的一个基本单位,像启动一个springboot项目,操作系统会为它创建一个进程,这个进程会单独占用一部分系统资源。而在进程中可以创建多个线程,也就是刚刚的springboot项目,假设我们cpu是8核的,一个cpu核心可以执行一个线程。那我就可以创建八个线程,每个线程会通过指令去通过CPU执行指令。协程是jdk19后面才有的,也叫虚拟线程。它更加的轻量化,它是用户态的一个资源分配的基本单位,一个线程内部可以有多个协程,在JVM中进行创建和销毁。还有个共享变量的问题,进程之间不共享全局变量,线程之间能共享进程的堆和方法区资源,每个线程有自己的程序计数器虚拟机栈和本地方法栈,协程能共享线程和进程的共享变量。

image.png

  • 进程是操作系统进行资源分配的基本单位。比如我们启动一个 Java 程序(双击 jar 包或用java命令运行),操作系统会为它创建一个进程,这个进程会独占一部分系统资源 —— 比如独立的内存空间(堆、方法区等)、文件描述符、CPU 时间片配额等。可以理解为 “一个正在运行的程序实例”。

  • 线程是进程内的执行单元,也是操作系统调度的基本单位。一个进程至少有一个线程(主线程),也可以创建多个线程,这些线程共享该进程的所有资源(比如内存、文件句柄),但每个线程有自己独立的执行路径(比如虚拟机栈、程序计数器)。可以理解为 “进程内的一个任务执行者”。

  • 协程是用户态的轻量级 “执行单元”,由程序(而非操作系统)自身管理调度。一个线程内部可以有多个协程,java中是由JVM进行创建和销毁的。

再从资源占用和开销来看,差异很明显:

  • 进程的资源是 “独立且厚重的”:每个进程有自己独立的内存空间,互不干扰,所以创建、销毁进程时,操作系统需要分配 / 回收大量资源(比如内存、IO 句柄),开销很大,启动速度慢。

  • 线程的资源是 “共享且轻量的”:线程共享所属进程的内存和资源,自己只需要少量私有资源(比如栈空间、程序计数器),所以创建、销毁线程的开销小得多,启动速度快(通常比进程快 10-100 倍)。

并发、并行和串行

并发就是在操作系统中,安装了多个程序,并发就是同一个时间段在宏观上多个程序同时运行。但是单核CPU,一个时刻只能执行一个程序,所以在微观上这些程序是切换交替运行的,那么并行就是比如4个程序运行在一个4核心的cpu操作系统中,他们是可以同时执行。串行就是当一个程序执行完了,再去执行下一个程序。

单核CPU如何处理多线程

通过时间片,之前说过一个核心只能处理一个线程。有时候我们其实观察到,比如安装虚拟机的时候只分配了一个核心,但是我们可能会部署mysql、redis、jar包,这明显不是单线程。在宏观上我们认为他们是同时执行的。但是其实在微观上并不是,依然是切换执行。它使用了时间片轮转算法,比如每一个时间片是1ms,实际是由操作系统内核设定,第一个时间片用来执行mysql程序了,1ms到了,切换到redis,依然给他1ms,1ms又到了,切换到jar。当然实际上并不是按照顺序来决定切换哪个线程的(调度算法)。这就涉及到几个问题,首先是频繁切换线程,肯定会消耗CPU资源。第二在每个线程切换的时候会涉及到线程上下文切换,CPU寄存器和程序计数器的内容会先保存,再切换,再恢复,极其消耗资源。所以单核CPU处理多线程瓶颈很大。

创建线程的方式

创建线程的方式是有三种,第一种继承Thread类,并重写run方法。但是继承只能继承一个类,所以一个类又要继承其他类又要创建线程,就要通过接口的形式,第二种实现runnable接口,重写run方法。但是这种方法也有个弊端,run方法是void,没法获取返回值。第三种是实现callable接口,这个接口可以抛异常和返回值,我们要先重写它的call方法。并将实例对象传到futureTask,因为futureTask实现了runnable接口,再将futureTask传入Thread构造器创建线程。可以通过futureTask获取创建的线程中call方法的返回值。

start和run区别

start在被线程执行的时候会调用本地方法,这里是由C++或者C写的,它会帮我们调度操作系统创建新的线程,操作系统会给新线程分配资源(程序计数器,虚拟机栈),把该线程状态改成就绪,等待cpu分配时间片,再通过JVM回调到Thread中的run方法,再执行run方法中的逻辑。所以我们线程也可以直接调用run方法。但是还是有区别的,首先是start方法是启动线程,而run方法只是执行线程中重写run方法的逻辑,它会被当前所在线程调用。第二start方法是异步的,当程序执行start方法,不会等待执行完毕,程序计数器会直接执行下一行字节码,而run方法是同步的需要等待线程执行完毕。start方法会创建一个新的线程,run方法只是执行了逻辑并不会创建新线程。

sleep

sleep会让对应的线程暂停指定时间(期间不参与 CPU 调度,主动释放资源),这个特性虽然不常用,但在 Web 服务器中常配合while(true)死循环,目的是让主线程保持存活(防止 JVM 退出),同时不占用 CPU 资源,让 CPU 可以专注调度 Tomcat 的工作线程处理请求。在 Spring Boot 内嵌 Tomcat 的源码中,我观察到启动后主线程会进入一个每 10 秒sleep一次的死循环,这正是为了实现上述效果 —— 主线程 “休息” 时,CPU 会优先处理请求相关的线程。

tomcat服务器启动

这里细说一下。Spring内嵌tomcat服务器怎么通过sleep做到服务一直挂起的。当通过SpringApplication.run启动主线程的时候,去启动tomcat的线程服务,tomcat线程服务内部通过while(true)加sleep,一直挂起,假设分配十秒的睡眠时间,这样主线程会保持启动。同时每十秒springboot会判断内嵌tomcat服务器的启动状态,比如遇到了服务超时,启动失败,都可以抛出异常让主线程停止,同时tomcat服务器停止,抛出停止原因。Spring2.x同样也可以通过守护线程+await方法,做到保持主线程不退出,内嵌的tomcat服务器持续运行。Spring创建一个守护线程,守护线程调用tomcat服务器线程的await方法。当主线程停止,守护线程也会停止,tomcat线程也就会停止等待进入终止了。

join

这里有两种用法,第一种假设创建了一个线程t1。我在主线程先启动t1线程,t1.start——t1.join.后面才是主线程的代码。我们知道如果没有join,正常会由线程调度器给t1和主线程分配时间片,决定执行谁一会再执行另一个线程一会。但是在主线程加了t1.join,主线程就会等待t1执行完毕,再执行主线程的代码。
第二join方法中可以添加一个long参数,当添加了long参数,依旧拿刚刚的情况举例子,表示主线程愿意等待t1的时间,我个人这么理解的,假如设置的1s。如果t1执行的时间一共会是5s,那么一秒过后,主线程就不等了,线程执行顺序就有线程调度器来分配时间片决定了。如果t1执行的时间小于1s,那么t1执行完以后主线程执行。就和不添加long参数一样了。

线程的几种状态

我看过Thread源码中的一个state的枚举类,里面是有六种,我按照线程的执行的经过顺序说一下。首先是NEW状态,这个是线程被创建了但是没有执行start方法的时候,此时是NEW状态,当线程执行start方法会进入RUNNABLE状态,也就是运行时。如果线程1和线程2竞争锁资源了,没拿到锁资源的线程会进入阻塞BLOCKED状态。如果线程在执行run方法的时候,其中有join、wait(obejct)等方法,该线程是WAITING等待状态。如果线程在执行run方法的时候,其中有sleep、wait或者join都加了时间参数,join的话线程执行时间要大于它设置的等待参数。这个时候就会进入TIME_WAITING。线程如果执行完毕就进入TERMINATED终止状态

线程的痛点

  1. 线程的频繁创建和销毁
    d8b58dfa-6629-453b-a2c3-78f9efb9b7fd.png
  2. 线程不断的创建出现的两个问题

内存溢出(OOM):每个线程占用独立的栈内存(即使是轻量级线程,也需占用一定内存),当线程数量达到数万甚至数十万时,栈内存总和会超出 JVM 堆外内存限制,直接导致进程崩溃。
CPU上下文频繁切换: 我们知道一个核心在微观上只能执行一个线程,在宏观上之所以看起来单核也能执行多个线程,只是分配了时间片进行线程的切换,如果线程数量远远大于CPU的核心数,那CPU会频繁在不同线程的上下文(程序计数器、寄存器、栈指针)之间切换 —— 每次切换需保存当前线程状态、加载目标线程状态。这个过程会消耗大量的CPU时间,导致切换线程的时间都大于任务的实际执行时间了,降低了吞吐量。

  1. 手动创建的线程是分散的,无法集中管理,要单独配置线程的优先级、命名规则、异常处理啊或者 若需停止所有线程(如应用停机),只能手动遍历所有线程调用interrupt(),但无法保证线程能安全释放资源(如正在操作数据库的线程被强制中断,可能导致事务回滚失败),容易引发数据一致性问题。

线程池解决线程的痛点

  1. 解决线程频繁的创建和销毁: 创建线程池的时候指定核心线程数和最大线程数,将线程统一放入池子中管理,线程池提交任务,分配线程去处理任务,任务处理完,线程不会立刻结束回收,而是通过自旋的方式,它会从阻塞队列调用take或者poll方法,等待从队列中获取新的任务执行。当线程池执行关闭的时候,分两种情况,shutdown和shutdownNew,最后线程 统一销毁。见shutdown和shutdownNow
  2. 将线程放入线程池统一管理,通过线程工厂其实内部就是一个接口并且只有一个方法,就是创建线程,我们一般使用默认的defaultFactoryThread就好了,当然可以自定义线程工厂全局设置异常处理器,提供线程池优雅关闭,自动统一销毁线程。

线程池的执行流程

正常创建线程会频繁的创建和销毁,只要任务执行完毕就会销毁,极其浪费CPU资源和性能。这个时候就可以使用线程池实现线程复用,将线程统一放入一个池子中。假设现在我给线程池设置了最大线程数10,核心线程数6个。现在有任务源源不断的来了,线程池会分配核心线程去执行任务,当这六个核心线程都在执行任务,还有新的任务来了,怎么办,线程池会创建一个任务队列,它是由阻塞队列实现,将新来的任务放入任务队列中。任务队列有两种,一种有队列,一种无界队列。先拿有界队列来说,当核心线程执行完手里的任务,会去任务队列中执行新的任务。这是理想情况,假如任务来的很快,来的速度比处理速度更快,或者有几个任务执行时间很长,导致任务队列满了,这个时候所有的核心线程也在执行任务,又来了个任务3,任务3发现队列满了,之前我们不是设置过核心线程数和最大线程数么,又发现核心线程数没到最大线程数,这个时候线程池会创建非核心线程来执行这个新来的任务3。如果又有任务来,核心线程还在执行,队列还是满的,线程池就会创建新的非核心线程来执行新任务。直到任务N来了,发现核心线程数+非核心线程数等于最大线程数了,并且队列还是满的,就会执行线程池设置好的拒绝策略,可能会抛出异常终止任务,可能会由提交任务的线程处理,可能会直接丢弃,当然你也可以自定义拒绝策略,比如把任务放数据库或者通知MQ,后续重试。那核心线程和非核心线程都能处理任务,为什么还分开?在默认情况下,非核心线程如果空闲了,我们在创建线程池的时候会设置参数,空闲时间,当非核心线程超过设置的空闲时间keepAliveTime,就会自动回收。我们也可以给核心线程设置空闲时间,这样核心线程就像非核心线程一样超过空闲时间了就回收。当然有新任务来了线程池会自动创建核心线程的。
image.png

线程池核心参数七个

  1. 核心线程数int corePoolSize:线程池创建时候,会创建的核心线程数量,即使空闲也不会被回收,但是可以设置threadPool.setAllowCoreThreadTimeOut(true)这样当核心线程超过空闲时间KeepAliveTime也会被回收。
  2. 最大线程数int maximumPoolSize:线程池允许创建的最大线程数量,也就是核心线程+非核心线程。
  3. 空闲时间long KeepAliveTime:当线程池默认允许非核心线程或者设置核心线程允许空闲回收的空闲时间,当线程空闲时间超过设置的空闲时间,则线程回收。
  4. 时间单位TimeUnit unit:空闲时间的时间单位
  5. 任务队列kingQueue<Runnable> workQueue: 用于缓存等待执行的任务的阻塞队列,当核心线程全忙时,新任务会先进入队列等待。缓冲任务峰值,实现 “削峰填谷”,避免任务直接被拒绝。
  6. 线程工厂ThreadFactory threadFactory:用于创建线程的工厂,可自定义线程的名称、优先级、是否为守护线程等。标准化线程配置,方便问题排查(如通过线程名定位日志)。
  7. 拒绝策略 RejectedExecutionHandler handler:当 任务队列满 + 线程数达 maximumPoolSize时,对新提交任务的处理策略。

任务队列

  • ArrayBlockingQueue(有界队列):需指定容量(如 new ArrayBlockingQueue<>(100)),队列满时会触发非核心线程创建,适合资源受限场景。
  • LinkedBlockingQueue(无界队列):默认容量为 Integer.MAX_VALUE(约 20 亿),任务可无限入队,可能导致 OOM(内存溢出),此时 maximumPoolSize 失效。
  • SynchronousQueue(同步队列):不存储任务,每个任务必须立即被线程处理(若没有空闲线程,则直接创建非核心线程,直到达到 maximumPoolSize),适合任务处理快、提交频繁的场景。
  • PriorityBlockingQueue(优先级队列):按任务优先级排序,适合需要优先处理重要任务的场景。

拒绝策略

JDK 内置 4 种拒绝策略

  • AbortPolicy:直接抛出异常中断任务提交流程
  • CallerRunsPolicy:由提交任务的线程自己执行任务(在哪个线程提交的哪个线程执行)
  • DiscardPolicy:直接丢弃新提交的任务,不抛异常也不执行
  • DiscardOldestPolicy:丢弃任务队列头部(最先进队列)的任务,然后将心任务加入队列
  • 自定义:实现RejectedExecutionHandler接口,重写rejectedExecution方法,可以将任务放数据库/消息队列,后续重试。具体:可以重写rejectedExecution,把runnable任务,设置为可重试任务,线程池拒绝任务时发送到 MQ,并做一个发送失败的兜底保存到数据库或者本地文件,当线程池关闭的时候,停止接受新任务,把任务队列任务执行完毕,再处理被拒绝的任务,执行重试逻辑,发起重试的通知或者数据库查讯,确保之前被拒绝的任务全部处理完毕。

shutdown和shutdownNow

线程池中shutdown和shutdownNow的区别在于,前者会等待所有任务包括排队的任务执行完毕才关闭,而后者只等待正在执行的线程完成便关闭。比如当前线程池内有5个线程在执行任务,还有五个任务在等待,此时如果执行了shutdown,线程池会等排队的五个线程的任务也执行完毕再关闭。如果是shutdownNow则会等正在执行任务的五个线程执行完毕,就关闭,排队的五个线程任务就不被执行了。

execute和submit

execute方法用于执行无返回值任务,不返回结果且异常需在子线程处理;submit方法支持Runnable和Callable,返回Future对象,可通过get获取结果或异常,异常在调用get时抛出,便于主线程统一处理,更适用于需要返回值或异常管理的场景。

Double-checked locking

懒惰实例化,目的:即使在多线程的环境下也只实例化一次,两次判断INSTANCE成员变量。当类没被实例化的时候,线程A和线程B都进入了第一次if判断,synchronized加锁了,只有一个线程能拿到,实例化了对象。其他等待锁的线程,拿到锁执行的时候,if判断发现已经实例化了,跳出返回。此时被实例化以后,其他线程再来实例化的时候都发现被实例化了,在拿锁之前就返回了。提高了效率和安全性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public final class Singleton {
private Singleton() { }
private volatile static Singleton INSTANCE = null;//volatile 禁用指令重排,防止初始化没完全
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

CAS

好处:比起synchronized锁,能防止线程阻塞,涉及到线程的上下文切换,因为线程阻塞休眠,会保留阻塞前的状态,等待唤醒,再加载阻塞前的状态。提升效率!

synchronized优化