跳转至

网易游戏 — 后端一面(2026-04-03)

标签: C++ 大拷打 · 无算法手撕
侧重: 内存模型、智能指针、容器底层、并发


Q1. 结构体内两个 int,两个线程分别修改,会有并发问题吗?

回答

结论:从语言标准来看没有数据竞争(data race),但存在 false sharing 性能问题。

1)为什么没有数据竞争

C++ 标准([intro.races])规定:两个内存操作构成 data race 的前提是它们 访问同一个内存位置(memory location) 且至少一个是写。int aint b 是两个不同的内存位置,各线程只操作自己的那个变量,互不重叠,因此 不构成 data race,行为是良定义的

struct S {
    int a;  // 线程 1 写
    int b;  // 线程 2 写
};

注意: 如果换成 位域(bit-field),情况完全不同——相邻位域可能共享同一存储单元,此时两个线程分别写不同位域就 构成 data race

2)False Sharing 性能问题

虽然语义正确,但 ab 大概率落在 同一条 cache line(通常 64 字节)。两个核分别写同一 cache line 时,硬件缓存一致性协议(MESI)会不断地将该 cache line 在两核之间弹来弹去(cache line bouncing),导致性能急剧下降。

3)如何解决 false sharing

struct S {
    alignas(64) int a;  // 独占一条 cache line
    alignas(64) int b;
};

或使用 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,核心要素:

  1. 引用计数 分配在堆上,所有共享同一对象的 shared_ptr 实例共享同一个计数器
  2. 拷贝 时计数 +1,析构 时计数 -1,计数归零时释放资源
  3. 移动 语义转移所有权,不改变引用计数
  4. 赋值 要处理自赋值和旧资源释放

完整代码

#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 ptr;
// 等价于:
ptr->~T();              // 1. 调用析构函数
operator delete(ptr);   // 2. 释放内存(底层通常调 free)

delete[] 如何知道数组长度

编译器在 new T[n] 时会多分配一块空间(通常在返回指针之前的若干字节),存储元素个数 n,这叫 array cookie

内存布局:
[ cookie (存 n) ][ elem 0 ][ elem 1 ] ... [ elem n-1 ]
                  ^
                  new[] 返回的指针

delete[] 时,编译器先将指针前移读取 cookie 得到 n,再逆序调用 n 次析构函数,最后释放整个内存块。

free 如何知道要释放多大的内存

malloc 分配时在返回的指针前面维护一个 元数据头(chunk header),记录了块大小、是否空闲等信息。free 根据传入的指针回退找到这个 header,得知块大小后归还给堆管理器。

内存布局(简化):
[ chunk header: size | flags ][ 用户数据区 ]
                               ^
                               malloc 返回的指针

不同分配器(glibc ptmalloc、tcmalloc、jemalloc)具体的 header 格式不同,但原理一致。

注意事项

  • deletedelete[] 不能混用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 个即可,mmapalloca 属于加分项
  • placement new 不分配内存,需手动调析构函数、手动管理内存生命周期
  • callocmalloc 的区别:calloc 会零初始化
  • realloc 可能原地扩容也可能搬迁,返回的指针可能变化

细看: 内存管理与 Allocator


Q5. malloc 和 new 的区别;在哪部分内存操作;进程有哪些类型的内存

回答

malloc vs new

对比维度 malloc new
来源 C 标准库函数 C++ 运算符
返回类型 void*,需强制转换 正确类型的指针
构造/析构 不调用 自动调用构造函数
失败行为 返回 NULL 抛出 std::bad_allocnothrow 版返回 nullptr
大小计算 手动指定字节数 编译器自动计算 sizeof(T)
可重载 不可 operator new 可全局/类内重载
配对释放 free delete / delete[]

两者 都在堆(heap)上分配内存。底层路径:newoperator newmalloc(典型实现)→ sbrkmmap

进程虚拟内存布局(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 甚至主存中取数据。

vector 内存:  [1][2][3][4][5][6][7][8]  ← 连续,一次预取一排
list 内存:    [1]→ ... [2]→ ... [3]→    ← 散落,每次跳转

量级差距: 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 - _start
  • capacity() = _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 指针精确知道新元素应放在哪。

扩容机制

  1. 计算新容量:通常为当前容量的 2 倍(GCC libstdc++),MSVC 为 1.5 倍
  2. 分配新内存块:调用 operator new → 底层走 malloc → 小块通过 sbrk 在堆上扩展,大块(通常 ≥128KB)通过 mmap 直接映射虚拟页
  3. 移动/拷贝元素:优先使用移动构造(如果 noexcept),否则回退到拷贝构造以保证强异常安全
  4. 析构旧元素 + 释放旧内存
  5. 更新三指针

操作系统视角

用户态: 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 容器 vectorstring 自动管理内存,替代裸数组
at() 代替 [] at() 做边界检查,越界时抛 std::out_of_range
智能指针 unique_ptr / shared_ptr 避免手动 delete,杜绝 use-after-free
RAII 资源在构造时获取、析构时释放,不靠手动管理
std::span(C++20) 带长度信息的视图,替代裸指针 + 长度
避免 C 风格字符串操作 strcpystd::stringsprintfstd::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、边界检查手段