卷 C · 操作系统与系统编程 · 高频面试题

通用软件工程师 / FDE 高频面试题库 · 主线卷 C 题源:牛客网公开面经(notes/corpus.jsonl,85 份独立面试经历帖)+ C/C++ 系统编程通用知识 题目统计:普通问答 26 · 手撕 4 · N 备选 0 · 合计 30 个条目。

难度分布(全卷,含手撕):L1 12 · L2 17 · L3 1

频次口径:🔥×N = N = 该题在独立面经帖中的命中数(同义题合并后计),检索键写在每题正文的 <!-- src: ... --> 里,由 tools/fill_freq.py 机械回填、CI 用 --check 校验。低频实录·1源 = 只有 1 份独立面经支撑;补充 = 非面经的整理题,不伪造频次。

使用方式:题目默认折叠,点开看答案。建议按 L1 → L2 → L3 顺序刷;同级内按频次从高到低。手机端原生支持。项目类 / 场景类问题的背景已用自然语言写进题干;「追问」列出常见后续考点。

与其他卷的关系:本卷考的是操作系统 + C/C++ 系统编程这条主线——内存怎么布局、进程线程怎么调度、锁怎么用、协议字节流怎么处理。与卷 B(后端)互补:卷 B 问"服务怎么设计",卷 C 问"底下这层机器怎么工作"。适合岗位:后端 / 基础架构 / 客户端 / 系统开发 / FDE(现场排障面)。

01 内存模型与 C++ 基础(12 题)

L1 🔥×5 Q01 · 栈、堆、静态区(BSS)分别存什么?

答:栈:函数调用时的局部变量、返回地址、寄存器现场,由编译器自动分配与回收,分配快(只移动栈指针)、但空间小(线程默认几 MB)、函数返回后数据就没了;递归太深会栈溢出。堆:动态分配的内存(malloc/new),生命周期由程序员控制、空间大但有碎片与泄漏风险,分配慢。静态区:全局变量与 static 变量,随程序存在整个运行期——.data 段(有非零初值,文件里占空间)、.rodata(只读常量与字符串字面量)、.bss(零初始化变量,不占文件空间、加载时清零,大数组放这里能省 Flash)。大数组放 .bss(零初始化、不占文件空间)是常规做法,可显著减小二进制体积。

易错:以为局部变量一定在栈上——被 static 修饰的局部变量在静态区、生命周期是整个程序;以及把大数组定义在函数栈上,直接爆栈。

L1 🔥×4 Q02 · 空指针、野指针、悬空指针的区别,怎么防?

答:空指针是显式置为 nullptr/NULL,解引用必崩(段错误)。野指针是未初始化就被用,值是随机垃圾,可能指向任意地址——最难查,崩的地方往往离出错点很远。悬空指针是对象已释放但指针还存着原地址,再解引用是未定义行为;更隐蔽的是"释放后仍能访问",不崩但数据可能已被复用。防护:①指针一律初始化为 nullptr;②释放后立即置空,且置空的是唯一"拥有者";③优先用智能指针表达所有权(独占用 unique_ptr,共享用 shared_ptr);关键路径加 assert 与日志,把崩溃提前到出错点。

易错:认为"delete 后置空就万事大吉"——多个指针指向同一块内存时,只置空其中一个,另一个仍悬空(防护要盯住所有权,而不只是置空某个指针)。注意这和 shared_ptr 循环引用不是一回事:循环引用的根因是 A/B 互相持有 shared_ptr,引用计数永远不归零、对象无法析构。

L2 🔥×9 Q03 · 深拷贝与浅拷贝的区别?什么时候必须自己写拷贝控制?

答:浅拷贝只复制指针值、共享同一块资源;深拷贝连资源内容一起复制。当类里持有原始指针、句柄、文件描述符、共享内存这类外部资源时,默认拷贝构造会连指针一起复制,两个对象都以为资源归自己,析构时各释放一次 → double free。这时必须自己写拷贝构造与拷贝赋值(深拷贝),或直接删除拷贝(= delete)改用移动语义。规则叫 Rule of Three / Five:写了析构、拷贝构造、拷贝赋值之一就要审视其余;现代 C++ 更推荐 Rule of Zero——用 std::vector、std::string、std::unique_ptr 让编译器生成正确的拷贝与移动。

易错:以为"有智能指针就万事大吉"——shared_ptr 的默认拷贝是共享(不是深拷贝),语义上仍要确认是否符合预期。

L1 🔥×8 Q04 · unique_ptr / shared_ptr / weak_ptr 怎么选?循环引用怎么破?

答:unique_ptr 独占、不可拷贝只能移动,默认首选(零开销、析构自动释放)。shared_ptr 共享,内部引用计数(拷贝加一、析构减一),但计数本身线程安全、对象本身不是。weak_ptr 不加计数,只观察 shared_ptr,用于打破循环引用。两个对象互持 shared_ptr 时计数永远降不到 0,析构不触发、内存或句柄泄漏(典型表现是文件句柄 / 连接池对象没释放)。破法:把"子指回父"的一侧改成 weak_ptr(父用 shared、子到父用 weak),使用前 lock() 提升,失败说明对象已销毁。实时热路径优先 unique_ptr(shared 还有控制块开销)。

易错:以为 shared_ptr 的引用计数是线程安全的就等于对象安全——两个线程同时改对象内容仍是数据竞争,必须自己加锁或保证"只有一个线程写"。

L2 低频实录·1源 Q05 · shared_ptr 能转 unique_ptr 吗?什么是引用折叠?

答:不能直接转。标准库没有 unique_ptr<T> 接受 shared_ptr<T>&& 的构造函数,unique_ptr<T> u(std::move(sp)); 编译不过(LWG 1031 讨论过,未采纳)。要做所有权降级只能自己接管:unique_ptr<T> u(sp.get());,确认 sp 唯一持有者后 sp.reset()(否则 double free),或给 unique_ptr 配自定义 deleter。引用折叠:T& &、T& &&、T&& & 都折叠成 T&,只有 T&& && 得 T&&,是完美转发 std::forward 的基础。工程里极少手写折叠,但看懂它才能解释"为什么 std::move 之后形参还是左值"。

易错:以为加个 std::move 就能把 shared_ptr 转成 unique_ptr——编译不过;shared→unique 的本质是"所有权降级",必须显式处理其他持有者。

L2 补充 Q06 · placement new 是什么?一般在什么场景用?

答:new (ptr) T(args) 是在已有内存地址上构造对象,不额外向堆申请空间——普通 new 管"内存从哪来",placement new 管"对象在哪造"。构造完必须显式调用析构,否则资源不释放。常见用途:①内存池 / 静态 arena(预申请一大块内存,按需构造对象,避免频繁 malloc);②共享内存(把对象放到别的进程能映射的区域);③DMA / 对齐缓冲(按对齐要求手工放置对象)。两个要点:对齐必须满足类型要求,否则可能总线异常;"分配内存"与"构造对象"是两件事,释放要按原分配方式归还。

易错:以为 placement new 出来的对象也由 delete 释放——delete 会去调用全局分配器释放一块并不存在的堆内存;正确做法是显式调用析构,内存按原分配方式归还。

L2 🔥×6 Q07 · 结构体内存对齐是怎么回事?为什么会有 padding?

答:编译器按每个成员的自然对齐要求(如 int 对齐到 4 字节、double 对齐到 8)排列成员,并在成员之间插入填充字节(padding),使每个成员的起始地址满足其对齐要求;整个结构体的大小还要补齐到最大成员对齐的整数倍,这样结构体数组的每个元素都自然对齐。为什么要对齐:CPU 按字长访问内存,未对齐访问要么效率低(跨总线),要么直接触发异常(ARM Cortex-M7 上未对齐访问可能 HardFault)。工程影响:结构体末尾的 padding 会让 sizeof 比"成员之和"大,协议结构体要显式加 __attribute__((packed)) 或手动填充,否则和上位机/协议对不上;跨平台通信时还要注意两端结构体布局一致(端序 + 对齐 + 字段顺序三件事)。

易错:只用 #pragma pack(1) 压缩结构体却不做端序转换——字节序和打包是两个独立问题,只解决一个照样解析错。

L2 低频实录·1源 Q08 · 大端小端怎么判断?哪些场景必须关心字节序?

答:大端(BE)把多字节数据的高位字节放低地址,小端(LE)把低位字节放低地址。判断:uint32_t x = 0x01020304;,小端机 ((uint8_t*)&x)[0] == 0x04、大端是 0x01;也可用 __BYTE_ORDER__ 或 C++20 的 std::endian::native。必须关心的场景:①网络协议(TCP/IP 规定大端即"网络字节序",htons/ntohs 就是转换);②文件格式与结构体报文;③点云 / 图像等二进制存储;④寄存器与外设访问。注意 Cortex-M(M 核)只有小端配置;BE8 从 ARMv6 起就是 ARM 的大端默认格式(ARMv7-A/R 也支持),不是 ARMv8-A 独有的特性。

易错:认为"都是小端所以不用管"——协议报文、文件存储与跨架构迁移时仍会踩;以及把"字节序"和"位序"混为一谈。

L2 🔥×17 Q09 · 内存泄漏怎么定位?

答:三档工具。①Valgrind memcheck:valgrind --leak-check=full --show-leak-kinds=all ./程序,结束时报告未释放内存的大小、分配位置与调用栈——最有效但很慢,适合复现后离线跑。②编译期插桩:-fsanitize=address(ASan)直接指出越界/释放后使用并给栈信息;-fsanitize=leak(LSan)专查泄漏。③手工排查:重载 new/delete 计数对比;Qt 可用 QObject::dumpObjectTree() 查未释放对象。常见泄漏源:回调 / 观察者未注销、new 出来忘了 delete、shared_ptr 循环引用、异常分支漏释放(用 RAII 根治)。

易错:只在正常流程下测一遍就说"没有泄漏"——异常分支与反复启停才是泄漏高发区。

L1 低频实录·1源 Q10 · const / constexpr / static 分别有什么作用?

答:const 是运行期只读语义——const int a = get(); 合法,初始化可来自运行期结果;作用是表达"不应被修改"。constexpr 强调编译期可求值,用于常量表达式、模板参数、数组长度、case 标签;constexpr 函数在参数为常量时可编译期求值,否则退化为运行期调用。常用它把表项、位掩码、配置常量做成编译期常量,值可参与 constexpr 计算与模板参数。static 有两个完全不同的含义:①存储期——变量整个运行期只存在一份,函数内 static 局部变量有线程安全初始化保证(C++11 起的 magic static);②内部链接——只在当前文件可见。面试把 static 两种含义分开讲,很能区分水平。

易错:以为 const 的数据一定在 ROM 里——const 对象仍可能占 RAM,除非放进 constexpr/.rodata 且不被修改。

L1 🔥×5 Q11 · C++ 指针和引用的区别?

答:指针是变量,存的是对象地址、本身占内存;引用是对象的别名,语义上不占额外存储(实现层通常就是指针)。区别五点:①初始化——指针可不初始化(野/悬空指针来源),引用定义时必须绑定、且终身不可改绑;②空值——指针可为 nullptr,引用没有"空引用";③重绑定——指针可重新指向别的对象,引用不能;④运算——指针可自增、算术、遍历数组,引用无此运算;⑤取地址——&p 是"指针变量自身的地址",&ref 是"被引用对象的地址"。选型:参数必然存在、不改绑、且要避免大对象拷贝就用 const &;"可能为空/可选"才用指针。

易错:把"引用不占内存"绝对化——实现层引用通常是编译器藏的指针,sizeof(ref) 返回的是被引用对象大小;以及拿引用替代"可选参数",忘了它不能为 nullptr,会崩。

L2 🔥×13 Q12 · 虚函数和多态是什么?析构函数为什么建议写成 virtual?

答:多态 = 用基类指针/引用调用派生类重写的方法、运行时决定调哪个,靠虚表(vtable)+ 虚指针(vptr)实现:含虚函数的类各有一张 vtable,对象内 vptr 指向它,虚调用时按 vptr→vtable 查函数地址。virtual 让函数进 vtable,override 声明重写,final 禁止再重写。析构为什么 virtual:通过基类指针 delete 派生类对象时,若基类析构非 virtual,只调基类析构、派生类析构不执行 → 派生类堆成员/句柄泄漏、资源不释放。所以"类可能被继承就写成 virtual 析构";final 类或不作基类的类不必加。注意:虚函数带来 vptr 间接寻址 + 阻止内联,热路径慎用。

易错:构造函数不能是 virtual(构造时 vptr 还没建立,无从虚派发);以及"析构写成 virtual 后派生类析构会自动连锁调用",不是手动调基类析构。

02 进程、并发与同步原语(7 题)

L2 低频实录·1源 Q13 · 上下文切换具体保存和恢复了什么?

答:上下文 = 一个任务的执行现场。切换时做两件事:①保存当前任务:把通用寄存器、程序计数器、栈指针(SP)等写进该任务的任务控制块(TCB),并把栈指针切回该任务自己的栈(内核的 PendSV 触发时会自动压一部分寄存器,软件再保存其余的)。②恢复目标任务:从它的 TCB 读出寄存器值与栈指针,恢复栈与寄存器,然后 bx lr 跳回它上次被切走的位置继续跑。工程上要记住两点:每个任务有独立栈,所以栈溢出只影响自己那个任务(但也可能破坏相邻任务的 TCB,后果更严重);切换开销与栈深度有关,高频切换(1kHz 中断 + 任务)时要看 context switch time 指标。面试可补充:FreeRTOS 的切换由 PendSV 异常触发,优先级最低,保证在临界区结束后才切换。

易错:以为"上下文切换保存的是内存数据"——它保存的是寄存器现场与栈指针,任务的局部变量本来就在各自的栈上,不需要额外搬;切换开销由保存的寄存器上下文决定,与栈深度无关(栈深影响的是每个任务占用的内存)。

L2 补充 Q14 · 优先级反转是什么?优先级继承怎么解决?

答:优先级反转:低优先级任务 L 持有互斥量,高优先级任务 H 就绪后抢占 CPU、随后在锁上阻塞;此时中优先级任务 M 就绪,M 抢占的是 L(不是 H),于是 L 迟迟拿不到 CPU 去释放锁,H 的等待时间被 M 放大——高优先级任务的表现反而比中优先级还差。优先级继承是解法:当高优先级任务等待某个锁、而锁被低优先级任务持有时,内核临时把持锁者的优先级提升到等待者的优先级,这样持锁者尽快运行并释放锁,优先级随后恢复。配套措施还有优先级天花板(拿锁就直接提到该锁允许的最高优先级)。互斥量(mutex)自带优先级继承,而二值信号量没有——这是"保护共享资源必须用 mutex 而不是 semaphore"的根本原因。

易错:用二值信号量代替互斥量实现"互斥访问共享资源"——丢掉了优先级继承,控制任务可能被中优先级任务长期拖延,表现为"偶发控制周期超时"。

L1 🔥×14 Q15 · 死锁的四个必要条件?工程上怎么避免?

答:四个必要条件同时满足才会死锁:①互斥:资源一次只能被一个使用者占用;②持有并等待:持有资源 A 的同时等待资源 B;③不可剥夺:资源不能被强行抢走;④循环等待:等待关系形成环(A 等 B、B 等 A)。工程上五条对策:①统一加锁顺序(全局给所有锁编号,规定只能按序获取)——最常用、最有效;②加超时(try_lock + 超时后释放并重试),避免无限等待;③减少持锁时间,能不放锁内的计算移出临界区;④用优先级继承消除无界的优先级反转(否则高优先级任务可能被无限期推迟;注意这与活锁是两种不同故障);⑤设计阶段做资源依赖图检查是否有环。排查手段:抓所有线程栈(gdb thread apply all bt)看是不是互相等锁。

易错:只答"加锁顺序一致"就停——面试官会追问"如果两个线程分别持有一把锁又互相要对方的怎么办",要能补上超时/try_lock 方案。

L1 🔥×4 Q16 · 队列 / 信号量 / 互斥量三者的区别?

答:队列传数据:按值拷贝、FIFO,可设上限(满了阻塞或丢弃),适合把采样数据从中断/采集任务交给处理任务。信号量传计数或事件:二值 = 一次事件通知,计数 = 资源计数(如 3 个缓冲区);它不涉及所有权,任何任务都能 take/give。互斥量表示对共享资源的独占访问:有所有权(谁拿谁放)、可优先级继承、可加超时;普通互斥量不可递归加锁,需要递归加锁时用 xSemaphoreCreateRecursiveMutex(Linux 用 PTHREAD_MUTEX_RECURSIVE)。三条选型口诀:传数据用队列;通知事件用信号量;保护共享资源用互斥量。典型用法:任务间传数据走队列,事件通知用信号量,共享缓冲区用互斥量。

易错:用二值信号量当互斥量用(丢优先级继承,且不区分持有者);以及往队列里塞大对象(拷贝开销大、实时性差,应传指针或分块)。

L2 🔥×2 Q17 · mutex 和 spinlock 的区别?什么场景用自旋?

答:共同点都是互斥进入临界区,区别在拿不到锁时的行为:mutex会让当前线程睡眠并被调度出去,等锁释放再唤醒——有上下文切换开销,但不空耗 CPU,且可以配合优先级继承。spinlock则忙等(自旋)直到锁可用——不睡眠、切换开销极小,但空耗 CPU,且如果持锁者优先级低于自旋者,可能出现"自旋者抢占持锁者"的活锁。因此用自旋锁的条件是:临界区极短(几十纳秒到几微秒)、不会睡眠、且在抢占式内核里要小心优先级反转。典型用自旋锁的场景:内核中中断上下文访问共享数据、多核下保护很短的计数器;用 mutex 的场景:临界区长、可能加别的锁、可能在锁内调用可能阻塞的函数。

易错:在中断上下文用 mutex(不能睡眠);以及在持锁者需要被更高优先级线程推进的路径上用自旋锁,形成活锁。

L3 🔥×6 Q18 · 原子操作与内存屏障,为什么驱动里要写内存屏障?

答:编译器与 CPU 都会重排读写:编译器优化掉"看起来没用"的读写,CPU 的乱序执行让写操作的对外可见顺序与程序顺序不同。单线程无感,多核或"中断 + 主流程"并发时就会出现"标志位已置位、数据还没写进去"的竞态。解法是原子操作 + 内存序:std::atomic<T> 提供 load/store/exchange/compare_exchange,用 memory_order 指定强度——relaxed(只保证原子性)、acquire/release(建立同步关系)、seq_cst(最强)。驱动里的典型写法:"先写数据缓冲区,再用 release 语义的 store 写 doorbell 寄存器";Linux 内核对应 wmb()/rmb()、smp_wmb()。

易错:用 volatile 代替原子操作——volatile 只保证"不被编译器优化掉",不提供原子性、不提供跨核的内存顺序,在多核上依然会出错。

L2 补充 Q19 · 内存踩踏可能是什么原因?怎么防?

答:内存踩踏(memory stomping)指变量被意外覆盖成别的值:通常不是显式越界写,而是缓冲区溢出破坏了相邻内存——多写 1 字节(off-by-one)、数组下标没检查、指针算错(p + n 写成 p + n * size)、结构体长度与协议不一致。症状是"某个变量莫名变成 0 或魔法值",且崩溃点离出错点很远。防法:①边界检查(std::vector、必要时 at());②编译期插桩(-fsanitize=address 直接指出越界位置,嵌入式可用 -fstack-protector-strong);③结构体填充保护敏感字段;④静态分析(-Wall -Wextra、clang-tidy);⑤共享内存加 magic 与版本号校验。

易错:只加日志不加边界检查——被踩的变量可能正好是日志系统自己用的内存。

03 进程间通信与网络编程(3 题)

L1 🔥×6 Q20 · Linux 进程间通信有哪些方式?各自适用什么场景?

答:六类。①管道 / 命名管道:内核缓冲的字节流,适合父子进程或同机简单数据流。②消息队列:带消息边界的内核队列,可设优先级,适合传结构化消息。③共享内存:把物理页映射到多个进程,零拷贝、最快,但要配信号量/互斥量同步。④信号量:进程间同步与计数。⑤信号:轻量异步通知。⑥Socket(含 Unix domain socket):同机跨机都行。口诀:同机传大数据 → 共享内存 + 信号量;传结构化消息 → 消息队列;跨机 → Socket。

易错:以为管道能传结构体——管道是字节流,结构体直接写进去会因对齐/填充导致接收端解析错位,必须自己定协议。

L1 🔥×2 Q21 · TCP 粘包 / 拆包是什么?怎么处理?

答:TCP 是字节流协议、没有消息边界:连发两次,接收端可能一次 recv 拿到两半(粘包),一次 send 也可能被拆成多次(拆包)。四种办法:①定长消息——最简单但费带宽;②分隔符(如 \n)——适合文本协议;③长度字段(帧头含长度)——最常用,二进制协议标准做法;④自描述协议(magic + 长度 + 类型 + 校验)。实现要点:接收端维护接收缓冲区,循环"凑够一帧"再交业务,否则丢包或解析错位;还要处理半包与超时(凑不齐就丢弃并计数)。UDP 是数据报、保留边界,但会丢包且不保证顺序。

易错:认为"调大 socket 缓冲区"能解决粘包——它只影响吞吐,不影响消息边界;必须自己定协议。

L2 🔥×8 Q22 · 虚拟内存是什么?缺页和页面置换是怎么回事?

答:虚拟内存让每个进程看到独立的、连续的地址空间,物理内存由操作系统按需映射;它同时提供隔离、保护(页权限)与超量装载(多出的部分换出到磁盘)。

缺页(page fault):CPU 访问的虚拟页不在物理内存时触发异常:页面合法且在磁盘 → 缺页异常,从 swap / 可执行文件读入物理页、更新页表后重新执行那条指令;页面非法 → 段错误。

页面置换:物理内存不足时把暂时不用的页换出。算法:OPT / Belady(理论最优,需预知未来、无法实现)、LRU(最常用的近似,实际用时钟算法 / 采样 LRU)、FIFO(可能抖动)。抖动(thrashing)= 换进换出过于频繁,表现为 CPU 忙而磁盘更忙。

易错:以为"缺页 = 程序崩溃"——缺页是正常的按需调页,只有非法访问才是段错误。

04 虚拟内存、文件系统与排障(4 题)

L2 🔥×4 Q23 · 什么是 inode?Linux 的文件系统与 page cache 是怎么回事?

答:inode 是文件元数据(大小、权限、属主、时间戳、数据块位置)的抽象,不含文件名——文件名存在目录里,目录本身也是文件,内容是"名字 → inode 号"的映射。所以可以硬链接同一 inode(多个名字指向同一文件)。

文件描述符(fd) 是进程打开文件后拿到的整数,内核用 fd 找到对应 inode。open 同一文件两次得到两个 fd,但指向同一个 inode。

page cache:Linux 把文件内容缓存在内存里,读先查 cache,写先写 cache 再由回写线程按脏页比例刷盘。所以 write 返回不等于已落盘——需要持久化要用 fsync。

易错:以为"文件已写入 = 已落盘"——write 只进 page cache,进程崩溃数据还在,机器断电才丢;ls 看到的是逻辑大小,不等于实际占用块数(du 看的是实际占用)。

追问:① 硬链接 vs 软链接?② 零拷贝为什么依赖 page cache?③ fsync 一定能让数据落盘吗?

L2 🔥×7 Q24 · 协程是什么?和线程、进程有什么区别?

答:协程是用户态可调度的轻量执行流,多个协程可以复用少量系统线程。

三层对比:

  • 进程:内核调度单位,地址空间隔离,切换最重。
  • 线程:内核调度单位,共享地址空间,切换中等。
  • 协程:用户态调度(语言运行时 / 库实现),同一线程内切换只需保存少量寄存器,最轻。

典型实现:Go 的 goroutine(运行时调度,多个 goroutine 复用 M 个线程)、C++20 的 coroutine(语言级协程)、Python 的 asyncio(事件循环驱动)、ucontext / Boost.Coroutine。

为什么快:切换不进内核、不涉及缺页与调度器,且栈可以按需增长。

易错:以为"协程 = 比线程并发更高"——协程是协作式调度,一个协程不 yield/await,其它协程就没法跑(CPU 密集任务会卡死整个循环);await 点必须真正让出,否则事件循环空转。

追问:① 协作式 vs 抢占式?② 协程怎么实现栈切换?③ asyncio 的事件循环怎么驱动回调?

L1 低频实录·1源 Q25 · 排查 CPU / 内存 / 磁盘问题常用哪些 Linux 命令?

答:按"看什么"记:

  • CPU / 负载:top(按 1 看各核)、uptime(load average 是 1/5/15 分钟可运行任务数,大于核数说明过载)、vmstat 1(看 r 队列与 cs 上下文切换)。
  • 内存:free -m(看 available 而非 free)、ps aux --sort=-%mem | head。
  • 磁盘:df -h(inode 也可能耗尽,看 iused%)、du -sh 逐层找大文件、iostat -x 1(看 %util 与 await)。

易错:不会看 load average 与核数的关系;只看 free 发现还有内存就以为没内存问题——真正可用的是 available。

追问:① vmstat 里 r 队列和 cs 分别说明什么?② 怎么区分磁盘 IO 瓶颈和 CPU 瓶颈?③ df 显示有空间但写不进去,可能是什么原因?

L1 补充 Q26 · 排查网络 / 进程 / 日志问题常用哪些 Linux 命令?

答:

  • 网络:ss -antp | grep <port>(谁连着这个端口)、netstat -i(网卡错误 / 丢包)、tcpdump -i any port 80(抓包)、curl -v(看握手与响应头)。
  • 进程 / 内核:ps -ef、kill -3 <pid>(让 JVM 打印线程栈,不杀进程)、dmesg | tail(看内核日志与 OOM killer)、strace -p <pid>(看系统调用)、lsof -i :<port>(端口被谁占)。
  • 日志:tail -f、grep -C 5(带上下文)、awk 做统计、journalctl -u <svc> --since "10 min ago"。

易错:用 kill -TERM 去看 JVM 栈——那会把进程杀掉,要用 kill -3;只 tail 当前日志不按时间窗过滤,线上日志量大时根本翻不到。

追问:① ss 和 netstat 的区别?② 怎么确认一个端口是被 TIME_WAIT 占住还是真的在监听?③ 抓包抓到明文 HTTP 有什么合规风险?

05 操作系统手撕代码(4 题)

L1 🔥×2 ✍ H01 · 手撕位操作:置位 / 清零 / 读某一位

考察点:寄存器操作的位掩码写法,以及"读-改-写"的原子性风险。

实现:

def set_bit(value, bit):        # 置位
    return value | (1 << bit)

def clear_bit(value, bit):      # 清零
    return value & ~(1 << bit)

def toggle_bit(value, bit):     # 取反
    return value ^ (1 << bit)

def test_bit(value, bit):       # 读某位
    return (value >> bit) & 1

def set_bits(value, mask):      # 掩码置位(一次改多位)
    return value | mask

def clear_bits(value, mask):    # 掩码清零
    return value & ~mask

# 典型用途:把若干标志位打包进一个字节再发给下位机
flags = set_bit(0, 0) | set_bit(0, 3)
assert test_bit(flags, 0) == 1 and test_bit(flags, 3) == 1
assert clear_bit(flags, 0) & (1 << 0) == 0

易错:清零时直接写 value = ~(1 << bit)(把其他位也清了)——必须用掩码与运算;以及对同一寄存器的"读-改-写"在中断里被打断,会丢失其它线程的修改(应使用原子置位/清除接口)。

L2 补充 ✍ H02 · 手撕带粘包处理的 TCP 服务端

考察点:字节流没有边界——必须用"长度字段"分帧,并处理半包与多包。

实现:

import socket

MAX_FRAME = 1 << 20          # 单帧上限 1 MiB,防止长度头异常导致无限拼接

def recv_exactly(conn, n, buf):        # 从 buf + 后续读取中凑满 n 字节(处理半包)
    while len(buf) < n:
        chunk = conn.recv(4096)
        if not chunk:                     # 对端关闭
            raise ConnectionError('peer closed')
        buf += chunk
    return buf[:n], buf[n:]

def handle(conn):
    buf = b''
    while True:
        head, buf = recv_exactly(conn, 4, buf)   # 4 字节长度头
        length = int.from_bytes(head, 'big')      # 4 字节长度头 → 整数
        if not 0 <= length <= MAX_FRAME:         # 长度头可能被伪造/错乱,必须先校验
            raise ValueError(f'bad frame length: {length}')
        body, buf = recv_exactly(conn, length, buf)
        conn.sendall(b'OK')                       # 交给业务解析:打印帧长、回确认

def serve(host='0.0.0.0', port=9000):
    with socket.socket() as srv:
        srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        srv.bind((host, port))
        srv.listen(5)
        while True:
            conn, _ = srv.accept()
            try:
                with conn:
                    handle(conn)
            except (ConnectionError, ValueError) as e:   # 单个客户端断开/帧异常不能带崩监听循环
                print('连接结束:', e)

易错:把 recv 的返回值当成"一条完整消息"——一次 recv 可能拿到半帧或多帧;以及只 recv 不保留剩余字节,帧边界全部错位。另三点:accept 循环写在模块层,import 即占端口,应收进 serve();长度头要先校验上限,否则伪造的长度能让服务端无限收包;serve() 要按连接兜异常,否则一个客户端断开就带崩监听循环。

L2 🔥×4 ✍ H03 · 手撕对象池(placement new + 显式析构)

考察点:把"分配内存"与"构造对象"分开——pool 负责内存,对象自己管生命周期;取用时 placement new,归还时显式析构。

实现:

import ctypes

class Slot:
    __slots__ = ("buf", "alive")
    def __init__(self, size):
        self.buf = ctypes.create_string_buffer(size)   # 预分配,不动全局分配器
        self.alive = False

class ObjectPool:
    def __init__(self, obj_size=64, capacity=32):
        self.obj_size = obj_size
        self.free = [Slot(obj_size) for _ in range(capacity)]

    def acquire(self):
        if not self.free:
            raise RuntimeError("pool exhausted")
        slot = self.free.pop()
        slot.alive = True
        return slot

    def release(self, slot):
        # C++ 语义:先显式调用析构,再按原分配方式归还内存
        slot.alive = False
        self.free.append(slot)

pool = ObjectPool(64, 2)
s1 = pool.acquire()
assert s1.alive and len(pool.free) == 1
pool.release(s1)
assert not s1.alive and len(pool.free) == 2
try:
    pool.acquire(); pool.acquire(); pool.acquire()
    raised = False
except RuntimeError:
    raised = True
assert raised, "池耗尽应抛错而不是无限分配"

易错:归还时不显式析构——对象持有外部资源(文件、句柄)时不会释放;用 ctypes 模拟 C++ 时忘记「析构 → 归还」的顺序;池耗尽时默默继续分配(失去池的意义)。

L2 🔥×7 ✍ H04 · 手撕开放地址法哈希表(线性探测 + 扩容)

考察点:开放地址法解决冲突;删除必须用墓碑标记而非直接置空,否则会截断探测链;负载因子超阈值要扩容。

实现:

class HashMap:
    TOMBSTONE = object()          # 墓碑:表示"这里曾有元素但已删"

    def __init__(self, capacity=8):
        self.cap = capacity
        self.size = 0              # 活元素数
        self.used = 0              # 活元素 + 墓碑数(决定何时扩容)
        self.table = [None] * capacity

    def _hash(self, key):
        return hash(key) % self.cap

    def _probe(self, key):
        """返回 (槽位, 是否命中, 是否可插入)。可插入 = 空槽或墓碑槽。"""
        i = self._hash(key)
        first_free = -1
        for _ in range(self.cap):
            cur = self.table[i]
            if cur is None:
                # 空槽:探测链到此为止
                return (first_free if first_free >= 0 else i), False, True
            if cur is self.TOMBSTONE:
                if first_free < 0:
                    first_free = i     # 记录第一个墓碑,可复用
            elif cur[0] == key:
                return i, True, False
            i = (i + 1) % self.cap
        # 整圈探测完:只有墓碑可插入,没有空槽
        return first_free, False, first_free >= 0

    def put(self, key, value):
        # 负载因子按 used(含墓碑)算,否则"删太多再插"会把表填满
        if (self.used + 1) / self.cap > 0.7:
            self._resize(self.cap * 2)
            i, _, _ = self._probe(key)
            self.table[i] = (key, value)
            self.size += 1
            self.used += 1
            return
        i, found, insertable = self._probe(key)
        if not insertable:
            self._resize(self.cap * 2)     # 无空槽也无墓碑 → 必须扩容
            i, _, _ = self._probe(key)
            self.table[i] = (key, value)
            self.size += 1
            self.used += 1
            return
        if not found and self.table[i] is self.TOMBSTONE:
            pass                          # 复用墓碑槽:used 不变
        elif not found:
            self.used += 1
        self.table[i] = (key, value)
        if found:
            self.size -= 1                # 覆盖已有 key,活元素数不变
            self.size += 1                # 保持语义:下面统一处理
        self.size = sum(1 for c in self.table
                        if c is not None and c is not self.TOMBSTONE)

    def get(self, key):
        i, found, _ = self._probe(key)
        return self.table[i][1] if found else None

    def remove(self, key):
        i, found, _ = self._probe(key)
        if not found:
            return
        self.table[i] = self.TOMBSTONE    # 墓碑:不能置 None,否则探测链断裂
        self.size -= 1                    # used 不减:墓碑仍占着槽位

    def _resize(self, new_cap):
        old = self.table
        self.cap = new_cap
        self.table = [None] * new_cap
        self.size = 0
        self.used = 0
        for cur in old:
            if cur is not None and cur is not self.TOMBSTONE:
                self.put(cur[0], cur[1])

易错:删除时置 None——后面的元素探测时会提前终止,找不到本该存在的 key;扩容时忘记跳过墓碑;负载因子只算活元素——大量删除后墓碑把表占满,插入时找不到空槽就会写坏数据(必须把墓碑计入占用,或在探测不到空槽时强制扩容)。


低频备选(未入主表)