前言
Java 程序员每天都在和线程打交道。
我们会创建线程:
new Thread(task).start();
会使用线程池:
Executors.newFixedThreadPool(10);
也会使用:
synchronized
但很多人并没有真正想过:
程序、进程和线程到底是什么关系?
Java 创建一个 Thread,操作系统发生了什么?
JVM 运行在用户态,为什么还能读磁盘和访问网络?
平台线程为什么昂贵?
虚拟线程为什么可以创建很多个?
CPU 到底根据什么决定运行哪个线程?
系统调用、中断和 Thread.interrupt 是一回事吗?
这篇文章,我们从操作系统的角度出发,一步步理解一个 Java 线程到底是如何运行起来的。
一、程序、进程和线程有什么区别?
先从三个最容易混淆的概念开始:
程序
进程
线程
1. 什么是程序?
程序本质上是存储在磁盘上的一组静态文件。
例如:
java
java.exe
应用程序的 class 文件
JAR 包
动态链接库
配置文件
它们静静地躺在磁盘上时,还没有真正执行。
所以:
程序是静态的代码和数据。
当我们执行:
java Application
操作系统才会开始创建运行环境。
2. 什么是进程?
程序启动后,操作系统会为它创建一个进程。
这个过程可以简单理解为:
读取可执行文件
↓
创建虚拟地址空间
↓
加载代码和数据
↓
分配运行所需资源
↓
创建初始线程
↓
开始执行
因此,进程并不只是“正在运行的程序”。
更准确地说:
进程是操作系统为一个运行中的程序建立的资源容器。
一个进程通常拥有或管理:
虚拟地址空间
打开的文件描述符
网络连接
信号处理配置
安全凭证
进程标识符 PID
一个或多个线程
启动一个 Java 应用后,JVM 本身就是一个操作系统进程。
例如:
Java 应用
↓
JVM 进程
↓
多个 Java 线程
同一个程序也可以启动多个进程。
例如,连续启动两个 Java 应用实例:
java Application
java Application
操作系统会创建两个彼此独立的 JVM 进程。
它们拥有各自的虚拟地址空间,默认情况下不能像线程一样直接访问对方的普通变量。
3. 什么是线程?
进程提供运行所需的资源,但真正执行指令的是线程。
一个进程可以只有一个线程,也可以包含多个线程。
例如,一个聊天软件进程中可能存在:
界面线程
网络通信线程
文件读写线程
消息处理线程
后台清理线程
这些线程执行不同任务,但属于同一个进程。
因此,可以用一句常见的话概括:
进程是资源管理和隔离的重要单位,线程是 CPU 调度执行的基本单位。
需要注意,这句话是一种便于理解的概括,而不是所有操作系统内部实现的完整定义。
Linux 调度器真正选择运行的对象,是可运行的线程或任务,而不是笼统地让“整个进程”一起运行。Linux 的调度接口也明确以线程为更精确的调度对象。
4. 进程和线程分别拥有什么?
同一进程中的线程通常共享:
进程地址空间
Java 堆
方法区或元数据区域
打开的文件
网络连接
全局变量
但每个线程还需要保存自己的执行现场,例如:
线程栈
程序计数位置
寄存器状态
调度状态
线程局部数据
可以简单画成:
JVM 进程
│
├── Java 堆
├── 类元数据
├── 文件和网络资源
│
├── 线程 A
│ ├── 线程栈
│ ├── 当前执行位置
│ └── 寄存器上下文
│
├── 线程 B
│ ├── 线程栈
│ ├── 当前执行位置
│ └── 寄存器上下文
│
└── 线程 C
├── 线程栈
├── 当前执行位置
└── 寄存器上下文
所以:
线程共享进程资源
但拥有独立的执行现场
这正是多线程既高效又危险的原因。
共享内存让线程交换数据非常方便,但也会带来:
竞态条件
可见性问题
原子性问题
死锁
伪共享
二、Linux 眼中的进程和线程
很多课程会说:
Linux 中,线程就是一个普通进程。
这个说法有一定道理,但还不够准确。
1. Linux 使用统一的任务抽象
从 Linux 内核调度的角度看,进程和线程都可以被当作任务进行管理。
创建任务时,可以决定新任务与原任务共享哪些资源。
例如:
是否共享虚拟地址空间
是否共享文件描述符表
是否共享信号处理配置
是否加入同一个线程组
Linux 的 clone 机制可以通过不同标志控制这些共享关系。
其中:
CLONE_VM
表示调用者和新任务共享同一虚拟内存空间。
一个任务对共享内存的修改,可以被另一个任务观察到。
而:
CLONE_THREAD
会让新任务加入调用者所在的线程组。
同一线程组中的线程拥有共同的线程组 ID,同时每个线程仍然具有自己唯一的线程 ID。
因此,更准确的表述是:
Linux 使用相对统一的任务模型描述进程和线程,二者的关键区别之一,在于它们共享了哪些资源,以及是否属于同一个线程组。
2. 为什么线程创建通常比进程轻?
传统进程一般拥有独立的虚拟地址空间。
线程则可以共享所属进程的地址空间和其他资源。
因此,创建线程时通常不需要重新建立一整套独立的进程资源。
可以简单理解为:
创建进程:
建立新的资源容器
+
建立新的执行路径
创建线程:
复用已有资源容器
+
建立新的执行路径
不过,“线程比进程轻”并不意味着线程没有成本。
每个平台线程仍然需要:
线程栈
内核调度数据
寄存器上下文
线程局部存储
操作系统管理资源
线程数量过多,仍然可能消耗大量内存和调度资源。
三、为什么多线程需要同步?
同一进程中的线程共享内存。
这意味着线程 A 和线程 B 可以同时访问同一个 Java 对象。
例如:
class Counter {
int value;
void increment() {
value++;
}
}
两个线程同时执行:
counter.increment();
最终结果可能小于预期。
因为:
value++;
不是一个不可分割的动作。
它大致包含:
读取 value
执行加 1
写回 value
两个线程可能发生:
线程 A 读取 value = 0
线程 B 读取 value = 0
线程 A 写回 1
线程 B 写回 1
最终:
value = 1
而不是预期的:
value = 2
因此,操作系统和编程语言需要提供互斥与同步机制。
1. Mutex 是什么?
Mutex 是 Mutual Exclusion 的缩写,也就是:
互斥。
它的核心目标是:
同一时刻
只允许一个执行单元
进入受保护区域
例如:
线程 A 获得锁
↓
线程 B 尝试获得锁
↓
线程 B 等待
↓
线程 A 释放锁
↓
线程 B 继续执行
但 Mutex 是一种通用概念,并不代表 Java 中的 synchronized 会直接、机械地对应一个操作系统 Mutex。
2. synchronized 底层就是 Mutex 吗?
不能简单地这样说。
Java 语言层面将 synchronized 定义为对象监视器语义。
例如:
synchronized (lock) {
count++;
}
在 JVM 字节码层面,同步代码块通常涉及:
monitorenter
monitorexit
每个 Java 对象都可以与一个监视器关联。
线程进入同步代码块时,需要获得对应监视器的所有权;退出时释放监视器。
HotSpot JVM 可以根据竞争情况使用不同实现路径。
例如:
无竞争或低竞争
↓
尽量在 JVM 用户空间内快速完成
竞争加剧
↓
使用更完整的 ObjectMonitor 结构
↓
必要时挂起和唤醒线程
↓
可能与操作系统阻塞机制交互
所以,更准确的表述是:
synchronized在 Java 层面表达监视器互斥语义,JVM 会根据运行状态选择具体实现;发生严重竞争时,最终可能需要借助操作系统提供的线程阻塞与唤醒能力。
HotSpot 内部将膨胀后的 Java 监视器表示为 ObjectMonitor 等运行时结构,而不是简单地把每个 Java 对象直接绑定为一个操作系统 Mutex。
四、用户态和内核态是什么?
JVM 本质上是一个普通的用户程序。
它通常运行在:
用户态。
操作系统内核运行在:
内核态。
1. 为什么需要区分两种模式?
如果任何程序都能直接执行所有硬件操作,就可能发生:
普通应用随意修改其他进程内存
应用直接覆盖磁盘关键区域
应用关闭中断
应用修改页表
应用接管网卡
整个系统将无法保证安全和稳定。
因此,CPU 和操作系统建立了权限隔离机制。
在常见的 x86 Linux 环境中,通常可以简化理解为:
Ring 3:用户态
Ring 0:内核态
用户态程序权限受限,不能直接执行某些特权指令,也不能随意访问内核地址空间。
2. JVM 为什么可以读文件和访问网络?
因为 JVM 可以请求操作系统代为完成这些操作。
例如,Java 执行:
Files.readString(path);
底层最终需要使用操作系统提供的文件操作接口。
整个过程可以抽象为:
Java 代码
↓
JDK 类库
↓
JVM 或本地代码
↓
系统调用
↓
进入内核态
↓
文件系统和设备驱动处理
↓
返回用户态
类似操作还包括:
创建操作系统线程
读取网络数据
写入磁盘
申请虚拟内存
等待事件
创建进程
系统调用是用户程序请求内核服务的受控入口。
Linux 的系统调用入口会先由架构相关汇编代码建立内核执行环境,再进入内核中的系统调用处理逻辑。
3. 系统调用一定很慢吗?
系统调用需要跨越用户态与内核态边界,因此通常比普通函数调用复杂。
它可能涉及:
权限级别切换
保存部分执行状态
切换到内核栈
参数检查
执行内核代码
恢复用户态状态
但这并不表示一次系统调用一定会触发线程切换。
例如:
用户态进入内核态
≠
当前线程一定被切走
如果内核可以立即完成请求,当前线程可能在内核中处理完成后直接返回用户态。
只有在发生阻塞、时间片耗尽、被高优先级任务抢占等情况下,才可能进一步引发调度和上下文切换。
五、Java 平台线程是如何运行的?
在 Java 虚拟线程出现之前,我们平时创建的 Java 线程基本都是平台线程。
例如:
Thread thread = new Thread(() -> {
System.out.println("Hello");
});
thread.start();
1. 平台线程是什么?
平台线程是:
由 Java
Thread对象包装的操作系统线程。
平台线程运行 Java 代码时,会长期占用一个对应的操作系统线程。
可以理解为:
Java Platform Thread
│
│ 一对一
↓
Operating System Thread
│
↓
由操作系统调度
Oracle 当前的 Java 文档也将平台线程描述为操作系统线程的薄包装,并指出平台线程数量会受到可用操作系统线程数量和相关资源的限制。
2. 调用 start() 后发生了什么?
当 Java 执行:
thread.start();
大致会经历:
创建 Java Thread 对象
↓
JVM 请求创建平台线程
↓
运行时与操作系统交互
↓
建立线程栈和线程上下文
↓
线程进入可运行状态
↓
操作系统调度器选择它
↓
开始执行 run() 方法
需要注意:
thread.start();
并不表示线程会在这一行之后立刻获得 CPU。
它只是让线程具备被调度执行的条件。
真正什么时候运行,由操作系统调度器决定。
3. 什么是上下文切换?
假设一个 CPU 核心正在运行线程 A。
调度器决定改为运行线程 B 时,需要保存 A 的执行现场,再恢复 B 的执行现场。
可以简单表示为:
线程 A 正在运行
↓
保存 A 的寄存器和执行位置
↓
选择线程 B
↓
恢复 B 的寄存器和执行位置
↓
线程 B 继续运行
这就是上下文切换。
上下文切换可能带来:
保存和恢复状态的成本
CPU 流水线受到影响
缓存局部性下降
TLB 和缓存命中率变化
调度器运行成本
因此:
线程并不是越多越快。
当可运行的平台线程远多于 CPU 核心数量时,大量线程会竞争有限的 CPU 时间。
4. 并发不等于并行
假设机器只有一个 CPU 核心。
它仍然可以运行多个线程:
线程 A 执行一会儿
切换到线程 B
线程 B 执行一会儿
再切换到线程 A
从宏观上看,多个任务都在推进,这叫:
并发。
但同一时刻真正执行的仍然只有一个线程。
如果机器拥有多个 CPU 核心,多个线程才可能在同一时刻分别运行在不同核心上。
这叫:
并行。
因此:
并发:多个任务在一段时间内共同推进
并行:多个任务在同一时刻真正同时执行
六、平台线程为什么不能无限创建?
课程中经常会看到这样的实验:
创建 1 万个线程
创建 10 万个线程
比较运行时间
这种实验可以展示线程资源消耗,但不能简单得出:
某台机器能够启动十万个线程,所以线程可以随便创建。
1. 平台线程会消耗哪些资源?
每个平台线程通常需要:
操作系统线程结构
线程栈空间
JVM 线程数据
线程局部存储
调度器管理成本
线程栈大小不是永远固定为某个值。
它会受到:
JVM 参数
操作系统
CPU 架构
JDK 实现
应用调用深度
等因素影响。
所以,“一个线程永远占用 1 MB”只能作为特定环境下的粗略示例,不能当作通用结论。
2. 线程过多会发生什么?
平台线程过多时,可能出现:
本地内存消耗过大
无法继续创建操作系统线程
上下文切换频繁
系统响应速度下降
CPU 花大量时间进行调度
进程或机器失去响应
即使线程大部分时间都在等待网络或数据库,它们仍然会占用操作系统线程资源。
这也是传统 Java 服务经常使用线程池的原因:
平台线程比较昂贵
↓
不能为每个任务无限创建线程
↓
创建固定数量的工作线程
↓
让任务进入队列等待执行
但线程池解决的是平台线程稀缺问题,并不会凭空提高数据库、磁盘和网络服务的承载能力。
七、Java 现在支持纤程了吗?
课程笔记中写道:
Java 不支持纤程,需要等待 Project Loom。
这个结论现在已经过时。
Java 已经正式支持:
Virtual Thread,虚拟线程。
JEP 444 在 JDK 21 中正式交付了虚拟线程,不再是预览功能。
1. 什么是虚拟线程?
虚拟线程仍然是:
java.lang.Thread
但它不再在整个生命周期中固定占用一个操作系统线程。
可以简单表示为:
大量虚拟线程
│
│ 由 JVM 调度
↓
少量平台线程 Carrier Thread
│
│ 由操作系统调度
↓
CPU 核心
负责承载虚拟线程执行的平台线程,通常称为:
Carrier Thread,载体线程。
虚拟线程运行代码时,会被挂载到一个载体线程上。
当它因为支持的阻塞 I/O 操作而等待时,JVM 可以暂停这个虚拟线程,并让它从载体线程上卸载。
此时载体线程就可以执行其他虚拟线程。
2. 如何创建虚拟线程?
可以直接创建:
Thread thread = Thread.startVirtualThread(() -> {
System.out.println("Hello Virtual Thread");
});
thread.join();
也可以使用每任务一个虚拟线程的执行器:
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
// 执行一次独立任务
return callRemoteService();
});
}
}
这里的执行器不是维护一个固定大小的虚拟线程池。
它会为每个提交的任务创建一个新的虚拟线程。
3. 虚拟线程为什么更轻量?
平台线程需要在整个生命周期中绑定操作系统线程。
例如:
平台线程发送网络请求
↓
等待网络响应
↓
对应的操作系统线程也被占用
虚拟线程则可以在等待期间卸载:
虚拟线程发送网络请求
↓
任务进入等待状态
↓
虚拟线程从载体线程卸载
↓
载体线程执行其他虚拟线程
↓
网络数据到达
↓
原虚拟线程重新被调度
因此,虚拟线程特别适合:
网络请求
数据库访问
文件 I/O
大量阻塞等待任务
Thread-Per-Request 模型
4. 虚拟线程会让代码运行得更快吗?
不一定。
虚拟线程的主要优势是:
提高大量阻塞任务场景下的并发能力和吞吐量。
它不会让一段纯计算代码自动变快。
例如:
calculatePi();
如果任务一直占用 CPU,那么无论创建平台线程还是虚拟线程,真正的并行能力仍然受到 CPU 核心数量限制。
Oracle 文档也明确指出:虚拟线程不是“更快的线程”,适合大量等待阻塞的任务,而不是长时间运行的 CPU 密集型任务。
因此:
I/O 密集型任务
↓
虚拟线程通常非常合适
CPU 密集型任务
↓
线程数量通常应接近可用计算资源
5. 虚拟线程需要放进线程池吗?
通常不需要。
平台线程昂贵,所以需要池化复用。
虚拟线程本身就是廉价的任务载体,推荐:
一个并发任务
对应一个虚拟线程
而不是建立固定大小的虚拟线程池。
如果数据库只允许 20 个并发请求,应该使用:
Semaphore semaphore = new Semaphore(20);
限制访问数据库的并发量,而不是为了限流去池化虚拟线程。
OpenJDK 和 Oracle 的官方指南都明确建议不要池化虚拟线程;需要限制稀缺资源时,应使用信号量等专门的并发控制工具。
6. synchronized 会固定住虚拟线程吗?
这需要区分 JDK 版本。
在较早的虚拟线程实现中,虚拟线程在某些 synchronized 代码中阻塞时,可能被固定在载体线程上,无法卸载。
这叫:
Pinning。
但 JDK 24 交付了 JEP 491,使虚拟线程在 synchronized 方法和代码块中阻塞时,通常也可以释放载体线程。
因此,下面这种旧结论已经不能直接套用到当前 JDK:
使用 synchronized 一定会导致虚拟线程固定载体线程。
从 JDK 24 开始,这类固定问题已经基本消除;当前仍可能导致固定的主要场景包括执行某些 native 方法或外部函数。
八、进程是如何创建的?
在 Linux 中,创建进程时经常会提到:
fork();
fork() 会创建一个子进程。
1. fork 会完整复制所有内存吗?
从逻辑上看,子进程开始时拥有父进程地址空间的副本。
但现代 Linux 不会在调用 fork() 的瞬间,立即把父进程所有内存物理复制一遍。
通常会使用:
Copy-on-Write,写时复制。
可以简单理解为:
fork 刚完成
↓
父子进程暂时共享物理页面
↓
某一方尝试修改页面
↓
内核再复制对应页面
↓
修改方获得自己的副本
Linux 的 fork() 文档说明,其实现使用写时复制页面,初始主要成本包括复制页表和为子进程创建任务结构。
2. 什么是父进程和子进程?
调用 fork() 的进程称为:
父进程
新创建的进程称为:
子进程
fork() 会在父进程和子进程中分别返回。
常见逻辑类似:
pid_t pid = fork();
if (pid == 0) {
// 子进程
} else if (pid > 0) {
// 父进程
} else {
// 创建失败
}
父进程获得子进程的 PID,子进程获得返回值 0。
九、什么是僵尸进程和孤儿进程?
这两个概念名字很吓人,但并不复杂。
1. 僵尸进程
当子进程执行结束时,内核需要保留少量退出信息,例如:
进程 PID
退出状态
资源使用统计
父进程可以通过:
wait();
或者:
waitpid();
获取这些信息并完成回收。
如果子进程已经结束,但父进程一直没有执行等待和回收操作,这个已经终止的子进程就会处于:
僵尸状态。
可以表示为:
子进程结束
↓
大部分运行资源已经释放
↓
退出信息仍由内核保存
↓
父进程没有调用 wait
↓
成为僵尸进程
僵尸进程并不是还在执行。
它通常只保留内核回收所需的最小信息。
但如果僵尸进程数量过多,会持续占用进程表槽位,最终可能影响新进程创建。
2. 孤儿进程
如果父进程先结束,而子进程仍然继续运行,那么子进程就成为:
孤儿进程。
可以表示为:
父进程退出
子进程仍在运行
↓
子进程失去原父进程
↓
被系统中的收养进程接管
需要注意:
孤儿进程不一定永远由 PID 为 1 的 init 直接收养。
现代 Linux 中,它也可能由最近的子进程收割器,也就是 subreaper 收养;PID 命名空间和容器环境也会影响我们看到的父进程。
所以,更准确的说法是:
孤儿进程会被系统指定的收养者接管,并由它负责后续回收。
3. 两者有什么区别?
| 类型 | 谁先结束 | 子进程是否仍运行 | 核心问题 |
|---|---|---|---|
| 僵尸进程 | 子进程先结束 | 否 | 父进程没有回收退出状态 |
| 孤儿进程 | 父进程先结束 | 是 | 子进程需要新的父进程负责管理 |
可以简单记忆:
僵尸:孩子已经死了,父亲没处理后事
孤儿:父亲先走了,孩子还活着
十、CPU 如何决定运行哪个线程?
系统中可能同时存在成百上千个线程,但 CPU 核心数量有限。
那么:
到底哪个线程先运行?
这个问题由操作系统调度器负责。
Linux 文档对调度器的定义非常直接:
调度器决定下一个由 CPU 执行的可运行线程。
1. 什么是可运行线程?
线程并不是任何时候都能占用 CPU。
它可能处于:
正在运行
等待 CPU
等待锁
等待网络
等待磁盘
睡眠
已经终止
调度器主要从可运行任务中选择下一位执行者。
可以简单理解为:
等待 CPU 的线程集合
↓
调度器执行选择
↓
某个线程获得 CPU
↓
运行一段时间
↓
阻塞、结束或被抢占
2. 什么是抢占式调度?
现代通用操作系统通常采用抢占式调度。
这意味着:
线程不必主动同意,操作系统就可以暂停它,并把 CPU 交给其他线程。
例如:
线程 A 正在执行
↓
时间片或调度条件发生变化
↓
调度器暂停线程 A
↓
切换到线程 B
如果调度完全依赖任务主动让出 CPU,那么一个死循环程序就可能长期霸占处理器。
抢占式调度可以避免普通任务无限占用 CPU,并改善系统公平性和响应速度。
3. 普通调度和实时调度
Linux 提供多种调度策略。
普通应用通常运行在普通时间共享调度策略下,例如:
SCHED_OTHER
实时调度策略包括:
SCHED_FIFO
SCHED_RR
此外还有:
SCHED_DEADLINE
SCHED_FIFO
FIFO 是先进先出实时调度。
同一优先级下,它没有普通的时间片轮转。
运行中的线程通常会一直执行,直到:
主动阻塞
主动让出 CPU
被更高优先级线程抢占
执行结束
SCHED_RR
RR 是 Round Robin,也就是轮询。
它在 FIFO 基础上增加了时间片。
相同实时优先级的线程会轮流获得 CPU。
SCHED_DEADLINE
Deadline 调度允许任务声明:
运行预算
截止时间
周期
内核据此进行截止时间相关调度。
Linux 的调度文档明确区分了普通、FIFO、RR 和 Deadline 等调度策略,并规定实时线程的静态优先级通常处于 1 到 99。
4. CFS 是 Linux 当前唯一的调度算法吗?
不是。
CFS,也就是:
Completely Fair Scheduler
完全公平调度器
从 Linux 2.6.23 开始长期承担普通任务的公平调度工作,替代了更早的 O(1) 调度器。
它的核心思想不是给所有任务机械分配完全相同的固定时间片,而是根据任务获得 CPU 的历史情况,尽量公平地分配处理器时间。
但是,现代 Linux 的公平调度实现已经开始转向:
EEVDF
Earliest Eligible Virtual Deadline First
Linux 从 6.6 版本开始逐步向 EEVDF 过渡。
EEVDF 会结合任务的虚拟运行情况和虚拟截止时间,选择符合条件且截止时间更早的任务,有助于兼顾公平性和低延迟任务的响应性。
因此,文章中更准确的表述应该是:
O(1) 调度器
↓
CFS 长期作为普通任务公平调度基础
↓
现代内核逐步转向 EEVDF
而不能只写:
Linux 现在使用 CFS。
具体行为还取决于实际运行的 Linux 内核版本和配置。
5. nice 值是什么?
普通 Linux 任务可以通过 nice 值影响获得 CPU 的相对权重。
常见范围是:
-20 到 19
其中:
-20:更受调度器偏爱
19:更少获得 CPU
需要注意:
nice 值不是“这个线程必须第几个执行”的绝对命令。
它只是影响普通调度任务之间的相对 CPU 分配。
在 Linux 中,nice 值实际是每线程属性,同一进程中的不同线程可以拥有不同 nice 值。
6. I/O 密集型和 CPU 密集型
任务通常可以粗略分为两类。
CPU 密集型
大部分时间都在执行计算:
图像处理
数据压缩
加密计算
科学计算
大型循环
这种任务真正需要的是 CPU 时间。
线程数量超过 CPU 核心太多,通常不会提高计算吞吐量,反而可能增加调度开销。
I/O 密集型
大部分时间都在等待:
数据库返回
网络响应
磁盘读写
远程服务调用
这类任务在等待时并不持续消耗 CPU。
所以可以允许更多任务同时存在。
虚拟线程特别适合这类场景,因为等待中的虚拟线程通常不需要独占平台线程。
十一、中断到底是什么?
中断也是操作系统中非常重要的概念。
它的核心作用是:
让 CPU 及时处理异步发生的事件。
例如:
网卡收到数据
磁盘完成读取
键盘产生输入
定时器到期
硬件报告异常
1. 硬件中断
假设 CPU 正在执行 Java 程序。
此时网卡收到一个数据包。
网卡不需要等 CPU 主动轮询自己,而可以通过中断机制通知处理器:
网卡收到数据
↓
产生中断请求
↓
CPU 进入中断处理入口
↓
操作系统执行中断处理程序
↓
记录和处理设备事件
↓
返回原执行流程
中断让硬件能够主动通知操作系统。
2. 系统调用就是软中断吗?
不能直接画等号。
在早期或兼容模式的 x86 系统中,系统调用可能通过:
int 0x80
这种软件触发的陷入指令进入内核。
但现代 x86-64 系统通常使用专门的:
syscall
sysret
指令。
32 位环境中还可能出现:
sysenter
sysexit
Linux 内核文档也将 syscall、兼容模式的 int 0x80 和硬件中断列为不同的内核入口。
更重要的是,Linux 内核中的:
Softirq
是一个有特定含义的内核延迟处理机制。
它并不等同于“用户程序调用系统调用”。
因此:
系统调用
≠
硬件中断
≠
Linux softirq
它们都可能让 CPU 进入内核代码,但来源、目的和处理方式不同。
3. 中断一定会发生线程切换吗?
不一定。
发生硬件中断时,CPU 可以暂时执行中断处理逻辑,然后继续运行原来的线程。
所以:
处理中断
≠
一定切换到另一个线程
调度器可能在中断处理结束后决定重新调度,但这是另一个步骤。
4. 中断和上下文切换有什么区别?
中断表示:
有事件需要 CPU 或内核处理
上下文切换表示:
CPU 从一个线程的执行现场
切换到另一个线程的执行现场
一次中断可能引发上下文切换,也可能不会。
一次上下文切换也可能由其他原因引发,例如:
线程主动阻塞
等待锁
时间片相关调度
更高优先级线程变为可运行状态
因此,两者不能混为一谈。
十二、Thread.interrupt 是操作系统中断吗?
Java 中也有:
thread.interrupt();
虽然名字中包含 interrupt,但它和硬件中断完全不是同一回事。
1. Thread.interrupt 做了什么?
它主要用于:
向另一个 Java 线程发送协作式中断请求。
例如:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
doWork();
}
});
worker.start();
worker.interrupt();
调用:
worker.interrupt();
通常不会粗暴地强制杀死线程。
它主要会:
设置线程的中断状态
或让某些阻塞方法抛出 InterruptedException
目标线程需要自己检查并响应中断。
例如:
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
所以 Java 线程中断是一种:
协作式取消和通知机制。
2. 为什么不能直接强制终止线程?
因为线程可能正在:
持有锁
修改共享对象
写入文件
执行数据库事务
维护数据结构
如果在任意机器指令位置强制停止线程,可能留下不一致状态。
因此,现代 Java 更推荐:
发送中断请求
由线程在安全位置自行退出
而不是强行杀死线程。
十三、十万个线程就等于高并发吗?
不等于。
线程数量只是高并发系统中的一个因素。
一个请求可能还需要:
数据库连接
Redis 连接
文件描述符
网络带宽
内存
下游服务容量
锁和共享资源
即使虚拟线程可以轻松创建很多个,如果数据库连接池只有 20 个连接,那么真正能够同时访问数据库的任务仍然只有大约 20 个。
其他虚拟线程只能等待连接。
所以:
能创建大量线程
≠
下游资源能处理同样数量的请求
虚拟线程主要解决的是:
操作系统线程成为高并发阻塞应用瓶颈的问题。
它不会自动解决:
慢 SQL
数据库连接不足
锁竞争
网络带宽不足
服务限流
内存不足
下游接口性能差
高并发系统仍然需要:
限流
缓存
连接池
批处理
读写分离
分库分表
减少锁竞争
合理设置超时
背压
虚拟线程让并发代码更容易编写,但不会替代完整的系统容量设计。
十四、把整条链路串起来
现在可以把一个 Java 线程的运行过程串起来。
首先,我们启动 Java 程序:
执行 java 命令
↓
操作系统创建 JVM 进程
↓
建立虚拟地址空间和进程资源
↓
启动 main 线程
如果创建平台线程:
Java Thread.start()
↓
JVM 创建或请求创建操作系统线程
↓
线程进入可运行状态
↓
Linux 调度器选择线程
↓
CPU 执行 Java 代码
如果线程需要访问文件或网络:
Java 代码调用 JDK API
↓
进入本地运行时
↓
发起系统调用
↓
从用户态进入内核态
↓
内核和驱动处理请求
如果平台线程发生阻塞:
平台线程等待 I/O
↓
对应操作系统线程不能执行当前任务
↓
调度器选择其他线程
如果使用虚拟线程:
虚拟线程等待支持的阻塞 I/O
↓
虚拟线程从载体线程卸载
↓
载体线程运行其他虚拟线程
↓
I/O 完成后虚拟线程重新调度
而 CPU 最终运行哪个平台线程,由操作系统调度器决定:
线程状态
调度策略
优先级
公平性
实时要求
CPU 亲和性
当前系统负载
共同影响最终的调度结果。
完整链路可以表示为:
Java 任务
↓
Java Thread
↓
平台线程或虚拟线程
↓
JVM 调度与运行时
↓
操作系统线程
↓
Linux 调度器
↓
CPU 核心
十五、总结
程序、进程和线程之间的关系可以概括为:
程序是静态代码
进程是运行时资源容器
线程是具体执行路径
同一进程中的线程共享地址空间和大量进程资源,但拥有独立的栈、执行位置和调度上下文。
JVM 运行在用户态。
当 Java 程序需要:
访问文件
使用网络
创建平台线程
等待操作系统事件
时,需要通过系统调用请求内核完成相关操作。
synchronized 表达的是 Java 对象监视器语义,而不是简单地等于一个操作系统 Mutex。
平台线程通常与操作系统线程一对一对应,因此会消耗操作系统调度和内存资源。
虚拟线程则由 JVM 调度,大量虚拟线程可以复用较少的平台载体线程。
但虚拟线程并不会让 CPU 计算自动变快,它主要适用于大量阻塞等待的 I/O 密集型任务。
Linux 调度器负责决定下一个运行的线程。
CFS 曾长期承担普通任务的公平调度,而现代 Linux 正逐步转向 EEVDF。
中断、系统调用、上下文切换和 Java 的 Thread.interrupt() 也不是同一个概念:
硬件中断:设备通知 CPU 和内核
系统调用:用户程序主动请求内核服务
上下文切换:CPU 更换正在运行的线程
Thread.interrupt:Java 线程间的协作式通知
最后,整篇文章最重要的结论是:
Java 中的线程并不是孤立存在的。
它的背后连接着:
JVM
用户态与内核态
操作系统线程
系统调用
CPU 调度器
硬件中断
内存和 I/O 设备
理解这些底层关系之后,我们才能真正明白:
为什么平台线程昂贵,为什么需要线程池,以及为什么虚拟线程能够重新定义 Java 高并发编程。
评论区