操作系统:进程、IO 模型与容器资源

梳理进程线程协程、虚拟内存、epoll 与 CPU/内存/磁盘排查方法,并补上容器环境下 JVM 的资源边界

Java 后端的操作系统题通常带着「线上」两个字:不是让你背内核源码,而是能不能用 top、vmstat、iostat 解释现象,并知道 JVM 与容器的边界。

现象先看什么常见原因
CPU 高top、top -Hp、线程栈死循环、GC、正则回溯、加密计算
负载高但 CPU 不高vmstat 的 r 和 b 列IO 等待、不可中断睡眠
内存涨free、RSS、cgroup 指标堆外内存、page cache、线程栈
磁盘慢iostat -x 的 await、%util随机 IO、fsync 频繁、磁盘打满

1. 进程、线程、协程有什么区别?

进程是资源分配的基本单位,拥有独立地址空间、文件描述符和信号处理;线程是调度的基本单位,同一进程内的线程共享堆和方法区,各自有栈和寄存器。

线程切换要进入内核,保存和恢复上下文,还可能刷新 TLB、影响缓存局部性,成本通常是微秒级。进程切换还要切换地址空间,开销更大。

协程是用户态的轻量执行单元,由运行时调度,切换不经过内核,成本低。它适合大量阻塞在 IO 上的任务,但不等于「免费」:阻塞系统调用仍会占用底层线程,CPU 密集任务也不会因此变快。

Java 21 的虚拟线程由 JVM 调度到载体线程上,遇到阻塞 IO 会自动让出,本质上是把协程模型引入 JVM。注意它不能突破数据库连接池、下游限流等外部瓶颈。

2. 虚拟内存是怎么工作的?

每个进程看到的是独立的虚拟地址空间,通过页表映射到物理内存。CPU 访问地址时由 MMU 查表,TLB 缓存最近的映射,避免每次都走多级页表。

内存按页管理,通常 4KB。访问未映射或未加载的页会触发缺页中断:如果是合法映射但数据不在内存,就从磁盘或 swap 调入;如果是非法访问,进程收到段错误。

这种按需加载让进程可以申请超过物理内存的地址空间,也让多个进程共享同一份文件页。文件读写默认走 page cache,写操作先改缓存,变成脏页后由内核回写。

问题在于,free 显示的可用内存很少不一定代表内存不足,page cache 会在压力下回收。真正要关注的是可用内存、swap 使用和 OOM 事件。

3. 五种 IO 模型怎么理解?epoll 为什么高效?

阻塞 IO 在等待数据和拷贝数据两个阶段都阻塞;非阻塞 IO 轮询数据是否就绪,就绪后拷贝仍阻塞;IO 多路复用在单个线程里等待多个 fd 就绪;信号驱动在就绪时收到信号;异步 IO 连数据拷贝完成后再通知。

select 用位图传递 fd,有数量上限,返回后要遍历全部 fd;poll 用数组去掉了上限,但仍需遍历。epoll 把 fd 注册到内核的红黑树,就绪的 fd 放入就绪链表,调用时只返回就绪项。

epoll 支持水平触发和边缘触发。水平触发只要还有数据就会持续通知,编程简单;边缘触发只在状态变化时通知一次,必须一次读到 EAGAIN,通常配合非阻塞 fd,效率更高但更容易写错。

Reactor 模式就是把 epoll 的事件循环与业务处理解耦,Netty、Redis、Nginx 都是这个思路。

4. CPU 使用率高怎么排查?

先用 top 看是用户态还是内核态高:us 高多半是业务计算、GC 或死循环;sy 高可能是系统调用、锁竞争或上下文切换过多。

找到进程后,top -Hp <pid> 定位到具体线程,把线程 id 转成十六进制,再在 jstack 输出里按 nid 搜索,就能看到对应栈。Java 进程还可以用 JFR 或 async-profiler 生成火焰图,直接看热点方法。

GC 也会表现为 CPU 高:用 jstat -gcutil 看回收频率和耗时,若 Full GC 频繁,问题在内存而不是计算。

负载高但 CPU 不高时,看 vmstat 的 b 列和 iostat 的 await,可能是 IO 阻塞或大量不可中断睡眠。别只盯着 CPU 使用率一个指标。

5. 内存不足时系统会发生什么?

当物理内存和可回收的 page cache 都不足时,内核会尝试回收、压缩,必要时使用 swap。swap 频繁换入换出会导致系统抖动,延迟急剧上升。

如果仍然无法满足,Linux 的 OOM Killer 会按评分选择进程杀掉,容器里则可能表现为 OOMKilled、退出码 137。这类问题不会在 Java 堆 dump 里留下明显证据,要看 dmesg 和 cgroup 的内存事件。

Java 进程的内存不只是堆:还有元空间、直接内存、线程栈、JIT 代码缓存和本地库。容器内存限制下,堆要预留这些空间,不能简单地把 -Xmx 设成容器上限。

排查用 free -h、/proc/meminfo、smem、jcmd VM.native_memory,以及容器的 memory.current 和 memory.max。

6. 磁盘 IO 慢怎么定位?

先看 iostat -x 1:r/s、w/s 是 IOPS,rkB/s、wkB/s 是吞吐,await 是平均等待时间,%util 表示设备忙碌程度,aqu-sz 反映排队深度。

await 高而 %util 低,可能是单次 IO 很大或底层存储延迟高;%util 接近 100% 且队列长,说明设备已经饱和。

再看是顺序还是随机:数据库和日志写入的随机 IO 对机械盘很不友好,SSD 的随机性能好得多。文件系统、RAID 和云盘的 IOPS 上限也要纳入考虑。

应用层要区分「读写量大」和「fsync 频繁」。每次写都同步刷盘会放大延迟,批量提交和合理的刷盘策略往往比换硬件更有效。

7. 死锁的四个条件和处理方式?

死锁需要互斥、持有并等待、不可抢占、循环等待同时成立。破坏任意一个就能避免,但工程上最常用的是固定加锁顺序和超时重试。

不可抢占意味着已经拿到的锁不能被强制收回,所以「按顺序申请,按逆序释放」能消除循环等待;tryLock 带超时可以让线程在拿不到锁时退出,避免无限等待。

检测方面,Java 用 jcmd Thread.print 或 jstack,输出会标出相互等待的线程;数据库用 SHOW ENGINE INNODB STATUS;操作系统层面可以看 /proc/<pid>/stack 和等待链。

还要区分死锁、活锁和饥饿:活锁是线程一直在重试但无法推进,饥饿是低优先级线程长期拿不到资源。

8. 容器里的 JVM 要注意什么?

容器用 namespace 做隔离、cgroup 做资源限制。JDK 10 起 UseContainerSupport 默认开启,JVM 会读取 cgroup 的 CPU 和内存限制,而不是宿主机总量;更早的版本会误判,必须显式设置。

内存方面,堆上限可以用 -XX:MaxRAMPercentage 按容器内存比例设置,但要给元空间、直接内存、线程栈和 JIT 留出空间。设置过满时,容器 OOM Killer 会直接杀掉进程,而不是抛 Java 堆 OOM。

CPU 方面,-XX:ActiveProcessorCount 会影响 GC 线程数和默认并行度;cpu.max 的配额可能导致 JVM 看到的核数与实际可用算力不一致,线程池要按实际配额压测。

还要注意 cgroup v1 与 v2 的指标路径不同,监控采集和 JVM 版本都要确认支持。

易错点与自测

  • 协程不是「更快的线程」,它优化的是阻塞等待的调度成本。
  • free 少不等于内存不足,page cache 可回收;容器 OOM 与 Java 堆 OOM 是两件事。
  • epoll 高效的前提是 fd 已注册且事件就绪,边缘触发下必须读到 EAGAIN。

自测:① 说明 await 高但 %util 低的可能原因;② 解释容器内存限制下 -Xmx 为什么要留余量;③ 用 top、printf 和 jstack 串起一次 CPU 高排查。

参考资料

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~