网易游戏 — 后端一面(2026-04-03)¶
标签: C++ 大拷打 · 无算法手撕
侧重: 内存模型、智能指针、容器底层、并发
Q1. 结构体内两个 int,两个线程分别修改,会有并发问题吗?¶
回答¶
结论:从语言标准来看没有数据竞争(data race),但存在 false sharing 性能问题。
1)为什么没有数据竞争¶
C++ 标准([intro.races])规定:两个内存操作构成 data race 的前提是它们 访问同一个内存位置(memory location) 且至少一个是写。int a 和 int b 是两个不同的内存位置,各线程只操作自己的那个变量,互不重叠,因此 不构成 data race,行为是良定义的。
注意: 如果换成 位域(bit-field),情况完全不同——相邻位域可能共享同一存储单元,此时两个线程分别写不同位域就 构成 data race。
2)False Sharing 性能问题¶
虽然语义正确,但 a 和 b 大概率落在 同一条 cache line(通常 64 字节)。两个核分别写同一 cache line 时,硬件缓存一致性协议(MESI)会不断地将该 cache line 在两核之间弹来弹去(cache line bouncing),导致性能急剧下降。
3)如何解决 false sharing¶
或使用 C++17 std::hardware_destructive_interference_size:
#include <new>
struct S {
alignas(std::hardware_destructive_interference_size) int a;
alignas(std::hardware_destructive_interference_size) int b;
};
注意事项¶
- 面试官常希望你先回答「没有 data race」,再 主动提出 false sharing,这是加分项
- 记得提一句位域的特殊情况作为补充
- false sharing 是高频追问点,可以结合 MESI 协议讲
细看: 并发编程 · 进程 · 线程 · 内存
Q2. 手撕 shared_ptr¶
思路¶
实现一个简化版 shared_ptr,核心要素:
- 引用计数 分配在堆上,所有共享同一对象的
shared_ptr实例共享同一个计数器 - 拷贝 时计数 +1,析构 时计数 -1,计数归零时释放资源
- 移动 语义转移所有权,不改变引用计数
- 赋值 要处理自赋值和旧资源释放
完整代码¶
#include <iostream>
#include <utility> // std::exchange
template <typename T>
class SharedPtr {
public:
// 默认构造
SharedPtr() : ptr_(nullptr), ref_count_(nullptr) {}
// 裸指针构造
explicit SharedPtr(T* ptr) : ptr_(ptr), ref_count_(new size_t(1)) {}
// 拷贝构造
SharedPtr(const SharedPtr& other)
: ptr_(other.ptr_), ref_count_(other.ref_count_) {
if (ref_count_) {
++(*ref_count_);
}
}
// 移动构造
SharedPtr(SharedPtr&& other) noexcept
: ptr_(std::exchange(other.ptr_, nullptr)),
ref_count_(std::exchange(other.ref_count_, nullptr)) {}
// 拷贝赋值(copy-and-swap 惯用法)
SharedPtr& operator=(SharedPtr other) {
swap(other);
return *this;
}
// 移动赋值
SharedPtr& operator=(SharedPtr&& other) noexcept {
if (this != &other) {
release();
ptr_ = std::exchange(other.ptr_, nullptr);
ref_count_ = std::exchange(other.ref_count_, nullptr);
}
return *this;
}
// 析构
~SharedPtr() { release(); }
// 解引用
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
T* get() const { return ptr_; }
size_t use_count() const { return ref_count_ ? *ref_count_ : 0; }
explicit operator bool() const { return ptr_ != nullptr; }
void reset(T* ptr = nullptr) {
release();
if (ptr) {
ptr_ = ptr;
ref_count_ = new size_t(1);
} else {
ptr_ = nullptr;
ref_count_ = nullptr;
}
}
void swap(SharedPtr& other) noexcept {
std::swap(ptr_, other.ptr_);
std::swap(ref_count_, other.ref_count_);
}
private:
void release() {
if (ref_count_) {
--(*ref_count_);
if (*ref_count_ == 0) {
delete ptr_;
delete ref_count_;
}
ptr_ = nullptr;
ref_count_ = nullptr;
}
}
T* ptr_;
size_t* ref_count_;
};
注意事项¶
| 要点 | 说明 |
|---|---|
| 构造函数必须 explicit | 防止裸指针隐式转换 |
| 拷贝赋值用 copy-and-swap | 天然异常安全且处理自赋值 |
| 移动后置空源对象 | 否则析构时 double free |
| 引用计数在堆上 | 不能用栈变量或 static,多个实例必须共享同一计数器 |
| 线程安全版 | 面试追问时说:把 size_t 换成 std::atomic<size_t>,引用计数的 +±- 用原子操作 |
| 自定义删除器 | 进阶追问:模板参数或 std::function 存储删除器 |
| weak_ptr | 标准库把引用计数拆成 strong_count + weak_count,weak_ptr 不增加 strong_count |
Q3. delete 底层是怎么实现的,怎么知道要删除哪些内存¶
回答¶
delete 的两步操作¶
delete[] 如何知道数组长度¶
编译器在 new T[n] 时会多分配一块空间(通常在返回指针之前的若干字节),存储元素个数 n,这叫 array cookie:
delete[] 时,编译器先将指针前移读取 cookie 得到 n,再逆序调用 n 次析构函数,最后释放整个内存块。
free 如何知道要释放多大的内存¶
malloc 分配时在返回的指针前面维护一个 元数据头(chunk header),记录了块大小、是否空闲等信息。free 根据传入的指针回退找到这个 header,得知块大小后归还给堆管理器。
不同分配器(glibc ptmalloc、tcmalloc、jemalloc)具体的 header 格式不同,但原理一致。
注意事项¶
delete和delete[]不能混用:delete不读 cookie,不会调用数组中每个元素的析构函数- 对内置类型(如
int*)混用delete/delete[]在某些实现上碰巧不出错,但属于 未定义行为 - 面试追问「虚析构函数」:基类指针 delete 派生类对象时,若析构函数非虚,只会调基类析构 → 资源泄漏
细看: 内存管理与 Allocator · 对象模型与内存布局
Q4. C++ 有哪些申请内存的方法¶
回答¶
| 方法 | 层级 | 内存区域 | 特点 |
|---|---|---|---|
new / new[] |
C++ 运算符 | 堆(heap) | 调用构造函数,类型安全 |
malloc / calloc / realloc |
C 库函数 | 堆(heap) | 返回 void*,不调构造函数 |
placement new |
C++ 运算符 | 任意已有内存 | 在指定地址上构造对象,不分配内存 |
operator new |
C++ 全局/类内重载 | 堆 | 只负责分配原始内存,不调构造 |
std::allocator<T>::allocate |
STL 分配器 | 堆 | 容器默认使用,底层调 operator new |
mmap / VirtualAlloc |
系统调用 | 用户虚拟地址空间 | 直接向 OS 要整页内存 |
alloca |
编译器内建 | 栈(stack) | 函数返回后自动释放,不要在循环中用 |
sbrk |
系统调用(Linux) | 堆(调整 brk) | 过时,glibc malloc 小块用此、大块用 mmap |
注意事项¶
- 面试核心讲前 4 个即可,
mmap和alloca属于加分项 placement new不分配内存,需手动调析构函数、手动管理内存生命周期calloc与malloc的区别:calloc会零初始化realloc可能原地扩容也可能搬迁,返回的指针可能变化
细看: 内存管理与 Allocator
Q5. malloc 和 new 的区别;在哪部分内存操作;进程有哪些类型的内存¶
回答¶
malloc vs new¶
| 对比维度 | malloc |
new |
|---|---|---|
| 来源 | C 标准库函数 | C++ 运算符 |
| 返回类型 | void*,需强制转换 |
正确类型的指针 |
| 构造/析构 | 不调用 | 自动调用构造函数 |
| 失败行为 | 返回 NULL |
抛出 std::bad_alloc(nothrow 版返回 nullptr) |
| 大小计算 | 手动指定字节数 | 编译器自动计算 sizeof(T) |
| 可重载 | 不可 | operator new 可全局/类内重载 |
| 配对释放 | free |
delete / delete[] |
两者 都在堆(heap)上分配内存。底层路径:new → operator new → malloc(典型实现)→ sbrk 或 mmap。
进程虚拟内存布局(Linux,从低地址到高地址)¶
┌──────────────────────────┐ 高地址
│ 内核空间 │ 用户态不可访问
├──────────────────────────┤
│ 栈 (Stack) │ 局部变量、函数调用帧,向下增长
├──────── ↓ ────────────────┤
│ (空闲区间) │
├──────── ↑ ────────────────┤
│ 内存映射区 (mmap) │ 共享库、大块 malloc、文件映射
├──────────────────────────┤
│ 堆 (Heap) │ malloc/new,向上增长
├──────────────────────────┤
│ BSS 段 │ 未初始化的全局/静态变量(零初始化)
├──────────────────────────┤
│ 数据段 (Data) │ 已初始化的全局/静态变量
├──────────────────────────┤
│ 代码段 (Text) │ 可执行指令,只读
└──────────────────────────┘ 低地址
注意事项¶
new底层 一般会调用malloc,但标准并不要求,面试时说 "典型实现" 更严谨- 堆和栈的增长方向相对,中间是自由区间
- mmap 区在堆和栈之间,共享库(
.so)也加载在此 - 面试追问「栈多大」:Linux 默认 8MB(
ulimit -s),可调
细看: 进程 · 线程 · 内存 · 内存管理与 Allocator
Q6. vector 和 list 有什么区别,为什么遍历 list 会慢¶
回答¶
核心区别¶
| 维度 | std::vector |
std::list |
|---|---|---|
| 底层结构 | 连续动态数组 | 双向链表 |
| 随机访问 | O(1) | O(n),不支持 [] |
| 头部插入/删除 | O(n)(搬移元素) | O(1) |
| 尾部插入 | 均摊 O(1) | O(1) |
| 中间插入/删除 | O(n) | O(1)(已知迭代器位置) |
| 迭代器失效 | 扩容时全部失效 | 仅被删除节点的迭代器失效 |
| 内存开销 | 紧凑,仅数据 + 少量预留 | 每个节点额外 2 个指针(prev + next) |
为什么遍历 list 慢 — 缓存不友好(cache unfriendly)¶
vector 元素在内存中连续排列。CPU 读取一个元素时,该 cache line(64B)上的 相邻元素也一起被预取 进 L1 cache,后续访问直接命中,这就是 空间局部性(spatial locality)。
list 的每个节点是独立 new 出来的,散落在堆的各处。遍历时每次 node = node->next 几乎都是一次 cache miss,需要从 L2/L3 甚至主存中取数据。
量级差距: L1 cache 命中约 1ns,主存访问约 100ns。遍历百万元素时,list 可能比 vector 慢一个数量级以上。
注意事项¶
- 面试回答先说数据结构差异,再 主动提 cache 局部性,这是面试官最想听的
- 追问「什么时候用 list」:频繁在中间任意位置插入/删除,且不需要随机访问
std::deque是折中方案:分块连续,支持头尾 O(1) 插入
细看: STL 容器深入
Q7. vector push_back 如何知道在哪放新元素;扩容时怎么变化(结合操作系统)¶
回答¶
vector 的三指针模型¶
std::vector 内部维护三个指针:
// 简化示意
template<typename T>
class vector {
T* _start; // 已分配内存的起始地址
T* _finish; // 最后一个有效元素的下一个位置
T* _end_of_storage; // 已分配内存的末尾
};
size()=_finish - _startcapacity()=_end_of_storage - _start
push_back 流程¶
if (_finish < _end_of_storage) {
// 空间充足:在 _finish 位置 placement new 构造元素
new (_finish) T(value);
++_finish;
} else {
// 空间不足:触发扩容
reallocate_and_push_back(value);
}
所以 vector 通过 _finish 指针精确知道新元素应放在哪。
扩容机制¶
- 计算新容量:通常为当前容量的 2 倍(GCC libstdc++),MSVC 为 1.5 倍
- 分配新内存块:调用
operator new→ 底层走malloc→ 小块通过sbrk在堆上扩展,大块(通常 ≥128KB)通过mmap直接映射虚拟页 - 移动/拷贝元素:优先使用移动构造(如果
noexcept),否则回退到拷贝构造以保证强异常安全 - 析构旧元素 + 释放旧内存
- 更新三指针
操作系统视角¶
用户态: vector::push_back → operator new → malloc
↓
C 库: malloc → ptmalloc 从 arena/bin 分配
↓ (空闲内存不足时)
内核: sbrk() 调整 brk 指针(小块)
或 mmap() 映射新的匿名页(大块)
↓
内核分配虚拟页 → 首次访问时触发缺页中断(page fault)
→ 分配物理页帧 → 建立页表映射
关键点:
malloc返回的是虚拟地址,物理内存直到实际写入时才通过 缺页中断(demand paging) 真正分配。所以扩容malloc了一大块内存不会立刻消耗物理 RAM。
注意事项¶
- 面试先答「三指针 + _finish 定位」,再展开扩容流程
- 扩容因子为什么不是更大或更小: 2 倍在时间和空间上折中;1.5 倍可能让旧内存块可被后续分配复用
- 扩容后 所有迭代器、指针、引用全部失效
- 如果确定元素数量,用
reserve()提前预分配避免多次扩容
细看: STL 容器深入 · 进程 · 线程 · 内存
Q8. 开发时如何避免内存越界问题¶
回答¶
编码层面¶
| 手段 | 说明 |
|---|---|
| 使用 STL 容器 | vector、string 自动管理内存,替代裸数组 |
用 at() 代替 [] |
at() 做边界检查,越界时抛 std::out_of_range |
| 智能指针 | unique_ptr / shared_ptr 避免手动 delete,杜绝 use-after-free |
| RAII | 资源在构造时获取、析构时释放,不靠手动管理 |
| std::span(C++20) | 带长度信息的视图,替代裸指针 + 长度 |
| 避免 C 风格字符串操作 | strcpy → std::string;sprintf → std::format / snprintf |
工具层面¶
| 工具 | 用途 |
|---|---|
| AddressSanitizer (ASan) | 编译时加 -fsanitize=address,运行时检测越界、use-after-free、double-free |
| Valgrind (Memcheck) | 运行时内存错误检测,无需重新编译(但会慢 10-20x) |
| ThreadSanitizer (TSan) | 检测数据竞争 |
| 静态分析 | Clang-Tidy、Coverity、PVS-Studio 在编译前发现潜在越界 |
| 模糊测试(Fuzzing) | AFL / libFuzzer 自动生成输入触发边界条件 |
设计层面¶
- 最小化裸指针使用:能用引用就不要用指针
- 文档化所有权:明确谁负责释放
- Code Review 重点关注数组访问和指针运算
- CI 集成 ASan:每次提交自动跑 sanitizer
注意事项¶
- 面试回答分三层(编码 → 工具 → 设计),展示系统性思维
- ASan 是最高频被问到的工具,原理:编译时在每次内存访问前后插入检查代码,维护 shadow memory 标记可访问区域
- 追问「性能开销」:ASan 约 2x 减速,Valgrind 约 10-20x
细看: 内存管理与 Allocator
Q9. (同 Q1)结构体内两个 int,两个线程分别修改¶
此题与 Q1 完全相同,面试官可能是首尾呼应或重新确认回答。答案见上方 Q1。
总结¶
| # | 知识域 | 核心考点 |
|---|---|---|
| Q1/Q9 | 并发 | false sharing、cache line、MESI |
| Q2 | 智能指针 | 引用计数、copy-and-swap、移动语义 |
| Q3 | 内存管理 | delete 两步操作、array cookie、chunk header |
| Q4 | 内存管理 | C++ 内存分配方式全景 |
| Q5 | 内存/OS | malloc vs new、进程虚拟内存布局 |
| Q6 | STL 容器 | vector vs list、cache 局部性 |
| Q7 | STL + OS | 三指针模型、扩容流程、demand paging |
| Q8 | 工程实践 | ASan、RAII、边界检查手段 |