网易 软件开发-C++ 实习一面

1. 自我介绍

2. 线上 C++ 服务出现偶发崩溃,core 文件里栈已经被破坏,怎么定位

答案:栈被破坏时不能只依赖 bt,因为调用链可能已经不可信。一般会先看崩溃信号,比如 SIGSEGVSIGABRTSIGBUS,再看寄存器、崩溃地址和附近内存。如果怀疑是越界写、use-after-free 或 double free,我会优先用 ASan 复现;如果线上不能开 ASan,就增加关键对象的 canary、对象 ID、构造析构日志,配合灰度流量复现。

还可以从最近一次合法日志、线程状态、堆分配器报错、异常请求入手。对于高并发服务,栈破坏经常来自数组越界、协议长度没校验、异步回调访问已释放对象,或者跨线程使用非线程安全容器。如果 core 无法直接定位,我会构造最小复现,然后用 ASan/UBSan/TSan 和压测一起跑。

代码:

ulimit -c unlimited
gdb ./gateway core.xxx

(gdb) info registers
(gdb) x/32gx $rsp
(gdb) thread apply all bt
(gdb) disassemble

3. C++ 对象内存布局在单继承、多继承、虚继承下有什么区别

答案:单继承下,如果基类有虚函数,派生类对象通常包含一份虚表指针,基类子对象在派生类对象的起始位置附近。通过基类指针调用虚函数时,会根据对象里的 vptr 找到对应虚表。多继承下,一个派生类对象里可能包含多个基类子对象,每个有虚函数的基类子对象都可能有自己的 vptr。把派生类指针转换成第二个基类指针时,指针地址可能需要调整。

虚继承主要解决菱形继承中基类子对象重复的问题。它会引入虚基类表或类似机制,对象布局更复杂,访问虚基类成员时可能需要额外偏移计算。所以虚函数、多继承、虚继承都会影响对象大小、指针转换和 ABI,工程里如果不是确实需要,不建议设计过深的继承层次。

代码:

#include <iostream>
using namespace std;

struct A {
    virtual void fa() {}
    int a;
};

struct B {
    virtual void fb() {}
    int b;
};

struct C : public A, public B {
    int c;
};

int main() {
    C obj;
    A* pa = &obj;
    B* pb = &obj;

    cout << &obj << endl;
    cout << pa << endl;
    cout << pb << endl; // 多继承下这里可能和 &obj 不同
}

4. shared_ptr 的控制块里一般有什么,为什么不能用同一个裸指针构造两个 shared_ptr

答案:shared_ptr 不只是保存对象地址,它还关联一个控制块。控制块里一般有强引用计数、弱引用计数、删除器、分配器等信息。多个 shared_ptr 共享同一个控制块,才能正确维护引用计数。

如果对同一个裸指针分别构造两个 shared_ptr,会产生两个独立控制块。两个控制块都以为自己拥有这个对象,最后强引用计数分别归零时,会对同一个对象执行两次 delete,导致 double free。所以对象要么用 make_shared 创建,要么确保裸指针只交给一个 shared_ptr 接管,后续都从这个 shared_ptr 拷贝。

代码:

#include <memory>
using namespace std;

struct Obj {};

int main() {
    Obj* raw = new Obj();

    shared_ptr<Obj> p1(raw);
    // shared_ptr<Obj> p2(raw); // 错误:会产生第二个控制块,导致重复释放

    shared_ptr<Obj> p2 = p1;    // 正确:共享同一个控制块
}

5. enable_shared_from_this 的作用和使用坑

答案:enable_shared_from_this 用来让对象在成员函数里安全地拿到管理自己的 shared_ptr。它的典型场景是异步回调里需要延长当前对象生命周期,比如连接对象发起异步读写时,回调执行前对象不能被释放。

坑在于,只有对象已经被 shared_ptr 管理之后,才能调用 shared_from_this()。如果对象是栈对象,或者在构造函数里调用 shared_from_this(),通常会抛 bad_weak_ptr 或产生未定义行为。所以一般通过工厂函数创建对象,构造完成后再调用初始化逻辑。

代码:

#include <memory>
#include <iostream>
using namespace std;

class Session : public enable_shared_from_this<Session> {
public:
    static shared_ptr<Session> create() {
        auto p = shared_ptr<Session>(new Session());
        p->init();
        return p;
    }

    void init() {
        auto self = shared_from_this();
        cout << "init session\n";
    }

private:
    Session() = default;
};

6. 无锁 MPSC 队列怎么设计,和 SPSC 队列难点有什么不同

答案:SPSC 队列只有一个生产者和一个消费者,生产者只写 tail,消费者只写 head,通常用环形数组加 acquire/release 就能实现。MPSC 是多个生产者、单个消费者,难点在于多个生产者会同时竞争写入位置,必须用 CAS 或 fetch_add 分配槽位。还要处理槽位还没写完但 tail 已经推进的问题,否则消费者可能读到未初始化数据。

一种常见做法是每个槽位加 sequence number。生产者先通过 CAS 抢到位置,再写数据,最后发布序号;消费者只有看到对应序号已经发布,才读取数据。这类结构对内存序要求很高,工程里如果不是瓶颈明确,通常会先用成熟库或者加锁队列。

代码:

#include <atomic>
#include <array>
using namespace std;

template <typename T, size_t N>
class MPSCQueue {
    struct Cell {
        atomic<size_t> seq;
        T data;
    };

    array<Cell, N> buffer_;
    atomic<size_t> tail_{0};
    size_t head_{0};

public:
    MPSCQueue() {
        for (size_t i = 0; i < N; ++i) {
            buffer_[i].seq.store(i, memory_order_relaxed);
        }
    }

    bool push(const T& value) {
        size_t pos = tail_.load(memory_order_relaxed);

        while (true) {
            Cell& cell = buffer_[pos % N];
            size_t seq = cell.seq.load(memory_order_acquire);
            intptr_t diff = (intptr_t)seq - (intptr_t)pos;

            if (diff == 0) {
                if (tail_.compare_exchange_weak(pos, pos + 1,
                                                memory_order_relaxed)) {
                    cell.data = value;
                    cell.seq.store(pos + 1, memory_order_release);
                    return true;
                }
            } else if (diff < 0) {
                return false;
            } else {
                pos = tail_.load(memory_order_relaxed);
            }
        }
    }

    bool pop(T& value) {
        Cell& cell = buffer_[head_ % N];
        size_t seq = cell.seq.load(memory_order_acquire);
        intptr_t diff = (intptr_t)seq - (intptr_t)(head_ + 1);

        if (diff == 0) {
            value = cell.data;
            cell.seq.store(head_ + N, memory_order_release);
            ++head_;
            return true;
        }

        return false;
    }
};

7. C++ 内存序里 relaxed、acquire、release、seq_cst 分别解决什么问题

答案:memory_order_relaxed 只保证原子变量本身操作是原子的,不保证和其他内存访问的顺序关系,适合计数、统计这类不依赖同步关系的场景。memory_order_release 通常用于写线程发布数据,保证 release 之前的写入不会被重排到 release 之后。memory_order_acquire 通常用于读线程获取数据,保证 acquire 之后的读取不会被重排到 acquire 之前。acquire 和 release 配合,可以建立跨线程的 happens-before 关系。

memory_order_seq_cst 是最强的顺序一致性,语义简单但可能更贵。写无锁结构时,不能只想着“都用 relaxed 更快”,如果没有正确的同步关系,消费者可能看到标志位已经更新,但数据本身还没对它可见。

8. 虚函数调用为什么在构造函数和析构函数里不会表现出正常多态

答案:构造对象时,基类部分先构造,派生类部分还没有构造完成。此时如果基类构造函数调用虚函数,只会调用基类版本,因为派生类对象还不完整。析构时顺序相反,派生类部分先析构,进入基类析构函数时,派生类部分已经不再可靠,因此虚函数也不会分发到派生类版本。

这个规则是为了避免访问尚未构造或已经析构的派生类成员。工程里如果需要对象初始化后执行可扩展逻辑,通常会使用工厂函数,构造完成后显式调用 init(),不要在构造函数里依赖虚函数分发。

9. epoll 在多线程 Reactor 模型里怎么设计连接分发

答案:常见做法是一个主 Reactor 负责监听 fd 和 accept,新连接建立后,通过轮询或 hash 分配给某个子 Reactor。每个子 Reactor 运行在固定线程里,维护自己的 epoll 实例和连接集合。连接一旦绑定到某个子 Reactor,后续读写、定时器和状态修改都尽量在这个线程内完成,避免多个线程同时操作同一个连接对象。

主线程把新 fd 交给子线程时,可以用 eventfd 或 pipe 唤醒子 Reactor。子 Reactor 收到通知后,从队列取出新

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

C++ 常考面试题总结 文章被收录于专栏

本专栏系统梳理C++方向, 大中厂高频高频面试考点 , 内容皆来自真实面试经历,从基础语法、内存管理、STL与设计模式,到操作系统与项目实战,结合真实面试题深度解析,帮助开发者高效查漏补缺,提升技术理解与面试通过率,打造扎实的C++工程能力.

全部评论

相关推荐

评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务