京东 C++ 二面 面经
二面基本不扫知识面,每个问题都往深里挖,答完他会继续追问,感觉整场面试都在被拷问细节。项目聊了将近三十分钟,他对架构设计和性能优化特别感兴趣,问了很多"你当时有没有考虑过其他方案"和"这个方案的瓶颈在哪里"。
1. 讲一下 C++ 的对象模型,一个含有虚函数的类,它的对象在内存里是什么布局?多继承时虚函数表是怎么组织的?
答:一个含有虚函数的类,对象内存布局从低地址开始:首先是虚指针(vptr),占一个指针大小(64 位系统 8 字节),指向该类的虚函数表。然后是按声明顺序排列的成员变量,编译器可能在成员之间插入填充字节满足对齐要求。
虚函数表是一个函数指针数组,存放在只读数据段,每个类有一张,不是每个对象有一张。表里按声明顺序存放所有虚函数的地址,派生类重写了哪个虚函数,表里对应位置就替换为派生类的函数地址。
多继承时的情况:假设 D 继承自 B1 和 B2,D 的对象内存布局里有两个虚指针,一个对应 B1 的虚函数表,一个对应 B2 的虚函数表。D 自己新增的虚函数追加在第一个虚函数表(B1 的那张)的末尾。
当通过 B2 指针调用虚函数时,需要调整 this 指针,因为 B2 子对象在 D 对象里有一个偏移,不是从头开始的。编译器会在虚函数表里存放 thunk 代码,调用时先调整 this 指针再跳转到实际函数。
虚继承更复杂,虚基类子对象的位置不固定,需要通过虚基类指针或偏移表来定位,内存布局和访问开销都更大。
2. 讲一下 C++ 的模板特化和偏特化,SFINAE 是什么,std::enable_if 怎么用?
答:模板特化是为特定的类型参数提供专门的实现,分全特化和偏特化。全特化是把所有模板参数都指定为具体类型,偏特化是只指定部分模板参数,或者对模板参数加约束(比如指定为指针类型)。函数模板只支持全特化,类模板支持全特化和偏特化。
SFINAE(Substitution Failure Is Not An Error):模板参数替换失败时,编译器不报错,而是把这个候选从重载集合里移除,继续尝试其他候选。这个特性可以用来在编译期根据类型特征选择不同的函数重载。
std::enable_if 的用法:std::enable_if<condition, T>::type 在 condition 为 true 时等于 T,condition 为 false 时没有 type 成员,触发 SFINAE,这个重载被移除。
// 只对整数类型启用这个函数
template<typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
process(T value) {
// 整数类型的处理
}
// 只对浮点类型启用
template<typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
process(T value) {
// 浮点类型的处理
}
C++17 的 if constexpr 是更直观的替代方案,在编译期做条件分支,不需要 SFINAE 的绕弯子写法。C++20 的 Concepts 更进一步,可以直接用 requires 子句表达类型约束,代码更清晰。
3. 讲一下内存池的设计,为什么要用内存池,怎么解决内存碎片问题?
答:为什么要用内存池:系统的 malloc/free 每次调用都有开销,需要查找合适的空闲块、更新内存管理数据结构,频繁的小对象分配释放会产生大量碎片,而且分配时间不确定。内存池预先分配一大块内存,自己管理分配和释放,针对特定场景优化,可以做到 O(1) 的分配和释放,没有碎片,分配时间确定。
固定大小内存池:最简单的设计,预先分配 N 个固定大小的块,用空闲链表串起来。分配时从链表头取一个块,释放时插回链表头,都是 O(1)。没有碎片,因为所有块大小相同。适合频繁创建销毁同一类型对象的场景,比如网络连接对象、消息对象。
多级内存池:针对不同大小的对象,维护多个固定大小的内存池,比如 8 字节、16 字节、32 字节...每次分配时找到最接近的级别,从对应的池里分配。大对象直接用系统分配器。这是 tcmalloc 和 jemalloc 的基本思路,减少碎片,提高小对象分配效率。
解决碎片的方法:固定大小池天然没有外部碎片。对于变长分配,可以用伙伴系统(Buddy System),内存块大小是 2 的幂次,分配时找最小满足需求的块,释放时尝试和相邻的伙伴块合并,减少碎片。也可以定期做内存整理,把存活对象移动到连续区域,但需要更新所有指向这些对象的指针,实现复杂。
4. 讲一下 Linux 的虚拟内存机制,页表是怎么工作的,TLB 是什么,缺页中断的处理流程是什么?
答:虚拟内存让每个进程有独立的地址空间,进程看到的是虚拟地址,CPU 通过页表把虚拟地址翻译成物理地址。
页表工作原理:虚拟地址被分成多级页号和页内偏移。以 x86-64 为例,用四级页表,虚拟地址的高位依次索引 PGD、PUD、PMD、PTE 四级页表,最终得到物理页帧号,加上页内偏移得到物理地址。每级页表是一个数组,每个条目指向下一级页表或者最终的物理页。
TLB(Translation Lookaside Buffer):页表查找需要多次内存访问,开销大。TLB 是 CPU 内部的缓存,存放最近使用的虚拟地址到物理地址的映射,命中时直接得到物理地址,不需要查页表。TLB 容量有限,进程切换时需要刷新 TLB(或者用 ASID 标记区分不同进程的条目),这是进程切换开销的一部分。
缺页中断处理流程:CPU 访问一个虚拟地址,TLB 未命中,查页表发现页表项无效(页不在物理内存里),触发缺页中断,陷入内核。内核的缺页处理函数检查这个地址是否合法(是否在进程的虚拟地址空间内),合法则分配一个物理页,从磁盘(swap 分区或文件)把数据读入,更新页表,返回用户态重新执行触发缺页的指令。如果地址非法,发送 SIGSEGV 信号给进程,也就是常见的段错误。
5. 讲一下 C++ 的移动语义在实际项目中的应用,什么情况下移动构造函数会被自动调用?
答:移动语义的核心价值是避免不必要的深拷贝,把资源所有权从一个对象转移到另一个对象,通常只是几个指针的赋值,比深拷贝快很多。
移动构造函数自动调用的场景:
函数返回局部对象:编译器会优先做 RVO(返回值优化),直接在调用方的内存里构造对象,完全省去拷贝和移动。如果 RVO 不适用(比如根据条件返回不同的局部变量),编译器会尝试移动而不是拷贝,因为局部变量在 return 语句后就不再使用了,可以被移动。
用临时对象初始化或赋值:MyClass obj = createObject(); 里 createObject() 返回的临时对象会触发移动构造。
显式 std::move:把左值转换为右值引用,告诉编译器可以移动,比如把对象放入容器时 vec.push_back(std::move(obj))。
容器扩容:std::vector 扩容时,如果元素的移动构造函数是 noexcept 的,会用移动而不是拷贝来搬运元素。这就是为什么移动构造函数要标记 noexcept,否则 vector 为了保证异常安全会退回到拷贝。
实际项目中的应用:持有大量数据的对象(比如包含 、 的结构体)在传递时用移动语义,避
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
本专栏系统梳理C++技术面试核心考点,涵盖语言基础、面向对象、内存管理、STL容器、模板编程及经典算法。从引用指针、虚函数表、智能指针等底层原理,到继承多态、运算符重载等OOP特性从const、static、inline等关键字辨析,到动态规划、KMP算法、并查集等手写实现。每个知识点以面试答题形式呈现,注重原理阐述而非冗长代码,帮助你快速构建完整知识体系,从容应对面试官提问,顺利拿下offer。
查看17道真题和解析