Linux & 操作系统面试八股文 30 道|深度详解版(Java 后端必备)

本文是 Linux & 操作系统面试八股文深度详解版,共 30 题,覆盖进程/线程、内存管理、IO 模型、文件系统、调度算法等核心考点。每道题都配有原理分析 + 实战命令 + Java 视角,适合 Java 后端开发者面试前系统复习。


Q1. 进程 vs 线程的本质区别(从 Linux 内核 task_struct 角度解释)

核心结论

在 Linux 内核里,进程和线程都是 task_struct 结构体的实例,本质上都是调度单元。区别在于资源的共享程度。

task_struct 关键字段

1
2
3
4
5
6
7
8
9
struct task_struct {
pid_t pid; // 进程/线程自己的 ID
pid_t tgid; // 线程组 ID(主线程的 pid)
struct mm_struct *mm; // 虚拟内存空间(进程独有,线程共享)
struct files_struct *files; // 文件描述符表
struct fs_struct *fs; // 文件系统信息
struct signal_struct *signal; // 信号处理
// ...
};

判断是进程还是线程:看 pid == tgid 是否成立。

  • 主线程:pid == tgid,是进程领头羊
  • 子线程:pid != tgid,属于同一线程组

关键区别对比

维度 进程 线程
资源 独立的虚拟地址空间、文件描述符 共享进程的内存、fd、堆
创建开销 大(fork() 需拷贝页表) 小(clone() 共享内存)
崩溃影响 独立,不影响其他进程 一个线程崩溃导致整个进程崩溃
通信 IPC 机制(复杂) 共享内存(简单但需同步)
Linux 系统调用 fork() / exec() clone(CLONE_VM | CLONE_FILES)

Java 视角

Java 的 Thread 底层就是通过 pthread_create 创建内核线程,对应 Linux 的 clone() 系统调用。每个 Java 线程都有独立的栈(默认 512KB~1MB),但共享堆内存。

1
2
3
4
# 查看 Java 进程的线程
ps -eLf | grep java
# 或者
ls /proc/$(pgrep java)/task/ # 每个子目录就是一个线程的 tid

Q2. 进程间通信(IPC)6种方式

总览

方式 速度 适用场景
管道 中 父子进程、Shell 命令链
消息队列 中 异步解耦、带格式的消息
共享内存 最快 大量数据传输,需配合信号量
信号 快(无数据) 异常通知、进程控制
信号量 快 互斥/同步控制
Socket 慢(最灵活) 跨机器、跨语言通信

1. 管道(Pipe)

匿名管道:只能在父子进程间使用,单向,内核缓冲区通常 64KB。

1
ls -la | grep java   # Shell 管道,ls 的 stdout → grep 的 stdin

命名管道(FIFO):通过文件系统路径访问,无亲缘关系的进程也能用。

1
2
3
mkfifo /tmp/myfifo
cat /tmp/myfifo & # 读端
echo "hello" > /tmp/myfifo # 写端

2. 消息队列

内核维护的链表,按消息类型过滤,支持优先级。比管道多了消息边界和类型过滤。

1
2
3
4
// 发送消息
msgsnd(msgid, &msg, sizeof(msg.text), 0);
// 接收特定类型消息
msgrcv(msgid, &msg, sizeof(msg.text), msg_type, 0);

3. 共享内存(最快的 IPC)

直接映射同一块物理内存到不同进程的虚拟地址空间,零拷贝,但需要同步机制。

1
2
int shmid = shmget(key, SIZE, IPC_CREAT | 0666);
void *ptr = shmat(shmid, NULL, 0); // 附加到进程

4. 信号(Signal)

异步通知机制,只能携带信号编号,不能携带数据。常见信号:

  • SIGTERM(15):优雅停止
  • SIGKILL(9):强制杀死(不可捕获)
  • SIGUSR1/2:用户自定义

5. 信号量(Semaphore)

本质是一个计数器,用于进程间互斥/同步,不传数据。

6. Socket

最通用,支持同机(Unix Domain Socket,比 TCP 快)和跨机(TCP/UDP)。

1
2
# Unix Domain Socket 比 TCP 性能高 ~30%(无需网络协议栈)
# Nginx 与 PHP-FPM 通信就用 Unix Domain Socket

Java 视角

Java 微服务间通信(RPC/HTTP)本质是 Socket。JVM 进程内可以用 Queue,但跨 JVM 就需要 MQ(Kafka/RabbitMQ)。


Q3. 线程间同步的4种方式

为什么需要同步

多线程共享内存,并发访问导致竞态条件(Race Condition),需要同步保证操作的原子性和可见性。

1. Mutex(互斥锁)

同一时刻只有一个线程能持有锁,其他线程阻塞等待。

1
2
3
4
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&lock);
// 临界区
pthread_mutex_unlock(&lock);

Java 对应:synchronized、ReentrantLock

注意:mutex 是不可重入的(pthread_mutex_t),Java 的 synchronized 是可重入的。

2. 条件变量(Condition Variable)

配合 mutex 使用,让线程在满足特定条件前主动让出锁并挂起,条件满足时被唤醒。

1
2
3
pthread_cond_wait(&cond, &mutex);   // 原子释放锁并等待
pthread_cond_signal(&cond); // 唤醒一个等待线程
pthread_cond_broadcast(&cond); // 唤醒所有等待线程

Java 对应:Object.wait()/notify()/notifyAll(),Condition.await()/signal()

经典用法:生产者-消费者模式

3. 信号量(Semaphore)

计数信号量,允许 N 个线程同时访问资源(N=1 时退化为 mutex)。

1
2
3
4
sem_t sem;
sem_init(&sem, 0, N); // 初始化为 N
sem_wait(&sem); // P 操作,计数-1,为0时阻塞
sem_post(&sem); // V 操作,计数+1,唤醒等待线程

Java 对应:java.util.concurrent.Semaphore

1
2
3
Semaphore semaphore = new Semaphore(10);  // 最多10个并发
semaphore.acquire();
try { /* 访问资源 */ } finally { semaphore.release(); }

4. 读写锁(ReadWriteLock)

允许多个读线程并发,写线程独占。适合读多写少的场景。

1
2
3
pthread_rwlock_rdlock(&rwlock);  // 读锁(共享)
pthread_rwlock_wrlock(&rwlock); // 写锁(独占)
pthread_rwlock_unlock(&rwlock);

Java 对应:ReentrantReadWriteLock,StampedLock(支持乐观读)

选择建议

  • 简单互斥 → mutex / synchronized
  • 等待条件 → 条件变量 / wait-notify
  • 限流并发 → 信号量
  • 读多写少 → 读写锁

Q4. 用户态线程 vs 内核态线程 vs 协程

三种模型

内核态线程(KLT - Kernel Level Thread)

由操作系统内核直接管理,每次线程切换都需要系统调用,有用户态↔内核态切换开销。

  • Linux 的 pthread 就是内核线程(1:1 模型)
  • Java 传统线程(JDK 19 之前)= 内核线程
  • 调度由内核控制,可以利用多核
  • 每个线程需要独立内核栈(通常 4~8MB),大量线程时内存消耗大

用户态线程(ULT - User Level Thread)

由用户空间的运行时(Runtime)管理,内核看到的只有少数内核线程(N:M 模型)。

  • 切换不需要系统调用,速度极快(纳秒级)
  • 无法利用多核(除非绑定到多个内核线程)
  • Go 的 goroutine 早期用此模型,现在是 M:N

协程(Coroutine)

用户态的协作式调度,由程序员显式让出控制权(yield/await),而非抢占式。

1
2
3
async def fetch():
data = await http.get(url) # 让出控制权,等IO完成再恢复
return data

Java 的演进

时期 模型 说明
JDK 1~18 1:1(平台线程) Java线程 = 内核线程,重量级
JDK 19~20 预览:虚拟线程 M:N,由JVM调度
JDK 21+ 正式:虚拟线程 Project Loom,轻量级协程
1
2
3
4
5
// Java 21 虚拟线程:百万级并发不是梦
Thread vt = Thread.ofVirtual().start(() -> {
// IO阻塞时,JVM自动把载体线程让给其他虚拟线程
doBlockingIO();
});

虚拟线程阻塞时不会阻塞内核线程,JVM 运行时自动挂起并在 IO 完成后恢复,实现了协程语义。


Q5. 进程状态机(5种状态及转换)

五大状态

1
2
3
4
5
6
7
8
9
10
11
12
13
新建(New) ──fork()──> 就绪(Ready)
│ ↑
调度选中 │ │ 时间片到/被抢占
↓ │
运行(Running)
│
等待资源/IO │
↓
阻塞(Blocked/Sleeping) ──资源就绪──> 就绪
│
exit() │
↓
终止(Terminated/Zombie)

Linux 实际进程状态

ps aux 的 STAT 列:

字符 含义
R Running 或 Runnable(就绪队列中)
S Interruptible Sleep(可中断睡眠,等IO)
D Uninterruptible Sleep(不可中断,正在IO,不能被信号打断)
T Stopped(被 SIGSTOP 暂停)
Z Zombie(僵尸进程)
I Idle(内核线程空闲)

D 状态很危险:进程卡在内核 IO 路径,kill -9 也杀不死,只能等 IO 完成或重启。

1
2
3
4
5
# 查看 D 状态进程
ps aux | awk '$8 == "D"'

# 查看具体在等待什么
cat /proc/<pid>/wchan

状态转换触发条件

  • 就绪 → 运行:调度器选中(schedule() 函数)
  • 运行 → 就绪:时间片耗尽、被高优先级进程抢占
  • 运行 → 阻塞:read() 等待 IO、sleep()、wait()
  • 阻塞 → 就绪:IO 完成(硬件中断)、notify()、定时器触发

Q6. 僵尸进程 vs 孤儿进程

僵尸进程(Zombie Process)

定义:子进程已经退出(exit),但父进程还没有调用 wait()/waitpid() 回收其 task_struct,导致进程表项残留。

特征:

  • 进程状态为 Z
  • 不占用 CPU 和内存,但占用 PID 和进程表槽位
  • kill -9 无法杀死(已经死了)

产生原因:父进程没有正确处理 SIGCHLD 信号。

1
2
3
4
5
6
// 父进程没有 wait,子进程变僵尸
pid_t pid = fork();
if (pid == 0) {
exit(0); // 子进程退出
}
sleep(100); // 父进程不 wait,子进程成僵尸

如何消灭:

  1. 杀死父进程(僵尸子进程会被 init 收养并回收)
  2. 父进程调用 wait()
  3. 父进程注册 SIGCHLD 处理函数

孤儿进程(Orphan Process)

定义:父进程先于子进程退出,子进程成为孤儿,被 init 进程(PID=1,或 systemd)收养。

特征:正常运行,不会产生危害,init 会负责回收。

1
2
# 验证孤儿进程的父进程变成1
ps -o pid,ppid,cmd | grep <孤儿进程名>

Java 视角

1
2
3
4
5
// Java 中 ProcessBuilder 创建子进程后需要处理
Process p = new ProcessBuilder("some-script.sh").start();
// 必须消费 stdout/stderr,否则缓冲区满后子进程阻塞
// 最后调用 p.waitFor() 避免僵尸
int exitCode = p.waitFor();

实际排查:ps aux | grep Z | grep -v grep,若僵尸进程持续增多,通常是 Java 应用频繁 Runtime.exec() 但未调用 waitFor()。


Q7. fork() 的 Copy-On-Write(COW)机制

没有 COW 时的问题

fork() 创建子进程需要复制父进程所有内存。一个 4GB 堆的 JVM 进程 fork 一次需要复制 4GB 内存,耗时数秒,完全不可接受。

COW 的原理

fork() 后,父子进程共享相同的物理内存页,页表项都标记为只读。

当任意一方尝试写入某个页面时:

  1. CPU 触发写保护异常(Page Fault,写保护类型)
  2. 内核处理函数 do_wp_page() 被调用
  3. 内核分配一个新的物理页,复制原页内容
  4. 将触发写操作的进程的页表项指向新页,标记可写
  5. 恢复执行写操作
1
2
3
4
5
6
7
fork() 之后:
父进程页表: VA → PA[x] (只读)
子进程页表: VA → PA[x] (只读) ← 共享同一物理页

子进程写入后:
父进程页表: VA → PA[x] (只读)
子进程页表: VA → PA[y] (可写) ← 新分配的页

fork() + exec() 的优化

exec() 会替换进程的整个地址空间,所以 fork() + 立即 exec() 根本不会触发大量 COW,只复制极少量页(如栈、页表本身)。

Linux 还有 vfork():子进程完全共享父进程地址空间,父进程挂起直到子进程 exec() 或 exit(),专为 fork-exec 场景优化。

Java 视角 与 Redis 的 COW

Redis 的 BGSAVE 命令就是 fork() 子进程进行持久化,利用 COW 保证数据一致性。

Java 需要注意:JVM 进程 fork 后,父进程继续写入堆(GC 活动),会大量触发 COW,导致内存使用量翻倍。这就是为什么建议在 JVM 进程中尽量不 fork,而是用线程或进程间通信。

1
2
# 查看进程内存使用(包含 COW 后的实际使用)
cat /proc/<pid>/status | grep -i vm

Q8. 虚拟内存的作用与实现(页表、TLB、多级页表)

虚拟内存解决了什么问题

  1. 地址空间隔离:每个进程以为自己独占所有内存,互不干扰
  2. 内存超量使用:物理内存不足时,部分数据放到磁盘(swap)
  3. 内存共享:多个进程可以映射同一物理页(共享库 libc 只需一份)
  4. 内存保护:进程无法访问其他进程的内存

页表(Page Table)

地址翻译过程:

1
2
3
虚拟地址(VA) = 页号(VPN) + 页内偏移(offset)
↓(查页表)
物理地址(PA) = 物理帧号(PFN) + 页内偏移(offset)

x86-64 使用4级页表(Linux 5.x 支持5级):

1
2
CR3寄存器 → PGD(L4) → PUD(L3) → PMD(L2) → PTE(L1) → 物理页
↑每级9位索引,共4×9=36位 + 12位偏移 = 48位虚拟地址

TLB(Translation Lookaside Buffer)

页表存在内存中,每次地址翻译都要走4次内存访问,太慢!TLB 是页表的硬件缓存(通常64~1024个条目),命中时直接给出物理地址。

TLB 失效(TLB miss):

  • 访问新地址 → 硬件 Page Table Walk
  • 进程切换 → 整个 TLB 失效(除非支持 ASID 标记)

上下文切换慢的主要原因之一就是 TLB 失效。

多级页表的意义

32位进程:虚拟地址空间 4GB,若 4KB 一页,需要 100万个页表项,每项 4B = 4MB 页表(每个进程都要!)。

4级页表:只需为实际用到的虚拟地址范围分配页表,节省大量内存。

1
2
3
4
5
# 查看进程虚拟内存映射
cat /proc/<pid>/maps

# 查看 TLB 相关统计
perf stat -e dTLB-load-misses,dTLB-store-misses ./your-program

Q9. 缺页中断(Page Fault)的处理流程

什么是缺页中断

CPU 访问虚拟地址时,页表项中对应的物理页不在内存中(页表项 Present 位为0),触发 Page Fault 异常(中断号14)。

三种 Page Fault 类型

类型 原因 处理
Minor Fault 页在内存,但页表未建立(如 mmap 后首次访问) 建立映射,无需IO
Major Fault 页在 Swap 或文件,需要磁盘IO 从磁盘读入,慢!
Invalid Fault 非法访问(如空指针) 发送 SIGSEGV 信号

完整处理流程

1
2
3
4
5
6
7
8
9
10
11
1. CPU 触发 Page Fault 异常,保存现场(寄存器、PC)
2. 跳转到内核 Page Fault 处理函数 do_page_fault()
3. 检查故障地址(CR2 寄存器保存虚拟地址)
4. 查找 VMA(Virtual Memory Area),判断地址是否合法
└── 非法地址 → 发送 SIGSEGV,进程崩溃
5. 确认 Fault 类型:
├── COW Fault → 分配新页,复制内容
├── Anonymous Page → 分配新物理页(清零),建立映射
└── File-backed Page → 从文件/Swap 读入数据
6. 更新页表,刷新 TLB
7. 返回用户态,重新执行触发 Fault 的指令

Java 视角

1
2
3
4
5
# 查看进程的 Page Fault 统计
cat /proc/<pid>/stat | awk '{print "Minor:", $10, "Major:", $12}'

# 或者用 ps
ps -o pid,minflt,majflt,cmd -p <pid>

Java 启动慢的原因之一:JVM 加载大量类文件,触发大量 Minor Page Fault(mmap 映射 jar 包后,首次访问时才加载)。

JVM 的 -XX:+AlwaysPreTouch:JVM 启动时主动触碰所有堆内存,提前触发 Page Fault,避免运行时首次 GC 时卡顿。


Q10. 内存分配:malloc/free 的底层实现(brk vs mmap 阈值)

用户空间内存布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
高地址
┌─────────────────┐
│ 内核空间 │ (用户不可访问)
├─────────────────┤ 0x7fff...
│ 栈 (Stack) │ ↓ 向下增长
│ │
├─────────────────┤
│ mmap 区域 │ (共享库、mmap文件、大块malloc)
│ │
├─────────────────┤
│ 堆 (Heap) │ ↑ 向上增长(brk/sbrk控制)
├─────────────────┤ program break
│ BSS段 │ (未初始化全局变量)
│ Data段 │ (已初始化全局变量)
│ Text段 │ (代码)
└─────────────────┘ 0x0

malloc 的两种分配策略

策略1:brk()——移动堆顶指针(小内存)

1
2
3
4
// 分配小块内存(通常 < 128KB)
// glibc 调用 brk()/sbrk() 扩展堆
void *p = malloc(1024); // 从堆上分配,碎片化后不一定归还OS
free(p); // 归还给 glibc,不一定调用 brk 收缩

策略2:mmap()——直接映射匿名页(大内存)

1
2
3
4
// 分配大块内存(> MMAP_THRESHOLD,默认 128KB)
// glibc 调用 mmap(MAP_ANONYMOUS) 映射独立内存区域
void *p = malloc(1024 * 1024); // 走 mmap
free(p); // 调用 munmap,立即归还OS

glibc malloc 的内存池(bins)

glibc 使用 ptmalloc2 算法,维护多个大小的 free list(bins):

  • fast bins:16~64字节,单链表,不合并相邻块
  • small bins:< 512字节,双向链表
  • large bins:≥ 512字节,按大小排序
  • unsorted bin:刚释放的块先放这里

Java 视角

JVM 的内存分配走的是 JVM 自己的分配器(不直接调用 glibc malloc),但 JVM native 代码(JNI)分配的内存走 glibc。

1
2
3
4
5
6
7
8
# 调整 mmap 阈值(大对象直接还给OS)
export MALLOC_MMAP_THRESHOLD_=65536

# 使用 jemalloc 替换 glibc malloc(更低碎片化)
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so java ...

# Java 进程堆外内存排查
cat /proc/<pid>/smaps | grep -A5 heap

Q11. 内存泄漏的检测方法

内存泄漏的本质

已分配的内存,程序不再使用但无法释放(C:忘记 free;Java:对象有强引用但业务已不需要)。

Linux 层面检测工具

1. valgrind(最全面)

1
2
3
valgrind --leak-check=full --show-leak-kinds=all \
--track-origins=yes --verbose \
./your-program 2>&1 | tee valgrind.log

输出示例:

1
2
3
4
LEAK SUMMARY:
definitely lost: 1,024 bytes in 1 blocks # 确定泄漏
indirectly lost: 0 bytes in 0 blocks
possibly lost: 512 bytes in 2 blocks # 可能泄漏

2. /proc/PID/smaps 监控

1
2
3
4
5
6
7
8
9
# 持续监控进程内存
while true; do
cat /proc/<pid>/smaps | grep -E "^(Size|Rss|Pss)" | \
awk '{sum+=$2} END{print strftime("%T"), sum"KB"}' >> mem.log
sleep 10
done

# 查看具体内存区域
cat /proc/<pid>/smaps_rollup

3. /proc/PID/status 关键字段

1
2
3
4
cat /proc/<pid>/status
# VmRSS:实际物理内存(RSS)
# VmHWM:历史最高 RSS(high water mark)
# VmPeak:历史最高虚拟内存

4. pmap 命令

1
2
pmap -x <pid>  # 查看各内存段的大小
pmap -x <pid> | sort -k3 -n -r | head # 找最大内存块

Java 内存泄漏排查

1
2
3
4
5
6
7
8
9
10
11
# 堆内存泄漏
jmap -histo:live <pid> | head -30 # 查看对象数量
jmap -dump:format=b,file=heap.hprof <pid> # 生成堆转储
# 用 MAT/VisualVM 分析 hprof

# 堆外内存泄漏(DirectByteBuffer/NIO)
java -XX:NativeMemoryTracking=detail ...
jcmd <pid> VM.native_memory detail # 查看原生内存

# 元空间泄漏(类加载器泄漏)
jstat -gcmetacapacity <pid> 1000

Q12. 进程调度算法

FCFS(先来先服务)

按到达顺序调度,简单但护航效应严重(长进程阻塞后面的短进程)。非抢占式。

SJF(短作业优先)

选择预估执行时间最短的进程,平均等待时间最优,但需要预知执行时间(实际难以做到)。

SRTF(最短剩余时间)= 抢占式 SJF

轮转调度(Round Robin)

每个进程分配固定时间片(通常 10~100ms),时间片到则抢占,放到队尾。

  • 时间片太大 → 退化为 FCFS
  • 时间片太小 → 上下文切换开销过大

优先级调度

高优先级进程优先运行,可抢占低优先级进程。问题:低优先级进程饥饿(Starvation)。

解决:老化(Aging)——随时间推移提升等待进程的优先级。

CFS(完全公平调度)— Linux 默认

核心思想:让每个进程都获得公平的 CPU 时间,以虚拟运行时间(vruntime)衡量公平性。

1
vruntime += 实际运行时间 × (默认权重 / 进程权重)
  • **优先级(nice 值)**影响权重,而非直接影响调度顺序
  • 使用红黑树存储所有可运行进程,vruntime 最小的进程最先运行(最左节点)
  • 时间复杂度 O(log n)
1
2
3
4
5
6
7
8
9
# 查看进程的调度信息
cat /proc/<pid>/sched

# 设置进程优先级(nice值:-20最高优先级,19最低)
nice -n 10 java -jar app.jar # 低优先级启动
renice -n -5 -p <pid> # 提升运行中进程优先级

# 实时调度(绕过CFS)
chrt -f 50 ./realtime-process # FIFO实时调度,优先级50

Java 视角

Java 线程优先级(1-10)会被映射到 Linux nice 值,但映射关系因 JVM 实现而异,实际效果有限。更推荐通过业务架构(线程池隔离、任务队列)来控制优先级。


Q13. 上下文切换的开销

什么是上下文

进程/线程的”运行现场”:CPU 寄存器(通用寄存器、PC、SP、状态寄存器)+ 内核栈 + 内存映射信息。

上下文切换的步骤和开销

1. 保存当前线程上下文

1
2
- 保存所有 CPU 寄存器到 task_struct.thread
- 保存浮点/SSE寄存器(如果使用)← 这个很重!

2. 切换内存空间(进程间切换才有)

1
2
- 加载新进程的页表基址到 CR3 寄存器
- TLB 完全失效(没有 ASID 时)← 主要开销来源

3. 恢复新线程上下文

1
2
- 从 task_struct 恢复寄存器
- 返回用户态

Cache 失效的间接开销

上下文切换后,新线程访问的数据不在 CPU Cache 中(L1/L2/L3 Cache Miss),触发大量内存访问,这个间接开销比直接开销大得多。

估算:

  • 直接开销:110 微秒(寄存器保存恢复)
  • 间接 Cache 冷启动:可高达 100+ 微秒

减少上下文切换的方法

1
2
3
4
5
6
7
8
9
10
# 查看系统上下文切换频率
vmstat 1 | awk '{print $12, $13}' # cs 列是上下文切换次数

# 绑定 CPU(减少跨CPU迁移导致的Cache失效)
taskset -c 0,1 java -jar app.jar

# 查看线程上下文切换
cat /proc/<pid>/status | grep ctxt
# voluntary_ctxt_switches:主动切换(IO等待)
# nonvoluntary_ctxt_switches:被抢占(时间片到)

Java 视角

线程池大小 = CPU 核数(CPU 密集型)或更多(IO 密集型)。线程过多 → 大量上下文切换 → 性能下降。

Java 21 虚拟线程的优势:虚拟线程切换不需要系统调用,开销仅在 JVM 层面(纳秒级),大幅减少内核上下文切换。


Q14. 死锁的4个必要条件及 Banker 算法

四个必要条件(Coffman 条件)

  1. 互斥(Mutual Exclusion):资源一次只能被一个进程使用
  2. 持有并等待(Hold and Wait):进程持有资源的同时,等待其他进程持有的资源
  3. 不可剥夺(No Preemption):已分配的资源不能被强制剥夺
  4. 循环等待(Circular Wait):存在进程等待链形成环路

只要破坏其中一个,死锁就不会发生。

实际避免策略

策略 破坏条件 示例
资源一次性申请 持有并等待 事务开始前锁定所有资源
有序加锁 循环等待 约定锁的获取顺序
超时机制 不可剥夺 tryLock(timeout)
乐观锁/无锁 互斥 CAS 操作

Banker 算法(银行家算法)

动态避免死锁,判断资源分配后系统是否仍处于安全状态(存在安全序列)。

1
2
安全序列:存在一个进程执行顺序 P1, P2, ..., Pn
使得每个进程 Pi 需要的最大资源 ≤ 当前可用 + 前面所有进程释放的资源

简化版算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def is_safe(available, max_need, allocation):
need = max_need - allocation
work = available.copy()
finish = [False] * n

while True:
found = False
for i in range(n):
if not finish[i] and all(need[i] <= work):
work += allocation[i] # Pi完成,释放资源
finish[i] = True
found = True
if not found:
break

return all(finish) # 全部完成 → 安全状态

Java 死锁检测

1
2
3
4
5
6
7
8
9
// Java 代码层面的死锁检测
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = bean.findDeadlockedThreads();

// 命令行检测
jstack <pid> | grep -A5 "deadlock"

// jcmd
jcmd <pid> Thread.print | grep -i dead

有序加锁示例(破坏循环等待):

1
2
3
4
5
6
7
8
// 错误:可能死锁
synchronized(lockA) { synchronized(lockB) { ... } }
synchronized(lockB) { synchronized(lockA) { ... } }

// 正确:按固定顺序(如hashCode)加锁
Object first = System.identityHashCode(a) < System.identityHashCode(b) ? a : b;
Object second = first == a ? b : a;
synchronized(first) { synchronized(second) { ... } }

Q15. 文件系统:inode 是什么,软链接 vs 硬链接

inode 的本质

inode(Index Node)是文件系统中存储文件元数据的数据结构,每个文件/目录有唯一一个 inode。

inode 中存储什么:

  • 文件类型(普通文件、目录、符号链接…)
  • 权限(rwxrwxrwx)
  • 硬链接计数
  • 所有者 UID、GID
  • 文件大小
  • 时间戳(atime/mtime/ctime)
  • 数据块指针(直接块 + 间接块)

inode 中不存储:文件名(文件名存在目录项中!)

1
2
3
4
5
6
7
8
9
# 查看 inode 号
ls -i filename
stat filename

# 查看文件系统 inode 使用情况
df -i

# 查看 inode 内容
stat /etc/hosts

目录的本质

目录是一个特殊文件,内容是文件名 → inode 号的映射表:

1
2
/etc/hosts 目录项:
"hosts" → inode 123456

硬链接(Hard Link)

创建另一个目录项指向同一个 inode,inode 的链接计数 +1。

1
2
3
4
5
6
ln /etc/hosts /tmp/hosts_backup  # 硬链接

# 两个文件的 inode 号相同
ls -i /etc/hosts /tmp/hosts_backup
# 123456 /etc/hosts
# 123456 /tmp/hosts_backup

特点:

  • 删除原文件不影响硬链接(inode 引用计数 > 0)
  • 不能跨文件系统(inode 号只在同一文件系统内唯一)
  • 不能指向目录(防止循环)

创建一个新文件(新 inode),内容是目标路径字符串。

1
2
3
4
5
ln -s /etc/hosts /tmp/hosts_link  # 软链接

ls -li /tmp/hosts_link
# 789012 lrwxrwxrwx ... /tmp/hosts_link -> /etc/hosts
# ↑ 新的 inode 号

特点:

  • 可以跨文件系统
  • 可以指向目录
  • 原文件删除后,软链接变为悬空链接(dangling link)
  • 访问软链接需要额外一次路径解析

对比总结

特性 硬链接 软链接
inode 相同 不同(新建)
跨文件系统 不支持 支持
目标目录 不支持 支持
原文件删除 文件仍可访问 链接失效
本质 目录项引用 路径字符串文件

Q16. VFS(虚拟文件系统)的作用

问题背景

Linux 支持数十种文件系统:ext4、xfs、btrfs、tmpfs、procfs、nfs……用户程序难道要为每种文件系统写不同的代码?

VFS 的解决方案

VFS 是 Linux 内核中的文件系统抽象层,为上层提供统一的接口,屏蔽不同文件系统的实现差异。

1
2
3
4
5
6
7
8
9
10
11
12
用户程序
│ open() read() write() close()
↓
系统调用接口
↓
VFS 层(统一接口:super_block, inode, dentry, file)
├── ext4 文件系统
├── xfs 文件系统
├── tmpfs(内存文件系统)
├── procfs(/proc,进程信息)
├── sysfs(/sys,设备信息)
└── nfs(网络文件系统)

VFS 四大核心对象

对象 作用
super_block 描述已挂载文件系统的整体信息
inode 描述文件元数据(对应具体FS的inode)
dentry 目录项缓存,路径名 → inode 的映射
file 进程打开文件的上下文(当前偏移量等)

dentry 缓存(dcache)

路径查找是高频操作,VFS 用 dcache 缓存路径解析结果,避免每次都从磁盘读取目录项。

1
2
3
4
5
6
# 查看 dcache 使用情况
cat /proc/sys/fs/dentry-state
# 格式:dentry数量 未使用数量 年龄限制 ...

# 查看 inode 缓存
cat /proc/sys/fs/inode-state

Java 视角

Java 的 File、Path、FileChannel 等 API 最终都通过 JNI 调用 Linux 的 open()、read() 等系统调用,走 VFS 层。正因为有 VFS,Java 代码不需要关心底层是 ext4 还是 xfs。

/proc 和 /sys 也是 VFS 的文件系统,所以 Java 可以直接读取 /proc/self/status 获取 JVM 进程信息。


Q17. 磁盘调度算法

背景

传统机械硬盘(HDD)磁盘寻道时间(5~15ms)是主要瓶颈,调度算法目标是减少总寻道时间。(SSD 无寻道时间,这些算法意义较小,但 Linux 仍提供调度层抽象。)

FCFS(先来先服务)

按 IO 请求到达顺序处理,简单公平,但可能导致磁头来回大幅移动。

SSTF(最短寻道时间优先)

每次选择距当前磁头最近的请求,减少总寻道距离,但可能导致远端请求饥饿。

SCAN(电梯算法)

磁头像电梯一样,向一个方向移动,处理路径上的所有请求,到达端点后反向。

C-SCAN(循环 SCAN):只向一个方向扫描,到达端点后立即返回起点,再次向同一方向扫描。响应时间更均匀。

实际使用

1
2
3
4
5
6
7
8
9
10
11
12
# 查看磁盘使用的调度算法
cat /sys/block/sda/queue/scheduler
# [mq-deadline] kyber bfq none

# 查看所有可用调度器并切换
echo "bfq" > /sys/block/sda/queue/scheduler

# 各调度器适用场景:
# noop/none:SSD(无寻道开销)
# deadline:数据库(保证延迟上限)
# bfq:桌面系统(交互响应好)
# mq-deadline:通用服务器

查看磁盘 IO 性能

1
2
3
4
5
6
7
8
9
10
iostat -x 1        # 查看磁盘利用率、等待时间
iotop -d 1 # 查看进程IO使用量
blktrace /dev/sda # 详细跟踪块设备IO

# 查看 Java 进程的磁盘IO
cat /proc/<pid>/io
# rchar: 读取字节数
# wchar: 写入字节数
# read_bytes: 实际磁盘读
# write_bytes: 实际磁盘写

Q18. IO 的5种模型

以 recvfrom 系统调用为例,等待 UDP 数据包到来:

1. 阻塞 IO(Blocking IO)

1
2
3
4
调用 recvfrom
→ 内核等待数据到达(阻塞,什么都不干)
→ 数据到达,内核将数据从内核缓冲区拷贝到用户缓冲区
→ 返回,进程继续

简单,但线程被阻塞,无法处理其他事情。

2. 非阻塞 IO(Non-blocking IO)

1
2
3
4
调用 recvfrom(socket设为O_NONBLOCK)
→ 数据未就绪 → 立即返回 EAGAIN/EWOULDBLOCK
→ 进程轮询(busy-wait),浪费 CPU
→ 数据就绪 → 返回数据

不阻塞,但轮询浪费 CPU。

3. IO 多路复用(IO Multiplexing)

1
2
3
select/poll/epoll 监控多个 fd
→ 有 fd 就绪时返回
→ 调用 recvfrom 读取数据(这步仍然是同步的)

单线程管理多个连接,是 Nginx、Redis 的基础。

4. 信号驱动 IO(Signal-driven IO)

1
2
3
4
注册 SIGIO 信号处理函数,调用 sigaction
→ 立即返回,进程继续执行
→ 数据就绪时,内核发送 SIGIO 信号
→ 信号处理函数中调用 recvfrom

实际很少用(信号处理复杂,存在竞态)。

5. 异步 IO(Asynchronous IO)

1
2
3
4
调用 aio_read(Linux POSIX AIO 或 io_uring)
→ 立即返回
→ 内核等待数据、拷贝到用户缓冲区
→ 完成后通知进程(回调/信号)

真正的异步,连数据拷贝也是内核做的。Linux 的 io_uring(5.1+)是目前最高效的实现。

区分同步异步、阻塞非阻塞

  • 阻塞/非阻塞:描述调用者是否等待(发起调用到得到结果这段时间)
  • 同步/异步:描述 IO 操作是否由调用者自己完成
    • 同步:调用者自己调用 recvfrom 拷贝数据
    • 异步:内核完成数据拷贝,通知调用者

前4种模型都是同步 IO(都需要调用者调用 recvfrom 等拷贝数据),只有第5种是异步 IO。


Q19. select / poll / epoll 的区别与触发模式

select

1
2
3
fd_set readfds;
FD_SET(sock_fd, &readfds);
select(maxfd + 1, &readfds, NULL, NULL, &timeout);

缺点:

  • fd 数量上限 1024(FD_SETSIZE)
  • 每次调用都要把 fd 集合从用户态拷贝到内核态(O(n))
  • 内核需要遍历所有 fd 检查就绪状态(O(n))
  • 返回后用户还要遍历所有 fd 找到就绪的(O(n))

poll

1
2
3
struct pollfd fds[N];
fds[0] = {.fd = sock_fd, .events = POLLIN};
poll(fds, N, timeout);

相对 select 的改进:去掉了 1024 的限制(用链表)。
仍然有:O(n) 拷贝、O(n) 遍历的问题。

epoll

1
2
3
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ev); // 注册,O(log n)
epoll_wait(epfd, events, MAX_EVENTS, -1); // 只返回就绪的fd

核心优势:

  • 内核用红黑树存储所有监听 fd(O(log n) 增删)
  • 内核用就绪链表存储就绪 fd
  • epoll_wait 只返回就绪 fd,O(1) 处理
  • fd 信息存在内核,无需每次拷贝

水平触发(LT)vs 边缘触发(ET)

水平触发(Level Trigger,默认):

  • 只要缓冲区有数据,每次 epoll_wait 都会通知
  • 没读完数据也没关系,下次还会通知
  • 更安全,不容易丢事件

边缘触发(Edge Trigger):

  • 只在状态变化时(从无数据→有数据)通知一次
  • 必须一次性把数据读完(用非阻塞 IO + 循环读到 EAGAIN)
  • 更高效,减少系统调用次数
1
2
3
4
5
6
7
// ET 模式必须循环读完
while (true) {
n = recv(fd, buf, sizeof(buf), 0);
if (n == -1 && errno == EAGAIN) break; // 读完了
if (n <= 0) { close(fd); break; }
process(buf, n);
}

Java NIO / Netty 使用 epoll ET 模式(Linux 上),性能更高。


Q20. epoll 的内部实现(红黑树 + 就绪链表)

整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
epoll_create1() 创建:
┌─────────────────────────────────────┐
│ epoll 实例 (eventpoll struct) │
│ │
│ ┌─────────────────┐ │
│ │ 红黑树 (rbr) │ ← 所有监听的fd │
│ │ │ │
│ │ [fd1] [fd2] │ │
│ │ [fd3] │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ 就绪链表 (rdllist)│ ← 已就绪的fd │
│ │ fd2 → fd5 → ...│ │
│ └─────────────────┘ │
└─────────────────────────────────────┘

epoll_ctl 添加 fd 的过程

1
2
3
4
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event)
1. 创建 epitem 结构体,包含 fd 和监听事件
2. 将 epitem 插入红黑树(按 fd 排序)
3. 在 fd 对应的 socket 等待队列上注册回调函数 ep_poll_callback

数据就绪时的回调机制

1
2
3
4
5
网络数据到达 → 触发硬件中断 → 软中断 → 内核协议栈
→ 数据放入 socket 接收缓冲区
→ 调用 socket 等待队列上的回调 ep_poll_callback
→ ep_poll_callback 把 epitem 加入就绪链表 rdllist
→ 唤醒在 epoll_wait 上等待的进程

epoll_wait 的执行

1
2
3
4
5
6
7
epoll_wait(epfd, events, maxevents, timeout)
1. 检查就绪链表是否为空
2. 若空,挂起进程等待(或超时)
3. 就绪链表非空时唤醒
4. 将就绪链表中的 epitem 拷贝到用户空间 events 数组
5. LT 模式:就绪 fd 重新放回就绪链表(下次还会通知)
ET 模式:移除,等下次状态变化才再加入

关键性能数据

操作 select/poll epoll
注册 fd O(1) O(log n)
检查就绪 O(n) O(1)
拷贝 fd 集合 O(n) O(就绪数)
最大 fd 数 1024 / 无限制 无限制(内存限制)

Q21. mmap 的原理及与 read/write 的对比

传统 read/write 的数据路径

1
2
磁盘 → 内核页缓存(Page Cache)→ [CPU拷贝] → 用户态缓冲区
用户态缓冲区 → [CPU拷贝] → 内核页缓存 → 磁盘

每次 IO 涉及2次CPU拷贝 + 2次系统调用。

mmap 的原理

mmap() 将文件的某个区域直接映射到进程的虚拟地址空间,文件内容成为内存中的字节数组。

1
2
3
4
5
6
7
8
9
// 将文件映射到内存
void *addr = mmap(NULL, file_size, PROT_READ|PROT_WRITE,
MAP_SHARED, fd, 0);

// 直接像操作内存一样操作文件
memcpy(dest, addr, n); // 读
*(addr + offset) = value; // 写(自动写回Page Cache)

munmap(addr, file_size);

数据路径:

1
2
磁盘 → 内核页缓存(Page Cache)= 进程虚拟地址空间
(文件内容和 Page Cache 是同一块物理内存,无需额外拷贝)

对比总结

方式 拷贝次数 系统调用 适用场景
read/write 2次 2次/IO 小文件、流式处理
mmap 1次(Page Fault时) 1次(mmap本身) 大文件随机访问

Java 中的 mmap

1
2
3
4
5
6
// Java NIO MappedByteBuffer
FileChannel fc = new FileInputStream("large.dat").getChannel();
MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_ONLY,
0, fc.size());
// 直接读写,背后是 mmap
byte b = mbb.get(offset);

Kafka 为什么快:Kafka 的消费者读取消息用 sendfile(零拷贝),索引文件用 mmap(快速随机查找),是经典的高性能 IO 设计。


Q22. 零拷贝(Zero-Copy):sendfile vs mmap,Java NIO 的 transferTo()

传统文件传输的4次拷贝

1
2
3
磁盘 →[DMA拷贝]→ 内核Page Cache →[CPU拷贝]→ 用户缓冲区
→[CPU拷贝]→ Socket发送缓冲区 →[DMA拷贝]→ 网卡
共4次拷贝,2次CPU拷贝,4次上下文切换

sendfile(Linux 2.1+)

1
2
sendfile(socket_fd, file_fd, &offset, count);
// 文件内容直接从 Page Cache 传到 Socket 缓冲区,无需经过用户态

数据路径:

1
2
磁盘 →[DMA]→ Page Cache →[CPU拷贝]→ Socket缓冲区 →[DMA]→ 网卡
共3次拷贝(2次DMA + 1次CPU),0次用户态拷贝

sendfile + DMA Gather(Linux 2.4+,真正零拷贝)

支持 scatter-gather DMA 的网卡:

1
2
3
磁盘 →[DMA]→ Page Cache →[DMA]→ 网卡
Page Cache 的物理地址直接传给网卡,CPU 完全不参与数据拷贝
共2次拷贝(2次DMA),0次CPU拷贝

Java NIO 的 transferTo()

1
2
3
4
5
6
// Java 底层在 Linux 上调用 sendfile
FileChannel src = new FileInputStream("file.txt").getChannel();
SocketChannel dest = socketChannel;

// 真正的零拷贝
src.transferTo(0, src.size(), dest);

Netty 的零拷贝还包括:

  • CompositeByteBuf:逻辑合并多个 ByteBuf,不做内存拷贝
  • slice():切片,共享原 Buffer 内存

mmap vs sendfile

特性 mmap sendfile
适用 需要在用户态处理内容 纯文件→网络传输
修改内容 支持 不支持
CPU 拷贝 1次 0次(scatter-gather)
使用场景 Kafka索引、数据库 Nginx静态文件

Q23. Linux 信号机制:SIGKILL vs SIGTERM vs SIGINT,Java 的 ShutdownHook

常用信号

信号 编号 默认行为 可捕获/忽略
SIGTERM 15 终止进程 可捕获
SIGKILL 9 强制杀死 不可捕获
SIGINT 2 终止(Ctrl+C) 可捕获
SIGQUIT 3 终止+Core Dump 可捕获
SIGHUP 1 终止(终端挂断) 可捕获
SIGSEGV 11 Core Dump 可捕获(但建议不处理)
SIGUSR1/2 10/12 用户自定义 可捕获

关键区别

  • SIGTERM(15):礼貌地请求程序退出,程序可以捕获并做清理工作(关闭连接、flush 缓冲区)
  • SIGKILL(9):内核直接杀死进程,程序没有机会做任何清理,慎用
  • SIGINT(2):Ctrl+C,类似 SIGTERM,程序通常会捕获并退出

最佳实践:先发 SIGTERM,等几秒,再发 SIGKILL(Docker stop 的默认行为就是这样)

1
2
3
kill -15 <pid>   # 或 kill <pid>(默认 SIGTERM)
sleep 5
kill -9 <pid> # 如果还活着,强制杀

Java 的 ShutdownHook

JVM 在收到 SIGTERM 时会触发 ShutdownHook:

1
2
3
4
5
6
7
8
9
10
11
12
13
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("JVM 正在关闭,执行清理...");
// 关闭线程池
executorService.shutdown();
try {
executorService.awaitTermination(30, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 关闭数据库连接
dataSource.close();
System.out.println("清理完成");
}));

注意:

  • SIGKILL 和 JVM 崩溃不会触发 ShutdownHook
  • Spring Boot 的 server.shutdown=graceful 在 ShutdownHook 中等待请求完成
  • ShutdownHook 里的代码要控制超时,避免关闭卡住
1
2
3
# 给 Java 进程发送 SIGUSR1(可用于触发自定义诊断)
kill -USR1 <pid>
# JVM 默认 SIGUSR1 不处理,可在 JVM 内注册

Q24. /proc 文件系统的常用路径

/proc 的本质

/proc 是一个内存文件系统,不占用磁盘空间,内容由内核动态生成,用于暴露内核和进程信息。

进程排查常用路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 进程基本信息
cat /proc/<pid>/status # 状态、内存、线程数
cat /proc/<pid>/stat # 原始调度统计(单行)
cat /proc/<pid>/cmdline # 启动命令行(null分隔)
cat /proc/<pid>/environ # 环境变量
ls -la /proc/<pid>/exe # 可执行文件路径

# 文件描述符
ls -la /proc/<pid>/fd/ # 所有打开的fd
ls -la /proc/<pid>/fd/ | wc -l # fd数量
cat /proc/<pid>/fdinfo/<fd> # 某个fd的详细信息

# 内存
cat /proc/<pid>/maps # 虚拟内存映射
cat /proc/<pid>/smaps # 详细内存统计(每个区域)
cat /proc/<pid>/smaps_rollup # 汇总版 smaps

# 网络
cat /proc/<pid>/net/tcp # TCP连接
cat /proc/<pid>/net/tcp6 # IPv6 TCP连接
ls /proc/<pid>/net/ # 所有网络信息

# IO
cat /proc/<pid>/io # IO字节统计

# 线程
ls /proc/<pid>/task/ # 每个子目录是一个线程

Java 进程排查实用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PID=$(pgrep -f "java.*your-app")

# 1. 查看 JVM 打开了多少个文件
ls /proc/$PID/fd | wc -l
# 超过 ulimit 就会报 "Too many open files"

# 2. 查看哪些文件被打开
ls -la /proc/$PID/fd/ 2>/dev/null | grep -v "^total" | awk '{print $NF}'

# 3. 查看 JVM 内存映射(找 jar 包、so 库)
cat /proc/$PID/maps | grep "\.jar"
cat /proc/$PID/maps | grep "\.so"

# 4. 查看 JVM 进程的 TCP 连接数
ls /proc/$PID/fd | xargs -I{} readlink /proc/$PID/fd/{} 2>/dev/null | \
grep socket | wc -l

# 5. 查看 JVM 内存使用
grep -E "VmRSS|VmHWM|Threads" /proc/$PID/status

Q25. top/htop 命令关键指标解读

top 输出解析

1
2
3
4
5
6
7
8
top - 14:30:25 up 10 days, 3:45,  2 users,  load average: 1.23, 1.45, 1.67
Tasks: 256 total, 1 running, 255 sleeping, 0 stopped, 0 zombie
%Cpu(s): 12.5 us, 3.2 sy, 0.0 ni, 82.3 id, 1.8 wa, 0.0 hi, 0.2 si, 0.0 st
MiB Mem : 15880.2 total, 2345.6 free, 10234.5 used, 3300.1 buff/cache
MiB Swap: 2048.0 total, 1800.0 free, 248.0 used. 4892.3 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
12345 tomcat 20 0 8.2g 2.1g 45.6m S 35.0 13.2 45:12.34 java

Load Average 解读

load average: 1.23, 1.45, 1.67 — 1分钟、5分钟、15分钟的平均负载

负载 = 运行中 + 等待CPU + D状态(不可中断等待IO)的进程数

  • 单核 CPU:负载 = 1.0 表示满负载
  • 4核 CPU:负载 = 4.0 表示满负载
  • 负载持续 > CPU核数就要关注了
1
2
3
4
# 快速获取 CPU 核数
nproc
# 或
grep -c processor /proc/cpuinfo

CPU 时间百分比

指标 含义
us 用户态 CPU(应用代码)
sy 内核态 CPU(系统调用)
ni nice 调整后的用户态进程
id 空闲
wa IO 等待(高 wa → 磁盘/网络瓶颈)
hi 硬件中断
si 软件中断(高 si → 网络包处理多)
st 被虚拟机 Steal 的时间(云服务器 CPU 抢占)

进程内存字段

字段 含义
VIRT 虚拟内存总量(包括未使用的堆、mmap区域)
RES 实际物理内存(RSS,真正占用的RAM)
SHR 共享内存(共享库等,多进程共用)
%MEM RES / 总物理内存

Java 进程的 VIRT 通常很大(几个GB),这是正常的,JVM 预先保留虚拟地址空间。看 RES 才是实际物理内存使用。

1
2
3
4
5
6
7
8
# 实时监控 Java 进程内存
watch -n 1 "ps -o pid,vsz,rss,cmd -p $(pgrep -f java)"

# htop 的优势:彩色、鼠标操作、直观的CPU/内存条
htop -p $(pgrep -f java)

# 按内存排序
top -o %MEM

Q26. 系统调用的执行流程

用户态 → 内核态切换过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户程序调用 read(fd, buf, len)
↓
glibc 的 read() 函数(用户态库函数)
↓ 执行 syscall 指令(x86-64)/ svc 指令(ARM)
↓
CPU 切换到内核态(Ring 3 → Ring 0)
↓ 保存用户寄存器到内核栈
↓
从 syscall 号(RAX 寄存器)查 syscall 表,找到 sys_read()
↓
执行 sys_read() 内核函数
↓
完成后恢复用户寄存器
↓
返回用户态,read() 返回读取的字节数

syscall 指令的作用

syscall(x86-64)是专用指令,执行时:

  1. CPU 特权级从 Ring 3 → Ring 0
  2. 跳转到 MSR(Model Specific Register)中预设的内核入口点
  3. 切换到内核栈
  4. 开始执行内核代码

上下文切换 vs 系统调用

操作 说明 开销
系统调用 同一线程,切换特权级 ~100ns
上下文切换 切换到不同线程/进程 110μs

减少系统调用的技术

批量 IO:writev() / readv() 一次系统调用完成多次 IO

vDSO(Virtual Dynamic Shared Object):

1
2
ls -la /proc/<pid>/maps | grep vdso
# 7fff...000 r-xp ... [vdso]

glibc 的 gettimeofday() 在 vDSO 中实现,不需要真正的系统调用(内核映射到用户空间的只读页)。

io_uring(Linux 5.1+):
用户态和内核态共享环形队列,批量提交 IO,大幅减少系统调用次数。

1
2
3
# 查看进程的系统调用
strace -p <pid> -c # 统计系统调用频率
strace -p <pid> -e read,write # 只追踪特定系统调用

Q27. NUMA 架构对 Java 应用的影响

什么是 NUMA

NUMA(Non-Uniform Memory Access,非一致内存访问)是多 CPU 插槽服务器的内存架构:

1
2
3
4
5
6
7
NUMA Node 0               NUMA Node 1
┌─────────────┐ ┌─────────────┐
│ CPU 0~15 │ │ CPU 16~31 │
│ L1/L2/L3 │ │ L1/L2/L3 │
│ 本地内存 │←──QPI──→│ 本地内存 │
│ 64GB │ │ 64GB │
└─────────────┘ └─────────────┘

访问本地内存(Local Memory)延迟约 80ns,访问远端内存(Remote Memory,跨 NUMA 节点)延迟约 160ns,慢约 2 倍。

NUMA 对 Java 的影响

JVM 分配对象时,如果堆内存跨 NUMA 节点,GC 和对象访问时就会产生大量跨节点内存访问,降低性能。

诊断 NUMA 问题

1
2
3
4
5
6
7
8
# 查看 NUMA 节点信息
numactl --hardware

# 查看 NUMA 内存命中率(numa_miss 高说明有跨节点访问)
numastat -p <pid>

# 查看 NUMA 节点的内存使用
cat /proc/<pid>/numa_maps | head -20

优化方案

1. JVM 启用 NUMA 感知

1
2
3
java -XX:+UseNUMA -XX:+UseParallelGC -jar app.jar
# UseNUMA 让 JVM 在每个 NUMA 节点分配独立的 Eden 区
# 线程优先在本节点 Eden 上分配对象

2. 绑定进程到单一 NUMA 节点

1
2
3
4
5
# 绑定到 NUMA Node 0
numactl --cpunodebind=0 --membind=0 java -jar app.jar

# 绑定到特定 CPU
numactl --physcpubind=0-15 --membind=0 java -jar app.jar

3. interleave(适合无状态服务)

1
2
# 轮询使用所有 NUMA 节点的内存(适合内存密集、访问均匀的场景)
numactl --interleave=all java -jar app.jar

最佳实践:大内存(>= 64GB)多 CPU 插槽的服务器,NUMA 优化可带来 10~30% 的性能提升。


Q28. Linux OOM Killer

触发条件

当系统物理内存 + Swap 都耗尽,且无法回收足够内存时,内核的 OOM Killer(Out-Of-Memory Killer)介入,强制杀死一个进程以释放内存。

oom_score 算法

内核为每个进程计算 oom_score(0-1000),分数越高越容易被杀。

1
2
3
4
5
6
7
# 查看进程的 oom_score
cat /proc/<pid>/oom_score

# 手动调整 oom_score_adj(-1000到1000)
cat /proc/<pid>/oom_score_adj
# -1000: 完全免杀
# 1000: 最先被杀

oom_score 计算因素:

  • 进程占用内存越多 → 分数越高
  • 运行时间越短 → 分数越高
  • 子进程越多 → 分数越高
  • root 进程适当降分

保护 Java 进程

1
2
3
4
5
6
7
8
9
10
# 设置 Java 进程不被 OOM Killer 杀死(需 root)
echo -1000 > /proc/$(pgrep -f java)/oom_score_adj

# 或者用 oom_adj(旧接口)
echo -17 > /proc/$(pgrep -f java)/oom_adj

# 通过 systemd 配置(推荐)
# /etc/systemd/system/my-java-app.service
[Service]
OOMScoreAdjust=-500

OOM 发生后的排查

1
2
3
4
5
6
7
8
9
10
# 查看 OOM Killer 日志
dmesg | grep -i "oom\|killed"
journalctl -k | grep -i oom

# 典型输出
# Out of memory: Kill process 12345 (java) score 856 or sacrifice child
# Killed process 12345 (java) total-vm:4194304kB, anon-rss:3145728kB

# 查看内存使用历史
sar -r 1 10 # 每1秒采集10次内存数据

预防措施

1
2
3
4
5
6
7
8
# 限制 Java 进程内存(防止无限增长)
# cgroup v2
mkdir /sys/fs/cgroup/java-app
echo "4294967296" > /sys/fs/cgroup/java-app/memory.max # 4GB 限制
echo <pid> > /sys/fs/cgroup/java-app/cgroup.procs

# Docker/Kubernetes 的内存 limit 本质上就是 cgroup
# java -Xmx 不要超过容器 limit 的 75%(留给 JVM 本身)

Q29. ulimit 限制对 Java 服务的影响

ulimit 是什么

ulimit 是 Linux 对每个进程资源使用的上限控制,分为软限制(soft,可以提高到硬限制)和硬限制(hard,只有 root 能提高)。

1
2
3
4
5
ulimit -a       # 查看所有限制
ulimit -n # 查看文件描述符限制
ulimit -u # 查看最大进程数
ulimit -s # 查看栈大小限制(KB)
ulimit -v # 查看虚拟内存限制

对 Java 服务的关键影响

1. open files(-n,默认 1024)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 报错症状:java.io.IOException: Too many open files

# Java 每个连接 = 1个fd,每个打开的文件 = 1个fd
# 高并发 Java 服务至少需要设置 65535
ulimit -n 65535

# 永久生效(/etc/security/limits.conf)
echo "* soft nofile 65535" >> /etc/security/limits.conf
echo "* hard nofile 65535" >> /etc/security/limits.conf

# 验证
cat /proc/<pid>/limits | grep "open files"

# 实时查看 Java 进程打开了多少 fd
ls /proc/$(pgrep -f java)/fd | wc -l

2. max user processes(-u,默认 1024 或 4096)

1
2
3
4
5
6
7
8
# 报错症状:java.lang.OutOfMemoryError: unable to create new native thread
# 原因:每个 Java 线程 = 一个内核线程(占用进程槽位)

ulimit -u 65535 # 或修改 /proc/sys/kernel/threads-max

# 查看系统线程总数
cat /proc/sys/kernel/threads-max
ps -eLf | wc -l # 当前线程数

3. stack size(-s,默认 8192KB = 8MB)

1
2
3
4
5
6
7
# 影响:每个线程的栈大小
# Java 线程栈不受 ulimit 控制,由 -Xss 参数控制(默认 512KB~1MB)
# 但 Linux 进程本身的主线程栈受 ulimit 限制

java -Xss256k -jar app.jar # 减小线程栈,允许更多线程

# 栈溢出症状:java.lang.StackOverflowError(与 ulimit 无直接关系)

4. 虚拟内存(-v)

通常不建议限制(JVM 大量使用虚拟地址空间,-v 过小导致 JVM 无法启动)

systemd 服务配置

1
2
3
4
5
6
7
8
9
# /etc/systemd/system/my-java-app.service
[Service]
LimitNOFILE=65535
LimitNPROC=65535
LimitSTACK=8388608

# 生效后重新加载
systemctl daemon-reload
systemctl restart my-java-app

Q30. 实战:Java 服务器 CPU 飙高完整排查流程

场景

生产环境 Java 服务 CPU 使用率突然飙到 90%+,如何快速定位根因?

第一步:top 看全局

1
2
3
4
5
6
7
8
top -d 1

# 关键观察:
# 1. CPU 百分比是 us(用户态)高还是 sy(内核态)高?
# - us 高 → Java 代码问题(死循环、大量计算、GC)
# - sy 高 → 大量系统调用(频繁 IO、网络)
# 2. load average 趋势(上升还是下降)
# 3. wa 高 → IO 等待,可能不是 CPU 问题

第二步:ps 找到耗 CPU 的线程

1
2
3
4
5
6
7
8
9
# 找到 CPU 最高的 Java 线程(LWP = Linux线程ID)
ps -eLf | grep java | sort -k5 -r | head -20

# 或者更方便的
top -H -p $(pgrep -f "java.*your-app") -d 1
# -H:显示线程模式
# 按 P 排序 CPU

# 记下 CPU 最高的线程 PID(假设是 12345)

第三步:转换线程 ID(LWP → 十六进制)

1
2
3
4
5
6
# Java jstack 里的线程 ID 是十六进制
printf "%x\n" 12345 # 输出:3039

# 批量转换(找前5个高CPU线程)
ps -eLf | grep java | sort -k5 -r | head -5 | \
awk '{printf "TID: %s, HEX: %x\n", $4, $4}'

第四步:jstack 获取线程快照

1
2
3
4
5
6
7
PID=$(pgrep -f "java.*your-app")

# 生成线程快照
jstack -l $PID > /tmp/jstack_$(date +%Y%m%d_%H%M%S).txt

# 查找刚才找到的 HEX 线程 ID(如 3039)
grep -A 30 "nid=0x3039" /tmp/jstack_*.txt

典型输出:

1
2
3
4
5
6
7
"GC task thread#0 (ParallelGC)" os_prio=0 tid=0x... nid=0x3039 runnable
# → GC 线程 CPU 高 → 可能是频繁 GC

"http-nio-8080-exec-1" #20 daemon prio=5 os_prio=0 tid=0x... nid=0x303a runnable
at com.example.SomeService.heavyCompute(SomeService.java:123)
at ...
# → 业务代码死循环或大量计算

第五步:根据 jstack 内容分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 场景1:大量 GC 线程 CPU 高
jstat -gcutil $PID 1000 10 # 查看 GC 频率和耗时
# FGC(Full GC)频繁 → 内存问题,dump heap 分析

# 场景2:业务线程 CPU 高(找具体方法)
jstack $PID | grep -A 20 "nid=0x3039"
# 找到具体的方法栈,定位代码

# 场景3:大量 BLOCKED 线程(锁竞争)
jstack $PID | grep "BLOCKED" | wc -l
jstack $PID | grep -B5 "BLOCKED" | grep "waiting to lock"

# 场景4:死循环(线程一直是 runnable 且相同栈)
# 多次 jstack 对比,同一线程栈一直不变

完整排查脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/bin/bash
# cpu-diagnose.sh

APP_NAME="your-app"
PID=$(pgrep -f "java.*$APP_NAME")

if [ -z "$PID" ]; then
echo "未找到进程: $APP_NAME"
exit 1
fi

echo "=== Java 进程 CPU 诊断 ==="
echo "PID: $PID"
echo ""

echo "--- 进程 CPU/内存概况 ---"
top -bn1 -p $PID | tail -4

echo ""
echo "--- Top 5 高CPU线程(LWP) ---"
ps -eLf | grep $PID | grep -v grep | sort -k5 -r | head -5 | \
awk '{printf "TID: %-6s CPU%%: %-5s HEX: 0x%x\n", $4, $5, $4}'

echo ""
echo "--- 生成 jstack 快照 ---"
JSTACK_FILE="/tmp/jstack_${PID}_$(date +%H%M%S).txt"
jstack -l $PID > $JSTACK_FILE
echo "已保存到: $JSTACK_FILE"

echo ""
echo "--- jstack 线程状态统计 ---"
grep "java.lang.Thread.State" $JSTACK_FILE | sort | uniq -c | sort -r

echo ""
echo "--- GC 统计 ---"
jstat -gcutil $PID 1000 3

echo ""
echo "=== 诊断完成 ==="
echo "提示:用 'grep -A 30 \"nid=0xHEX\" $JSTACK_FILE' 查看指定线程的栈"

常见 CPU 飙高原因汇总

现象 可能原因 解决方向
GC 线程 CPU 高 内存不足/内存泄漏 增大堆/优化对象创建
业务线程 Runnable 且栈不变 死循环 找代码里的无限循环
大量线程 BLOCKED 锁竞争 减锁范围/换并发结构
sy CPU 高 频繁系统调用 strace 找高频 syscall
si CPU 高 网络包处理 检查网络流量/连接数
负载高但 us 不高 D 状态进程多 检查磁盘/NFS IO

总结:Linux & OS 面试八股文的核心在于理解底层机制,而不是死记硬背。建议在自己的 Linux 服务器上亲手运行本文中的每一条命令,理解每个输出代表的含义。Java 后端开发者懂 OS 原理,才能在遇到线上问题时快速定位根因,而不是靠撞运气。祝面试顺利!


本文首发于个人技术博客,欢迎转载,转载请注明出处。


Linux & 操作系统面试八股文 30 道|深度详解版(Java 后端必备)
https://whyalwaysme.lol/2026/09/05/2026-09-05-linux-os-interview-deep/
作者
Cassiur
发布于
2026年9月5日
许可协议