想象一次压测现场:接口 QPS 死活上不去,CPU 利用率却已经 90%+。打开 top 定睛一看,us(用户态)只占 40%,sy(内核态)却占了一半——CPU 忙了半天,一大半时间不是在跑你的业务代码,而是在替你跑内核的代码。
时间去哪了?要回答这个问题,得先搞懂这篇文章的主题:CPU 其实有两个"档位",你的代码和内核的代码,各在其中一个档位上跑。这两个档位怎么划分、什么时候切换、切换为什么有开销,就是"用户态与内核态"的全部内容。
文末附一个能自己跑的实测程序:纯用户态操作和"进内核"操作,单次耗时差了四五个数量级。这不是编的数字,跑完你就信了。
一、为什么要有两个态
最早的计算机没有这套机制:程序在硬件上裸奔,想干什么干什么——直接读写磁盘、直接访问任何一块内存、直接给网卡塞数据。
听起来自由,实际上随时翻车。一个程序写坏指针,把别的程序的数据覆盖了;一个程序死循环占着磁盘,全系统跟着卡。**任何程序的任何 bug,后果都是整个系统一起买单。**根本原因就是没有隔离:每个程序都拥有全部权限。
解决思路很简单:把权限收回来,分层。CPU 在硬件层面设计了几个"特权级"(x86 上叫 Ring 0 到 Ring 3),操作系统只用到其中两级:
- Ring 3,用户态:你的代码跑在这里。Java 应用、Nginx、MySQL——再大牌的进程,在 CPU 眼里都是普通应用,没有特权。
- Ring 0,内核态:操作系统内核跑在这里。它是唯一能执行特权指令的档位——操作磁盘网卡这类设备、修改页表、关中断,全是它的专属权力。
这套机制由 CPU 硬件强制执行:用户态代码只要敢执行特权指令,CPU 直接触发异常,控制权立刻交给内核——想越级?门都没有。注意这不是软件约定的"君子协定",是硬件层面的物理隔离,比任何杀毒软件都可靠。
生活化一点:把系统想象成一个小区。用户态是住户,内核态是物业。住户可以随便在自己家里活动,但想动水电总闸、进配电房、拆承重墙——必须报修,由物业来干。物业也不是白使唤的,你得走流程(下一节的系统调用),流程是有成本的。
二、边界澄清:用户态到底"能不能碰内存"
很多资料一句话带过:"用户态不能直接操作硬件。"这话没错,但容易读岔——内存不算硬件吗?count++ 不就是在读写内存吗?为什么它不用进内核?
关键区别不是"碰没碰硬件",而是"有没有越过权限线"。
你程序里看到的内存地址全是虚拟地址。内核早就把"属于你的那块内存"映射进了你的页表,之后你每次读写内存,CPU 里的 MMU(内存管理单元)硬件自动完成"虚拟地址 → 物理地址"的翻译和越界检查——门禁是自动放行的,全程不需要内核出面,一条普通指令就执行完了。
而真正需要内核代办的内存操作是另一批:
- 申请新内存(
malloc/new底层的brk/mmap系统调用)——要的是"新地盘",页表是特权资源,只有内核能改; - 访问别的进程的内存——MMU 直接拦下,触发异常,由内核决定是拒绝还是代劳(调试器就是这么读你进程内存的);
- 第一次访问某个还没映射的页——触发缺页中断,内核把数据从磁盘调入并补好映射,你的程序才继续跑。
两条路径放一起看:
所以"用户态不能碰硬件"的准确版本是:不能碰特权资源。页表、设备、别人的内存,全是特权资源;而分给你的那块内存,你随便用——这是内核提前授权过的。
这一节值得单独强调,因为它是理解后面一切的基础:系统调用不是"每次读写内存都要经过内核",而是"要新资源、越权限线时才请内核"。
三、系统调用:用户态通往内核态的正门
住户要找物业,得走报修流程;用户态要请内核代办,走的流程叫系统调用(system call,简称 syscall)。它是用户态进入内核态唯一合法的通道。
以读文件为例,一次 read 的完整往返:
- 保存现场:你的程序执行到 read 这一行,CPU 先把"执行到哪了、寄存器里都是什么"记下来——办完事还得从这儿接着跑;
- 切档:执行
syscall指令,CPU 从 Ring 3 切到 Ring 0,从此刻起跑的是内核代码; - 内核干活:查文件位置、驱动磁盘读数据(慢的话你的线程还会被挂起让出 CPU)、把数据拷进你的缓冲区;
- 切回:带着返回值切回 Ring 3;
- 恢复现场:从刚才记下的位置继续往下跑。
系统调用看着像函数调用,本质完全不同。函数调用只是跳个地址;系统调用是跨权限级的切换,要保存现场、切档、办完再切回来——固定的"过路费"每单都要交。
常用的系统调用其实就几十个,按用途分四组:
| 分组 | 代表 | 干什么 |
|---|---|---|
| 文件 | read / write / open / close | 读写文件(Java 的 FileInputStream 底层就是它) |
| 网络 | socket / connect / accept / send / recv | 建连接、收发数据 |
| 内存 | brk / mmap | 申请内存、映射文件 |
| 进程 | fork / execve / wait | 创建进程、执行新程序 |
想亲眼看看一个程序发了哪些系统调用,Linux 上用 strace(以下为 Linux 环境的真实输出示例,Windows 上没有对应工具):
# 跟踪 cat 命令读一个文件
$ strace cat hello.txt 2>&1 | head -5
execve("/usr/bin/cat", ["cat", "hello.txt"], ...) = 0
openat(AT_FDCWD, "hello.txt", O_RDONLY) = 3
read(3, "hello world\n", 131072) = 12
write(1, "hello world\n", 12) = 12
close(3) = 0一个最简单的"读文件打印出来",背后是 openat → read → write → close 四次系统调用。每行末尾 = 0、= 3、= 12 是返回值——每单过路费,都记在账上。
四、切换的开销:什么在偷偷吃你的 sy
先看实测数据。下面这个程序对比两类操作的单次耗时(完整代码在文末,Windows 11 + JDK 21 实测):
[用户态] 内存累加 100000000 次: 24 ms, 平均 0.2 ns/次
[进内核] 打开文件+读 1 字节 x 20000 次: 583 ms, 平均 29172 ns/次
单次比值: 进内核 / 纯用户态 ≈ 100000 倍纯内存操作 0.2 纳秒一次;每次都跨越用户态/内核态边界的小文件读,接近 3 万纳秒一次——差了四五个数量级。当然这个对比里还混着磁盘驱动等因素,不是纯粹的开销比,但量级差距是实打实的:进内核这件事,贵。
这些开销花在哪了?① 保存和恢复现场(寄存器一堆);② 权限级切换本身;③ 内核路径长——从你的 read 到磁盘驱动,中间要经过虚拟文件系统、页缓存、块设备层好几站,每一站都是代码。再叠加一个隐性成本:跑内核代码会把 CPU 缓存里的数据换掉,切回来之后你的程序要重新"热身"。
聊开销必须辨析一对概念——态切换不等于进程切换:
- 态切换(mode switch):同一个进程,权限档位变一下。read 完文件回来继续跑,页表没换、缓存基本还热,开销相对小;
- 进程切换(context switch):CPU 从进程 A 改跑进程 B。要换页表(TLB 大面积作废)、保存全套上下文,缓存里全是对别人有用的数据,贵得多。
切进程必然伴随态切换,反过来不成立。排查性能问题时这个区分很关键:top 里 sy 高,说明 CPU 时间花在内核上——常见元凶是高频小 IO(每次 read 都交过路费)和锁竞争(线程阻塞/唤醒底层靠 futex 系统调用,竞争越激烈切换越频繁)。
对应两条经典优化思路,本质上都是"少交过路费":
- 攒着一批再办:缓冲区(BufferedOutputStream)、批量提交,1 万次小 IO 合并成 10 次大 IO;
- 压根不进内核:能留在用户态算的别碰系统调用——Java 里
ByteBuffer的堆内操作、无锁数据结构,都是在减少对内核的打扰。
顺着锁竞争这条线往下挖,就是《Java 并发编程》的领地——volatile、synchronized 这些工具解决的是用户态内部的可见性与原子性问题,而线程阻塞唤醒的那一刻,就是 futex 系统调用带着线程进内核的时刻。两篇文章正好在边界上接上了。
五、常见误区
误区一:用户态程序不能访问内存。 错。分给你的内存随便访问(MMU 自动放行),不能碰的是特权资源:页表、设备、别人的内存。参见第二节。
误区二:系统调用约等于一次函数调用。 差着数量级。函数调用就是跳个地址,几纳秒;系统调用要保存现场、跨权限级切换、恢复现场,实测微秒级。高频路径上滥发系统调用是性能大忌。
误区三:us 占 100% 说明系统没问题,sy 高才有问题。us 高只说明时间花在用户态,可能是计算型业务的正常表现,也可能是一个死循环。sy 高确实值得警惕(大概率在疯狂 IO 或锁竞争),但两个指标都要结合业务看,不能单看。
误区四:内核态是"更慢的代码"。 内核代码本身跑得一点不慢,贵在切换成本和路径长度,不在代码质量。真实原因往往还包括:进了内核之后如果要等磁盘、等网卡,你的线程还会被挂起——等待的时间才是大头。
误区五:容器有自己独立的内核。 没有。一台机器上所有容器共享同一个内核——这正是容器比虚拟机轻量的根本原因(虚拟机是每台都带一整套内核)。展开写在《Docker 入门》里。
六、收个尾
把这篇的核心串成三句话:
- CPU 分两个权限档:用户态(你的代码)和内核态(操作系统),硬件强制隔离,谁也别想越级;
- 用户态要办"越权限线"的事(碰设备、要资源、访问别人),唯一的通道是系统调用——它安全,但每次都要交"过路费";
- 过路费是性能优化的重要战场:攒批、缓冲、少打扰内核,成熟工具的思想都能追溯到这里。
下次在 top 里看到 sy 异常,你就知道该去查什么了:谁在频繁发起系统调用。日常排查 Linux 进程和资源占用,可以接着看《top 命令详解》。
附:实测程序(Java,Windows 11 + JDK 21 实测,任何平台可复跑)
import java.io.*;
public class KernelModeCostVerify {
public static void main(String[] args) throws Exception {
File tmp = File.createTempFile("syscall-cost", ".bin");
byte[] oneByte = new byte[1];
try (FileOutputStream fo = new FileOutputStream(tmp)) {
fo.write(oneByte);
}
// 预热,让 JIT 稳定
memOps(5_000_000);
ioOps(tmp, oneByte, 2_000);
// 测试 1:纯用户态内存操作
int memN = 100_000_000;
long t1 = System.nanoTime();
memOps(memN);
long t2 = System.nanoTime();
System.out.printf("[用户态] 内存累加 %d 次: %d ms, 平均 %.1f ns/次%n",
memN, (t2 - t1) / 1_000_000, (t2 - t1) / (double) memN);
// 测试 2:每次都要进内核的文件 IO
int ioN = 20_000;
long t3 = System.nanoTime();
ioOps(tmp, oneByte, ioN);
long t4 = System.nanoTime();
System.out.printf("[进内核] 打开文件+读 1 字节 x %d 次: %d ms, 平均 %.0f ns/次%n",
ioN, (t4 - t3) / 1_000_000, (t4 - t3) / (double) ioN);
tmp.deleteOnExit();
}
static long memOps(int n) {
long[] a = new long[1024];
long s = 0;
for (int i = 0; i < n; i++) {
a[i & 1023] = i;
s += a[(i + 1) & 1023];
}
return s;
}
static void ioOps(File f, byte[] buf, int n) throws IOException {
for (int i = 0; i < n; i++) {
try (FileInputStream fi = new FileInputStream(f)) {
fi.read(buf);
}
}
}
}说明:对比里"进内核"一侧混有磁盘驱动、文件系统等因素,测的是"每次跨越边界的操作"整体耗时,不是纯粹的切换开销;但量级差距(四五个数量级)能稳定复现,结论不受影响。
