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 高排查。
参考资料
- Linux man-pages:epoll — 水平/边缘触发与就绪模型
- Linux man-pages:proc — 进程状态、内存与线程信息
- Java SE 21:容器感知 —
availableProcessors与容器 CPU 限制 - JEP 444:Virtual Threads — 虚拟线程与平台线程的调度差异