大家好,我是阿Q。
中秋国庆假期已过半,你们玩的还好吗?
最近看完很多面经后发现,面经是真的不好写,还是很感谢同学们面试完认真的复盘和分享。为什么不好写?
多看C++后端开发的面经,你就会发现,其实在校招过程中,面试的问题七八成都是那些,好像他们都有一样的模板一样。。。因为是一个方向的,除了项目之外,八股就都大差不差的。
所有,每篇面经的输出,都要我不断的筛选,努力把不一样的分享给伙伴们。
今天是一位同学在科大讯飞面试过程中的问题,首先感谢他的分享,再就是预祝他能顺利拿到讯飞的offer。
正文如下。。
来源:
https://www.nowcoder.com/feed/main/detail/07d1d6d07d5c4625866fd70e719d6475
虚拟地址是由操作系统分配给进程的地址空间中的地址。 每个运行的进程都有自己独立的虚拟地址空间,这使得每个进程认为它在独占整个计算机的内存。 虚拟地址是相对于进程的起始地址的偏移量,通常从0开始,直到进程的最大地址。 虚拟地址是一种抽象,它隐藏了底层物理硬件的复杂性。
物理地址是硬件内存中的真实地址,它对应于计算机的RAM(随机存取存储器)中的特定位置。 物理地址是硬件层面的,不同于虚拟地址,它不受操作系统的管理和控制。 操作系统的内存管理单元(Memory Management Unit,MMU)负责将虚拟地址映射到物理地址,这个过程称为内存地址转换。
编译阶段:虚拟地址和物理地址的转换的第一个阶段发生在程序的编译阶段。在编译时,源代码被编译成可执行文件,而这个文件包含了程序的机器指令以及其他数据。在这个阶段,编译器生成了虚拟地址,这些地址是相对于程序的起始地址(通常是0)的偏移量。编译器不需要知道程序将在内存中的哪个位置执行,因此生成的地址是虚拟的,它们只是相对于程序的内部结构而言的。 加载阶段:当用户运行程序时,操作系统的加载器负责将可执行文件加载到内存中的某个位置。在这个阶段,操作系统分配物理内存,并将程序的代码和数据加载到这些物理地址上。此时,操作系统将虚拟地址映射到物理地址,并建立一个映射表,通常称为页表或段表。这个表将虚拟地址和物理地址之间的映射关系记录下来,以便后续的地址转换。 运行时阶段:一旦程序开始执行,它会生成虚拟地址,例如在访问变量、执行指令或调用函数时。这些虚拟地址需要在运行时被转换为物理地址,以便在物理内存中找到对应的数据或指令。这个虚拟地址到物理地址的转换是通过页表或段表来完成的。操作系统和硬件会根据虚拟地址的一部分(通常是高位或低位)来查找页表中的映射关系,以找到相应的物理地址。 访问检查阶段:在虚拟地址到物理地址的转换过程中,还可以进行访问控制检查,以确保程序只能访问其被授权的内存区域。如果程序尝试访问未授权的内存区域,操作系统将产生异常并终止该操作。
代码段(Text Segment):代码段是程序的机器指令所在的内存区域。它包含了程序的可执行指令,也就是程序的代码部分。这些指令是程序在运行时被CPU执行的操作。代码段通常是只读的,因为程序的指令在运行时不应该被修改。通常,代码段位于程序的起始位置,并在内存中保留一个固定的地址范围。 数据段(Data Segment):数据段用于存储程序的静态数据,包括全局变量、静态变量和字符串常量等。这些数据在程序运行期间保持不变,因此数据段通常是可读写的,但也可能包含只读数据,如字符串常量。数据段的大小在程序编译时就确定,并在运行时分配。 堆(Heap):堆是用于动态内存分配的内存区域。在堆上分配的内存由程序员显式分配和释放,通常用于存储动态生成的数据结构,如链表、树等。堆内存的生命周期由程序员控制,因此需要手动释放以防止内存泄漏。 栈(Stack):栈用于存储函数调用的上下文信息以及局部变量。每当函数被调用时,其局部变量和函数调用信息都会被推入栈中,当函数返回时,这些信息会被弹出栈。栈是一种先进先出(LIFO)的数据结构,它具有快速的内存分配和释放速度,但生命周期受限于函数调用的范围。 BSS 段:BSS 段(Block Started by Symbol)是用于存储未初始化的全局和静态变量的内存区域。这些变量在程序启动时会被初始化为零或默认值。BSS 段通常不存储实际的数据,而只是为这些变量分配了足够的内存空间。
修饰局部变量
修饰全局变量
修饰函数
修饰成员变量
修饰成员函数
最重要的特性:隐藏
&(即不使用引用参数)时,会触发额外的拷贝操作,导致对象的拷贝构造函数被递归调用,最终导致栈溢出或性能下降,具体取决于对象的复杂性和大小。这是因为对象的拷贝构造函数会被用于复制参数传递给函数的对象,从而导致无限循环的拷贝。
#include <iostream>class MyClass {public:MyClass() {std::cout << "Default Constructor" << std::endl;}MyClass(const MyClass& other) {std::cout << "Copy Constructor" << std::endl;}};void foo(MyClass obj) {// Some code here}int main() {MyClass obj;foo(obj);return 0;}
foo函数的参数是一个
MyClass类型的对象,没有使用引用。当
foo函数被调用时,会触发对象的拷贝构造函数,这导致了无限循环的拷贝操作,最终导致栈溢出错误。
const T&),以避免不必要的拷贝,提高性能,并避免递归拷贝。
class MyClass {public:// ...MyClass(const MyClass& other) {std::cout << "Copy Constructor" << std::endl;}};void foo(const MyClass& obj) {// Some code here}
foo函数的参数是一个常量引用,不会触发额外的拷贝,因此不会出现无限循环的问题。
虚函数的声明和定义:
virtual关键字来标记虚函数。
class Base {public:virtual void foo() {// Base class implementation}};
虚函数表(vtable): 对于每个包含虚函数的类,编译器会为其创建一个虚函数表,该表是一个指针数组,其中存储了虚函数的地址。 虚函数表是在程序运行时分配和维护的,每个包含虚函数的对象都有一个指向其类的虚函数表的指针。 虚函数表包含了该类中所有虚函数的地址,按照它们在类中声明的顺序排列。 虚函数指针(vpointer): 对于每个包含虚函数的对象,编译器会在对象的内存布局中添加一个虚函数指针(通常位于对象的开头)。 虚函数指针指向该对象的虚函数表,这样程序可以在运行时动态查找并调用适当的虚函数。 动态分派: 当通过基类指针或引用调用虚函数时,程序会使用虚函数指针来查找该对象的虚函数表。 然后,程序根据函数在虚函数表中的位置来调用相应的虚函数。 这个过程称为动态分派,因为它在运行时根据对象的实际类型来决定调用哪个函数。
如果功能的接口由团队A(或者是一个通用的功能提供者)来定义,而团队B负责实际的功能实现,那么通常情况下虚函数应该添加到基类中。 团队A定义基类,其中包含虚函数声明,但不提供实现。 团队B创建派生类,继承自基类,并重写虚函数以提供特定功能的实现。 这种方式允许团队A定义接口规范,而团队B则根据规范来实现功能。
如果团队A负责定义接口规范,而团队B负责实际功能的实现,并且这两个团队分离开来,可以采用接口类和功能类分离的方式。 团队A定义接口类,其中包含纯虚函数(纯虚函数没有默认实现),这些函数代表功能的接口规范。 团队B创建功能类,继承自接口类,并实现纯虚函数以提供特定功能的实现。 这种方式强调了接口和实现的分离,确保团队B实现了接口中定义的功能。
如果整个功能由一个团队负责实现,并且不需要将接口规范分离为基类和派生类,那么可以在单一实现类中定义虚函数。 团队A定义一个类,其中包含虚函数,用于表示功能的不同方面或操作。 团队A负责提供这些虚函数的实现,以完成功能。 这种方式适用于较小的项目或不需要多态性的情况。
尾插入时间复杂度: vector
的尾插入操作的平均时间复杂度是 常数时间,通常为 O(1)。这是因为 vector
内部是一个动态数组,当进行尾插入操作时,只需要将元素添加到当前的末尾,并更新vector
的大小和末尾指针即可。vector扩容机制: 当 vector
的内部数组空间不足以容纳新的元素时,需要进行扩容。vector
会创建一个更大的新数组,然后将现有元素逐个拷贝到新数组中。扩容时通常会分配比当前容量更大的内存块,以减少扩容的频率,常见的策略是将容量翻倍。 扩容操作的时间复杂度是线性的,通常为 O(N),其中 N 是当前 vector
中的元素数量。扩容可能会导致重新分配内存、复制数据等开销,但由于扩容不频繁(每次扩容都可以容纳多个元素),平均情况下尾插入操作仍然具有常数时间复杂度。
std::map: std::map
是基于红黑树实现的有序关联容器。插入(Insertion)和删除(Deletion)操作的时间复杂度是 O(log N),其中 N 是 map
中元素的数量。这是因为红黑树的高度受到平衡性的限制,因此操作的时间复杂度保持在对数级别。 std::unordered_map: std::unordered_map
是基于哈希表实现的无序关联容器。插入和删除操作的平均时间复杂度是 O(1)。但是在最坏情况下,如果发生哈希冲突,时间复杂度可能会退化到 O(N),其中 N 是 unordered_map
中元素的数量。通常情况下,哈希表的均匀分布和好的哈希函数可以确保大多数操作都在常数时间内完成。
如果需要保持元素的有序性,并且对插入和删除操作的性能要求不是非常苛刻,可以使用 std::map
。如果对元素的顺序没有要求,且对插入和删除操作的性能有较高要求,可以使用 std::unordered_map
,但需要注意处理哈希冲突的情况。
C++ 原子操作库: C++ 的 <atomic>
库定义了一组模板类和函数,用于执行原子操作。这些操作可用于多线程编程,以确保共享资源的线程安全性。主要的原子操作类型包括 std::atomic
,std::atomic_flag
,std::atomic<T>
,以及各种原子操作函数。std::atomic<T>: std::atomic<T>
是一个模板类,用于实现原子操作,其中 T 是要操作的数据类型。可以使用 std::atomic<T>
对象来执行一些常见的原子操作,如加载、存储、交换、递增、递减等。原子操作的优点: 原子操作保证了多线程环境下的数据一致性和线程安全性,避免了竞态条件(Race Condition)。 不需要使用互斥锁等同步机制,因此可以提高多线程程序的性能。 std::atomic_flag: std::atomic_flag
是一个特殊的原子类型,通常用于实现互斥锁。std::atomic_flag
只有两个操作:test_and_set
和clear
,分别用于设置标志和清除标志。内存顺序: C++ 的原子操作库还提供了内存顺序(Memory Order)的概念,用于控制原子操作的执行顺序。 可以使用 memory_order
参数来指定操作的内存顺序,如memory_order_relaxed
,memory_order_acquire
,memory_order_release
,memory_order_seq_cst
等。内存顺序可以帮助程序员控制原子操作之间的可见性和排序规则。
std::atomic<int> counter(0);
counter.fetch_add(1); / 原子递增
自动类型推断(Type Inference):引入 auto
关键字,允许编译器自动推断变量的类型。范围-based for 循环: for (auto elem : container)
,简化了迭代容器的代码。Lambda 表达式:允许定义匿名函数,提供更方便的函数对象。 智能指针: std::shared_ptr
,std::unique_ptr
, 和std::weak_ptr
,用于管理动态分配的内存。右值引用和移动语义:提高了内存和性能效率,引入 &&
来支持移动构造函数和移动赋值操作符。初始化列表(Initializer List):使用大括号 {}
初始化对象和容器。新的容器类: std::array
,std::unordered_set
,std::unordered_map
等。多线程支持: std::thread
,std::mutex
,std::condition_variable
等多线程相关的库。普通字符串字面值: u8
,u
,U
和R
前缀的字符串字面值。类型别名(Type Aliases):使用 using
定义类型别名。
泛型 Lambda 表达式:Lambda 表达式支持模板参数。 返回类型推断:可以使用 auto
推断函数的返回类型。二进制文字:引入 0b
前缀支持二进制文字。std::make_unique:类似于 std::make_shared
,用于创建std::unique_ptr
。std::index_sequence 和 std::make_index_sequence:用于元编程中生成索引序列。
结构化绑定:允许从 std::tuple
或类似结构中轻松提取成员。折叠表达式(Fold Expressions):简化可变参数模板的使用。 if constexpr:编译时条件语句,可以根据条件在编译时决定不同的代码路径。 std::optional:表示可能为空的值。 并行算法:引入 std::for_each
,std::transform
,std::reduce
等并行算法。文件系统库: std::filesystem
提供了文件系统操作的标准接口。
概念(Concepts):引入了概念检查,允许对模板参数进行约束。 协程(Coroutines):支持异步操作和生成器。 范围(Ranges):引入了范围操作,如 std::ranges::for_each
,std::ranges::filter
等。三向比较操作符(Three-Way Comparison):通过 <=>
运算符实现自定义类型的比较。初始化改进:聚合初始化支持使用 =
进行。多线程改进:引入 std::jthread
和std::stop_token
等用于线程管理的类。std::format:提供了更灵活的格式化字符串功能。
泛型编程的进一步增强:引入更多的概念、约束和元编程功能。 协程改进:进一步完善协程支持。 范围模式匹配(Pattern Matching):类似于其他编程语言的模式匹配功能。 元组改进:引入更多元组相关的特性。
volatile
的作用:volatile
主要用于修饰变量,告诉编译器不要对该变量进行优化。这通常用于描述一些可能会被外部因素(如硬件、操作系统、其他线程等)更改的变量。使用场景: 硬件寄存器: volatile
可用于描述与硬件寄存器通信的变量,因为这些变量的值可能在编译器无法预测的时间被硬件更改。多线程编程:在多线程环境中,一个线程修改的变量可能会被另一个线程读取,这时 volatile
可以确保对变量的读取和写入不会被优化掉。信号处理器中使用:在信号处理器中,被信号处理函数修改的变量应该声明为 volatile
,以确保编译器不会对它们进行优化。不足之处: volatile
仅告诉编译器不要对变量进行优化,但它并不能解决多线程并发问题。在多线程环境中,还需要使用更强大的同步机制(如互斥锁、条件变量等)来确保线程安全性。volatile
并不适用于所有情况,因为它仅告诉编译器不要优化,但不提供同步机制。如果需要精确的同步和互斥,应该使用其他多线程编程工具。示例
volatile int hardwareRegister; // 描述硬件寄存器的变量void signalHandler(int sig) {volatile bool flag = true; // 信号处理器中的变量// ...}
volatile关键字用于告诉编译器不要对变量进行优化,通常用于描述那些可能被外部因素改变的变量。在多线程环境中,它应该与其他同步机制一起使用来确保线程安全性。但需要注意,
volatile并不是解决多线程问题的最终解决方案,更复杂的同步机制可能需要用于确保数据一致性。
以上便是我对这篇面经的一些认识和建议,希望大家能够有更多的收获,也再次感谢这位同学毫不吝啬的分享。
看完还麻烦你们用拿offer的小手帮我点点赞和关注~
最后,阿Q最近刚刚建立了一个学习交流群,人虽然不多,但都是想努力上进的小伙伴,感兴趣可免费进,我们一起进步和成长!!

文章转载自阿Q正砖,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。




