C/C++
1. 描述C++程序的内存由哪几部分组成,每个区域分别有什么作用和特点
答案: C++程序的内存布局通常分为以下几个区域:
- 栈区:由编译器自动管理,用于存放函数的局部变量、参数、返回值等。分配和释放效率高,但容量有限。生命周期与函数调用相关。
- 堆区:由程序员手动管理(
malloc/new,free/delete)。容量大,但分配和释放速度较慢,容易产生内存碎片。生命周期由程序员控制,管理不当会导致内存泄漏。 - 全局/静态存储区:存放全局变量和静态变量(包括
static变量)。该区域在程序开始时分配,程序结束时释放。初始化的变量和未初始化的变量分别存放在相邻的区域(如.data和.bss段)。 - 常量存储区:存放字符串常量和其他用
const定义的常量。该区域的内容在程序运行期间是只读的,修改它会引发未定义行为。 - 代码区:存放程序的二进制机器指令(函数体的二进制代码)。该区域通常是只读的,用于防止程序指令被意外修改。
2. 什么时候分配内存会产生内存碎片
答案: 内存碎片主要发生在堆内存分配中,分为两种:
- 外部碎片:当频繁地分配和释放不同大小的内存块后,空闲内存会被分割成许多不连续的小块。当需要分配一块较大的连续内存时,即使总空闲内存足够,也无法找到一块连续的足够大的空间,从而导致分配失败。这是
malloc和new面临的主要问题。 - 内部碎片:当分配器分配给程序的内存块大小大于程序实际请求的大小时,多出来的部分就被浪费了。例如,由于内存对齐的要求,程序申请13字节,但分配器可能实际分配16字节,这3字节就是内部碎片。
3. 负数的编码方式是什么,简述一下它的原理
答案: 负数在计算机中通常采用补码 表示。其原理如下:
- 原码:最高位为符号位(0正1负),其余位表示数值。原码表示0时有+0和-0之分,且加减运算复杂。
- 反码:正数的反码是其本身,负数的反码是其原码的符号位不变,其余位取反。同样存在±0问题。
- 补码:正数的补码是其本身。负数的补码是其原码的符号位不变,其余位取反后加1。
- 优势:
- 统一了零的表示:+0的补码是全0,而-0的补码计算后也是全0。
- 将减法运算转换为加法运算:
A - B可以等价为A + (-B的补码),CPU只需一套加法器电路即可处理加减法。
4. 浮点数的编码方式是什么,简述一下它的原理
答案: 现代计算机普遍采用IEEE 754标准来表示浮点数。它将一个浮点数分为三个部分:
- 符号位:最高位,0表示正数,1表示负数。
- 指数位:中间的若干位,表示2的幂次。为了表示负指数,引入了偏移码(例如,8位指数的偏移量是127,实际指数 = 编码值 – 127)。
- 尾数位:最低的若干位,表示有效数字的小数部分。它是一个规格化数(1.xxxxx),因此尾数部分默认省略了前导的1(称为”隐藏位”)。
- 公式:
(-1)^符号位 × 1.尾数 × 2^(指数 - 偏移量) - 这种表示法可以覆盖非常大和非常小的数值范围,但会存在精度损失问题(因为有些十进制小数无法精确表示为二进制小数)。
5. 可执行程序是如何生成的
答案: 可执行程序的生成通常需要四个步骤:
- 预处理:处理源代码中的预处理指令,如
#include(头文件包含)、#define(宏展开)、#ifdef(条件编译)等,生成一个纯粹的C/C++源文件(.i或.ii)。 - 编译:将预处理后的C/C++源代码翻译成汇编代码(
.s)。此阶段进行词法分析、语法分析、语义分析、优化等。 - 汇编:将汇编代码翻译成机器指令,生成目标文件(
.o或.obj)。目标文件是二进制格式,包含机器码、数据以及符号表(函数名、变量名等)。 - 链接:将一个或多个目标文件以及所需的库文件(如C++标准库)组合成一个最终的可执行文件(如
.exe或无后缀文件)。链接器主要完成两项工作:地址重定位(为函数和变量分配最终的内存地址)和符号解析(解决跨文件的函数和变量引用)。
6. 可执行程序是如何变成进程的
答案: 当你在shell中输入一个可执行程序的名字或调用exec系列函数时,操作系统会执行以下步骤将其变为一个进程:
- 创建进程控制块:操作系统创建一个新的PCB,这是进程存在的唯一标志,包含了进程的所有管理信息(如PID、优先级、状态等)。
- 分配资源:为新进程分配必要的内存空间(如代码段、数据段、堆栈段)和其他资源。
- 加载程序:将可执行文件的代码和数据从磁盘加载到分配的内存中。这包括建立之前描述的内存布局(栈、堆、全局区等)。
- 设置运行上下文:设置CPU的寄存器,特别是程序计数器,使其指向程序的入口点(如
_start或main函数)。 - 转入就绪状态:将新创建的进程放入操作系统的就绪队列,等待CPU调度执行。从此,这个程序就成为了一个活跃的进程。
7. 在C语言中如何调用C++函数
答案: 由于C++支持函数重载,它会进行名称修饰,导致编译后的函数名与C语言不同。因此,在C中直接调用C++函数会链接失败。解决方法是在C++代码中使用extern "C"来告诉编译器按C语言的方式进行编译链接。
- 步骤:
- 在C++头文件中,用
extern "C"包裹需要被C调用的函数声明。 - 该C++头文件既可以被C++包含,也可以被C包含(C语言不认识
extern "C",需要用宏__cplusplus来条件编译)。
#ifdef __cplusplus
extern “C” {
#endif
void cpp_function_called_from_c(int arg);
#ifdef __cplusplus
}
#endif- 在C源文件中,包含这个头文件并正常调用函数即可。
- 在C++头文件中,用
8. 请描述几种常见的C/C++的缺陷和陷阱
答案:
- 内存管理:手动管理内存容易导致内存泄漏(忘记释放)、野指针(释放后继续使用)和悬空指针(指向已释放内存)。
- 缓冲区溢出:对数组或指针进行操作时未检查边界,可能导致数据覆盖相邻内存,引发崩溃或安全漏洞。
- 未定义行为:语言标准未明确定义的行为,如解引用空指针、有符号整数溢出、修改字符串常量等。不同编译器可能产生不同结果,难以调试。
- 宏的陷阱:宏是简单的文本替换,缺乏类型检查,容易因运算符优先级等问题产生错误(例如
#define MULTIPLY(a, b) a * b,调用MULTIPLY(x+1, y+1)会出错)。 - 隐式类型转换:编译器自动进行的类型转换可能丢失精度或产生非预期结果,如整数除法、
int与unsigned int比较等。 - 多线程数据竞争:多个线程不加锁地访问共享数据,导致结果不确定。
9. 重写,重载,重定义这三者有什么区别
答案:
- 重载:
- 范围:发生在同一个类中(或全局作用域)。
- 条件:函数名相同,但参数列表(参数类型、个数、顺序)必须不同。
- 特点:编译时根据调用时的实参确定调用哪个函数(静态多态)。
- 重写:
- 范围:发生在继承关系的父类和子类之间。
- 条件:子类重新定义父类中的虚函数。函数名、参数列表、返回类型都必须完全相同(协变返回类型除外)。
- 特点:运行时根据对象的实际类型确定调用哪个函数(动态多态)。
- 重定义:
- 范围:发生在继承关系中。
- 条件:子类重新定义父类中的非虚函数(函数名相同即可,参数列表可以不同,但这实际上会隐藏父类的同名函数)。
- 特点:根据指针或引用的静态类型(编译时类型)来确定调用哪个函数,不构成多态。
10. 说一说strcpy,sprintf,memcpy这三个函数的不同之处
答案:
strcpy:- 功能:专门用于字符串的拷贝,从源地址复制字符直到遇到
\0结束符,并会自动将\0也复制过去。 - 安全性:不安全,不检查目标缓冲区大小,容易导致缓冲区溢出。
- 功能:专门用于字符串的拷贝,从源地址复制字符直到遇到
sprintf:- 功能:将格式化的数据写入一个字符串缓冲区。功能强大,可以将数字、字符串等按指定格式组合成一个字符串。
- 安全性:同样不安全,不检查目标缓冲区大小。
memcpy:- 功能:用于拷贝任意内存块(不限于字符串)。它按字节进行拷贝,指定要拷贝的字节数,不关心内容,遇到
\0也不会停止。 - 安全性:需要程序员自行保证拷贝的字节数不超过目标缓冲区大小。需要注意内存重叠问题,重叠时使用
memmove更安全。
- 功能:用于拷贝任意内存块(不限于字符串)。它按字节进行拷贝,指定要拷贝的字节数,不关心内容,遇到
11. strlen和sizeof的区别
答案: 这是两个完全不同的操作。
strlen:- 性质:是一个库函数,在运行时计算。
- 功能:计算一个以
\0结尾的字符串的长度(不包含结尾的\0)。 - 参数:必须是
const char*(指向字符串的指针)。
sizeof:- 性质:是一个运算符,在编译时确定结果。
- 功能:计算一个类型或一个对象所占用的内存字节数。
- 参数:可以是类型或表达式。
- 示例:cpp复制下载char str[] = “hello”;
cout << strlen(str); // 输出 5
cout << sizeof(str); // 输出 6 (包含结尾的\0)
char* ptr = str;
cout << strlen(ptr); // 输出 5
cout << sizeof(ptr); // 输出 4或8 (指针变量本身的大小)
12. 二维数组是什么,函数指针是什么
答案:
- 二维数组:
- 定义:是一个”数组的数组”。在内存中是连续存储的,按行优先排列。
- 声明:
int arr[3][4];表示一个3行4列的整型数组。 - 访问:
arr[i][j]等价于*(*(arr + i) + j)。
- 函数指针:
- 定义:是一个指向函数的指针变量。它存储的是函数代码的入口地址。
- 声明:
返回类型 (*指针变量名)(参数列表);例如:int (*funcPtr)(int, int);指向一个接受两个int参数并返回int的函数。 - 用途:用于实现回调函数、函数表等,是C/C++中实现动态行为的重要机制。
13. 简述值传递,指针传递的区别
答案:
- 值传递:
- 机制:将实参的拷贝传递给形参。函数内部对形参的任何修改都不会影响原始实参。
- 优点:简单,避免了函数意外修改外部变量。
- 缺点:对于大型结构体或对象,拷贝开销大。
- 指针传递:
- 机制:将实参的地址传递给形参(形参是指针类型)。函数内部通过解引用指针来直接操作原始实参所在的内存。
- 优点:效率高,无需拷贝大量数据;函数可以修改外部变量的值。
- 缺点:语法稍复杂;需要警惕空指针和野指针。
14. C++中const关键字的作用
答案: const用于定义常量,表示”只读”。其主要作用有:
- 修饰变量:变量值在初始化后不可修改。
const int a = 10; - 修饰指针:
const int* p或int const* p:指向常量的指针(指针指向的内容不可变)。int* const p:指针常量(指针本身指向的地址不可变)。const int* const p:指向常量的指针常量(两者都不可变)。
- 修饰函数参数:防止函数内部修改参数值,提高代码可读性和安全性。
void func(const MyClass& obj); - 修饰函数返回值:表示返回值是常量,不可修改。
const int& getValue(); - 修饰成员函数:常成员函数,承诺不会修改类的成员变量(除非成员被
mutable修饰)。void display() const;
15. C++中static关键字的作用
答案: static的含义是”静态的”,其作用因上下文而异:
- 在函数内(局部变量):将变量的生命周期延长至整个程序运行期,但作用域仍局限于该函数内。每次函数调用都访问同一个变量。
- 在全局作用域(全局变量/函数):将变量或函数的作用域限制在当前文件内(内部链接),避免与其他文件中的同名符号冲突。
- 在类中(静态成员变量):该成员变量属于类本身,而不是某个对象。所有对象共享同一份静态成员变量。必须在类外单独定义。
- 在类中(静态成员函数):该函数属于类本身,不能访问类的非静态成员(因为没有
this指针)。可以通过类名直接调用。
16. C++中class和struct的区别
答案: 在C++中,class和struct的唯一区别是默认的成员访问权限和默认的继承方式。
struct:- 默认成员访问权限是public。
- 默认继承方式是public继承。
class:- 默认成员访问权限是private。
- 默认继承方式是private继承。
- 习惯用法:
struct通常用于表示纯粹的数据结构(POD, Plain Old Data),而class用于表示具有数据和行为的对象。
17. 单例的自动释放
答案: 单例模式确保一个类只有一个实例。为了避免内存泄漏,需要在程序结束时自动释放单例对象。常见方法有:
- 使用静态局部变量(Meyer’s Singleton):利用局部静态变量在第一次进入作用域时初始化,程序结束时自动析构的特性。这是C++11之后最推荐的方式,线程安全。cpp复制下载class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // 线程安全(C++11起)
return instance;
}
private:
Singleton() = default;
~Singleton() = default;
// 禁止拷贝和赋值
}; - 使用智能指针:在
getInstance中,使用一个static std::unique_ptr或static std::shared_ptr来管理单例对象。 - 使用atexit:在创建单例后,使用
std::atexit注册一个清理函数,在程序退出时调用该函数来销毁单例。
18. 敲代码 – 单例的三种线程安全的实现方式
答案: 方式一:局部静态变量(C++11起,最佳实践)
cpp
复制下载
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // C++11保证初始化是线程安全的
return instance;
}
void doSomething() {}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
方式二:双重检查锁定(DCLP,适用于C++11前,现代C++中已不常用)
cpp
复制下载
class Singleton {
public:
static Singleton* getInstance() {
if (instance_ == nullptr) { // 第一次检查,避免每次加锁
std::lock_guard<std::mutex> lock(mutex_);
if (instance_ == nullptr) { // 第二次检查,确保唯一性
instance_ = new Singleton();
}
}
return instance_;
}
private:
static Singleton* instance_;
static std::mutex mutex_;
Singleton() = default;
~Singleton() = default;
};
// 需要在类外定义静态成员
Singleton* Singleton::instance_ = nullptr;
std::mutex Singleton::mutex_;
方式三:饿汉式(程序启动即初始化,线程安全)
cpp
复制下载
class Singleton {
public:
static Singleton& getInstance() {
return instance_;
}
private:
static Singleton instance_; // 静态成员,程序开始时就初始化
Singleton() = default;
~Singleton() = default;
};
// 在类外定义并初始化静态成员
Singleton Singleton::instance_;
19. string的底层实现原理
答案: 现代C++标准库中的std::string通常采用一种称为短字符串优化的实现策略。其底层一般包含三个成员:
- 一个指针:指向动态分配的堆内存。
- 一个大小:存储字符串的实际长度。
- 一个容量:存储当前分配的内存能容纳的字符总数(不包括
\0)。
- SSO:对于较短的字符串(例如15或22个字符,取决于实现),为了优化性能,不会在堆上分配内存,而是直接存储在
string对象自身的栈内存空间中。这避免了小字符串动态分配的开销。 - 因此,
string对象的大小是固定的(通常是sizeof(指针) + sizeof(size_t) * 2再加上SSO的缓冲区),与字符串内容的长短无关(除非超过SSO阈值)。
20. COW
答案:
- 全称:Copy-On-Write,写时复制。
- 原理:一种优化策略。当多个对象共享同一份资源时,最初并不进行实际的拷贝,只是共享。只有当某个对象需要修改这份资源时,才真正地为该对象创建一份资源的私有副本进行修改。
- 在C++中的应用:早期的一些
std::string实现(如GCC 4.x之前)使用COW来优化字符串拷贝。拷贝一个字符串时,只拷贝指针,引用计数加一,非常高效。只有当其中一个字符串被修改时,才进行深拷贝。 - 缺点:
- 在多线程环境下,需要原子操作来维护引用计数,有性能开销。
- “读操作”也可能因为判断是否需要COW而带来轻微开销。
- 不符合C++11标准对迭代器失效的要求。因此,现代C++标准库的实现已很少使用COW,转而使用SSO。
21. oversee,overload,override的区别
答案:
override:这是C++11引入的关键字,用于明确表示一个函数是重写基类的虚函数。它帮助编译器检查函数签名是否与基类的虚函数完全匹配,如果不匹配则会报错,防止因拼写错误等意外情况导致没有成功重写。这是一个描述性/辅助性的关键字。overload:重载。指在同一作用域内,函数名相同但参数列表不同的多个函数。它是一个概念,不是关键字。overseer:这不是C++中的标准术语。可能是一个笔误或混淆。正确的术语是重写,即override所描述的行为。
22. 什么是虚函数
答案:
- 定义:用
virtual关键字修饰的成员函数。 - 目的:为了实现运行时多态。允许在基类中定义一个函数的接口,而在派生类中提供不同的实现。当通过基类的指针或引用调用虚函数时,程序会根据指针或引用所指向的实际对象类型来决定调用哪个版本的函数。
- 核心机制:虚函数通过虚函数表来实现。每个包含虚函数的类(或有虚基类的类)都有一个
vtable,表中存放着该类虚函数的地址。每个对象内部有一个隐藏的指针vptr,指向其所属类的vtable。
23. 多态的使用条件
答案: 要使用C++的动态多态(运行时多态),必须同时满足以下三个条件:
- 继承:必须存在类的继承关系。
- 虚函数重写:基类中必须有虚函数,并且派生类中要对这个虚函数进行重写。
- 基类指针/引用调用:必须通过基类的指针或基类的引用来调用这个虚函数。
- 如果通过对象本身调用,属于静态绑定,不会有多态效果。
24. 虚函数的原理是什么/工作机制
答案: 虚函数的原理基于虚函数表和虚函数表指针。
- 虚函数表:编译器会为每一个包含虚函数的类(或者从包含虚函数的类派生而来的类)创建一个虚函数表。这个表是一个函数指针数组,其中的每个元素指向该类的一个虚函数的实际实现。
- 虚函数表指针:编译器会向包含虚函数的类的对象中,隐式地添加一个成员变量,通常称为
vptr。这个指针在对象构造时被设置,指向该对象所属类的虚函数表。 - 调用过程:当通过基类指针调用虚函数时(例如
basePtr->virtualFunc();),编译器会生成代码来执行以下步骤: a. 通过basePtr找到对象内部的vptr。 b. 通过vptr找到该对象所属类的vtable。 c. 在vtable中找到virtualFunc对应的槽位(索引在编译时确定)。 d. 调用该槽位中存储的函数地址。 这个过程发生在运行时,因此能够根据对象的实际类型来调用正确的函数。
25. const在二级指针的应用
答案: const修饰二级指针时,位置不同,含义也不同。考虑int **pp:
const int** pp:指向const int*的指针。不能通过pp修改其所指向的int值(即**pp = 10;是非法的),但可以修改pp本身指向哪个int*。int* const* pp:指向int* const(常量指针)的指针。pp指向的int*本身是常量(即*pp = &some_int;是非法的),但可以通过这个常量指针修改它所指向的int值(即**pp = 10;是合法的)。int** const pp:pp本身是一个常量指针。pp的指向不能改变(即pp = &some_ptr;非法),但可以通过pp修改任何级别的值(*pp和**pp)。const int* const* pp:指向const int* const的指针。既不能通过pp修改int值,也不能修改pp所指向的指针的指向。
26. 面向对象与面向过程的区别
答案:
- 面向过程:
- 核心:以函数为中心,关注解决问题的步骤。数据和对数据的操作是分离的。
- 设计思想:自顶向下,逐步求精。将一个大问题分解成若干个小步骤(函数)。
- 特点:程序流程清晰,但代码复用性和可维护性相对较差,尤其是在需求变化时。
- 面向对象:
- 核心:以对象为中心,对象是数据和操作的封装体。关注对象之间的交互。
- 三大特征:封装、继承、多态。
- 设计思想:自底向上,先抽象出系统中的类和对象,再让它们相互协作解决问题。
- 特点:代码复用性高(通过继承、组合),可维护性和扩展性好(通过多态),更符合人类对现实世界的认知。
27. 拷贝构造的调用时机
答案: 拷贝构造函数在以下三种情况下会被调用:
- 用一个对象初始化另一个对象时:
MyClass obj2 = obj1;(拷贝初始化)MyClass obj3(obj1);(直接初始化)
- 函数参数传递时,如果是值传递:
void func(MyClass obj) { ... }// 形参obj通过拷贝实参来初始化MyClass myObj;func(myObj);// 调用拷贝构造,将myObj拷贝给形参obj
- 函数返回一个对象时,如果返回值是值类型(且编译器未做返回值优化):
MyClass createObj() { MyClass obj; return obj; }// 返回时可能会调用拷贝构造创建一个临时对象
28. 为什么构造函数不能是虚函数
答案:
- 从
vptr的创建时机看:虚函数机制依赖于对象的虚函数表指针vptr。而vptr是在构造函数执行期间被初始化的,指向当前类的虚函数表。在构造函数调用之前,对象还不完整,vptr还没有被正确设置。如果构造函数是虚函数,需要通过vptr来查找调用哪个函数,但此时vptr尚未就绪,形成矛盾。 - 从语义上看:虚函数的意义在于允许在只知道基类接口的情况下,调用派生类的具体实现。而构造函数的任务是创建特定类型的对象。在构造一个对象时,你必须明确知道要创建的是什么类型的对象,不存在”通过基类接口来构造一个不确定的派生类对象”的语义。
29. 为什么静态函数不能是虚函数
答案:
- 作用域冲突:虚函数是依赖于对象的,每个对象通过自己的
vptr来动态决定调用哪个虚函数。而静态成员函数是属于类本身的,它不依赖于任何对象,没有this指针。因此,静态函数无法通过对象的vptr来访问虚函数表。 - 语义不符:虚函数是实现运行时多态的,需要根据对象的实际类型来调用。而静态函数在编译时就已经与类绑定,不需要也不支持多态。
30. 为什么内联函数不能是虚函数
答案:
- 机制冲突:
inline是对编译器的建议,建议在调用点将函数体展开,避免函数调用的开销。这是一个编译期的优化。 - 而
virtual函数是运行时多态,其具体调用哪个函数需要在运行时通过查虚函数表才能确定。编译器在编译时无法知道内联展开哪个函数体。 - 因此,一个函数不能同时要求在编译期展开又在运行期动态绑定。虽然语法上可以将一个虚函数声明为
inline,但inline只是一个建议,并且”虚性”会覆盖”内联性”,该函数仍然是动态绑定的,不会被内联展开。
31. 为什么友元函数不能是虚函数
答案:
- 友元函数不是成员函数:虚函数必须是类的成员函数。友元函数虽然被授予了访问类的私有成员的权力,但它本身是一个全局函数,并不在类的作用域内。
- 无法被继承:虚函数的意义在于可以在派生类中被重写。而友元关系是不能继承的。每个类的友元函数都需要单独声明。因此,不存在”基类的友元函数在派生类中被重写”的概念。
32. 为什么模板函数不能是虚函数
答案:
- 实现复杂性:C++标准明确禁止模板函数成为虚函数。因为虚函数表的大小在编译时就需要确定。每个虚函数在
vtable中占据一个固定的槽位。 - 而模板函数会在编译时根据不同的模板参数实例化出多个不同的函数。如果允许模板虚函数,那么编译器无法在编译时确定这个虚函数表到底需要为这个模板准备多少个槽位,这会使得虚函数表的机制变得极其复杂甚至不可实现。
33. 为什么全局函数不能是虚函数
答案:
- 虚函数是C++中用于实现多态的机制,它必须是类的非静态成员函数。 全局函数不属于任何类,因此没有”虚函数”这一说。多态是基于对象类型的,而全局函数与任何对象类型无关。
34. 如果在构造函数中调用虚函数,调用的过程是怎么样的
答案: 在构造函数中调用虚函数,不会发生多态,即调用的总是当前构造函数所属类的版本,而不是最终派生类的版本。
- 原因:在构造基类部分时,派生类部分尚未被构造。此时对象的
vptr指向的是当前正在构造的类的虚函数表。这是为了安全考虑,因为如果调用派生类的重写版本,而派生类的成员还未初始化,会导致未定义行为。
35. list 的特殊操作
答案: std::list是双向链表,它支持一些序列容器(如vector)不支持的高效的特殊操作:
splice:将另一个链表中的元素(一个、一段或全部)拼接到当前链表的指定位置。这个过程不涉及元素的拷贝或移动,只是修改指针,因此是O(1) 操作。merge:将两个已排序的链表合并成一个有序链表。这也是一个高效的操作。sort:链表自身的sort成员函数,通常采用归并排序,效率优于通用算法std::sort(因为std::sort需要随机访问迭代器,而list不支持)。remove/remove_if:删除所有等于特定值或满足特定条件的元素。unique:删除连续的重复元素(通常需要先排序)。
36. vector的底层原理
答案: std::vector的底层是一个动态分配的数组。
- 三要素:它通常由三个指针(或一个指针加两个大小)来管理:
start:指向数组的第一个元素。finish:指向最后一个元素的下一个位置(即当前已使用空间的末尾)。end_of_storage:指向整个动态数组空间的末尾。
size() = finish - startcapacity() = end_of_storage - start
- 动态增长:当
size() == capacity()时,如果再添加新元素,vector会进行”重新分配”:- 申请一块更大的新内存(通常是原容量的2倍或1.5倍,取决于实现)。
- 将旧内存的所有元素移动或拷贝到新内存。
- 释放旧内存。
- 特点:支持随机访问(O(1)),在尾部插入删除效率高(O(1)摊销),在中间或头部插入删除效率低(O(n))。
37. 序列式容器的insert,erase的出错情况以及出错原因
答案:
insert:- 主要问题:可能导致迭代器失效。对于
vector和deque,插入操作可能引起重新分配,使得所有指向该容器的迭代器、指针、引用都失效。对于list,迭代器通常不会失效。 - 其他错误:提供的迭代器位置无效(如指向另一个容器);插入的元素类型与容器不匹配(编译错误)。
- 主要问题:可能导致迭代器失效。对于
erase:- 主要问题:同样会导致迭代器失效。对于
vector和deque,被删除元素及其之后的所有元素的迭代器都会失效。对于list,只有指向被删除元素的迭代器会失效。 - 经典错误:在循环中使用失效的迭代器。正确的做法是使用
erase的返回值(它返回被删除元素之后元素的有效迭代器)。cpp复制下载// 错误示范
for (auto it = vec.begin(); it != vec.end(); ++it) {
if (*it == value) {
vec.erase(it); // it 失效了,后续的 ++it 行为未定义
}
}
// 正确示范 (C++11后)
for (auto it = vec.begin(); it != vec.end(); ) {
if (*it == value) {
it = vec.erase(it); // erase 返回下一个有效的迭代器
} else {
++it;
}
}
- 主要问题:同样会导致迭代器失效。对于
38. 解决hash冲突的方法
答案: 当不同的键通过哈希函数映射到同一个桶(地址)时,就发生了哈希冲突。解决方法主要有:
- 链地址法:每个桶不直接存储元素,而是存储一个链表(或其它容器,如红黑树)。所有哈希到同一地址的元素都放入这个链表中。这是最常用的方法(如
std::unordered_map)。 - 开放定址法:当发生冲突时,按照某种探测序列(如线性探测、平方探测、双重哈希)在哈希表中寻找下一个空的桶。
- 线性探测:依次检查下一个桶。
- 优点:不需要额外的链表结构,缓存友好。
- 缺点:容易产生”聚集”现象,删除操作复杂(需要标记为”已删除”而非直接置空)。
- 再哈希法:准备多个不同的哈希函数,当第一个哈希函数发生冲突时,尝试用第二个、第三个哈希函数,直到找到空位。
39. 装载因子的知识
答案:
- 定义:装载因子 = 哈希表中已存储的元素个数 / 哈希表的桶总数。
- 意义:它衡量了哈希表的满的程度,是决定哈希表是否需要进行扩容的关键指标。
- 影响:
- 装载因子越大,说明表越满,发生哈希冲突的概率就越高,查询、插入的性能会下降。
- 装载因子越小,表越空,内存浪费越多,但冲突概率低,操作速度快。
- 默认值:在C++ STL的
unordered_map中,默认的最大装载因子通常是1.0。当实际装载因子超过这个最大值时,容器会自动增加桶的数量(通常是翻倍)并重新哈希所有元素,以降低装载因子。
40. unordered_set的hash和equal_to的实现方式
答案: unordered_set是一个关联容器,它存储唯一的元素,基于哈希表实现。它需要两个关键的函数对象:
- Hash函数:负责将元素的
Key映射到一个size_t类型的哈希值。对于内置类型(如int,std::string),标准库提供了默认的std::hash特化版本。对于自定义类型,你需要特化std::hash或传入自定义的哈希函数对象。cpp复制下载struct MyHash {
std::size_t operator()(const MyClass& obj) const {
// 计算并返回obj的哈希值
return …;
}
};
std::unordered_set<MyClass, MyHash> mySet; - Key相等性判断函数:负责判断两个
Key是否相等。因为哈希冲突的存在,即使哈希值相同,元素也可能不同。默认使用std::equal_to,它调用operator==。对于自定义类型,你需要确保重载了operator==或传入自定义的函数对象。
41. C++中类型转换有哪几种,简述一下他们的区别
答案: C++提供了四种命名的类型转换运算符,比C的风格转换更安全、更明确:
static_cast:- 用途最广泛。用于良性转换,如基本数据类型之间的转换(
int转double),非const转const,void*转其他指针,有转换构造函数的类类型转换等。 - 特点:在编译时进行类型检查,但不能移除
const或volatile属性。
- 用途最广泛。用于良性转换,如基本数据类型之间的转换(
dynamic_cast:- 主要用于继承体系中的安全向下转换。它会在运行时检查转换是否安全。如果指针转换失败则返回
nullptr,如果引用转换失败则抛出std::bad_cast异常。 - 要求:基类必须至少有一个虚函数(有虚函数表)。
- 主要用于继承体系中的安全向下转换。它会在运行时检查转换是否安全。如果指针转换失败则返回
const_cast:- 唯一用于修改类型的
const或volatile属性的操作符。常用于去掉const属性,但修改原为常量的对象是未定义行为。主要用于函数重载等场景。
- 唯一用于修改类型的
reinterpret_cast:- 最低层的转换。它仅仅重新解释底层的比特模式,不进行任何数据转换。例如,将指针转换为整数,或将一种类型的指针转换为另一种不相关类型的指针。
- 特点:非常危险,极易出错,应极度谨慎使用。
42. C++中函数指针和指针函数的区别
答案: 这是两个完全不同的概念,名称相似容易混淆。
- 指针函数:本质上是一个函数,其返回值类型是一个指针。
- 声明:
int* func(int a, int b);// 这是一个名为func的函数,返回一个int*。
- 声明:
- 函数指针:本质上是一个指针,这个指针指向一个函数。
- 声明:
int (*funcPtr)(int, int);// 这是一个名为funcPtr的指针,它可以指向一个接受两个int参数并返回int的函数。
- 声明:
43. void**的大小是多少
答案: void**是一个指向void*(万能指针)的指针。它本身依然是一个指针变量。
- 在32位系统上,所有类型的指针大小通常都是4字节。
- 在64位系统上,所有类型的指针大小通常都是8字节。
- 因此,
sizeof(void**)的值取决于目标平台的寻址能力,与sizeof(int*)、sizeof(MyClass*)等是相同的。
44. 简述malloc和free的实现原理
答案:
malloc:- 内存池:
malloc管理着一个堆内存池。它向操作系统申请大块内存(如通过brk或sbrk系统调用),然后将其分割成更小的块来满足用户请求。 - 内存块结构:每个分配的内存块通常带有一个头部,用于存储块的元数据,如块大小、是否空闲等。
- 分配策略:当收到分配请求时,
malloc会在空闲内存块中寻找一个足够大的块。常见的策略有首次适应、最佳适应等。找到后,可能将一块大的空闲块分割,一部分分配给用户,剩余部分放回空闲链表。
- 内存池:
free:- 标记为空闲:
free接收一个指针,通过指针前的元数据找到该内存块的大小,并将其标记为空闲。 - 合并空闲块:为了避免碎片化,
free会尝试将刚释放的块与相邻的空闲块合并成一个更大的空闲块,并放回空闲链表。
- 标记为空闲:
45. 为何free的时候,只需要传递堆空间的地址就可以了
答案: 因为malloc在分配内存时,会在返回给用户的内存块之前存储一些管理信息(元数据),例如块的大小。free函数接收到指针后,会向前偏移一定的位置(例如sizeof(size_t))来读取这个元数据,从而知道要释放的内存块有多大。因此,程序员不需要传递大小,free自己就能知道。
46. malloc申请内存后,怎么保证一定申请到了呢,你会申请完后直接使用这片内存吗
答案:
- 保证机制:无法100%保证。
malloc在内存不足时会返回NULL(空指针)。因此,良好的编程习惯是每次调用malloc后都必须检查返回值是否为NULL。 - 使用前检查:绝对不能在申请后不检查就直接使用。必须先判断指针非空。cpp复制下载int* ptr = (int*)malloc(100 * sizeof(int)); if (ptr == NULL) { // 处理分配失败的情况(如打印错误信息、退出程序等) fprintf(stderr, “Memory allocation failed!\n”); exit(EXIT_FAILURE); } // 只有在确认分配成功后才可以使用ptr *ptr = 10;在C++中,使用
new在失败时会抛出std::bad_alloc异常,而不是返回NULL。
47. new/delete与malloc/free的异同
答案:
- 相同点:都用于在堆上动态申请和释放内存。
- 不同点:
- 语言:
malloc/free是C库函数;new/delete是C++运算符。 - 构造与析构:
new在分配内存后会调用构造函数来初始化对象;delete在释放内存前会调用析构函数来清理资源。malloc/free只负责内存分配和释放,不调用构造和析构。 - 返回值与错误处理:
new返回具体类型的指针,失败时抛出异常;malloc返回void*,失败时返回NULL。 - 计算大小:
new由编译器根据类型计算大小;malloc需要程序员手动计算字节数。 - 重载:
operator new/delete可以被重载;malloc/free不能重载。 - 兼容性:
new/delete与C++的面向对象特性紧密集成;malloc/free与C++对象不兼容(不会调用构造/析构)。
- 语言:
48. delete和delete[]的区别
答案:
delete:用于释放new分配的单个对象的内存。它只会调用一次析构函数。delete[]:用于释放new[]分配的对象数组的内存。它会对数组中的每一个对象调用析构函数,然后再释放整块内存。- 混用的后果:如果对数组使用
delete,会导致只有第一个元素的析构函数被调用,其余元素的析构函数未被调用(资源泄漏),并且释放内存的方式也可能错误(因为new[]可能在分配的内存块头部存储了数组大小的元数据),引发未定义行为。反之亦然。
49. 指针和引用的区别
答案:
- 初始化:引用必须被初始化,且一旦初始化后就不能再绑定到其他对象(类似于一个常量指针)。指针可以不初始化(但危险),也可以随时改变指向。
- 空值:引用不能为
NULL,它必须总是指向一个有效的对象。指针可以为NULL,表示不指向任何对象。 - 操作:对引用的所有操作都等价于对其所绑定对象的操作。指针则需要使用解引用操作符
*来访问所指对象。 - 内存地址:引用本身不占用额外的存储空间(可以理解为是原变量的一个别名)。指针本身是一个变量,占用内存空间来存储地址。
- 多级:存在多级指针(
int**),但不存在多级引用。引用的引用实际上是原变量的别名。
50. 引用作为函数返回时为什么不能返回局部变量
答案: 因为局部变量的生命周期仅限于函数内部,当函数返回时,局部变量所占用的栈内存会被自动释放(销毁)。如果返回一个指向已销毁局部变量的引用,那么这个引用就成为了一个悬空引用,类似于野指针。后续通过这个引用访问数据,其行为是未定义的,通常会导致程序崩溃或数据错误。
- 正确做法:返回的引用必须指向一个在函数返回后依然有效的对象,例如:
- 静态局部变量或全局变量。
- 通过参数传入的引用或指针所指向的对象。
- 动态分配在堆上的对象(但需要谨慎管理内存)。
51. extern”C”的用法
答案: extern "C" 的主要目的是在 C++ 代码中链接 C 语言编写的函数或库。由于 C++ 支持函数重载,它会进行名称修饰(Name Mangling),将函数名和参数类型等信息组合成一个独特的内部名称。而 C 语言没有这个机制。
- 用法:用
extern "C"包裹 C 函数的声明,告诉 C++ 编译器按 C 语言的规则来编译和链接这些函数,即不进行名称修饰。cpp复制下载// 在 C++ 代码中这样声明一个 C 函数 extern “C” { #include “my_c_lib.h” // 包含 C 头文件 void c_function(int arg); // 或者直接声明 C 函数 } // 或者,在头文件中使用条件编译,使其同时适用于 C 和 C++ #ifdef __cplusplus extern “C” { #endif void c_function(int arg); #ifdef __cplusplus } #endif - 限制:被
extern "C"修饰的函数不能重载,因为 C 语言不支持。
52. 内联函数和宏定义的区别
答案: 虽然目标相似(避免函数调用的开销),但内联函数是 C++ 对宏的安全和可控的替代。
| 特性 | 宏定义 (#define) | 内联函数 (inline) |
|---|---|---|
| 处理阶段 | 预处理期,文本替换 | 编译期,编译成代码 |
| 类型检查 | 无,不安全,易产生副作用(如 MAX(a++, b++)) | 有,是真正的函数,进行严格的类型检查 |
| 调试 | 难以调试,因为替换后看不到宏名 | 可以调试(尽管可能被展开,但符号信息更完善) |
| 开销 | 纯文本替换,可能导致代码膨胀 | 编译器决策,可能被忽略(特别是复杂函数) |
| 作用域 | 无作用域,从定义处开始全局生效 | 有作用域,遵循 C++ 的作用域规则 |
| 其他 | 可定义函数、常量等,功能复杂 | 可访问类的私有成员 |
53. 静态变量什么时候初始化
答案: 静态变量的初始化时机取决于其类型和位置:
- 全局静态变量 / 类的静态成员变量:在
main函数执行之前进行初始化。具体来说,在程序的静态初始化阶段完成。 - 局部静态变量(函数内部):在第一次执行到其声明语句时进行初始化。这是线程安全的(C++11 起)。这种延迟初始化机制使得
Meyer's Singleton成为可能。
54. 动态编译和静态编译
答案: 这指的是程序与库的链接方式。
- 静态编译:
- 过程:在编译链接时,将库的代码直接复制到最终的可执行文件中。
- 结果:生成的可执行文件体积较大,但可以独立运行,不依赖运行环境的库版本。
- 库文件:在 Linux 下是
.a文件(Archive),在 Windows 下是.lib文件。
- 动态编译:
- 过程:在编译链接时,只记录需要哪些动态库函数。库的代码并不嵌入可执行文件中。
- 结果:生成的可执行文件体积小,但运行时需要系统中存在相应版本的动态库。
- 库文件:在 Linux 下是
.so文件(Shared Object),在 Windows 下是.dll文件。 - 优点:多个程序可共享同一个库,节省内存和磁盘空间;库升级方便,只需替换动态库文件(需注意版本兼容性)。
55. inline函数的使用,缺点是什么
答案:
- 使用:在函数声明或定义前加上
inline关键字。通常将短小、频繁调用的函数声明为内联。cpp复制下载inline int max(int a, int b) { return a > b ? a : b; } - 缺点:
- 代码膨胀:如果内联函数体很大或被多次调用,会导致最终的可执行文件体积显著增大。
- 并非强制内联:
inline只是对编译器的建议,编译器有权拒绝。复杂的函数(如包含循环、递归的函数)通常不会被内联。 - 调试困难:内联函数在调用处展开,可能给调试带来一些不便(例如,无法在函数内设置断点)。
- 可能降低性能:代码膨胀会导致指令缓存命中率降低,反而可能降低程序性能。
56. 为什么拷贝构造函数必须传引用而不能传值
答案: 这是一个经典的循环依赖问题。
- 如果拷贝构造函数按值传递:为了将实参传递给形参,需要调用拷贝构造函数来创建形参对象。
- 这就形成了一个无限递归:调用拷贝构造函数 -> 需要按值传参 -> 再次调用拷贝构造函数 -> … 直到栈溢出。
- 因此,拷贝构造函数的参数必须是引用(通常是
const引用),这样就避免了在传参时再次调用拷贝构造,从而打破了递归链。
57. 类中静态函数占用内存吗
答案: 不占用类的对象的内存。
- 静态成员函数属于类本身,而不是类的某个对象。它被所有对象共享。
- 静态成员函数的存在与类的对象实例化与否无关。它在程序的生命周期内只有一份,存储在代码区或其他全局数据区,不会随着对象的创建而分配,也不会随着对象的销毁而释放。
- 因此,
sizeof(MyClass)不会包含静态成员函数的大小。
58. 构造函数初始化和列表初始化的区别
答案: 这里指的是在构造函数体内赋值和使用初始化列表的区别。
- 初始化列表:
- 语法:
MyClass(int x, int y) : mem1(x), mem2(y) {} - 时机:在构造函数体执行之前,直接初始化成员变量。对于类类型的成员,这会调用它们的拷贝构造函数。
- 必须使用的情况:
- 初始化 const 成员。
- 初始化 引用成员。
- 初始化没有默认构造函数的类类型成员。
- 语法:
- 构造函数体内赋值:
- 语法:
MyClass(int x, int y) { mem1 = x; mem2 = y; } - 时机:在构造函数体内,这实际上是先默认初始化成员,然后再进行赋值操作。对于类类型的成员,这会先调用默认构造函数,再调用赋值运算符。
- 语法:
- 效率:对于非内置类型,使用初始化列表通常效率更高,因为它避免了先默认构造再赋值的开销。推荐总是使用初始化列表。
59. 虚函数表的作用和存储地址
答案:
- 作用:虚函数表是实现 C++ 动态多态(运行时多态)的核心机制。它是一个函数指针数组,存储了当前类的各个虚函数的实际地址。每个包含虚函数的类都有一个自己的虚函数表。
- 存储地址:
- 虚函数表(vtable)本身:编译器在编译期为每个类生成虚函数表,并将其作为只读数据存储在程序的代码段或静态数据段中。每个类的虚函数表在内存中只有一份。
- 虚函数表指针(vptr):每个包含虚函数的类的对象内部,编译器会隐式地添加一个指针成员
vptr。这个vptr在对象构造时被设置为指向其所属类的虚函数表。因此,vptr存储在每个对象的内存布局中(通常在最前面)。
60. 泛型编程的意义
答案: 泛型编程是一种编程范式,核心思想是将算法从特定的数据类型中抽象出来,使其能适用于多种数据类型。
- 在 C++ 中的实现:主要通过模板实现。
- 意义:
- 代码复用:编写一次算法或数据结构,即可用于不同的类型,大大提高了代码的复用性。例如,一个
sort模板函数可以排序int,double,string等。 - 类型安全:相比使用
void*的 C 风格泛型,模板在编译时进行类型检查,更加安全。 - 性能:模板是在编译期实例化的,生成的代码是特定于类型的,没有运行时开销,效率与手写针对该类型的代码相当(称为”零开销抽象”)。
- STL 的基础:C++ 标准模板库(STL)是泛型编程的典范,提供了大量通用的容器、算法和迭代器。
- 代码复用:编写一次算法或数据结构,即可用于不同的类型,大大提高了代码的复用性。例如,一个
61. 面向对象的三大特征的意义
答案:
- 封装:
- 意义:将数据(属性)和操作数据的方法(行为)绑定在一起,形成一个类;并通过对访问权限的控制(
public,private,protected)来隐藏对象的内部实现细节。 - 好处:提高了代码的安全性(数据不能被随意修改)和可维护性(内部实现改变不影响外部接口),降低了系统的耦合度。
- 意义:将数据(属性)和操作数据的方法(行为)绑定在一起,形成一个类;并通过对访问权限的控制(
- 继承:
- 意义:允许一个新类(派生类)继承现有类(基类)的特征(数据成员和成员函数)。
- 好处:实现了代码的复用,可以建立类的层次结构,使系统易于扩展。也是实现多态的基础。
- 多态:
- 意义:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。即”一个接口,多种实现”。
- 好处:增强了程序的灵活性和可扩展性。允许在运行时动态绑定具体行为,使得程序可以应对未来的变化,符合”开闭原则”(对扩展开放,对修改封闭)。
62. 类指针初始化为空指针后调用成员函数会出问题吗
答案: 取决于调用的成员函数类型。
- 调用非虚函数(普通成员函数/静态成员函数):不会出问题,可以正常调用。
- 原因:非虚函数的调用在编译时就已经确定(静态绑定),编译器根据指针的静态类型来生成调用代码。调用时不需要访问对象的内存(即不需要
this指针指向有效的对象)。函数代码是存在于代码区的,与对象无关。
- 注意:虽然语法上可行,但如果该非虚函数内部试图通过
this指针访问成员变量,则会对nullptr解引用,导致运行时崩溃。
- 原因:非虚函数的调用在编译时就已经确定(静态绑定),编译器根据指针的静态类型来生成调用代码。调用时不需要访问对象的内存(即不需要
- 调用虚函数:会出问题,导致运行时未定义行为(通常是崩溃)。
- 原因:虚函数的调用需要通过对象的虚函数表指针
vptr来查找函数地址(动态绑定)。当指针为nullptr时,访问vptr本身就是对空指针的解引用,程序会在查表之前就崩溃。
- 原因:虚函数的调用需要通过对象的虚函数表指针
63. 动态指针的判空
答案: 在 C++ 中,判断一个原生指针是否为空非常简单:
cpp
复制下载
int* ptr = nullptr; // 或 NULL 或 0
// 方法一:直接与 nullptr 比较(C++11 后推荐)
if (ptr == nullptr) {
// 指针为空
}
// 方法二:直接使用指针本身,因为空指针在布尔上下文中为 false
if (!ptr) {
// 指针为空
}
if (ptr) {
// 指针非空
}
对于智能指针(unique_ptr, shared_ptr),判空方式相同,因为它们重载了 operator bool():
cpp
复制下载
std::shared_ptr<int> smartPtr;
if (!smartPtr) { // 正确,判断是否为空
// ...
}
64. 构造函数可以设置成虚函数吗
答案: 不可以。原因在之前的第 28 题已经详细解释过:
- 从
vptr的创建时机看:虚函数机制依赖于vptr,而vptr是在构造函数中初始化的。在构造函数执行前,对象不完整,vptr未设置,无法进行虚函数调用。 - 从语义上看:构造函数的任务是创建特定类型的对象。你需要明确知道要创建什么类型,不存在”通过基类接口构造不确定的派生类对象”的语义。
65. new和malloc之间有什么区别
答案: 此问题与第 47 题(new/delete 与 malloc/free 的异同)本质相同。核心区别在于:
new是运算符,malloc是库函数。new会调用构造函数,malloc不会。new返回类型安全的指针,malloc返回void*。new失败时抛出异常,malloc失败时返回NULL。new的大小由编译器计算,malloc需要手动计算字节数。new/delete可以被重载,malloc/free不能。
66. 虚函数表里存放的内容是什么时候写进去的
答案: 虚函数表的内容(即各个虚函数的地址)是在编译期就由编译器确定并写入到程序的静态数据区的。
- 编译器分析类的层次结构,为每个包含虚函数的类生成一个唯一的虚函数表。
- 对于每个虚函数,编译器将其实际地址(对于非纯虚函数)或一个表示”纯虚”的特殊占位符地址(对于纯虚函数)填充到虚函数表的相应槽位中。
- 因此,虚函数表在程序加载到内存时就已经存在,并且在整个程序运行期间保持不变。
67. 虚函数和纯虚函数的区别
答案:
| 特征 | 虚函数 | 纯虚函数 |
|---|---|---|
| 声明语法 | virtual ReturnType func(); | virtual ReturnType func() = 0; |
| 定义 | 可以有定义,派生类可以选择是否重写。 | 可以没有定义(通常没有)。如果有定义,需要类外单独定义。 |
| 所在类 | 包含虚函数的类可以实例化。 | 包含纯虚函数的类是抽象类,不能实例化。 |
| 目的 | 提供默认实现,允许派生类选择性地重写以改变行为。 | 强制派生类必须提供实现,用于定义接口规范。 |
| 虚函数表 | 表中存放该函数的地址。 | 表中存放一个特殊值(或指向一个报错函数的地址),表示此函数是纯虚的。 |
68. 为什么析构函数一般写成虚函数
答案: 当基类的指针指向派生类的对象,并通过该指针删除对象时,如果基类的析构函数不是虚函数,就会导致未定义行为(通常是只调用了基类的析构函数,而派生类的析构函数没有被调用,造成资源泄漏)。
- 例子:cpp复制下载class Base { public: ~Base() { cout << “Base Destructor” << endl; } // 非虚析构 }; class Derived : public Base { public: ~Derived() { cout << “Derived Destructor” << endl; } }; int main() { Base* ptr = new Derived(); delete ptr; // 只输出 “Base Destructor”!Derived 部分的资源泄漏了。 return 0; }
- 解决方法:将基类的析构函数声明为虚函数。cpp复制下载class Base { public: virtual ~Base() { cout << “Base Destructor” << endl; } // 虚析构 }; // 此时 delete ptr; 会先调用 ~Derived(),再调用 ~Base(),正确释放资源。
- 原则:如果一个类打算作为基类被继承,那么它的析构函数应该声明为虚函数。
69. 动态多态的实现过程和静态多态的实现过程
答案:
- 静态多态(编译时多态):
- 实现:主要通过函数重载和模板实现。
- 过程:在编译阶段,编译器根据函数调用时传递的实参类型或模板的具体类型参数,就能确定具体调用哪个函数或生成哪个版本的代码。
- 例子:cpp复制下载// 函数重载 void print(int i) {…} void print(string s) {…} print(10); // 编译时决定调用 print(int) print(“hello”); // 编译时决定调用 print(string) // 模板 template<typename T> T add(T a, T b) { return a + b; } add(1, 2); // 编译时实例化 add<int> add(1.0, 2.0); // 编译时实例化 add<double>
- 动态多态(运行时多态):
- 实现:主要通过虚函数和继承实现。
- 过程:在编译阶段,编译器只能确定调用的是一个虚函数。具体调用哪个版本的函数(基类还是派生类的)需要等到程序运行时,根据指针或引用所指向的对象的实际类型,通过查虚函数表才能决定。
- 例子:cpp复制下载class Animal { virtual void speak() { … } }; class Dog : public Animal { void speak() override { … } }; class Cat : public Animal { void speak() override { … } }; Animal* ptr = new Dog(); ptr->speak(); // 运行时,根据ptr实际指向的Dog对象,调用Dog::speak() ptr = new Cat(); ptr->speak(); // 运行时,调用Cat::speak()
70. STL中vector删除其中的元素,迭代器如何变化,为什么是两倍空间,释放空间?
答案:
- 迭代器如何变化:在
vector中删除元素,会导致被删除元素之后的所有迭代器、指针、引用失效。因为删除操作会使后面的元素向前移动。erase函数会返回一个指向被删除元素之后那个元素的新迭代器。在循环中删除时,应该使用这个返回值来更新迭代器。
- 为什么是两倍扩容:这是一种常见的摊销策略,旨在保证
push_back操作的平均时间复杂度为 O(1)。虽然单次扩容(从容量 n 到 2n)是 O(n) 操作,但将这次开销分摊到接下来的 n 次插入操作上,每次插入的摊销成本就是 O(1)。1.5 倍也是常见策略,两者在时间和空间开销上有所权衡。 - 如何释放空间:
vector的clear()函数只销毁元素,将size设为 0,但不释放容量(capacity不变)。要真正释放内存,可以使用swap技巧:cpp复制下载std::vector<int> vec(1000); // … 使用 vec std::vector<int>().swap(vec); // 用一个空的临时vector和vec交换,临时vector离开作用域后释放大内存 // 或者 C++11 后使用 shrink_to_fit() 成员函数(请求,不保证) vec.shrink_to_fit();
71. map,set是怎么实现的,红黑树是怎么能够同时实现这两种容器,为什么使用红黑树
答案:
- 实现:
std::map和std::set通常基于红黑树实现。红黑树是一种自平衡的二叉搜索树。 - 如何同时实现:
set可以看作是一个键和值相同的特殊map。在底层,set<T>可以实现为红黑树节点只存储一个T类型的值。map<K, V>则实现为红黑树节点存储一个std::pair<const K, V>。- 两者的核心操作(插入、删除、查找)都依赖于对键的比较,而红黑树正是一种基于键值进行高效组织的数据结构。
- 为什么使用红黑树:相比于其他平衡树(如 AVL 树),红黑树在平衡性和调整开销之间取得了较好的折衷。
- 高效:查找、插入、删除的时间复杂度都是 O(log n)。
- 平衡性较好:能避免二叉搜索树退化成链表的最坏情况,但不像 AVL 树那样追求绝对平衡,因此插入和删除操作所需的旋转次数更少,整体性能更优。
72. 模板和实现可不可以不写在一个文件里面,为什么
答案: 通常必须写在一起(通常在头文件中)。
- 原因:模板的实例化发生在编译期。当编译器看到模板被使用(例如
MyClass<int> obj;)时,它需要能够看到模板的完整定义(而不仅仅是声明),才能根据具体的类型参数int生成对应的代码。 - 如果分离:如果将模板的声明放在
.h文件,定义放在.cpp文件。那么,在编译使用该模板的main.cpp时,编译器只看到了声明,无法实例化模板,会导致链接错误(undefined reference)。 - 解决方法:
- (推荐)将模板的声明和定义都放在头文件中。
- 使用 显式实例化:在模板定义的
.cpp文件中显式地实例化你需要的所有类型(如template class MyClass<int>;)。这种方法不灵活,需要预知所有会用到的类型。
73. 请简述你了解使用过的C++11的新特性
答案: C++11 是一个重大的更新,引入了大量现代语言特性。后端程序员常用的特性包括:
- 自动类型推导:
auto和decltype,让编译器推导变量类型,简化代码。 - 智能指针:
unique_ptr,shared_ptr,weak_ptr,用于自动内存管理,避免内存泄漏。 - 基于范围的 for 循环:
for (auto& x : container),简化容器遍历。 - 右值引用和移动语义:
&&和std::move,避免不必要的深拷贝,提升性能。 - Lambda 表达式:
[capture](params) -> ret { body },方便地定义匿名函数对象。 - nullptr:类型安全的空指针常量,取代
NULL。 - 并发支持:
std::thread,std::mutex,std::condition_variable,std::async等,用于多线程编程。 - 标准库增强:新的容器(如
unordered_map,unordered_set),新的算法等。 - 委托构造和继承构造:简化构造函数编写。
override和final关键字:使代码更清晰、更安全。- 强类型枚举:
enum class,避免了传统枚举的一些问题。
74. 说一说你了解的关于lambda函数的全部知识
答案: Lambda 表达式是 C++11 引入的一种定义匿名函数对象的方式。
- 完整语法:
[capture-list] (params) mutable? exception? -> ret { body } - 核心组成部分:
- 捕获列表
[capture]:指定如何捕获外部变量。[]不捕获任何变量。[=]以值的方式捕获所有外部变量。[&]以引用的方式捕获所有外部变量。[var]值捕获var。[&var]引用捕获var。[this]捕获当前类的this指针。- 可以组合,如
[=, &x]值捕获所有,但引用捕获x。
- 参数列表
(params):和普通函数的参数列表一样。 - 可变规范
mutable:允许修改按值捕获的变量(默认情况下,lambda 的operator()是const的)。 - 异常规范
exception:声明可能抛出的异常。 - 返回类型
-> ret:尾置返回类型。通常可以省略,由编译器推导。 - 函数体
{ body }:函数实现。
- 捕获列表
- 本质:编译器会为每个 lambda 表达式生成一个唯一的、匿名的函数对象类(仿函数)。捕获列表的变量会成为这个类的成员变量。
75. C++中的智能指针,三种指针解决的问题及区别
答案: 智能指针用于自动化内存管理,解决内存泄漏和悬空指针问题。
| 智能指针 | 解决的问题 | 所有权语义 | 特点 |
|---|---|---|---|
std::unique_ptr | 替代 new/delete,管理独占所有权的资源。 | 独占所有权 | 一个资源只能被一个 unique_ptr 拥有。不支持拷贝,只支持移动。轻量高效,无额外开销。 |
std::shared_ptr | 管理需要共享所有权的资源。 | 共享所有权 | 使用引用计数。多个 shared_ptr 可以指向同一个对象,当最后一个 shared_ptr 被销毁时,对象才被释放。有计数器的开销。 |
std::weak_ptr | 解决 shared_ptr 的循环引用问题。 | 弱引用 | 不控制对象的生命周期,不增加引用计数。它从一个 shared_ptr 创建,用于观察资源,但不会阻止资源释放。需要调用 lock() 来尝试获取一个有效的 shared_ptr。 |
76. 假设有一个指针,如何做到多次使用,一次释放
答案: 这正是 std::shared_ptr 的设计目的。
shared_ptr使用引用计数。每次拷贝一个shared_ptr(例如,传递给它,放入容器),其内部的引用计数就会加一。当每个shared_ptr被销毁(例如,离开作用域)时,引用计数减一。- 当引用计数减到 0 时,它才会自动调用
delete(或自定义的删除器)来释放所管理的对象。 - 因此,你可以创建多个
shared_ptr指向同一个对象,这些shared_ptr可以”多次使用”,而对象只会在最后一个shared_ptr失效时被”一次释放”。
77. 分别写出在if语句条件中,如何判断bool a,int a,float a,char *a是否为0
答案:
cpp
复制下载
bool a;
if (!a) {...} // 或 if (a == false)
int a;
if (a == 0) {...} // 或 if (!a) (因为0在布尔上下文中为false,但不推荐,降低可读性)
float a;
// 由于浮点数精度问题,不能直接与0比较
if (std::abs(a) < std::numeric_limits<float>::epsilon()) {...} // 判断是否接近0
// 或者,如果明确知道a应该是整数,可以转型后比较:if (static_cast<int>(a) == 0)
char *a;
if (a == nullptr) {...} // C++11后,判断是否为空指针
// 或 if (a == NULL) 或 if (a == 0) (旧式写法)
// 如果要判断指向的字符串是否为空字符串,则是:if (a == nullptr || *a == '\0')
78. 请解释32位/64位系统具体指的是什么长度,对系统有何影响
答案:
- 具体长度:通常指的是 CPU 通用寄存器的宽度,以及由此决定的寻址能力和数据处理能力。
- 32位系统:寄存器、数据总线、地址总线通常是 32 位宽。寻址空间为 2^32 = 4GB。
- 64位系统:通常是 64 位宽。寻址空间为 2^64,这是一个极其巨大的数字(16 EB),实际上目前只用了其中一部分(如 48 位)。
- 影响:
- 内存支持:32 位系统最大只能支持约 4GB 物理内存(实际可用更少)。64 位系统可以支持巨大的物理内存。
- 性能:64 位 CPU 一次可处理 64 位数据,对于大量数据处理有优势。寄存器数量和大小也可能增加。
- 指针大小:在 64 位系统上,指针类型占 8 字节(64位),而在 32 位系统上占 4 字节(32位)。这影响了数据结构的内存占用。
- 软件兼容性:64 位系统可以运行 32 位软件(通过兼容层),但 32 位系统不能运行 64 位软件。需要针对不同平台编译程序。
79. 简述系统物理内存和虚拟内存之间的联系与区别
答案:
- 物理内存:计算机硬件安装的实际内存条(RAM)。容量有限,直接由 CPU 访问,速度快。
- 虚拟内存:操作系统为每个进程提供的抽象的、连续的地址空间。它的大小由 CPU 的寻址能力决定(如 32 位系统是 4GB),与物理内存大小无关。
- 联系与工作方式:
- 虚拟内存通过分页机制与物理内存关联。操作系统和 CPU 的 MMU(内存管理单元)负责将虚拟地址映射到物理地址。
- 进程访问一个虚拟地址时,如果该地址所在的页已经在物理内存中(页命中),则直接访问。
- 如果不在(缺页错误),操作系统会从磁盘上的交换文件(swap file) 中将该页换入物理内存,然后更新映射关系,再让进程访问。
- 区别:
- 抽象层级:物理内存是硬件,虚拟内存是操作系统提供的抽象。
- 大小:物理内存固定;虚拟内存大小由 CPU 架构决定,且可以借助磁盘空间来扩展。
- 目的:虚拟内存为每个进程提供了独立的、受保护的地址空间,使得进程间不会互相干扰,并允许运行比物理内存更大的程序。
80. 简述你熟悉的编译器的不同优化级别,以及编译器优化一些基本的思想
答案: 以 GCC 为例,常用的优化级别有:
-O0:默认级别,不优化。编译速度快,便于调试。-O1或-O:进行基本的优化,试图减少代码大小和执行时间,但不进行需要大量编译时间的优化。-O2:更高级的优化。包括几乎所有不涉及空间速度权衡的优化。通常会牺牲编译速度来提升生成代码的性能。这是发布版本的常用级别。-O3:激进的优化。在-O2的基础上,进行更多优化,如函数内联、循环展开等。可能会增加代码大小,有时性能提升不明显甚至下降。-Os:优化代码大小。执行所有-O2中不会显著增加代码大小的优化。-Og:为调试体验而优化。在保持代码可调试性的前提下进行一些优化。
基本优化思想:
- 内联:将小函数调用处直接替换为函数体,避免调用开销。
- 循环优化:循环展开(减少循环判断次数)、循环不变代码外提(将循环内不变的计算移到循环外)。
- 常量传播/折叠:在编译期计算常量表达式的值。
- 死代码消除:删除永远不会被执行的代码。
- 寄存器分配:将频繁使用的变量保存在寄存器中,减少内存访问。
81. 函数 bool less(float x,float y)是否能正确计算float的大小关系
答案: 不能完全正确。
- 问题:浮点数存在精度限制和特殊的表示(如 NaN)。
- NaN:如果
x或y是 NaN,那么任何比较操作(包括<)都会返回false。less(NaN, 5.0)和less(5.0, NaN)都返回false,这不符合”全序关系”的预期。 - -0.0 和 +0.0:在比较中,
-0.0 == +0.0为真。但对于less,less(-0.0, +0.0)为假,less(+0.0, -0.0)也为假,这没问题,但需要注意它们被认为是相等的。
- NaN:如果
- 改进:如果需要处理 NaN 并遵循 IEEE 754 的标准全序比较,可以使用
std::isless函数,它在遇到 NaN 时不会引发无效操作异常,并且定义了 NaN 的比较行为(NaN 被视为无序)。
82. 有个char*p的字符串,这个字符串强转成int型指针,再进行自增运算
答案:
cpp
复制下载
char* p = "hello"; int* ip = (int*)p; // 强制类型转换 ip++; // 指针自增
ip++的结果:ip的值会增加sizeof(int)个字节,而不是sizeof(char)个字节。- 在 32 位系统上,
sizeof(int)通常是 4,所以ip会增加 4。 - 在 64 位系统上,
sizeof(int)通常也是 4(但int*的大小是 8 字节),所以ip也会增加 4。
- 在 32 位系统上,
- 风险和警告:
- 对齐问题:
int类型通常有对齐要求(如 4 字节对齐)。而char*可能指向任意地址,强制转换后的int*可能指向一个未对齐的地址,在某些架构上访问*ip会导致总线错误或性能下降。 - 访问越界:
p指向一个字符串,长度为 6 字节(包含\0)。ip++后,ip指向了p+4的位置。如果解引用*ip,将会访问从p+4开始的 4 个字节,这已经超出了原始字符串 “hello” 的范围(只到p+5),属于未定义行为。
- 对齐问题:
83. 谈一下模板template
答案: 模板是 C++ 支持泛型编程的核心机制。
- 概念:模板是一个蓝图,它允许我们编写与类型无关的代码。编译器会根据使用时提供的具体类型,实例化出特定版本的代码。
- 分类:
- 函数模板:生成通用函数的蓝图。cpp复制下载template<typename T> T max(T a, T b) { return (a > b) ? a : b; }
- 类模板:生成通用类的蓝图。cpp复制下载template<typename T> class Stack { … };
- 特性:
- 参数化类型:使用
typename或class关键字定义类型参数。 - 非类型参数:模板参数也可以是整型、指针或引用等值。cpp复制下载template<int N> class Array { … };
- 特化:可以为特定的类型提供特殊的实现。
- 编译期计算:模板元编程可以在编译期执行复杂的计算。
- 参数化类型:使用
84. 定义函数时,缺省函数的返回值类型,则函数的返回值类型为什么类型
答案: 在 C 语言中,如果函数没有显式声明返回值类型,默认返回 int 类型。
- 例子:c复制下载func() { // 默认返回 int return 10; }
但是,这种写法在现代 C 和 C++ 中已经被淘汰,被认为是过时且不安全的。 在 C99 和 C++ 标准中,都要求函数必须显式指定返回值类型。编译器通常会对此发出警告。在 C++ 中,绝对不允许这种写法。
85. 要实现动态联编,必须使用什么来调用虚函数
答案: 要实现动态联编(运行时多态),必须同时满足:
- 通过基类的指针来调用虚函数。
- 通过基类的引用来调用虚函数。
- 如果直接通过对象本身来调用虚函数,则属于静态联编(编译时绑定),不会有多态效果。
86. 什么是多态,多态分为几种,多态的应用场景有哪些
答案:
- 什么是多态:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。即”一个接口,多种实现”。
- 种类:
- 编译时多态(静态多态):
- 实现:函数重载、运算符重载、模板。
- 特点:在编译期确定具体调用。
- 运行时多态(动态多态):
- 实现:虚函数机制。
- 特点:在运行期根据对象实际类型确定具体调用。
- 编译时多态(静态多态):
- 应用场景:
- 设计模式:几乎所有模式都用到多态,如工厂模式、策略模式、观察者模式等。
- 库和框架设计:定义通用接口,允许用户提供自定义实现。例如,STL 中的比较器、分配器。
- 图形用户界面:处理不同 UI 控件(如 Button, TextBox)的同一事件(如
draw())。 - 游戏开发:处理不同游戏对象(如 Enemy, Player)的同一行为(如
update())。
87. 空类里有什么函数
答案: 在 C++ 中,如果你定义一个空类 class Empty {};,编译器会自动为它生成以下成员函数(如果程序中没有显式定义它们的话):
- 默认构造函数:
Empty() - 拷贝构造函数:
Empty(const Empty&) - 拷贝赋值运算符:
Empty& operator=(const Empty&) - 析构函数:
~Empty()
- 注意:从 C++11 开始,还会自动生成移动构造函数和移动赋值运算符。
- 这些函数都是
public且inline的。因此,一个空类的大小通常不是 0(为了确保每个对象有唯一的地址,通常为 1 字节),但确实拥有这些默认成员函数。
88. A继承B,C两个空类,对A进行强转成B,C,地址空间有什么变化呢
答案: 在 C++ 中,当发生多重继承时,派生类对象的内存布局会包含每个基类的子对象。
- 情况:
class A : public B, public C {};,其中B和C是空类(大小通常为 1 字节)。 - 内存布局:一个
A对象的内存中,先存放B的子对象,然后存放C的子对象,最后存放A自身的成员(如果有的话)。由于空基类优化,空基类子对象的大小可能被优化掉,但为了区分它们,仍然需要占据位置。 - 地址变化:
A* a_ptr = new A;B* b_ptr = static_cast<B*>(a_ptr);b_ptr的值等于a_ptr。因为B的子对象在开头。C* c_ptr = static_cast<C*>(a_ptr);c_ptr的值不等于a_ptr。它需要指向A对象内部C子对象的位置,因此编译器会在转换时进行地址偏移。
- 这种地址偏移是在编译时由编译器自动完成的。
89. public/private继承的关系及应用场景
答案: 继承方式决定了基类成员在派生类中的访问权限以及“is-a” 关系的语义。
- public 继承:
- 语义:表示 “is-a” 关系。派生类是基类的一种特殊形式。例如
Studentpublic 继承Person。 - 访问权限:基类的
public成员在派生类中仍是public;protected成员仍是protected。 - 应用场景:最常用,用于实现接口继承和子类型多态。
- 语义:表示 “is-a” 关系。派生类是基类的一种特殊形式。例如
- private 继承:
- 语义:表示 “is-implemented-in-terms-of”(根据……实现)关系。它是一种实现继承,而非接口继承。派生类不是基类的子类型。
- 访问权限:基类的所有成员(
public,protected)在派生类中都变成private。 - 应用场景:很少使用。通常可以用组合(在类中包含一个基类类型的成员变量)来替代,组合更灵活、耦合度更低。只有在需要重写基类的虚函数,或需要访问基类的
protected成员时,才考虑使用 private 继承。
- protected 继承:更少见,基类的
public成员在派生类中变为protected。
90. C++的多态如何实现
答案: 此问题与第 24 题(虚函数的原理)和第 86 题(多态的种类)相关。C++ 的多态主要通过以下机制实现:
- 基础:虚函数。在基类中将函数声明为
virtual。 - 重写:在派生类中提供虚函数的重写版本。
- 调用方式:通过基类的指针或引用来调用虚函数。
- 底层机制:虚函数表(vtable) 和虚函数表指针(vptr)。
- 编译器为每个包含虚函数的类创建一个 vtable,存放虚函数地址。
- 每个对象内含一个 vptr,指向其类的 vtable。
- 通过基类指针调用虚函数时,程序通过 vptr 找到 vtable,再通过 vtable 找到正确的函数地址进行调用。 这个过程发生在运行时,因此能够根据对象的实际类型来动态决定调用哪个函数,从而实现多态。
91. 基类和派生类的构造函数和析构函数的执行顺序
答案:
- 构造函数执行顺序:
- 基类构造函数:如果有多重继承,按照继承声明的顺序执行基类的构造函数。
- 成员对象构造函数:按照在类中声明的顺序执行成员对象的构造函数。
- 派生类自身的构造函数。
- 析构函数执行顺序:完全相反。
- 派生类自身的析构函数。
- 成员对象析构函数:按照声明的逆序执行。
- 基类析构函数:按照继承声明的逆序执行。
原则:构造时”从内到外”(先基类,再成员,最后自己);析构时”从外到内”。
92. 谈谈深拷贝和浅拷贝,以及如何实现
答案:
- 浅拷贝:
- 定义:只拷贝对象的数据成员的值(包括指针成员的值),即拷贝指针本身,而不是指针指向的内容。
- 问题:如果对象包含动态分配的资源(如堆内存),浅拷贝后,两个对象的指针成员指向同一块内存。析构时会导致同一内存被释放两次(重复释放),引发未定义行为。
- 默认行为:编译器默认生成的拷贝构造函数和赋值运算符就是浅拷贝。
- 深拷贝:
- 定义:不仅拷贝数据成员的值,还为指针成员重新分配内存,并拷贝指针指向的内容。这样两个对象就拥有各自独立的资源。
- 实现:需要程序员自定义拷贝构造函数和重载赋值运算符。cpp复制下载class MyString { private: char* m_data; public: // 深拷贝的拷贝构造函数 MyString(const MyString& other) { m_data = new char[strlen(other.m_data) + 1]; strcpy(m_data, other.m_data); } // 深拷贝的赋值运算符 MyString& operator=(const MyString& other) { if (this != &other) { // 防止自赋值 delete[] m_data; // 释放原有资源 m_data = new char[strlen(other.m_data) + 1]; strcpy(m_data, other.m_data); } return *this; } };
- 何时需要深拷贝:当类含有指针成员,并且该指针指向动态分配的资源时。
93. string的赋值操作是深拷贝还是浅拷贝
答案: 在 C++ 标准库的 std::string 实现中,赋值操作(=)是深拷贝。
- 它会创建一个原始字符串的独立副本。修改一个
string不会影响另一个。 - 现代实现可能使用写时复制 或短字符串优化 来优化性能,但这些优化都保证了语义上的深拷贝行为,即两个字符串对象在逻辑上拥有独立的内容。
94. 什么时候重载赋值运算符与复制拷贝函数
答案: “三法则”:如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么它很可能需要全部三个。
- 更现代的 “五法则” 还加上移动构造函数和移动赋值运算符。
- 具体场景:当类管理着动态分配的资源(如堆内存、文件句柄、网络连接等)时,默认的浅拷贝行为会导致问题(如重复释放、资源泄漏),就必须自定义这些函数来实现深拷贝或正确的资源转移。
95. 什么地方需要用到拷贝构造函数
答案: 拷贝构造函数在以下三种情况下被调用:
- 用一个对象初始化另一个对象:
MyClass obj2 = obj1;(拷贝初始化)MyClass obj3(obj1);(直接初始化)
- 函数参数传递:当函数参数是按值传递一个类对象时。
void func(MyClass obj);func(myObj);// 调用拷贝构造初始化obj
- 函数返回值:当函数按值返回一个类对象时(如果编译器未做返回值优化 RVO/NRVO)。
96. virtual()=0是什么意思
答案: 这是纯虚函数的声明语法。
virtual ReturnType FunctionName(Parameters) = 0;- 表示这个函数在基类中没有默认实现,派生类必须提供这个函数的实现。
- 包含纯虚函数的类称为抽象类,不能实例化对象。它用于定义接口规范。
97. 虚函数和虚继承是怎么实现的
答案: 这是两个不同的概念。
- 虚函数:通过虚函数表 实现。每个类一个 vtable,每个对象一个 vptr。调用时通过 vptr 找到 vtable,再找到函数地址。
- 虚继承:用于解决菱形继承(多继承)时的数据冗余和二义性问题。
- 实现:编译器会为虚继承的基类在派生类中安排一个共享的实例。通常通过一个额外的间接层(如一个指针)来访问这个共享的基类子对象。这个指针可能存储在派生类对象中,或者通过虚基类表来定位。
- 开销:虚继承增加了对象的内存开销和访问基类成员的时间开销(需要一次间接寻址)。
98. 如果我有一块地址空间,我怎么在这个地址空间内调用构造函数
答案: 这需要使用 “placement new” 运算符。
- 目的:在已分配好的内存地址上构造对象,而不进行动态内存分配。
- 语法:
new (address) Type(constructor_arguments); - 示例:cpp复制下载#include <new> // 需要包含这个头文件 void* memory = malloc(sizeof(MyClass)); // 预先分配好内存 MyClass* obj = new (memory) MyClass(10, 20); // 在memory指向的地址上构造MyClass对象 // 使用对象… obj->~MyClass(); // 必须显式调用析构函数! free(memory); // 然后释放内存
- 关键点:
- 它不分配内存,只调用构造函数。
- 使用完毕后,必须显式调用析构函数,然后释放原始内存块。
99. sizeof(A)是多少(针对题目中的类图/代码)
答案: 由于题目附带了图片(media/5zg3qj4gwbg7o1kmbeqsif.png)但我无法直接查看,我无法给出具体数值。但回答此类问题的思路如下:
- 基本原则:
- 内存对齐:编译器为了优化访问速度,会对数据成员进行内存对齐。结构体的大小通常是其最宽基本类型成员的整数倍。
- 虚函数:如果类包含虚函数,则对象内部会有一个虚函数表指针
vptr,通常占 4 或 8 字节(32/64 位系统)。 - 静态成员:静态成员变量不占用对象的空间,
sizeof不包含它们。 - 继承:派生类的大小包括基类子对象的大小加上自身数据成员的大小,并考虑对齐。
- 空类:即使是空类,
sizeof也为 1(为了确保每个对象有唯一地址)。
- 答题方法:分析类 A 的数据成员、基类、是否有虚函数,然后考虑对齐规则进行计算。
100. STL包括哪些内容
答案: STL(Standard Template Library)是 C++ 标准库的核心组成部分,主要包括六大组件:
- 容器:用于存储数据的通用数据结构。
- 序列式容器:
vector,deque,list,array,forward_list - 关联式容器:
set,multiset,map,multimap(通常基于红黑树) - 无序关联容器:
unordered_set,unordered_multiset,unordered_map,unordered_multimap(基于哈希表)
- 序列式容器:
- 算法:大量通用的算法模板函数,作用于容器上。
- 如
sort,find,copy,transform等。大约有 100 多个。
- 如
- 迭代器:类似于指针,用于遍历和访问容器中的元素,是容器和算法之间的桥梁。
- 分为输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器。
- 仿函数:行为类似函数的对象,重载了
operator()。可用于定制算法的行为。 - 适配器:一种接口类,用于修改容器、迭代器或仿函数的接口。
- 容器适配器:
stack,queue,priority_queue - 迭代器适配器:
reverse_iterator - 函数适配器:
bind,negate等(C++11 后功能被bind和 lambda 部分替代)。
- 容器适配器:
- 空间配置器:负责内存的分配和释放,对用户透明。通常不需要关心,但可以进行定制。
101. vector底层实现
答案: std::vector的底层是一个动态分配的数组。
- 三要素:它通常由三个指针(或一个指针加两个大小)来管理:
start:指向数组的第一个元素。finish:指向最后一个元素的下一个位置(即当前已使用空间的末尾)。end_of_storage:指向整个动态数组空间的末尾。
size() = finish - startcapacity() = end_of_storage - start
- 动态增长:当
size() == capacity()时,如果再添加新元素,vector会进行”重新分配”:- 申请一块更大的新内存(通常是原容量的2倍或1.5倍,取决于实现)。
- 将旧内存的所有元素移动或拷贝到新内存。
- 释放旧内存。
- 特点:支持随机访问(O(1)),在尾部插入删除效率高(O(1)摊销),在中间或头部插入删除效率低(O(n))。
102. vector和deque的区别
答案:
| 特性 | vector | deque |
|---|---|---|
| 底层结构 | 单端动态数组 | 双端队列,由一段段定长数组组成的中控器结构 |
| 内存布局 | 连续存储 | 分段连续,逻辑上连续 |
| 随机访问 | 支持,效率高 O(1) | 支持,效率稍低于 vector,但也是 O(1) |
| 头部插入/删除 | 效率低 O(n) | 效率高 O(1) |
| 尾部插入/删除 | 效率高 O(1)(摊销) | 效率高 O(1) |
| 中间插入/删除 | 效率低 O(n) | 效率低 O(n) |
| 迭代器失效 | 插入/删除可能使所有迭代器失效 | 插入/删除可能使部分迭代器失效,情况更复杂 |
| 内存增长 | 通常2倍或1.5倍 | 无需”重新分配”,只需增加新的定长数组段 |
103. vector和list的区别
答案:
| 特性 | vector | list |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 |
| 内存 | 连续存储 | 非连续存储 |
| 随机访问 | 支持,O(1) | 不支持,O(n) |
| 插入/删除 | 尾部高效 O(1),中间/头部低效 O(n) | 任意位置高效 O(1),只需修改指针 |
| 空间开销 | 小(仅需容量信息) | 大(每个节点需要两个指针) |
| 缓存友好性 | 好(连续内存) | 差(内存碎片化) |
| 迭代器类型 | 随机访问迭代器 | 双向迭代器 |
104. vector,list在添加删除的效率方面有什么不同
答案:
vector:- 尾部添加/删除:效率高,O(1)(摊销时间,考虑扩容开销)。
- 头部/中间添加/删除:效率低,O(n),因为需要移动后续元素。
list:- 任意位置添加/删除:效率都高,O(1),因为只需修改相邻节点的指针。
105. 释放vector的内存的处理方式
答案: vector的clear()函数只销毁元素,将size设为0,但不释放容量(capacity不变)。要真正释放内存,可以使用:
swap技巧:cpp复制下载std::vector<int> vec(1000); // … 使用 vec std::vector<int>().swap(vec); // 用一个空的临时vector和vec交换- C++11的
shrink_to_fit():cpp复制下载vec.clear(); vec.shrink_to_fit(); // 请求减少capacity以匹配size,但不保证
106. vector迭代器失效的情况有哪些
答案: 导致vector迭代器失效的操作主要有:
- 插入元素:
- 如果导致重新分配(
size == capacity时插入),则所有迭代器都会失效。 - 如果未重新分配,则插入点之后的所有迭代器会失效。
- 如果导致重新分配(
- 删除元素:被删除元素及其之后的所有迭代器都会失效。
resize():如果新大小大于容量导致重新分配,所有迭代器失效。reserve():如果申请的容量大于当前容量,会导致重新分配,所有迭代器失效。
107. map和unordermap的区别
答案:
| 特性 | map | unordered_map |
|---|---|---|
| 底层实现 | 红黑树(平衡二叉搜索树) | 哈希表 |
| 元素顺序 | 按键的升序排列(可自定义比较函数) | 无序(取决于哈希函数) |
| 查找效率 | O(log n) | 平均 O(1),最坏 O(n) |
| 插入/删除效率 | O(log n) | 平均 O(1),最坏 O(n) |
| 是否需要哈希函数 | 否,需要比较函数 (operator< 或自定义) | 是,需要 std::hash 和 operator== |
| 迭代器稳定性 | 插入/删除不会使迭代器失效(除被删除元素) | 插入可能导致 rehash,使所有迭代器失效 |
| 内存使用 | 通常较少(树结构开销) | 通常较多(需要维护桶和可能的空位) |
| 适用场景 | 需要有序遍历、键比较复杂或哈希成本高 | 需要极快查找速度,且不关心顺序 |
108. stl中的unordered_map和unordered_set有什么区别
答案:
unordered_map:存储的是 键值对 (std::pair<const Key, Value>)。通过键来快速查找对应的值。键是唯一的。unordered_set:只存储 键 (Key)。用于快速判断一个键是否存在。键是唯一的。- 底层实现:两者都是基于哈希表,实现原理非常相似。
unordered_set可以看作是unordered_map的一种特例(只有 key,没有 value)。
109. 自己实现unordered_map的话,你会考虑到什么问题呢
答案: 实现一个简易的 unordered_map 需要考虑:
- 哈希函数:设计一个良好的、分布均匀的哈希函数,减少冲突。
- 解决哈希冲突:采用链地址法(每个桶一个链表)还是开放定址法。
- 桶数组的管理:动态扩容(rehash)的时机(装载因子)和策略(新桶数组的大小)。
- 迭代器的实现:需要能够遍历所有桶中的所有元素。
- 性能:保证平均 O(1) 的查找、插入、删除操作。
- 异常安全:保证在异常发生时容器处于一致状态。
- 内存管理:高效地分配和释放节点内存。
110. clear和erase的区别
答案:
clear():- 功能:移除容器中的所有元素。
- 效果:容器的大小
size()变为 0。对于顺序容器,会调用每个元素的析构函数。不保证释放容量(capacity()可能不变)。
erase():- 功能:移除容器中一个或一段元素。
- 参数:接受迭代器或迭代器范围。
- 效果:移除指定元素,容器大小减小。返回指向被删除元素之后元素的迭代器。
- 总结:
clear()是批量erase()所有元素。
111. 迭代器失效问题
答案: 迭代器失效是指当容器发生某些操作(如插入、删除、扩容)后,原本指向容器元素的迭代器变得无效,不能再使用。
- 不同容器的失效规则不同:
vector/string:插入/删除可能导致迭代器失效(特别是引起重新分配时)。deque:在首尾插入只会使部分迭代器失效,在中间插入/删除会使所有迭代器失效。list/forward_list:插入不会使迭代器失效,删除只会使指向被删除元素的迭代器失效。- 关联容器(
map,set等):插入不会使迭代器失效,删除只会使指向被删除元素的迭代器失效。 - 无序容器(
unordered_map等):插入可能导致 rehash,使所有迭代器失效;删除只会使指向被删除元素的迭代器失效。
112. swap函数的作用
答案:
- 对于容器:
std::swap(container1, container2)或container1.swap(container2)会交换两个容器的内容。这个操作通常非常快,因为它只交换内部指针等控制信息,而不是逐个元素拷贝。常用于释放vector的内存(vector<T>().swap(v))。 - 通用作用:交换两个变量的值。对于自定义类型,应提供
swap的特化版本或成员函数以实现高效交换(如using std::swap; swap(a, b);)。
113. 简单叙述一下STL容器相关知识,特征等
答案: STL容器是用于存储数据的通用数据结构,分为以下几类:
- 序列式容器:元素按线性顺序排列。
array:固定大小数组。vector:动态数组,尾部操作快。deque:双端队列,头尾操作都快。list:双向链表,任意位置插入删除快。forward_list:单向链表。
- 关联式容器:元素按关键字排序,查找快。
set/multiset:关键字即值,set关键字唯一。map/multimap:存储键值对,map键唯一。
- 无序关联容器:元素无序,通过哈希存储,查找非常快。
unordered_set/unordered_multisetunordered_map/unordered_multimap
- 容器适配器:基于其他容器实现的接口。
stack:栈,LIFO。queue:队列,FIFO。priority_queue:优先队列。
114. stl当中vector,list,map在内存中的数据结构有什么区别
答案:
vector:连续内存数组。元素在内存中连续存储,支持随机访问。list:双向链表。每个元素存储在独立的节点中,节点包含指向前后节点的指针。内存不连续。map:红黑树。一种自平衡的二叉搜索树。每个节点存储键值对,并包含颜色标记和指向子节点的指针。内存不连续。
115. erase的返回值
答案:
- 对于顺序容器(
vector,deque,list,string),erase函数返回一个迭代器,该迭代器指向被删除元素的下一个元素。这在循环中删除元素时非常有用,可以避免迭代器失效。cpp复制下载for (auto it = vec.begin(); it != vec.end(); ) { if (condition) { it = vec.erase(it); // 用返回值更新it } else { ++it; } } - 对于关联容器(
map,set等),erase的返回值在 C++11 之前是void,在 C++11 之后:- 接受迭代器参数的版本返回指向下一个元素的迭代器。
- 接受键值的版本返回删除的元素个数(0或1)。
116. 静态变量和全局变量,局部变量的区别,在内存上是怎么分布的
答案:
| 变量类型 | 作用域 | 生命周期 | 内存分布 | 初始化 |
|---|---|---|---|---|
| 局部变量(非static) | 函数/块内部 | 函数/块执行期间 | 栈区 | 自动初始化(垃圾值)或显式初始化 |
| 静态局部变量 | 函数/块内部 | 整个程序运行期 | 全局/静态存储区(.data 或 .bss) | 第一次执行到声明时初始化 |
| 全局变量 | 从定义处到文件尾 | 整个程序运行期 | 全局/静态存储区(.data 或 .bss) | 程序启动时初始化(在 main 之前) |
| 静态全局变量 | 当前文件内部 | 整个程序运行期 | 全局/静态存储区(.data 或 .bss) | 程序启动时初始化 |
.data段:存放已初始化的全局/静态变量。.bss段:存放未初始化的全局/静态变量,程序开始时被初始化为0。
117. delete和free有什么区别
答案:
- 语言:
delete是 C++ 运算符;free是 C 语言库函数。 - 构造与析构:
delete会先调用对象的析构函数,再释放内存。free只释放内存,不会调用析构函数。
- 重载:
operator delete可以被重载;free不能重载。 - 配对使用:
new分配的内存必须用delete释放。malloc分配的内存必须用free释放。- 混用会导致未定义行为。
118. 什么是内存泄露,如何检测和防止
答案:
- 定义:程序在动态分配内存后,失去了对该内存的控制(即没有指针指向它),从而无法将其归还给系统,导致该内存块无法再被使用。
- 检测:
- 代码审查:仔细检查
new/delete,malloc/free是否成对出现。 - 工具:
- Valgrind(Linux):强大的内存调试工具。
- AddressSanitizer(GCC/Clang):编译时插桩,运行时检测。
- Visual Studio Diagnostic Tools(Windows):内置调试器中的内存 profiling 功能。
- 代码审查:仔细检查
- 防止:
- 使用 RAII:利用栈对象的生命周期自动管理资源。这是 C++ 的核心思想。
- 使用智能指针:
unique_ptr,shared_ptr等,自动管理内存释放。 - 遵循谁申请谁释放的原则,并确保在所有的退出路径(包括异常)上都能正确释放。
119. 什么操作会导致内存泄露
答案:
- 直接原因:分配了内存(
new,malloc),但没有释放(delete,free)。 - 常见场景:
- 指针被重新赋值:
ptr = new int; ptr = new int;(第一个new的内存丢失)。 - 异常导致提前退出:在
new和delete之间抛出异常,delete未执行。 - 容器中的指针:容器(如
vector<MyClass*>)销毁时,不会自动删除其元素指向的对象。 - 循环引用:使用
shared_ptr时,两个对象互相持有对方的shared_ptr,导致引用计数永不为0。
- 指针被重新赋值:
120. 什么是野指针,什么情况下会产生野指针
答案:
- 定义:指向”垃圾”内存或已释放内存的指针。对其解引用会导致未定义行为。
- 产生情况:
- 指针未初始化:
int* p;*p = 5;// p 的值是随机的。 - 指针被释放后未置空:
delete p;之后,p仍然指向原来的地址,但该内存可能已被系统回收或另作他用。 - 指针操作越界:对数组进行操作时,指针移动到数组范围之外。
- 返回局部变量的地址:函数返回后,局部变量被销毁,返回的指针成为野指针。
- 指针未初始化:
121. 如何避免野指针
答案:
- 初始化时置空:
int* p = nullptr; - 释放后立即置空:
delete p; p = nullptr; - 注意变量的作用域和生命周期,不要返回指向局部变量的指针或引用。
- 使用智能指针:智能指针在析构时会自动释放内存,并在释放后管理好内部指针状态。
122. 结构体和类有什么区别
答案: 在 C++ 中,struct 和 class 的唯一区别是默认的成员访问权限和默认的继承方式。
struct:- 默认成员访问权限是public。
- 默认继承方式是public继承。
class:- 默认成员访问权限是private。
- 默认继承方式是private继承。
- 习惯用法:
struct通常用于表示纯粹的数据结构(POD, Plain Old Data),而class用于表示具有数据和行为的对象。
123. 栈和堆的区别
答案:
| 特性 | 栈 | 堆 |
|---|---|---|
| 管理方式 | 编译器自动管理 | 程序员手动管理(new/delete, malloc/free) |
| 分配/释放效率 | 高(移动栈指针即可) | 低(需要寻找合适的内存块) |
| 空间大小 | 较小(通常几MB) | 较大(受限于系统虚拟内存) |
| 内存碎片 | 无 | 有(外部碎片和内部碎片) |
| 生长方向 | 通常向下(向低地址) | 向上(向高地址) |
| 分配内容 | 局部变量、函数参数等 | 动态分配的对象 |
| 生命周期 | 函数/块执行期间 | 由程序员控制(从 new 到 delete) |
124. 移动语义有什么作用,原理是什么
答案:
- 作用:避免不必要的深拷贝,提升性能。特别是当源对象是临时对象(右值)时,直接”偷”走其资源,而不是进行昂贵的拷贝。
- 原理:
- 右值引用:通过
&&来标识,可以绑定到临时对象(右值)。 - 移动构造函数和移动赋值运算符:参数为右值引用。在这些函数中,不是拷贝源对象的资源,而是将源对象的指针等资源”转移”给新对象,然后将源对象的指针置为
nullptr,使其处于有效但可析构的状态。cpp复制下载class MyString { public: // 移动构造函数 MyString(MyString&& other) noexcept : m_data(other.m_data) { other.m_data = nullptr; // 使源对象处于可析构状态 } // 移动赋值运算符 MyString& operator=(MyString&& other) noexcept { if (this != &other) { delete[] m_data; m_data = other.m_data; other.m_data = nullptr; } return *this; } }; std::move:将一个左值强制转换为右值引用,表示”我允许你移动我的资源”。
- 右值引用:通过
125. 左值引用和右值引用的区别
答案:
| 特性 | 左值引用 (T&) | 右值引用 (T&&) |
|---|---|---|
| 绑定对象 | 左值(有标识符、有地址的表达式) | 右值(临时对象,字面量,将亡值) |
| 生命周期 | 延长临时对象的生命周期(当绑定到常量左值引用 const T& 时) | 不延长,通常用于”掠夺”资源 |
| 主要用途 | 函数参数,避免拷贝;函数返回值(如 ostream&) | 实现移动语义,完美转发 |
| 例子 | int a; int& ref = a; | int&& rref = 10; MyString s2 = std::move(s1); |
126. define和typeof的区别(应为 typedef)
答案: #define 是预处理指令,typedef 是 C++ 关键字。
| 特性 | #define | typedef |
|---|---|---|
| 处理阶段 | 预处理期,文本替换 | 编译期,类型别名声明 |
| 作用域 | 从定义处开始,不受命名空间、类作用域限制 | 遵循 C++ 的作用域规则 |
| 调试 | 替换后看不到原名,难以调试 | 编译器知道是别名,调试信息清晰 |
| 功能 | 可定义常量、函数、代码片段等 | 只能为类型创建别名 |
| 例子 | #define INT_PTR int* INT_PTR a, b; (a是int*, b是int) | typedef int* IntPtr; IntPtr a, b; (a和b都是int*) |
- C++11 推荐:使用
using关键字进行类型别名定义,功能与typedef相同但更清晰:using IntPtr = int*;
127. constexpr和const的区别
答案:
const:主要表示”只读”。用于变量时,表示该变量的值在初始化后不可修改。它可以是运行时常量。constexpr:主要表示”常量表达式”。用于告诉编译器,这个变量或函数可以在编译期就计算出结果。constexpr变量:其值必须是编译时常量。constexpr函数:如果其参数是编译时常量,则可以在编译期求值;否则在运行期求值。
- 关系:所有
constexpr变量都是const的,但反之不成立。constexpr是对const的增强,要求更严格(必须是编译时常量),从而允许在更多上下文(如数组大小、模板参数)中使用。
128. 野指针和悬浮指针的区别
答案: 这两个术语经常混用,但细微区别如下:
- 野指针:通常指未初始化的指针,其值是随机的、不确定的。
- 悬浮指针:通常指指针指向的内存已经被释放,但指针本身还在。
- 在实践中,两者都指向无效内存,解引用它们都是未定义行为。避免方法也类似:初始化置空,释放后置空。
129. 内存对齐是什么,为什么需要考虑内存对齐
答案:
- 是什么:计算机系统对基本数据类型在内存中的存放位置有限制,要求这些数据的起始地址必须是某个值(通常是其大小或系统字长)的整数倍。
- 为什么:
- 性能:CPU 从内存中读取数据时,如果数据是自然对齐的,通常只需要一次内存访问。如果未对齐,可能需要两次访问并进行拼接,效率低下。
- 硬件限制:某些架构的 CPU(如 ARM、早期的 SPARC)根本不能处理未对齐的内存访问,会导致硬件异常。
- 规则:结构体的对齐要求是其所有成员中最宽基本类型的对齐要求。编译器会自动插入填充字节来满足对齐。
130. 有哪些访问修饰符
答案: C++ 有三个访问修饰符,用于控制类成员的访问权限:
public:公有成员。在任何地方都可以被访问。private:私有成员。只能在类的内部和友元中访问。是类的默认访问权限(对于class)。protected:保护成员。只能在类的内部、派生类的内部和友元中访问。
131. C++构造函数有几种,分别有什么作用
答案:
- 默认构造函数:无参数或所有参数都有默认值。用于创建对象时不提供初始值。
- 拷贝构造函数:接受一个同类型对象的常量引用作为参数。用于用一个已存在的对象初始化新对象。
- 移动构造函数(C++11):接受一个同类型对象的右值引用作为参数。用于”移动”资源,避免拷贝。
- 转换构造函数:只接受一个参数(或多个参数但第一个之后都有默认值)。可以用于隐式类型转换。用
explicit关键字修饰可以禁止隐式转换。 - 委托构造函数(C++11):一个构造函数可以调用同一个类的另一个构造函数,避免代码重复。
- 继承构造函数(C++11):使用
using Base::Base;来让派生类继承基类的构造函数。
132. 有哪些运算符不能重载
答案: 少数运算符不能重载,包括:
- 成员访问运算符:
.(点号) - 成员指针访问运算符:
.* - 作用域解析运算符:
:: - 条件运算符:
?:(三目运算符) sizeof运算符typeid运算符 这些运算符的功能与语言核心特性紧密相关,重载它们会带来极大的复杂性和歧义。
133. 类与类之间的关系,有哪几种,他们对应的英语单词有哪些
答案:
- 继承:
Inheritance(“is-a” 关系)。class B : public A; - 组合:
Composition(“has-a” 关系,部分与整体生命周期一致)。class A { B b; }; - 聚合:
Aggregation(“has-a” 关系,部分可以独立于整体存在)。class A { B* b; };(通常通过指针或引用,并在构造函数中从外部传入)。 - 关联:
Association(一种松散的”使用”关系,比聚合更弱)。class A { void use(B& b); }; - 依赖:
Dependency(一个类使用另一个类,是最弱的关系)。class A { void func(B b); };(B 作为参数或局部变量)。
134. 面向对象的设计原则有哪些
答案:(SOLID 原则)
- S – 单一职责原则:一个类应该只有一个引起它变化的原因。
- O – 开闭原则:对扩展开放,对修改关闭。软件实体应该可以扩展,但不可修改。
- L – 里氏替换原则:子类型必须能够替换掉它们的基类型。
- I – 接口隔离原则:不应该强迫客户依赖于它们不用的方法。使用多个专门的接口,而不是一个庞大的总接口。
- D – 依赖倒置原则:
- 高层模块不应该依赖于低层模块,二者都应该依赖于抽象。
- 抽象不应该依赖于细节,细节应该依赖于抽象。
135. 面向对象的设计模式有哪些
答案:(GoF 的 23 种设计模式,分类)
- 创建型模式:关注对象的创建过程。
- 单例、工厂方法、抽象工厂、建造者、原型。
- 结构型模式:关注类和对象的组合。
- 适配器、桥接、组合、装饰器、外观、享元、代理。
- 行为型模式:关注对象之间的通信和职责分配。
- 责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法、访问者。
136. C和C++的区别
答案:
- 编程范式:C 是过程式;C++ 是多范式(过程式、面向对象、泛型)。
- 面向对象:C++ 支持类、对象、封装、继承、多态;C 不支持。
- 函数重载:C++ 支持;C 不支持。
- 默认参数:C++ 支持;C 不支持。
- 引用:C++ 有引用;C 只有指针。
- 内存管理:C 用
malloc/free;C++ 用new/delete(会调用构造/析构)。 - 异常处理:C++ 支持
try/catch;C 使用错误码和setjmp/longjmp。 - 模板和STL:C++ 特有。
- 类型检查:C++ 更严格(如函数原型)。
struct:在 C 中只是数据集合;在 C++ 中等同于class(可包含函数,有访问控制)。
137. 什么是C++中的移动语义和完美转发
答案:
- 移动语义:如上所述,避免不必要的拷贝。
- 完美转发:指在函数模板中,将参数原封不动地(包括其类型信息:左值/右值,
const/volatile等)传递给另一个函数。- 问题:模板参数是右值引用 (
T&&) 时,它是个”万能引用”,但一旦有名字(如t),在函数内部它就变成了左值。 - 解决:使用
std::forward<T>(t)来保持参数的原始值类别(左值性或右值性)。 - 目的:常用于包装器函数和工厂函数,确保调用目标函数时参数的性质与调用包装器时一致。
- 问题:模板参数是右值引用 (
138. C++中move有什么作用,它的原理是什么
答案:
- 作用:
std::move用于将一个左值无条件转换为右值引用。它本身并不移动任何东西,只是告诉编译器:”这个对象可以被移动,请把它当作右值来处理”。 - 原理:
std::move本质上是一个简单的类型转换:cpp复制下载template<typename T> typename std::remove_reference<T>::type&& move(T&& t) noexcept { return static_cast<typename std::remove_reference<T>::type&&>(t); } - 使用场景:当你想调用移动构造函数或移动赋值运算符时,如果源对象是左值,就需要用
std::move来将其转换为右值。
139. C++中野指针和悬挂指针的区别
答案:
- 野指针:指向未初始化或随机内存地址的指针。
- 悬挂指针:指向已被释放的内存区域的指针。
- 共同点:都是无效指针,解引用它们会导致未定义行为。
- 区别:野指针通常是因为未初始化或指向了非法地址;悬挂指针是因为指向的内存已被释放。
140. C++什么场景下需要用到移动构造函数和移动赋值运算符
答案: 当类管理着昂贵的资源(如大块堆内存、文件句柄、网络连接等),并且预计会存在临时对象或需要转移资源所有权的场景时,就需要实现移动语义。典型场景:
- 从函数返回一个局部对象(编译器会尝试进行 RVO/NRVO,否则会使用移动构造)。
- 标准库容器的操作,如
vector::push_back,当传入临时对象时。 - 交换两个对象的内容(
swap)。 - 将对象放入
std::unique_ptr或作为线程函数参数传递时。
141. 函数重载的优点是什么,函数重载和函数重写的区别
答案:
- 函数重载的优点:
- 方便使用:为功能相似的函数提供统一的名称,根据参数不同自动调用合适的版本。
- 提高可读性:无需为细微差别的功能起不同的名字(如
print_int,print_double)。
- 重载 vs 重写:
| 方面 | 重载 | 重写 |
|---|---|---|
| 范围 | 同一作用域(通常同一类中) | 不同作用域(继承体系中) |
| 函数名 | 必须相同 | 必须相同 |
| 参数列表 | 必须不同 | 必须相同 |
| 返回类型 | 可以不同 | 必须相同(协变返回类型除外) |
virtual | 无关 | 基类函数必须是虚函数 |
| 绑定方式 | 静态绑定(编译时) | 动态绑定(运行时) |
142. C++中using和typedef的区别
答案: 两者都用于创建类型别名,但 using 语法更清晰强大。
- 等价功能:cpp复制下载typedef std::vector<int> IntVec; // C++98 using IntVec = std::vector<int>; // C++11,更直观
using的优势:- 模板别名:
using可以用于模板别名,而typedef不能。cpp复制下载// typedef 无法做到 template<typename T> using MyAllocVector = std::vector<T, MyAllocator<T>>; MyAllocVector<int> v; // 使用 - 可读性:
using的语法新名字 = 原类型更符合赋值习惯,易于理解。
- 模板别名:
143. C++中enum和enum class的区别
答案:
| 特性 | 传统枚举 (enum) | 枚举类 (enum class) |
|---|---|---|
| 作用域 | 枚举值直接暴露在外部作用域,容易造成污染 | 枚举值位于枚举类的作用域内,需通过 EnumClass::Value 访问 |
| 隐式转换 | 会隐式转换为整数 | 不会隐式转换为整数,类型更安全 |
| 底层类型 | 不确定,由编译器决定 | 可以指定底层类型(如 enum class Color : char) |
| 前向声明 | 不能(除非指定底层类型) | 可以(因为底层类型默认是 int,或可指定) |
- 推荐:C++11 后应优先使用
enum class,因为它更安全、更现代。
144. C++中this指针的作用
答案: this 是一个隐含的、存在于每个非静态成员函数内部的指针,它指向调用该成员函数的对象。
- 作用:
- 在成员函数内部,用于访问当前对象的成员变量和成员函数(当形参或局部变量与成员变量同名时,必须使用
this->来区分)。 - 从成员函数中返回当前对象本身(
return *this;),用于实现链式调用。 - 在对象之间进行自引用检查。
- 在成员函数内部,用于访问当前对象的成员变量和成员函数(当形参或局部变量与成员变量同名时,必须使用
145. C++中可以使用delete this吗
答案: 语法上可以,但极其危险,必须遵守严格条件。
- 条件:
- 对象必须是用
new在堆上创建的。 - 在
delete this;之后,不能再次访问该对象的任何成员变量或调用成员函数(因为对象已被销毁)。 - 不能再通过任何方式再次
delete这个对象。
- 对象必须是用
- 用途:在某些需要自我销毁的设计模式中偶尔使用(如引用计数为0时)。但通常有更安全的设计来替代。不推荐普通程序员使用。
146. 什么是C++中的RAII,它的使用场景
答案:
- 全称:Resource Acquisition Is Initialization,资源获取即初始化。
- 核心思想:将资源(如动态内存、文件句柄、锁、数据库连接)的生命周期与一个对象的生命周期绑定。
- 构造函数中获取资源(分配内存、打开文件、加锁)。
- 析构函数中释放资源(释放内存、关闭文件、解锁)。
- 优点:无论函数正常返回还是因异常退出,栈上局部对象的析构函数都会被调用,从而确保资源被自动释放,避免了资源泄漏。
- 使用场景:
- 智能指针:管理动态内存。
- 锁守卫:
std::lock_guard,std::unique_lock,用于自动加锁和解锁。 - 文件流:
std::fstream,在析构时自动关闭文件。 - 任何需要成对出现的操作(open/close, connect/disconnect)。
147. C++中thread的join和detach的区别
答案:
join():- 作用:阻塞当前线程(通常是主线程),直到被
join的线程执行完毕。 - 资源:线程执行完毕后,其资源(如线程ID)会被清理。
- 必须性:一个线程对象在销毁前,必须被
join或detach,否则std::terminate会被调用。
- 作用:阻塞当前线程(通常是主线程),直到被
detach():- 作用:将线程与线程对象分离,允许线程独立地在后台运行。调用
detach后,主线程可以继续执行而不必等待它。 - 资源:分离后的线程由C++运行时库在后台接管,当其执行完毕后自动释放所有资源。
- 风险:分离后,你无法再通过线程对象来控制或等待它。如果主线程先结束,分离的线程可能被迫终止。
- 作用:将线程与线程对象分离,允许线程独立地在后台运行。调用
- 选择:如果需要等待线程结果,用
join;如果任务是完全独立的”守护线程”,且不关心其结果,可以用detach。
148. C++中jthread和thread的区别(C++20)
答案: std::jthread 是 C++20 引入的,意为 “joining thread”。
- 与
std::thread的主要区别:- 自动连接:
jthread的析构函数会自动调用join()(如果线程仍可连接)。这避免了因忘记join而导致的程序终止(std::terminate),更安全。 - 协作中断:
jthread支持通过request_stop()方法向线程发送停止请求。线程内部可以通过检查std::stop_token来优雅地停止执行。
- 自动连接:
- 推荐:在 C++20 及以后,应优先使用
std::jthread,因为它更安全、功能更强。
149. C++中memcpy和memmove有什么区别
答案:
memcpy:- 假设:要求源内存区和目标内存区不重叠。
- 行为:如果内存区重叠,其行为是未定义的,可能导致数据损坏。但它可能更快。
memmove:- 设计:专门处理可能重叠的内存区域。
- 行为:它会先检查是否有重叠。如果有,会采用一种策略(例如从后往前拷贝)来确保数据正确性。因此比
memcpy稍慢,但更安全。
- 选择:如果确定内存不重叠,用
memcpy(追求性能);如果不确定,用memmove(保证安全)。
150. C++中function,bind,lambda都在什么场景下会调用
答案: 这三者都是 C++11 引入的用于函数式编程的工具。
std::function:- 是什么:一个通用的函数包装器,可以存储、拷贝、调用任何可调用对象(普通函数、成员函数、lambda、仿函数等)。
- 场景:当需要类型擦除,将一个可调用对象作为参数传递、存储起来稍后回调时使用。例如,实现回调机制、事件系统。
std::bind:- 是什么:用于绑定参数,生成一个新的可调用对象。可以绑定特定值,或将参数重新排序。
- 场景:将多参数函数变为少参数函数;将成员函数绑定到特定对象上;改变参数顺序。现在很多场景可以被 lambda 表达式替代。
- Lambda 表达式:
- 是什么:一种便捷的定义匿名函数对象的方式。
- 场景:任何需要临时、简单的函数对象的地方。特别是与 STL 算法结合使用(如
sort,for_each),或者用于创建简单的回调。它比bind更直观、更强大(可以捕获局部变量)。
151. C++中使用模板的优缺点
答案:
- 优点:
- 类型安全:编译器在编译时进行类型检查,比使用void*的C风格泛型更安全。
- 代码复用:编写一次,可用于多种数据类型,减少代码冗余。
- 性能:编译期实例化生成特定类型的代码,无运行时开销(零开销抽象)。
- 灵活性:是泛型编程和STL的基础,可以编写高度通用和灵活的代码。
- 缺点:
- 代码膨胀:模板实例化会为每种类型生成一份代码,可能导致最终可执行文件变大。
- 编译时间变长:模板需要在头文件中实现,每次修改都会导致大量代码重新编译。
- 调试困难:错误信息晦涩难懂,给调试带来挑战。
- 概念验证复杂:在C++20之前,对模板参数的限制需要通过复杂的SFINAE或traits技巧来实现。
152. C++中函数模板和类模板有什么区别
答案:
| 方面 | 函数模板 | 类模板 |
|---|---|---|
| 定义 | 生成函数的蓝图 | 生成类的蓝图 |
| 实例化 | 通过函数调用时的实参推导类型参数 | 必须显式指定类型参数(如 MyClass<int> obj;) |
| 特化 | 可以全特化,不能偏特化(但可以重载) | 可以全特化和偏特化 |
| 用法 | 直接调用函数 | 需要先实例化类,再使用对象 |
153. 什么是C++模板中的SFINAE,它的原则是什么
答案:
- 全称:Substitution Failure Is Not An Error(替换失败并非错误)。
- 原则:在模板重载解析过程中,当编译器尝试用具体的类型替换模板参数时,如果导致替换失败(例如,表达式无效或类型不满足约束),编译器不会报错,而是简单地将这个模板从重载集中剔除,然后继续尝试其他重载版本。
- 目的:利用这一规则,可以在编译期根据类型的特性选择不同的模板实现,从而实现编译期多态和类型Traits。在C++11之前,SFINAE是实现模板元编程的主要手段。C++20引入了Concepts,可以更直观地表达对模板参数的约束。
154. C++中为什么要使用std::array,它有什么优点
答案: std::array 是C++11引入的固定大小数组容器,相对于内置数组和vector,它有如下优点:
- 更安全:它知道自己的大小(通过
size()成员函数),并且不会自动退化为指针,避免了传递过程中大小信息的丢失。 - STL容器接口:支持迭代器,可以用于STL算法,提供了
begin(),end(),rbegin()等成员函数。 - 编译期固定大小:大小在编译期确定,存储在栈上,没有动态内存分配的开销。
- 支持赋值操作:内置数组不能直接赋值,而
std::array可以(例如array1 = array2;)。
155. C++中堆内存和栈内存的区别
答案:
| 特性 | 栈内存 | 堆内存 |
|---|---|---|
| 管理方式 | 编译器自动管理 | 程序员手动管理(new/delete) |
| 分配速度 | 快(移动栈指针) | 慢(需要查找合适的内存块) |
| 大小限制 | 较小(通常几MB) | 较大(受限于系统虚拟内存) |
| 生命周期 | 函数执行期间 | 直到被显式释放 |
| 碎片问题 | 无 | 有(外部碎片) |
| 访问方式 | 直接通过变量名 | 通过指针间接访问 |
156. C++的栈溢出是什么
答案: 栈溢出是指程序调用栈的大小超过了为其分配的最大栈空间(通常由操作系统或编译器设置)。常见原因:
- 无限递归:递归函数没有正确的终止条件,导致函数调用栈不断增长。
- 过大的栈上变量:例如在函数内声明一个非常大的数组(如
int hugeArray[1000000];)。 - 深层的函数调用链。 栈溢出会导致程序崩溃(如段错误)。
157. 什么是C++的回调函数,为什么需要回调函数
答案:
- 回调函数:一个通过函数指针(或可调用对象)传递,并在某个特定事件发生时被调用的函数。
- 为什么需要:为了实现反向调用和灵活性。允许底层代码调用高层代码,增强了模块间的独立性和可扩展性。
- 实现方式:
- 函数指针:C风格。
- 仿函数:重载了
operator()的类。 std::function:C++11,更通用。- Lambda表达式:C++11,方便inline定义。
158. C++中为什么要使用nullptr而不是NULL
答案:
- 类型安全:
NULL在C++中通常被定义为0或(void*)0,是一个整型常量。而nullptr是std::nullptr_t类型,可以隐式转换为任何指针类型,但不能转换为整数类型。这避免了在函数重载时可能出现的歧义。cpp复制下载void func(int); void func(char*); func(NULL); // 可能调用func(int),但意图可能是func(char*) func(nullptr); // 明确调用func(char*) - 代码清晰:
nullptr明确表示空指针,提高了代码的可读性。
159. C++是否可以include源文件
答案: 技术上可以,但强烈不推荐。
- 使用
#include "file.cpp"可以将源文件的内容包含到另一个文件中。 - 缺点:
- 多次定义:如果多个源文件都包含了同一个
.cpp文件,会导致函数、变量等被多次定义,链接错误。 - 破坏模块化:违背了分离编译的原则,使得编译时间变长,依赖关系混乱。
- 多次定义:如果多个源文件都包含了同一个
- 正确做法:将声明放在头文件(
.h)中,定义放在源文件(.cpp)中,然后使用#include "header.h"。
160. C++中命名空间有什么作用,如何使用
答案:
- 作用:解决名称冲突问题,将全局作用域划分为不同的命名空间,避免不同库中的同名标识符冲突。
- 使用:
- 定义命名空间:cpp复制下载namespace MyNamespace { int variable; void function(); }
- 使用命名空间中的成员:
- 作用域解析运算符:
MyNamespace::variable - using声明:
using MyNamespace::variable;(引入特定成员) - using指令:
using namespace MyNamespace;(引入整个命名空间,慎用,易引起冲突)
- 作用域解析运算符:
- 匿名命名空间:用于定义仅在当前文件内可见的标识符,替代C中的
static。
161. C++中如何设计一个线程安全的类
答案: 设计线程安全的类需要确保多个线程同时访问该类的对象时,其行为是正确的。主要方法:
- 使用互斥量:在类的成员函数中,使用
std::mutex等锁机制来保护共享数据。确保在修改数据前加锁,修改后解锁。 - 接口设计:提供原子操作接口,避免用户需要手动管理锁。
- 避免死锁:如果多个互斥量,按固定顺序获取。
- 使用RAII管理锁:如
std::lock_guard,确保异常安全。 - 谨慎处理返回引用或指针:避免将内部数据的引用或指针返回给外部,导致锁失效后的数据竞争。
- 考虑拷贝语义:如果对象需要拷贝,需决定是深拷贝还是其他方式。
162. C++如何调用C语言的库
答案: C++调用C库的关键在于使用extern "C",因为C++支持函数重载,会进行名称修饰,而C不会。
- 步骤:
- 在C++代码中,用
extern "C"包含C库的头文件。 - 链接时包含C库的二进制文件(如
.a或.so)。
- 在C++代码中,用
- 也可以在C库的头文件中使用
#ifdef __cplusplus来使其同时适用于C和C++。
163. 什么是C++中的auto和decltype
答案:
auto:用于自动类型推导。编译器根据初始化表达式自动推导变量的类型。- 优点:简化代码,特别是复杂类型(如迭代器、lambda表达式)。
- 注意:
auto会忽略引用和顶层const,除非显式指定(如auto&)。
decltype:用于查询表达式的类型,但不计算表达式的值。- 用途:声明与某个表达式类型相同的变量;用于模板编程中依赖表达式的返回类型。
decltype(auto):C++14,结合两者,用decltype的规则来推导auto。
164. C++中malloc申请的内存,可以delete释放吗
答案: 不可以。
malloc和free是C库函数,而new和delete是C++运算符。new会调用构造函数,delete会调用析构函数。malloc分配的内存只能用free释放,new分配的内存只能用delete释放。- 混用会导致未定义行为,可能引发内存泄漏、程序崩溃等问题。
165. 内存泄漏的经典场景,如何避免内存泄漏
答案:
- 经典场景:
- 直接泄漏:
new之后没有对应的delete。 - 异常导致泄漏:在
new和delete之间发生异常,delete未执行。 - 容器中的指针:容器销毁时,其中的指针指向的对象不会被自动删除。
- 循环引用:使用
shared_ptr时,两个对象互相持有对方的shared_ptr,导致引用计数永不为0。
- 直接泄漏:
- 避免方法:
- 使用RAII:将资源管理封装在对象中,利用析构函数自动释放。
- 使用智能指针:
unique_ptr、shared_ptr、weak_ptr。 - 遵循谁申请谁释放的原则。
- 使用容器管理对象:直接存储对象而非指针(如
vector<MyObject>而非vector<MyObject*>)。
166. 请介绍C++中unique_ptr,shared_ptr,weak_ptr的原理
答案:
unique_ptr:- 原理:独占所有权。内部保存一个指针,在
unique_ptr析构时,自动释放其管理的对象。禁止拷贝,允许移动。 - 实现:通常使用RAII模式,在析构函数中调用
delete。
- 原理:独占所有权。内部保存一个指针,在
shared_ptr:- 原理:共享所有权。使用引用计数。多个
shared_ptr可以指向同一个对象,当最后一个shared_ptr被销毁时,对象才被释放。 - 实现:内部包含两个指针:一个指向管理的对象,一个指向控制块(包含引用计数、弱引用计数、删除器等)。
- 原理:共享所有权。使用引用计数。多个
weak_ptr:- 原理:弱引用。不控制对象的生命周期,不增加引用计数。用于解决
shared_ptr的循环引用问题。 - 实现:与
shared_ptr共享控制块,但只增加弱引用计数。通过lock()方法尝试获取一个有效的shared_ptr。
- 原理:弱引用。不控制对象的生命周期,不增加引用计数。用于解决
167. C++中为什么要引入make_shared,它有什么优点
答案: make_shared是用于创建shared_ptr的工厂函数。
- 优点:
- 效率更高:
make_shared会一次性分配内存,同时存储对象本身和控制块(引用计数等)。而直接使用shared_ptr的构造函数可能需要两次分配(对象和控制块分开)。 - 异常安全:在函数参数传递时,使用
make_shared可以避免因参数求值顺序导致的潜在内存泄漏。cpp复制下载// 可能泄漏:如果new成功,但shared_ptr构造函数之前发生异常 process_shared(std::shared_ptr<MyClass>(new MyClass), some_function()); // 安全:make_shared是原子操作 process_shared(std::make_shared<MyClass>(), some_function());
- 效率更高:
- 缺点:由于对象和控制块在一起,即使
shared_ptr全部析构,如果还有weak_ptr存在,对象占用的内存可能不会立即释放(因为控制块还在被弱引用计数使用)。
168. C++中shared_from_this的作用是什么,它有什么优点
答案:
- 作用:当一个类需要被
shared_ptr管理,并且需要在其成员函数中获取指向自身的shared_ptr时,使用shared_from_this。 - 用法:类需要公有继承
std::enable_shared_from_this<T>,然后就可以在成员函数中调用shared_from_this()来获取一个与已有shared_ptr共享所有权的shared_ptr。 - 优点:安全地获取指向当前对象的
shared_ptr,避免直接使用this创建新的shared_ptr(会导致多个独立的shared_ptr管理同一个对象,从而多次析构)。 - 注意:在调用
shared_from_this()之前,必须已经有一个shared_ptr管理该对象。
169. C++的string内部使用的是堆内存还是栈内存
答案: 这取决于实现和字符串的长度。现代C++标准库的std::string通常采用短字符串优化。
- 短字符串:当字符串长度较短(例如15或22字符,取决于实现)时,
string对象会直接将字符串内容存储在自身的栈内存中(即对象内部的一个小数组),避免动态内存分配。 - 长字符串:当字符串长度超过这个阈值时,
string会在堆上分配内存来存储字符串内容。 - 因此,
string对象的大小是固定的(通常为24或32字节),与字符串长度无关(短字符串直接内嵌,长字符串存储指向堆内存的指针)。
170. C++中future,promise,packaged_task,async的区别
答案: 这些都是C++11引入的用于异步编程的组件。
std::promise:提供了一种设置值(或异常)的方式,以便在将来通过与之关联的future来获取。用于在线程之间传递结果。std::future:表示一个异步操作的结果。可以通过它获取promise设置的值,等待异步操作完成。std::packaged_task:将一个可调用对象(函数、lambda等)包装起来,使其可以异步执行。它内部包含一个promise,用于存储执行结果。返回一个future。std::async:一个更高级的接口,用于启动一个异步任务。它返回一个future。可以指定启动策略(立即启动、延迟启动等)。
关系:async内部可能使用packaged_task和thread,而packaged_task内部使用promise和future。
171. C++的async使用时有哪些注意事项
答案:
- 启动策略:
std::async可以接受启动策略:std::launch::async:在新线程中异步执行。std::launch::deferred:延迟执行,直到在future上调用get()或wait()时才执行。- 默认策略(不指定)是实现定义的,可能是两者之一或两者的组合。
- 异常处理:异步函数中抛出的异常会在调用
future::get()时重新抛出。 - 生命周期问题:确保传递给
async的参数在异步任务执行期间保持有效。特别是按引用传递时。 - 资源管理:即使不调用
future::get(),async返回的future析构时也会等待任务完成(对于async策略)。但最好显式保存future对象。 - 性能考虑:对于非常小的任务,创建线程的开销可能大于任务本身的计算开销。
172. 如何理解C++中的atomic
答案: std::atomic是C++11引入的模板类,用于提供原子操作。
- 原子操作:不可被中断的操作,要么完全执行,要么完全不执行。在多线程环境中,原子操作可以确保数据竞争的避免。
- 作用:无需使用锁就可以实现简单的线程安全操作,性能更高。
- 支持的类型:基本数据类型(如
int,long)以及指针。对于其他类型,可以使用std::atomic<T>,但需要满足一定的条件。 - 常用操作:
load(),store(),exchange(),compare_exchange_strong/weak()等,以及运算符重载(如++,+=)。 - 内存序:原子操作可以指定内存序(如
memory_order_relaxed),控制操作的内存可见性顺序。
173. 什么场景下使用锁,什么场景下使用原子变量
答案:
- 使用原子变量的场景:
- 简单的读-改-写操作(如计数器递增递减)。
- 标志位的设置和检查。
- 当性能要求极高,且操作简单时。
- 使用锁的场景:
- 需要保护复杂的逻辑或多个相关变量的修改。
- 需要执行多个操作作为一个原子单元(临界区)。
- 当操作无法用原子变量简单表达时。
- 简单原则:如果能用原子变量解决问题,优先使用原子变量;如果需要保护复杂逻辑或多个数据,使用锁。
174. C++中锁的底层原理是什么
答案: 锁的底层实现通常依赖于操作系统提供的原子指令和系统调用。
- 原子指令:如CAS(Compare-And-Swap),用于实现自旋锁或更高级锁的底层原语。
- 系统调用:如futex(Fast Userspace muTEX)在Linux上的使用。它结合了用户空间的原子操作和内核空间的等待队列。
- 加锁过程:先尝试原子操作获取锁,如果失败,则通过系统调用将线程放入等待队列并挂起。
- 解锁过程:原子操作释放锁,并通过系统调用唤醒等待队列中的线程。
- 内存屏障:确保临界区内的内存访问不会与锁操作重排序。
175. C++的6种内存序
答案: C++11定义了6种内存序,用于控制原子操作的内存可见性顺序:
memory_order_relaxed:最宽松,只保证原子性,不提供任何顺序保证。memory_order_consume:具有数据依赖关系的操作不会被重排序(较弱,不常用)。memory_order_acquire:本线程中所有后续的读操作必须在本操作之后执行(防止读操作重排序到acquire之前)。memory_order_release:本线程中所有之前的写操作必须在本操作之前执行(防止写操作重排序到release之后)。memory_order_acq_rel:同时具有acquire和release的效果。memory_order_seq_cst:最严格,顺序一致性。所有线程看到的操作顺序一致。是默认的内存序。
176. C++的条件变量为什么要配合锁使用
答案: 条件变量(std::condition_variable)必须与锁(通常是std::mutex)配合使用,主要原因有:
- 避免竞态条件:在检查条件和进入等待之间,其他线程可能修改条件并发出通知。如果没有锁保护,可能会导致通知丢失。
- 保证原子性:检查条件和等待操作必须是原子的。否则,可能在检查条件后、等待前,条件被其他线程改变。
- 内部实现:条件变量的
wait操作会在挂起线程前自动释放锁,并在被唤醒后重新获取锁。这确保了在等待期间,其他线程可以获取锁来修改条件。
典型用法:
cpp
复制下载
std::unique_lock<std::mutex> lock(mutex);
while (!condition) {
cv.wait(lock); // wait会自动释放锁,被唤醒后重新获取锁
}
// 条件满足,执行操作
177. 平时开发C++程序处理错误是使用try-catch还是错误码方式
答案: 这取决于具体场景和项目规范,两者各有优劣:
- 异常(try-catch):
- 优点:错误处理与正常代码分离,代码更清晰;能自动传播错误到调用栈上层;构造函数中报告错误的唯一方式。
- 缺点:性能开销(虽然正常路径无开销);可能导致代码不可预测的控制流;需要保证异常安全。
- 错误码:
- 优点:性能开销小;控制流明确;与C语言接口兼容。
- 缺点:错误处理与正常代码混杂,降低可读性;容易被忽略。
选择建议:
- 对于性能极度敏感的代码或底层库,可能选择错误码。
- 对于大型应用程序或高级抽象,异常可能更合适。
- 一致性:在一个项目中应保持一致的错误处理风格。
178. C++中如何使用线程局部存储,它的原理是什么
答案:
- 使用方法:
- 使用
thread_local关键字声明变量。cpp复制下载thread_local int per_thread_data = 0; - 每个线程都有该变量的一个独立副本。
- 使用
- 原理:
- 编译器会为每个
thread_local变量在每个线程中创建一个独立的存储位置。 - 通常通过线程特定的存储机制实现,如POSIX的pthread_setspecific/pthread_getspecific或Windows的TLS API。
- 线程访问
thread_local变量时,会通过一个内部的线程ID索引到该线程独有的存储区域。
- 编译器会为每个
179. C++如何进行性能优化
答案: C++性能优化涉及多个层面:
- 算法和数据结构:选择时间复杂度更优的算法和适合问题的数据结构。
- 编译器优化:使用适当的编译器优化选项(如
-O2,-O3)。 - 内存管理:
- 避免不必要的动态内存分配(使用栈对象或对象池)。
- 使用移动语义避免深拷贝。
- 优化内存布局(减少缓存未命中)。
- 并行化:使用多线程、向量化(SIMD)指令。
- I/O优化:使用缓冲、异步I/O、零拷贝技术。
- 内联函数:对小型频繁调用的函数使用
inline。 - 避免虚函数调用:在性能关键路径上,如果不需要多态,可以考虑其他设计。
- 使用性能分析工具:如gprof, perf, VTune等,找到热点进行针对性优化。
180. C++中模板的实现一定要写在头文件中吗
答案: 通常是的,但有以下例外情况:
- 原因:模板的实例化发生在编译期。编译器需要看到模板的完整定义才能为特定的类型参数生成代码。
- 解决方法(如果不想写在头文件):
- 显式实例化:在模板定义的
.cpp文件中显式实例化你需要的所有类型。cpp复制下载// mytemplate.cpp template class MyTemplate<int>; template class MyTemplate<double>; - C++20 Modules:C++20引入了模块,可以更好地隔离模板的实现和接口。
- 显式实例化:在模板定义的
- 推荐做法:对于通用库,通常将模板的声明和定义都放在头文件中。
181. 如何解决C++中条件变量的信号丢失和虚假唤醒问题
答案:
- 信号丢失:发生在发送信号时没有线程在等待。解决方法通常是确保在发送信号前有线程在等待,但这很难保证。
- 虚假唤醒:线程可能在没有收到信号的情况下被唤醒。这是允许的行为,为了性能考虑。
- 通用解决方案:总是在循环中检查条件,而不是使用if语句。cpp复制下载std::unique_lock<std::mutex> lock(mutex); while (!condition) { // 使用while而不是if cv.wait(lock); }这样即使发生虚假唤醒或信号”丢失”(实际是发送时没有等待者),线程也会重新检查条件,如果条件不满足,会继续等待。
182. C++什么场景下用继承,什么场景下使用组合
答案:
- 使用继承的场景:
- 表示“is-a”关系(如
Dogis anAnimal)。 - 需要多态行为,使用虚函数实现运行时动态绑定。
- 需要代码复用,且基类提供了可重用的接口和实现。
- 表示“is-a”关系(如
- 使用组合的场景:
- 表示“has-a”关系(如
Carhas anEngine)。 - 需要代码复用,但不需要多态。
- 优先选择组合,因为它更灵活、耦合度更低。
- 表示“has-a”关系(如
- 设计原则:优先使用组合而非继承,除非确实需要多态或”is-a”关系。
183. C++如何实现线程池
答案: 线程池的基本实现步骤:
- 创建线程:在构造函数中创建固定数量的工作线程。
- 任务队列:使用一个线程安全的队列来存储待执行的任务(通常是函数对象)。
- 工作线程循环:每个工作线程不断从任务队列中取出任务并执行。
- 提交任务:提供接口让用户提交任务到队列。
- 停止机制:提供优雅停止的方法,如设置停止标志,并通知所有线程。
关键点:
- 使用
std::vector<std::thread>管理线程。 - 使用
std::queue和互斥量、条件变量实现任务队列。 - 使用
std::function或模板接受各种任务。 - 处理异常,避免单个任务的异常影响整个线程池。
184. 请介绍一下C++的返回值优化
答案: 返回值优化(RVO)是编译器的一种优化技术,用于消除函数返回对象时的临时对象拷贝。
- RVO:当函数返回一个临时对象时,编译器直接在调用处的存储位置构造该对象,避免拷贝。
- NRVO(命名返回值优化):当函数返回一个局部变量时,编译器进行类似优化。
- 效果:即使拷贝/移动构造函数有副作用,在RVO/NRVO发生时也不会被调用。
- C++17要求:对于返回纯右值的情况,编译器必须进行RVO。
- 优点:显著提升性能,特别是对于大型对象。
185. 用过哪些C++网络框架
答案: 常见的C++网络框架包括:
- Boost.Asio:跨平台的异步I/O库,是很多其他框架的基础。
- POCO C++ Libraries:跨平台的C++类库,包含网络模块。
- cpp-netlib(已停止维护):一个开源的网络库。
- Muduo:基于Reactor模式的多线程C++网络库,陈硕开发。
- libevent/libev/libuv:C语言库,但有C++封装。
- gRPC:Google的高性能RPC框架,支持多种语言。
- Seastar:高性能的异步编程框架,特别适合需要极高吞吐量的应用。
186. 用过哪些C++数据库框架
答案: 常见的C++数据库框架/库包括:
- ODBC:标准的数据库访问接口。
- OTL:ODBC和数据库模板库。
- SOCI(Simple Oracle Call Interface):支持多种数据库的C++数据库访问层。
- libpqxx:PostgreSQL的C++接口。
- MySQL Connector/C++:MySQL的官方C++接口。
- SQLiteCpp:SQLite的C++封装。
- ODB:C++的ORM框架,支持多种数据库。
187. 用过哪些C++日志框架
答案: 常见的C++日志框架包括:
- spdlog:快速的C++日志库,支持多种后端。
- glog(Google Logging Library):Google的日志库。
- log4cxx:Apache的C++日志库,是log4j的C++版本。
- Boost.Log:Boost库中的日志组件。
- easyloggingpp:轻量级的单头文件日志库。
- plog:便携、简单的C++日志库。
188. 用过哪些C++单元测试框架
答案: 常见的C++单元测试框架包括:
- Google Test(gtest):Google开发的C++测试框架,应用广泛。
- Catch2:现代的、头文件-only的C++测试框架。
- Boost.Test:Boost库中的测试框架。
- CppUnit:JUnit风格的C++单元测试框架。
- doctest:C++11/14/17/20的单头文件测试框架,设计目标是快速编译。
189. C++多线程开发需要注意些什么,线程同步有哪些手段
答案:
- 注意事项:
- 数据竞争:多个线程不加锁地访问共享数据。
- 死锁:多个线程互相等待对方释放资源。
- 活锁:线程不断改变状态但无法前进。
- 优先级反转:低优先级任务持有高优先级任务需要的资源。
- 线程同步手段:
- 互斥锁(
std::mutex):保护临界区。 - 条件变量(
std::condition_variable):线程间通信,等待特定条件。 - 信号量:控制对共享资源的访问数量(C++20引入
std::counting_semaphore)。 - 原子操作(
std::atomic):无锁编程。 - 读写锁(
std::shared_mutex,C++17):允许多个读或一个写。 - 屏障(
std::barrier,C++20):同步多个线程到达某个点。
- 互斥锁(
190. C++迭代器和指针有什么区别
答案:
| 特性 | 迭代器 | 指针 |
|---|---|---|
| 抽象层级 | 更高层次的抽象,是容器和算法之间的桥梁 | 底层的内存地址 |
| 功能 | 提供统一的访问接口,隐藏容器实现细节 | 直接操作内存地址 |
| 类型安全 | 通常更安全,有类型检查 | 容易出错,如越界访问 |
| 失效规则 | 不同容器有不同的迭代器失效规则 | 指针失效规则相对简单 |
| 算术运算 | 只有随机访问迭代器支持完整的算术运算 | 所有指针都支持算术运算 |
| 泛型编程 | 是STL的核心,支持泛型算法 | 需要额外处理类型信息 |
191. C++中未初始化和已初始化的全局变量放在哪里,全局变量定义在头文件中有什么问题
答案:
- 内存分布:
- 已初始化的全局变量:放在
.data段。 - 未初始化的全局变量:放在
.bss段(程序启动时自动初始化为0)。
- 已初始化的全局变量:放在
- 头文件中定义全局变量的问题:
- 多次定义错误:如果头文件被多个源文件包含,会导致全局变量被多次定义,链接错误。
- 违反ODR(One Definition Rule):一个全局变量在程序中只能有一个定义。
- 正确做法:
- 在头文件中使用
extern声明全局变量。 - 在一个源文件中定义全局变量。
- 在头文件中使用
192. C++函数调用的原理是什么,什么是栈帧
答案:
- 栈帧(Stack Frame):每次函数调用时在栈上分配的一块内存区域,用于存储:
- 函数参数
- 返回地址
- 局部变量
- 保存的寄存器值
- 函数调用过程:
- 参数压栈:调用者将参数从右向左压入栈中。
- 返回地址压栈:将下一条指令的地址压栈。
- 跳转到函数:CPU跳转到函数代码开始处。
- 分配局部变量:调整栈指针为局部变量分配空间。
- 函数执行:执行函数体。
- 清理栈帧:恢复栈指针,弹出返回地址。
- 返回:跳回到返回地址继续执行。
193. 禁止复制,拷贝构造函数的手段
答案: 有几种方法可以禁止类的复制:
- C++11前:将拷贝构造函数和拷贝赋值运算符声明为
private且不实现。cpp复制下载class NonCopyable { private: NonCopyable(const NonCopyable&); // 不实现 NonCopyable& operator=(const NonCopyable&); // 不实现 }; - C++11后:使用
= delete明确删除。cpp复制下载class NonCopyable { public: NonCopyable(const NonCopyable&) = delete; NonCopyable& operator=(const NonCopyable&) = delete; }; - 继承Boost或标准库的实现:cpp复制下载#include <boost/noncopyable.hpp> class MyClass : private boost::noncopyable { }; // 或者C++11后 class MyClass { MyClass(const MyClass&) = delete; MyClass& operator=(const MyClass&) = delete; };
194. 什么时候应该注意隐式转换
答案: 应该注意隐式转换的情况包括:
- 构造函数隐式转换:单参数构造函数可能被编译器用于隐式转换。
- 解决方法:使用
explicit关键字禁止隐式转换。
- 解决方法:使用
- 运算符隐式转换:自定义类型转换运算符可能导致意外的隐式转换。
- 解决方法:使用
explicit关键字(C++11起支持对转换运算符使用explicit)。
- 解决方法:使用
- 算术运算中的隐式转换:不同类型的数值运算时会发生隐式转换,可能导致精度损失或意外结果。
- 注意点:有符号和无符号整数的混合运算;整数提升;浮点精度损失。
195. 简述下C++语言的特点
答案: C++的主要特点包括:
- 多范式:支持过程式、面向对象、泛型编程等多种编程范式。
- 高效性:接近C语言的执行效率,支持底层操作。
- 面向对象:支持封装、继承、多态等面向对象特性。
- 泛型编程:通过模板支持类型无关的编程。
- 内存管理:既支持自动内存管理(RAII、智能指针),也支持手动内存管理。
- 标准库丰富:拥有强大的标准模板库(STL)。
- 兼容C:与C语言高度兼容,可以调用C库函数。
- 可移植性:标准化的语言规范,支持跨平台开发。
196. 说说C语言和C++的区别
答案:
- 编程范式:C是过程式;C++是多范式(过程式、面向对象、泛型)。
- 面向对象:C++支持类、对象、封装、继承、多态;C不支持。
- 函数重载:C++支持;C不支持。
- 默认参数:C++支持;C不支持。
- 引用:C++有引用;C只有指针。
- 内存管理:C用
malloc/free;C++用new/delete(会调用构造/析构)。 - 异常处理:C++支持
try/catch;C使用错误码和setjmp/longjmp。 - 模板和STL:C++特有。
- 类型检查:C++更严格(如函数原型)。
struct:在C中只是数据集合;在C++中等同于class(可包含函数,有访问控制)。
197. 说一说数组和指针的区别
答案:
| 特性 | 数组 | 指针 |
|---|---|---|
| 本质 | 相同类型元素的集合,是数据类型 | 存储内存地址的变量 |
| 内存分配 | 静态或自动分配(栈或数据段) | 动态分配(堆)或指向现有内存 |
| 大小 | 编译时确定大小(sizeof返回数组总大小) | 指针的大小固定(4或8字节) |
| 赋值 | 数组名是常量,不能直接赋值 | 指针是变量,可以赋值 |
| 算术运算 | 指针算术以元素大小为步长 | 指针算术以字节为步长 |
| 作为参数 | 退化为指针 | 直接传递指针值 |
| 字符串常量 | char str[] = "hello"可修改 | char* str = "hello"可能只读 |
198. nullptr调用成员函数可以吗?为什么?
答案: 可以编译通过,但运行时行为未定义(通常是崩溃)。
- 原因:对于非虚成员函数,调用不依赖于对象的具体内容,编译器根据函数的静态类型生成调用代码。函数代码存在于代码段,与具体对象无关。因此语法上可以通过。
- 但是:如果成员函数内部通过
this指针访问成员变量,就会对空指针解引用,导致运行时错误。 - 对于虚函数:需要通过虚函数表指针查找函数地址,而空指针没有有效的虚函数表,会直接崩溃。
199. 说说运算符i++和++i的区别
答案:
i++(后置递增):先返回i的原始值,然后i加1。int j = i++;// j得到i的旧值,然后i增加1
++i(前置递增):先将i加1,然后返回i的新值。int j = ++i;// i先增加1,然后j得到i的新值
- 性能差异:对于内置类型,现代编译器通常能优化掉差异。对于自定义类型(如迭代器),
++i通常更高效,因为它不需要创建临时对象来保存旧值。 - 使用建议:在不关心返回值的情况下,使用
++i可能更高效。
200. 说说const和define的区别
答案:
| 特性 | const | #define |
|---|---|---|
| 处理阶段 | 编译期 | 预处理期 |
| 类型检查 | 有类型检查,更安全 | 无类型检查,文本替换 |
| 作用域 | 遵循C++作用域规则 | 从定义处开始,不受作用域限制 |
| 调试 | 有符号信息,可调试 | 替换后看不到原名,难以调试 |
| 内存 | 占用内存(有存储空间) | 不占用内存(纯文本替换) |
| 功能 | 只能定义常量 | 可定义常量、宏函数、代码片段等 |
| 例子 | const int MAX = 100; | #define MAX 100 |
现代C++推荐使用const而不是#define定义常量。
201. 只定义析构函数,会自动生成哪些构造函数
答案: 在C++中,如果你只定义了析构函数,编译器仍然会自动生成以下构造函数:
- 默认构造函数
- 拷贝构造函数
- 拷贝赋值运算符
但是,从C++11开始,规则有所变化:
- 如果你只定义了析构函数,编译器不会自动生成移动构造函数和移动赋值运算符。
- 这是为了保持与C++98的兼容性,避免意外的性能损失。
示例:
cpp
复制下载
class MyClass {
public:
~MyClass() {} // 只定义析构函数
// 编译器会自动生成:
// MyClass(); // 默认构造函数
// MyClass(const MyClass&); // 拷贝构造函数
// MyClass& operator=(const MyClass&); // 拷贝赋值运算符
// 但不会自动生成移动操作
};
202. 简述下向上转型和向下转型
答案:
- 向上转型:将派生类指针或引用转换为基类指针或引用。
- 方向:派生类 → 基类
- 安全性:总是安全的,因为派生类对象”是一个”基类对象。
- 方式:可以是隐式转换,也可以通过
static_cast。
- 向下转型:将基类指针或引用转换为派生类指针或引用。
- 方向:基类 → 派生类
- 安全性:不安全,因为基类指针可能并不指向派生类对象。
- 方式:应该使用
dynamic_cast(需要多态类型)并进行检查。
203. 简述一下什么是常函数,有什么作用
答案:
- 定义:在成员函数声明的参数列表后加上
const关键字,该函数就成为常成员函数。cpp复制下载class MyClass { public: int getValue() const { // 常函数 return value; } private: int value; }; - 作用:
- 承诺不修改对象状态:常函数内不能修改类的非静态成员变量(除非成员被声明为
mutable)。 - 可以被常对象调用:常对象只能调用常函数。
- 重载区分:常函数和非常函数可以形成重载。
- 接口设计:明确表示该函数是只读操作,提高代码的可读性和安全性。
- 承诺不修改对象状态:常函数内不能修改类的非静态成员变量(除非成员被声明为
204. 为什么纯虚函数不能实例化
答案: 包含纯虚函数的类称为抽象类。不能实例化抽象类的原因:
- 语义上不合理:纯虚函数没有实现,如果允许实例化,调用纯虚函数的行为无法定义。
- 设计目的:抽象类的目的是定义接口规范,强制派生类提供具体实现。实例化抽象类违背了这一设计初衷。
- 编译器限制:C++标准明确禁止创建抽象类的对象,编译器会报错。
cpp
复制下载
class AbstractClass {
public:
virtual void pureVirtual() = 0; // 纯虚函数
};
// AbstractClass obj; // 错误:不能实例化抽象类
205. 说说什么是虚基类,可否被实例化?
答案:
- 虚基类:在多重继承中,使用
virtual关键字继承的基类称为虚基类。用于解决菱形继承问题。cpp复制下载class A {}; class B : virtual public A {}; // A是B的虚基类 class C : virtual public A {}; // A是C的虚基类 class D : public B, public C {}; // D中只有一份A的实例 - 可否被实例化:可以。虚基类本身如果不是抽象类(没有纯虚函数),是可以被实例化的。虚继承只影响继承关系中的对象布局,不影响基类本身的实例化能力。
206. 解释下 C++ 中类模板和模板类的区别
答案: 这是一个术语上的细微区别:
- 类模板:是一个模板,是蓝图。它本身不是一个类,而是用来生成类的模板。cpp复制下载template<typename T> // 这是一个类模板 class Vector { // … };
- 模板类:是由类模板生成的具体的类。cpp复制下载Vector<int>; // 这是一个模板类(由类模板Vector生成的具体类) Vector<double>; // 这是另一个模板类
日常使用中,这两个术语经常混用,但严格来说:
- 类模板强调的是”模板”的特性
- 模板类强调的是”类”的特性
207. 请你来介绍一下 STL 的空间配置器
答案: STL空间配置器(allocator)负责内存的分配和释放,对用户透明但很重要。
- 主要职责:
- 内存的分配(
allocate) - 内存的释放(
deallocate) - 对象的构造(
construct) - 对象的析构(
destroy)
- 内存的分配(
- 设计特点:
- 分离内存分配与对象构造:先分配原始内存,再在内存上构造对象。
- 提高效率:对于小内存块,可能使用内存池技术减少内存碎片和分配开销。
- 可定制性:STL容器允许用户提供自定义的空间配置器。
- 经典实现:SGI STL的双层配置器:
- 第一层直接使用
malloc和free - 第二层使用内存池管理小内存块
- 第一层直接使用
208. STL 容器用过哪些,查找的时间复杂度是多少,为什么?
答案:
| 容器 | 查找时间复杂度 | 原因 |
|---|---|---|
vector | O(n) | 需要遍历所有元素 |
deque | O(n) | 需要遍历所有元素 |
list | O(n) | 需要遍历所有元素 |
set/map | O(log n) | 基于红黑树,树高为log n |
multiset/multimap | O(log n) | 基于红黑树 |
unordered_set/unordered_map | 平均O(1),最坏O(n) | 基于哈希表,平均情况下常数时间 |
209. 迭代器用过吗?什么时候会失效?
答案:
- 使用过:迭代器是STL中用于遍历容器的重要工具。
- 失效时机(主要容器):
vector/string:- 插入元素可能导致所有迭代器失效(如果引起重新分配)
- 删除元素会使被删元素及之后的所有迭代器失效
deque:- 在首尾插入可能使部分迭代器失效
- 在中间插入会使所有迭代器失效
- 删除元素会使被删元素及之后的所有迭代器失效
list/forward_list:- 插入不会使迭代器失效
- 删除只会使指向被删元素的迭代器失效
- 关联容器(
set,map等):- 插入不会使迭代器失效
- 删除只会使指向被删元素的迭代器失效
210. 说一下STL中迭代器的作用,有指针为何还要迭代器
答案:
- 迭代器的作用:
- 统一访问接口:为不同的容器提供一致的遍历方式
- 连接容器与算法:STL算法通过迭代器操作容器,实现数据与算法的分离
- 隐藏实现细节:用户不需要关心容器的内部结构
- 为什么不用指针:
- 不是所有容器都能用指针遍历:如
list、map等非连续存储的容器 - 安全性:迭代器可以包含边界检查等安全机制
- 功能丰富:迭代器分类(输入、输出、前向、双向、随机访问)支持不同层次的操作
- 抽象层次更高:迭代器是设计模式的体现,提供更优雅的抽象
- 不是所有容器都能用指针遍历:如
211. 说说 STL 中 resize 和 reserve 的区别
答案:
| 方面 | resize(n) | reserve(n) |
|---|---|---|
| 作用对象 | 容器的大小(size) | 容器的容量(capacity) |
| 影响 | 改变元素个数,可能构造新元素或销毁多余元素 | 只分配内存,不创建或销毁元素 |
| 参数含义 | n表示新的元素个数 | n表示希望预留的最小容量 |
| 适用容器 | vector, deque, string, list等 | 主要是vector, string |
| 元素值 | 可以指定新元素的值(第二个参数) | 不涉及元素值 |
cpp
复制下载
std::vector<int> vec; vec.resize(10); // size=10, 默认构造10个元素 vec.reserve(100); // capacity>=100, size仍为10
212. weak_ptr 能不能知道对象计数为 0,为什么?
答案: 不能直接知道,但可以间接判断。
- 原因:
weak_ptr的设计目的是观察shared_ptr而不影响其生命周期,因此它不参与引用计数。 - 判断方法:使用
weak_ptr::expired()方法或weak_ptr::lock()方法:cpp复制下载std::weak_ptr<MyClass> wp = sp; // sp是shared_ptr // 方法1:使用expired() if (wp.expired()) { // 对象已被释放 } // 方法2:使用lock()(更常用) if (auto sp2 = wp.lock()) { // 对象还存在,可以使用sp2 } else { // 对象已被释放 }
213. 请你回答一下智能指针有没有内存泄露的情况
答案: 有,智能指针不是万能的,在某些情况下仍可能导致内存泄漏:
- 循环引用:这是最常见的情况cpp复制下载class A { std::shared_ptr<B> b_ptr; }; class B { std::shared_ptr<A> a_ptr; // 循环引用! }; auto a = std::make_shared<A>(); auto b = std::make_shared<B>(); a->b_ptr = b; b->a_ptr = a; // 循环引用,内存泄漏!解决方法:将其中一个改为
weak_ptr。 - 静态变量持有智能指针:静态变量的生命周期是整个程序,如果它持有
shared_ptr,相关对象永远不会释放。 - 线程安全问题:如果多个线程不加锁地操作同一个智能指针,可能导致重复释放或内存泄漏。
214. 简述一下 C++11 中的可变参数模板新特性
答案: 可变参数模板允许模板接受任意数量和类型的参数。
- 基本语法:cpp复制下载template<typename… Args> // 模板参数包 void func(Args… args) { // 函数参数包 // … }
- 参数包展开:cpp复制下载// 递归展开 template<typename T, typename… Args> void print(T first, Args… rest) { std::cout << first; if constexpr (sizeof…(rest) > 0) { print(rest…); // 递归调用 } } // 折叠表达式(C++17) template<typename… Args> auto sum(Args… args) { return (args + …); // 折叠表达式 }
- 应用场景:
std::make_shared,std::make_uniquestd::tuple- 完美转发
- 日志函数等需要处理不定参数的场景
215. Reactor的简介
答案: Reactor是一种处理并发I/O的设计模式,主要用于网络编程。
- 核心思想:“不要用轮询来叫我,有事件时我会叫你”
- 主要组件:
- 事件多路分解器:如
select,poll,epoll,等待多个I/O事件 - 事件分发器:将就绪的事件分发给对应的事件处理器
- 事件处理器:处理具体的I/O事件
- 事件多路分解器:如
- 工作流程:
- 应用程序向事件多路分解器注册感兴趣的事件
- 事件多路分解器阻塞等待事件发生
- 当事件发生时,事件多路分解器返回就绪的事件
- 事件分发器调用相应的事件处理器进行处理
- 优点:
- 单线程可以处理大量连接
- 避免为每个连接创建线程的开销
- 编程模型相对简单
- 在C++中的应用:Boost.Asio、Muduo等网络库都基于Reactor模式
216. ++i和i++的区别,为什么++i的效率更高
答案:
- 区别:
++i(前置递增):先加1,后返回值i++(后置递增):先返回原值,后加1
- 效率差异: 对于内置类型,现代编译器通常能优化掉差异。但对于自定义类型(如迭代器),
++i更高效,因为:++i:直接修改对象并返回引用,不创建临时对象i++:需要创建临时对象保存原值,修改对象,返回临时对象
217. 什么时候使用引用,什么时候使用指针
答案:
| 场景 | 使用引用 | 使用指针 |
|---|---|---|
| 函数参数 | 推荐使用,更安全直观 | 需要传递nullptr或需要重新指向时 |
| 函数返回值 | 可以返回引用,但必须确保引用有效 | 可以返回指针,调用者检查nullptr |
| 成员变量 | 必须在初始化列表中初始化 | 可以延迟初始化,可以为nullptr |
| 重新赋值 | 不能重新绑定到其他对象 | 可以改变指向 |
| 容器元素 | 不能直接存储引用 | 可以存储指针 |
| 多态 | 基类引用可以绑定到派生类对象 | 基类指针可以指向派生类对象 |
一般原则:优先使用引用,除非需要指针的特性(如可为空、可重指向)。
218. 可执行程序由什么组成的
答案: 可执行程序通常由以下几部分组成:
- 代码段(.text):存放程序的机器指令
- 数据段(.data):存放已初始化的全局变量和静态变量
- BSS段(.bss):存放未初始化的全局变量和静态变量
- 堆:动态分配的内存区域
- 栈:用于函数调用、局部变量等
- 文件头:包含程序的元信息(如ELF头、PE头)
在磁盘上的可执行文件还包括重定位信息、符号表、调试信息等。
219. 存储期限和生命周期的区别
答案:
- 存储期限:对象在内存中存在的时间范围
- 自动存储期:局部变量的生存期
- 静态存储期:全局变量、静态变量的生存期
- 动态存储期:通过new分配的对象
- 线程存储期:thread_local变量的生存期
- 生命周期:对象从构造到析构的完整过程
- 开始:构造函数完成时
- 结束:析构函数完成时
关系:存储期限决定了对象在内存中的存在时间,生命周期是对象从生到死的完整过程。两者密切相关但不完全相同。
220. 值的分类
答案: C++11将表达式按值类别分为三类:
- 左值:
- 有标识符、有地址的表达式
- 可以出现在赋值号左边
- 例子:变量名、函数名、返回左值引用的函数调用
- 纯右值:
- 没有标识符、临时性的表达式
- 不能出现在赋值号左边
- 例子:字面量、返回非引用类型的函数调用、临时对象
- 将亡值:
- 即将被移动的对象
- 例子:返回右值引用的函数调用、std::move的返回值
简化的理解:
- 左值:持久存在的对象
- 右值:临时对象或将被移动的对象
Linux
221. 说说Linux中的常用的命令
答案: 常用Linux命令分类:
文件操作:
ls:列出目录内容cd:切换目录cp:复制文件mv:移动文件rm:删除文件cat:查看文件内容find:查找文件
系统信息:
ps:查看进程top:系统监控df:磁盘空间free:内存使用
网络相关:
ping:测试连通性netstat:网络状态ssh:远程登录
权限管理:
chmod:修改权限chown:修改所有者
222. 创建软连接的命令是什么
答案: 创建软链接(符号链接)的命令是:
bash
复制下载
ln -s 源文件 链接名
示例:
bash
复制下载
ln -s /path/to/original/file mylink
参数说明:
-s:创建符号链接(软链接)- 不加
-s创建硬链接
软链接 vs 硬链接:
- 软链接:类似Windows快捷方式,是一个独立的文件
- 硬链接:指向同一inode的多个文件名
223. /proc文件夹下放的是什么
答案: /proc是一个虚拟文件系统,不占用磁盘空间,存放的是内核和进程的运行时信息。
主要包含:
- 进程信息:每个进程有一个以PID命名的目录
- 系统信息:
/proc/cpuinfo:CPU信息/proc/meminfo:内存信息/proc/version:内核版本
- 内核参数:可以通过
/proc/sys调整内核参数
特点:
- 文件大小通常显示为0
- 读取时动态生成内容
- 用于系统监控和调试
224. Linux下有哪些文件类型
答案: Linux主要有7种文件类型:
- 普通文件:
-,如文本文件、二进制文件 - 目录文件:
d,包含其他文件的文件 - 符号链接:
l,指向另一个文件的快捷方式 - 字符设备文件:
c,按字符流访问的设备 - 块设备文件:
b,按数据块访问的设备 - 管道文件:
p,进程间通信 - 套接字文件:
s,网络通信
查看方法:
bash
复制下载
ls -l # 第一列显示文件类型
225. Linux查看内存,磁盘,端口,进程,线程的命令有哪些
答案:
内存:
free -h:查看内存使用情况cat /proc/meminfo:详细内存信息
磁盘:
df -h:磁盘空间使用情况du -sh:目录大小fdisk -l:磁盘分区信息
端口:
netstat -tuln:查看监听端口ss -tuln:更快的端口查看lsof -i :端口号:查看端口占用
进程:
ps aux:查看所有进程top:实时进程监控htop:增强版top
线程:
ps -eLf:查看线程信息top -H:以线程模式显示
226. 是否在Linux系统下用过gdb或者别的调试工具,对gdb来说,用过哪些功能
答案: 常用gdb功能:
基本命令:
gdb 程序名:启动调试run:运行程序break 行号/函数名:设置断点continue:继续执行
查看信息:
print 变量名:查看变量值backtrace:查看调用栈info locals:查看局部变量info registers:查看寄存器
控制执行:
next:单步执行(不进入函数)step:单步执行(进入函数)finish:执行完当前函数
内存调试:
x/格式 地址:查看内存内容watch 变量:设置观察点
227. gdb用法如果堆栈信息不准,怎么办(不能运行,不能修改代码),可能是哪里出了问题
答案: 堆栈信息不准的常见原因和解决方法:
可能原因:
- 编译优化:编译器优化可能导致堆栈信息不准确
- 调试信息缺失:编译时没有加
-g选项 - 内存损坏:堆栈被破坏
- 异步信号:信号处理函数干扰堆栈
解决方法:
- 重新编译:使用
-O0 -g选项关闭优化并包含调试信息 - 检查编译选项:确保没有使用
-fomit-frame-pointer等影响堆栈的选项 - 使用core文件:如果有core dump,用gdb分析core文件
- 静态分析:使用
objdump、readelf等工具分析二进制文件
228. 如果某个模块运行突然奔溃,但崩溃的几率不大,如何定位并解决这个问题
答案: 定位偶发性崩溃的方法:
- 核心转储:bash复制下载ulimit -c unlimited # 启用core dump ./program # 运行程序,崩溃时生成core文件 gdb program core # 用gdb分析core文件
- 日志记录:
- 在关键位置添加详细日志
- 使用syslog或专门的日志库
- 断言检查:cpp复制下载assert(condition); // 在怀疑的地方加断言
- 工具辅助:
valgrind:检查内存问题AddressSanitizer:内存错误检测器
- 代码审查:重点检查指针操作、资源管理、并发同步等
229. 如果是在一个循环内出现问题,使用gdb调试需要等待很长时间,应该怎么处理
答案: 处理长时间循环的调试技巧:
- 条件断点:gdb复制下载break 行号 if 条件 # 只有满足条件时才中断 break 行号 if i == 1000 # 示例
- 观察点:gdb复制下载watch 变量名 # 变量被修改时中断
- 命令自动化:gdb复制下载commands 断点编号 > print 变量 > continue > end
- 日志调试:在循环中添加日志输出,而不是频繁中断
- 采样调试:每隔N次循环中断一次gdb复制下载break 行号 condition 断点编号 $i % 1000 == 0 # 每1000次中断一次
230. 内存泄露怎么检查,怎么避免
答案: 检查方法:
- Valgrind(Linux):bash复制下载valgrind –leak-check=full ./program
- AddressSanitizer(GCC/Clang):bash复制下载g++ -fsanitize=address -g program.cpp ./a.out
- 工具:
mtrace,dmalloc等
避免方法:
- RAII原则:资源获取即初始化
- 智能指针:
unique_ptr,shared_ptr - 遵循规则:谁分配谁释放
- 代码规范:避免复杂的指针操作
- 代码审查:重点检查资源管理代码
231. 什么是coredump文件?怎么调试
答案:
- coredump:程序崩溃时生成的内存转储文件,包含崩溃时的内存状态。
- 生成coredump:bash复制下载ulimit -c unlimited # 设置core文件大小无限制 ./program # 运行程序,崩溃时生成core文件
- 调试coredump:bash复制下载gdb program core # 使用gdb调试core文件 (gdb) bt # 查看崩溃时的调用栈 (gdb) print 变量名 # 查看变量值 (gdb) info registers # 查看寄存器状态# 语法:gdb 程序名 core文件名 gdb myprogram core.1234
- 分析步骤:
- 查看调用栈确定崩溃位置
- 检查相关变量和内存状态
- 分析崩溃原因(空指针、越界等)
232. 什么时候使用静态库和动态库
答案:
| 方面 | 静态库 | 动态库 |
|---|---|---|
| 链接时机 | 编译时链接到可执行文件 | 运行时动态加载 |
| 文件大小 | 可执行文件较大 | 可执行文件较小 |
| 内存使用 | 每个进程独立一份库代码 | 多个进程共享库代码 |
| 更新维护 | 需要重新编译程序 | 只需更新库文件 |
| 加载速度 | 较快(已链接到程序中) | 稍慢(需要加载) |
选择建议:
- 静态库:程序独立性要求高,不希望依赖外部库
- 动态库:多个程序共用库,需要灵活更新库版本
233. 零拷贝技术有哪些
答案: 零拷贝技术避免数据在内存中的不必要的拷贝,提高I/O性能。
主要技术:
- sendfile系统调用:文件数据直接从内核缓冲区发送到网络,不经过用户空间
- splice系统调用:在两个文件描述符之间移动数据
- mmap + write:内存映射文件,直接操作内存
- DMA技术:直接内存访问,CPU不参与数据传输
应用场景:
- 文件下载服务器
- 视频流媒体服务
- 数据库系统
234. mmap的应用场景有哪些
答案: mmap将文件或设备映射到内存,应用场景包括:
- 文件I/O优化:大文件读写,避免频繁read/write系统调用
- 进程间通信:通过映射同一文件实现共享内存
- 内存分配:某些malloc实现使用mmap分配大内存块
- 程序加载:动态链接库的加载使用mmap
- 零拷贝:与网络传输结合实现零拷贝
优点:
- 减少数据拷贝次数
- 简化文件访问接口
- 支持共享内存通信
235. linux文件系统读入文件的过程
答案: Linux读取文件的主要步骤:
- 路径解析:将文件路径转换为inode号
- 权限检查:检查用户是否有读取权限
- 查找inode:根据inode号找到文件的元数据
- 检查缓存:
- 页缓存:检查文件数据是否在内存中
- dentry缓存:检查目录项是否在缓存中
- 磁盘读取:如果不在缓存中,从磁盘读取数据到页缓存
- 数据返回:将数据从内核空间拷贝到用户空间
优化:
- 预读机制:预测并提前读取后续数据
- 缓存机制:频繁访问的数据保留在内存中
// 1. 通过PCB找到打开文件表 struct task_struct *current = get_current(); struct files_struct *files = current->files; // 2. 通过fd找到file结构 struct file *file = files->fdt->fd[fd]; // 3. 通过file找到dentry和inode struct dentry *dentry = file->f_path.dentry; struct inode *inode = dentry->d_inode; // 4. 通过inode找到address_space(页缓存管理) struct address_space *mapping = inode->i_mapping;
236. 为什么文件描述符是一个整数
答案: 文件描述符是整数的原因:
- 简单高效:整数比较和操作非常快速
- 索引作用:文件描述符是进程文件描述符表的索引
- 系统调用参数:整数作为系统调用参数传递简单
- 资源标识:唯一标识打开的文件或I/O资源
文件描述符表:
- 每个进程有自己的文件描述符表
- 文件描述符是表中的索引
- 表项指向内核中的文件对象
237. 在Linux中什么是CFS
答案: CFS(Completely Fair Scheduler)是Linux内核的完全公平调度器。
- 设计目标:公平分配CPU时间给所有运行中的进程
- 核心思想:基于虚拟运行时间(vruntime)进行调度
- 工作原理:
- 每个进程维护一个vruntime值
- 调度器选择vruntime最小的进程运行
- 运行的进程的vruntime逐渐增加
- 保证所有进程的vruntime大致相等
- 特点:
- 公平性:所有进程获得公平的CPU时间
- 交互性:交互式进程响应快
- 可配置:通过nice值调整进程优先级
238. 负数的二进制如何表示
答案: 负数在计算机中通常用补码表示:
- 原码:最高位为符号位,其余为数值位
- +5:00000101
- -5:10000101
- 反码:正数不变,负数符号位不变,其余取反
- +5:00000101
- -5:11111010
- 补码(实际使用):
- 正数:与原码相同
- 负数:反码+1
- -5:11111011
补码的优点:
- 统一了0的表示(没有+0和-0之分)
- 加减法统一处理
- 范围对称:-128到127(8位有符号整数)
进程和线程
239. 进程和线程的区别
答案:
| 方面 | 进程 | 线程 |
|---|---|---|
| 资源分配 | 系统资源分配的基本单位 | CPU调度的基本单位 |
| 内存空间 | 有独立的地址空间 | 共享进程的地址空间 |
| 通信方式 | 管道、消息队列、共享内存等 | 直接读写进程数据 |
| 创建开销 | 大(需要分配资源) | 小(共享进程资源) |
| 稳定性 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
| 数据共享 | 需要显式IPC机制 | 天然共享进程数据 |
240. 多进程和多线程的区别,换句话说什么时候该用多线程,什么时候该用多进程
答案:
使用多线程的场景:
- 需要频繁通信:线程间共享内存,通信效率高
- 计算密集型任务:充分利用多核CPU
- I/O密集型任务:线程阻塞时其他线程可继续执行
- 需要快速响应:如GUI应用的后台任务
使用多进程的场景:
- 需要高稳定性:进程间相互隔离,一个崩溃不影响其他
- 需要安全性:进程有独立的地址空间,更安全
- 利用多机资源:进程可以分布在不同的机器上
- 利用现有程序:通过进程调用外部程序
241. 中断和异常的区别
答案:
| 方面 | 中断 | 异常 |
|---|---|---|
| 来源 | 外部设备产生 | CPU执行指令产生 |
| 时机 | 异步,与CPU执行无关 | 同步,由特定指令引起 |
| 类型 | 硬件中断、软件中断 | 故障、陷阱、中止 |
| 处理 | 通常可屏蔽 | 不可屏蔽 |
| 例子 | 键盘输入、网络数据到达 | 除零、页错误、系统调用 |
242. 进程间通信方式有哪些
答案: Linux进程间通信(IPC)主要方式:
- 管道:
- 匿名管道:
pipe(),用于有亲缘关系的进程 - 命名管道:
mkfifo(),用于无亲缘关系的进程
- 匿名管道:
- 消息队列:
msgget()等,进程通过消息通信 - 共享内存:
shmget()等,多个进程共享同一内存区域 - 信号量:
semget()等,用于进程同步 - 信号:
kill()等,简单的异步通知机制 - 套接字:网络套接字也可用于本机进程通信
243. 线程间通信的 方式
答案: 线程间通信由于共享地址空间,方式更简单:
- 全局变量:最简单直接的通信方式
- 互斥锁:保护共享数据的访问
- 条件变量:线程间的事件通知
- 信号量:控制对共享资源的访问
- 消息队列:线程安全的消息传递
- 原子操作:无锁的简单数据交换
特点:线程通信不需要跨越地址空间,效率远高于进程通信。
244. Linux程序运行找不到动态库.so文件的三种解决办法
答案:
- 设置LD_LIBRARY_PATH:bash复制下载export LD_LIBRARY_PATH=/path/to/library:$LD_LIBRARY_PATH ./program
- 使用rpath(编译时指定):bash复制下载gcc -Wl,-rpath,/path/to/library -o program program.c
- 更新ld.so.cache:bash复制下载# 将库路径添加到/etc/ld.so.conf或/etc/ld.so.conf.d/ sudo ldconfig # 更新缓存
- 将库文件放到标准路径:如
/usr/lib,/lib等
245. Linux进程同步的机制
答案: Linux进程同步主要机制:
- 信号量:
sem_init(),sem_wait(),sem_post() - 互斥锁:
pthread_mutex_init()等(也可用于进程间) - 条件变量:
pthread_cond_init()等 - 文件锁:
flock(),fcntl() - 信号:简单的同步机制
- 屏障:
pthread_barrier_init()等
选择依据:
- 简单同步:信号、文件锁
- 复杂同步:信号量、互斥锁+条件变量
246. 什么是同步,异步。什么是阻塞,什么是非阻塞
答案:
同步 vs 异步(关注结果通知方式):
- 同步:调用者主动等待结果返回
- 异步:调用后立即返回,结果通过回调等方式通知
阻塞 vs 非阻塞(关注等待状态):
- 阻塞:调用后当前线程被挂起,直到结果返回
- 非阻塞:调用后立即返回,不挂起当前线程
组合:
- 同步阻塞:普通的函数调用
- 同步非阻塞:轮询检查结果
- 异步非阻塞:I/O多路复用、回调机制
247. 进程的状态
答案: Linux进程主要有以下几种状态:
- 运行:正在CPU上执行或就绪等待执行
- 可中断睡眠:等待某个条件,可以被信号唤醒
- 不可中断睡眠:等待硬件条件,不能被信号唤醒
- 停止:收到SIGSTOP等信号暂停执行
- 僵尸:进程已终止,但父进程尚未回收
- 死亡:进程已终止并被回收
查看命令:
bash
复制下载
ps aux # STAT列显示进程状态
248. 什么是孤儿进程,什么是僵尸进程,怎么避免僵尸进程
答案:
- 孤儿进程:父进程先于子进程退出,子进程被init进程(PID=1)收养
- 无害,init进程会负责回收
- 僵尸进程:子进程已退出,但父进程尚未调用wait()回收
- 占用系统资源(进程表项)
- 无法被kill杀死
- 避免僵尸进程的方法:
- 父进程调用wait()/waitpid():主动回收子进程
- 信号处理:捕获SIGCHLD信号,在信号处理函数中调用wait()
- 双fork技巧:让子进程再fork孙子进程,然后立即退出
- 忽略SIGCHLD:
signal(SIGCHLD, SIG_IGN),让内核自动回收
249. 结束进程的方式有哪些
答案: 正常结束:
- main函数return
- 调用exit():清理后终止进程
- 调用_exit():直接终止,不清理
异常结束:
- 信号:
kill -9 PID:强制终止kill -15 PID:优雅终止(默认)Ctrl+C:发送SIGINT
- 断言失败:assert条件不满足
- 异常未捕获:C++异常没有被捕获
进程间结束:
- 父进程终止子进程
- 特权进程终止其他进程
250. 什么是会话
答案: 会话是一组进程组的集合,通常对应一个用户登录。
- 组成:
- 一个会话有一个会话首进程(通常是登录shell)
- 包含一个或多个进程组
- 每个进程组包含一个或多个进程
- 作用:
- 作业控制:支持前后台作业切换
- 终端管理:管理控制终端的分配
- 信号传播:向整个会话发送信号
- 相关概念:
- 控制终端:会话与一个终端关联
- 前台进程组:当前与终端交互的进程组
- 后台进程组:在后台运行的进程组
251. 守护进程和后台进程的区别,怎么创建这两个
答案:
- 后台进程:
- 在命令后加
&即可创建:./program & - 仍然与终端关联,如果终端关闭,会收到 SIGHUP 信号(默认终止)
- 标准输入/输出仍关联到终端
- 在命令后加
- 守护进程:
- 完全脱离终端,在后台独立运行
- 不受终端关闭影响
- 通常以 root 权限运行,生命周期长
- 创建守护进程的步骤:
fork()创建子进程,父进程退出setsid()创建新会话,脱离终端- 再次
fork()防止重新获取终端 - 更改工作目录到
/ - 重设文件权限掩码
- 关闭所有文件描述符
- 重定向标准输入/输出/错误到
/dev/null或日志文件
252. 写时拷贝
答案: 写时拷贝 是一种优化技术,用于在多个进程/线程共享相同数据时减少不必要的拷贝。
- 原理:
- 初始时,多个进程共享同一份物理内存页(标记为只读)
- 当某个进程尝试写入数据时,触发页错误
- 操作系统检测到写时拷贝,为该进程创建该内存页的副本
- 进程在副本上进行修改,其他进程仍共享原始页面
- 应用场景:
fork()系统调用:子进程与父进程共享内存,只有在写入时才复制- STL 字符串(早期实现):多个字符串对象共享同一内存
- 虚拟内存管理:共享库的代码段在进程间共享
- 优点:节省内存,提高性能(避免不必要的拷贝)
- 缺点:需要操作系统和硬件的支持,实现复杂
253. 自旋锁
答案: 自旋锁 是一种忙等待的锁机制。
- 工作原理:
- 当线程尝试获取锁时,如果锁已被占用,线程会循环检查锁的状态(自旋),而不是立即阻塞
- 一旦锁被释放,等待的线程可以立即获取锁
- 实现方式:cpp复制下载// 简单的自旋锁实现(原子操作) class SpinLock { std::atomic_flag flag = ATOMIC_FLAG_INIT; public: void lock() { while (flag.test_and_set(std::memory_order_acquire)) { // 自旋等待 } } void unlock() { flag.clear(std::memory_order_release); } };
- 适用场景:
- 临界区代码执行时间非常短
- 多核处理器环境(单核CPU上自旋锁没有意义)
- 不希望线程切换的开销
- 缺点:
- 浪费CPU周期(忙等待)
- 可能导致优先级反转问题
254. 谈一下对多线程的理解,如生产者-消费者问题
答案:
- 多线程理解:
- 多线程允许一个进程内同时执行多个任务
- 共享进程的资源(内存、文件描述符等)
- 提高CPU利用率,改善程序响应性
- 需要处理同步和竞态条件问题
- 生产者-消费者问题:
- 问题描述:生产者生产数据放入缓冲区,消费者从缓冲区取数据
- 同步要求:
- 缓冲区满时,生产者等待
- 缓冲区空时,消费者等待
- 生产者和消费者互斥访问缓冲区
- 解决方案:cpp复制下载// 使用互斥锁和条件变量 std::queue<int> buffer; std::mutex mtx; std::condition_variable not_empty, not_full; const int MAX_SIZE = 10; // 生产者 void producer() { while (true) { std::unique_lock<std::mutex> lock(mtx); not_full.wait(lock, []{ return buffer.size() < MAX_SIZE; }); int item = produce_item(); buffer.push(item); not_empty.notify_one(); } } // 消费者 void consumer() { while (true) { std::unique_lock<std::mutex> lock(mtx); not_empty.wait(lock, []{ return !buffer.empty(); }); int item = buffer.front(); buffer.pop(); not_full.notify_one(); consume_item(item); } }
255. 什么是死锁?死锁产生的条件?怎么解决死锁问题
答案:
- 死锁:两个或多个进程/线程互相等待对方持有的资源,导致所有进程都无法继续执行
- 死锁产生的四个必要条件:
- 互斥:资源只能被一个进程独占使用
- 持有并等待:进程持有资源的同时等待其他资源
- 不可剥夺:资源只能由持有者主动释放
- 循环等待:存在进程-资源的循环等待链
- 解决死锁的方法:
- 预防:破坏四个必要条件中的一个
- 破坏互斥:让资源可共享(不总是可行)
- 破坏持有并等待:一次性申请所有资源
- 破坏不可剥夺:允许强制剥夺资源
- 破坏循环等待:按顺序申请资源
- 避免:银行家算法,系统进行安全性检查
- 检测与恢复:允许死锁发生,但定期检测并恢复
- 忽略:鸵鸟策略,假设死锁不会发生或影响不大
- 预防:破坏四个必要条件中的一个
256. 信号量处理耗费多长时间,信号量同步会有什么问题
答案:
- 信号量处理时间:
- 用户态信号量操作(P/V操作)通常很快,主要是原子指令的开销
- 如果信号量不可用,线程阻塞会涉及上下文切换,开销较大(微秒级)
- 具体时间取决于硬件和操作系统实现
- 信号量同步的问题:
- 优先级反转:低优先级任务持有高优先级任务需要的信号量
- 死锁:不正确的信号量使用顺序导致循环等待
- 资源饥饿:某些线程长时间得不到信号量
- 错误检查困难:信号量状态不如互斥锁直观,调试困难
- 性能问题:过多的信号量操作会影响性能
257. 登录shell进程是如何启动的,shell是如何调用系统调用的
答案:
- 登录shell启动过程:
- 用户登录时,
getty或login进程启动 - 验证用户名和密码后,启动指定的shell(如bash)
- shell读取配置文件(
/etc/profile,~/.bashrc等) - 显示提示符,等待用户输入命令
- 用户登录时,
- shell调用系统调用的过程:
- 解析命令:shell解析用户输入的命令和参数
- 内置命令:如果是shell内置命令(如cd),直接执行
- 外部命令:
fork()创建子进程- 子进程调用
exec()系列函数加载并执行程序 - 父进程(shell)调用
wait()等待子进程结束
- 重定向和管道:shell设置好I/O重定向和管道后再执行命令
258. sleep()调用后进程有哪些过程,在sleep()的过程中进程占用CPU了吗
答案:
- sleep() 调用过程:
- 进程调用
sleep(n),请求睡眠n秒 - 操作系统将进程状态设置为可中断睡眠
- 将进程加入定时器队列,设置唤醒时间
- 调用调度器,切换到其他进程执行
- 定时器到期后,将进程状态改为就绪
- 调度器在适当时机重新调度该进程
- 进程调用
- CPU占用情况:
- 在
sleep()期间,进程不占用CPU - 进程处于阻塞状态,CPU可以执行其他任务
- 这与忙等待(如空循环)有本质区别
- 在
259. 线程池有什么好处
答案:
- 降低资源消耗:
- 避免频繁创建和销毁线程的开销
- 复用已创建的线程,减少系统资源消耗
- 提高响应速度:
- 任务到达时,可以直接使用空闲线程执行
- 无需等待新线程创建
- 提高线程可管理性:
- 可以统一管理线程数量、优先级等
- 避免无限制创建线程导致系统崩溃
- 提供更多功能:
- 支持定时执行、周期执行等功能
- 可以统计线程执行情况,进行监控和调优
- 避免竞争问题:
- 合理设置线程数量,避免过多线程竞争CPU资源
260. 讲一下线程池
答案:
- 线程池组成:
- 任务队列:存储待执行的任务
- 工作线程:从队列中取出任务并执行
- 线程管理器:创建、销毁、管理线程
- 任务接口:定义任务的执行方式
- 工作流程:
- 初始化时创建固定数量的工作线程
- 用户提交任务到任务队列
- 工作线程从队列中取出任务执行
- 任务执行完毕,线程继续等待新任务
- 线程池关闭时,优雅停止所有线程
- 关键问题处理:
- 线程安全:任务队列需要加锁保护
- 任务调度:支持优先级调度、定时任务等
- 异常处理:任务执行异常不应影响线程池
- 资源管理:防止任务积压导致内存耗尽
261. 什么是线程安全
答案:
- 线程安全:当多个线程同时访问某个类、对象或函数时,如果不考虑这些线程的调度和交替执行,且不需要额外的同步,这个类、对象或函数仍然能表现出正确的行为。
- 线程安全的级别:
- 不可变:对象一旦创建就不能修改(如Java的String)
- 绝对线程安全:无论运行时环境如何,调用者都不需要额外的同步措施
- 相对线程安全:对单个操作是线程安全的,但某些操作序列需要额外同步
- 线程兼容:对象本身不是线程安全的,但可以通过同步手段安全使用
- 线程对立:无论是否同步,都无法在多线程环境中安全使用
- 实现线程安全的方法:
- 使用不可变对象
- 使用线程安全类(如
ConcurrentHashMap) - 加锁(互斥锁、读写锁等)
- 使用原子操作
- 线程局部存储
262. 多线程间共享数据,用什么方式来保存他们的安全性
答案:
- 互斥锁:最基本的同步机制cpp复制下载std::mutex mtx; mtx.lock(); // 访问共享数据 mtx.unlock(); // 或使用RAII std::lock_guard<std::mutex> lock(mtx);
- 读写锁:读多写少的场景cpp复制下载std::shared_mutex rw_lock; // 读锁(共享) { std::shared_lock<std::shared_mutex> lock(rw_lock); // 读取数据 } // 写锁(独占) { std::unique_lock<std::shared_mutex> lock(rw_lock); // 修改数据 }
- 原子操作:简单的数据操作cpp复制下载std::atomic<int> counter(0); counter.fetch_add(1, std::memory_order_relaxed);
- 条件变量:线程间通信和协调
- 信号量:控制对多个资源的访问
- 设计原则:
- 尽量缩小临界区范围
- 避免在临界区内调用外部函数(可能造成死锁)
- 使用RAII管理锁资源
- 注意锁的粒度(粗粒度锁简单但性能差,细粒度锁复杂但性能好)
263. 什么是线程安全函数,工作中如何保证线程安全
答案:
- 线程安全函数:可以被多个线程同时调用而不会产生错误结果的函数。
- 保证线程安全的方法:
- 使用局部变量:函数只使用栈上的局部变量
- 使用线程局部存储:
thread_local变量 - 使用不可变对象:只读数据不需要同步
- 加锁保护共享数据
- 使用原子操作
- 设计为可重入函数
- 工作中的具体实践:cpp复制下载// 非线程安全 class NonThreadSafe { static int counter; public: void increment() { ++counter; } // 竞态条件! }; // 线程安全版本 class ThreadSafe { static std::atomic<int> counter; // 方法1:原子操作 public: void increment() { counter.fetch_add(1, std::memory_order_relaxed); } }; // 或者使用互斥锁 class ThreadSafeWithMutex { static int counter; static std::mutex mtx; // 方法2:互斥锁 public: void increment() { std::lock_guard<std::mutex> lock(mtx); ++counter; } };
264. 可重入函数是什么意思,为什么一定是线程安全的
答案:
- 可重入函数:可以被多个任务同时调用而不会出现数据错误或状态混乱的函数。
- 可重入函数的特点:
- 不使用静态或全局变量(或使用常量静态数据)
- 不返回指向静态数据的指针
- 只使用调用者提供的数据
- 不调用不可重入函数
- 可重入函数与线程安全的关系:
- 可重入函数一定是线程安全的:因为可重入函数不依赖共享状态,多个线程同时调用不会相互干扰
- 但线程安全函数不一定是可重入的:线程安全函数可能通过加锁来保护共享数据,这在信号处理函数中可能有问题(信号处理函数中不能加锁)
- 例子:cpp复制下载// 可重入函数(也是线程安全的) int add(int a, int b) { return a + b; // 只使用参数,无共享状态 } // 线程安全但不可重入(使用互斥锁) std::mutex mtx; int counter = 0; void increment() { std::lock_guard<std::mutex> lock(mtx); // 在信号处理函数中可能死锁 ++counter; }
265. 在Linux中如何区分fork后,那个是子进程,那个是父进程
答案:
fork()的返回值:- 在父进程中:返回子进程的PID(大于0)
- 在子进程中:返回0
- 出错时:返回-1
- 区分方法:c复制下载pid_t pid = fork(); if (pid == -1) { // 错误处理 perror(“fork failed”); exit(1); } else if (pid == 0) { // 子进程代码 printf(“这是子进程,我的PID是%d\n”, getpid()); } else { // 父进程代码 printf(“这是父进程,子进程的PID是%d\n”, pid); }
- 其他区分方法:
- 子进程的
getppid()返回父进程PID - 父进程的
getpid()与子进程中getppid()相同
- 子进程的
266. 当子线程退出时,会向父线程发出什么信号
答案:
- 线程退出信号:子线程退出时,不会向父线程发送信号。
- 线程与进程的区别:
- 进程:子进程退出时,会向父进程发送
SIGCHLD信号 - 线程:线程是进程内的执行流,退出时通过线程库的内部机制通知主线程,不涉及信号
- 进程:子进程退出时,会向父进程发送
- 线程退出处理:
- 线程可以通过
pthread_join()等待其他线程结束 - 可以设置线程分离属性(
pthread_detach()),让线程退出后自动回收资源 - 线程退出时,其资源不会立即释放,需要其他线程调用
pthread_join()来回收
- 线程可以通过
267. 并发和并行的区别
答案:
- 并发:指在一段时间内,多个任务交替执行(单个CPU通过时间片轮转实现)
- 宏观上同时,微观上交替
- 如:单核CPU上运行多个线程
- 并行:指在同一时刻,多个任务真正同时执行(需要多个CPU核心)
- 宏观和微观都同时
- 如:多核CPU上每个核心运行一个线程
- 关系:
- 并行是并发的特殊情况
- 并发关注任务的结构设计,并行关注任务的执行效率
- “并发程序”可以在单核或多核上运行,”并行程序”只能在多核上发挥优势
268. 解释一下用户态和核心态
答案:
- 用户态:
- 应用程序运行的权限模式
- 只能执行非特权指令
- 不能直接访问硬件设备
- 内存访问受限(只能访问用户空间)
- 核心态:
- 操作系统内核运行的权限模式
- 可以执行所有指令(包括特权指令)
- 可以直接访问硬件设备
- 可以访问整个内存空间
- 切换时机:
- 系统调用(用户态 → 核心态)
- 中断/异常处理(用户态 → 核心态)
- 进程调度(核心态 → 用户态)
- 目的:
- 保护操作系统免受应用程序错误影响
- 提供硬件抽象,简化应用程序开发
269. 在什么场景下用户态和内核态会发生切换
答案:
- 系统调用:
- 文件操作(open, read, write)
- 进程控制(fork, exec, exit)
- 网络通信(socket, send, recv)
- 内存管理(brk, mmap)
- 异常处理:
- 除零错误、页错误、非法指令等
- 访问无效内存地址
- 中断处理:
- 硬件中断(时钟中断、键盘输入、网络数据到达)
- 设备I/O完成
- 进程调度:
- 时间片用完,调度器选择新进程运行
- 每次切换的开销:
- 保存/恢复寄存器状态
- 切换堆栈指针
- 更新页表基址寄存器
- 刷新TLB(快表)
270. 进程调度算法
答案:
- 先来先服务:
- 按到达顺序排队执行
- 优点:简单公平
- 缺点:平均等待时间长,对短作业不利
- 短作业优先:
- 优先执行估计运行时间短的作业
- 优点:平均等待时间最短
- 缺点:可能导致长作业饥饿
- 优先级调度:
- 每个进程分配优先级,按优先级调度
- 可以静态或动态调整优先级
- 问题:低优先级进程可能饥饿
- 时间片轮转:
- 每个进程分配固定时间片,时间片用完重新排队
- 优点:响应性好,公平
- 缺点:时间片大小影响性能
- 多级反馈队列:
- 综合多种算法优点
- 多个优先级队列,时间片逐级增加
- 新进程进入最高优先级队列
- 用完时间片未完成则降级
- Linux CFS:
- 完全公平调度器
- 基于虚拟运行时间,保证所有进程公平获得CPU时间
271. 解释一下进程同步和互斥,以及如何实现进程同步和互斥
答案:
- 互斥:保证多个进程/线程不同时访问共享资源
- 如:多个线程不能同时修改同一个变量
- 同步:控制进程/线程的执行顺序
- 如:线程A必须在线程B完成后才能开始
- 实现互斥:
- 软件方法:Peterson算法、Dekker算法(已很少使用)
- 硬件方法:关中断、Test-and-Set指令、Swap指令
- 操作系统提供:互斥锁、信号量
- 实现同步:
- 信号量:通过P/V操作控制执行顺序
- 条件变量:与互斥锁配合使用
- 管程:高级同步机制
- 屏障:等待多个线程到达同步点
- 例子:cpp复制下载// 互斥:保护共享资源 std::mutex mtx; int shared_data = 0; void thread_func() { std::lock_guard<std::mutex> lock(mtx); shared_data++; // 临界区 } // 同步:控制执行顺序 std::condition_variable cv; bool ready = false; void worker() { // 等待主线程通知 std::unique_lock<std::mutex> lock(mtx); cv.wait(lock, []{ return ready; }); // 执行工作… } void master() { // 准备工作… { std::lock_guard<std::mutex> lock(mtx); ready = true; } cv.notify_all(); // 通知工作线程 }
272. 什么是死锁,如何预防死锁
答案:
- 死锁:两个或多个进程相互等待对方持有的资源,导致所有进程都无法继续执行
- 死锁的必要条件:
- 互斥:资源只能独占使用
- 持有并等待:进程持有资源的同时等待其他资源
- 不可剥夺:资源只能由持有者主动释放
- 循环等待:存在进程-资源的循环等待链
- 死锁预防(破坏必要条件):
- 破坏互斥:让资源可共享(如只读资源)
- 破坏持有并等待:一次性申请所有所需资源
- 破坏不可剥夺:允许强制剥夺资源(回滚操作)
- 破坏循环等待:按固定顺序申请资源
- 死锁避免:
- 银行家算法:系统进行安全性检查,只在安全状态下分配资源
- 实际开发中的建议:
- 按固定顺序获取锁
- 使用超时机制(如
try_lock) - 避免嵌套锁
- 使用RAII管理锁资源
273. 讲一讲你理解的虚拟内存
答案:
- 虚拟内存:为每个进程提供的独立、连续的地址空间抽象
- 核心思想:
- 地址空间分离:每个进程有自己的虚拟地址空间
- 分页机制:虚拟地址通过页表映射到物理地址
- 按需调页:只有实际访问的页面才加载到内存
- 组成部分:
- 页表:存储虚拟页到物理页帧的映射
- MMU:内存管理单元,负责地址转换
- TLB:快表,缓存常用页表项
- 工作流程:
- CPU发出虚拟地址
- MMU查询TLB获取物理地址
- TLB未命中时查询页表
- 页表项有效则完成地址转换
- 页表项无效则触发页错误,操作系统处理
- 优点:
- 简化内存管理(每个进程有独立地址空间)
- 提供内存保护(进程间隔离)
- 允许运行比物理内存大的程序
- 支持共享内存(多个进程映射到同一物理页)
274. 线程的同步的方式有哪些
答案:
- 互斥锁:最基本的同步机制cpp复制下载std::mutex mtx; mtx.lock(); // 临界区 mtx.unlock();
- 读写锁:读多写少的场景cpp复制下载std::shared_mutex rw_lock; // 读取(共享锁) std::shared_lock lock(rw_lock); // 写入(独占锁) std::unique_lock lock(rw_lock);
- 条件变量:线程间通信和协调cpp复制下载std::condition_variable cv; cv.wait(lock, predicate); // 等待条件满足 cv.notify_one(); // 通知一个等待线程
- 信号量:控制对多个资源的访问cpp复制下载std::counting_semaphore<10> sem; // C++20 sem.acquire(); // P操作 sem.release(); // V操作
- 屏障:同步多个线程到达某一点cpp复制下载std::barrier sync_point(4); // 等待4个线程 sync_point.arrive_and_wait(); // 到达屏障点
- 原子操作:无锁编程cpp复制下载std::atomic<int> counter(0); counter.fetch_add(1);
- 自旋锁:忙等待的锁,适用于临界区很短的情况
275. 介绍一下几种典型的锁
答案:
- 互斥锁:
- 最基本的锁,保证互斥访问
- 线程获取锁失败时会阻塞
- 实现简单,但可能引起死锁
- 读写锁:
- 允许多个读线程同时访问
- 写线程独占访问
- 适合读多写少的场景
- 自旋锁:
- 获取锁失败时忙等待(循环检查)
- 避免线程切换开销,但浪费CPU
- 适合临界区很短的情况
- 递归锁:
- 允许同一线程多次获取同一把锁
- 避免自死锁(同一线程重复加锁)
- 需要记录持有者和加锁次数
- 条件变量:
- 不是真正的锁,用于线程间通信
- 与互斥锁配合使用,实现复杂的同步逻辑
- 文件锁:
- 用于进程间同步,通过文件系统实现
flock():劝告锁fcntl():强制锁(某些系统支持)
276. 有哪些页面置换算法
答案:
- OPT:理想算法,淘汰未来最长时间不被访问的页面
- 理论上最优,但无法实现(需要预知未来)
- FIFO:先进先出,淘汰最早进入的页面
- 实现简单,但性能差(Belady异常)
- LRU:最近最久未使用,淘汰最长时间未被访问的页面
- 性能接近OPT,但实现复杂
- 需要记录页面访问时间戳或使用特殊硬件支持
- Clock:时钟算法,LRU的近似实现
- 每个页面有一个访问位,指针循环扫描
- 访问位为1时清0并继续,为0时淘汰
- 实现相对简单,性能较好
- LFU:最不经常使用,淘汰访问频率最低的页面
- 需要维护访问频率计数器
- 可能淘汰最近频繁访问但总体频率不高的页面
- 工作集模型:基于局部性原理,保留当前工作集页面
277. 数据结构的基本操作
答案: 不同数据结构支持的基本操作不同,但通常包括:
- 线性结构:
- 数组:随机访问、插入、删除
- 链表:插入、删除、遍历
- 栈:push、pop、peek
- 队列:enqueue、dequeue、peek
- 树形结构:
- 二叉树:插入、删除、查找、遍历(前序、中序、后序)
- 堆:插入、删除堆顶、堆化
- 图结构:
- 遍历:深度优先搜索、广度优先搜索
- 最短路径:Dijkstra、Floyd算法
- 最小生成树:Prim、Kruskal算法
- 哈希结构:
- 插入、查找、删除
- 通用操作:
- 创建、销毁、清空
- 判断空、获取大小
- 遍历、查找
278. 操作系统在进行线/进程切换时需要进行哪些动作
线程答案:
- 保存上下文:
- 保存通用寄存器值
- 保存程序计数器(PC)
- 保存栈指针(SP)
- 保存程序状态字(PSW)
- 更新调度信息:
- 更新当前运行线程的状态(运行→就绪/阻塞)
- 将线程控制块加入相应队列
- 选择新线程(切换地址空间):切换地址空间是线程和进程切换时的最大区别
- 根据调度算法选择下一个要运行的线程
- 更新线程状态(就绪→运行)
- 恢复上下文:
- 恢复新线程的寄存器值
- 恢复程序计数器
- 恢复栈指针
- 恢复程序状态字0
- 更新内存管理:
- 切换页表(如果新线程属于不同进程)
- 刷新TLB(快表)
- 切换开销:主要是保存/恢复上下文和更新MMU的开销
279. 什么是软中断,什么还硬中断
答案:
- 硬中断:
- 由硬件设备产生(如键盘、鼠标、网卡)
- 异步事件,与CPU当前执行无关
- 通过中断控制器通知CPU
- 处理过程:保存上下文→执行中断处理程序→恢复上下文
- 软中断:
- 由软件指令产生(如系统调用、异常)
- 同步事件,由当前执行的指令触发
- 包括:陷阱(trap)、故障(fault)、中止(abort)
- 处理过程类似硬中断,但触发方式不同
- 主要区别:
| 特性 | 硬中断 | 软中断 |
|---|---|---|
| 触发源 | 硬件设备 | CPU执行指令 |
| 时机 | 异步 | 同步 |
| 响应 | 可屏蔽 | 不可屏蔽 |
| 例子 | 时钟中断、I/O完成 | 除零错误、页错误、系统调用 |
280. 什么是分页,什么是分段
答案:
- 分页:
- 将虚拟地址空间划分为固定大小的页(通常4KB)
- 将物理内存划分为相同大小的页帧
- 通过页表建立虚拟页到物理页帧的映射
- 优点:简单高效,避免外部碎片
- 缺点:一维地址空间,不符合程序逻辑结构
- 分段:
- 按逻辑单元划分地址空间(代码段、数据段、堆栈段)
- 每个段有不同大小和属性
- 通过段表建立逻辑段到物理内存的映射
- 优点:符合程序结构,提供更好的保护和共享
- 缺点:产生外部碎片,管理复杂
- 段页式:
- 结合分段和分页的优点
- 先分段,段内再分页
- 既符合程序逻辑结构,又避免外部碎片
281. CPU使用率和CPU负载是指什么,它们之间有什么关系
答案:
- CPU使用率:
- 一段时间内CPU忙于执行任务的时间比例
- 如:70%使用率表示70%时间在执行任务,30%空闲
- 反映CPU的繁忙程度
- CPU负载:
- 单位时间内运行队列中的平均进程数
- 包括正在运行的进程和等待运行的进程
- 如:负载1.5表示平均有1.5个进程在竞争CPU
- 关系:
- 概念不同:使用率关注CPU时间分配,负载关注任务排队情况
- 数值意义:
- 使用率0%-100%,表示繁忙程度
- 负载可以大于1,表示系统过载
- 关联性:
- 高使用率通常伴随高负载,但不等同
- I密集型任务:高使用率,高负载
- I/O密集型任务:可能低使用率但高负载(进程等待I/O)
- 监控命令:
top、htop:查看实时使用率和负载uptime:查看1、5、15分钟平均负载
282. 为什么网络IO会被阻塞
答案:
- 阻塞的本质:等待某个条件满足(如数据到达、连接建立)
- 网络I/O阻塞的具体原因:
- 数据未就绪:
- 读取时:接收缓冲区没有数据
- 写入时:发送缓冲区已满
- 网络延迟:数据在网络传输中需要时间
- 对端处理慢:对端应用程序处理速度慢
- 连接建立:TCP三次握手需要时间
- 数据未就绪:
- 阻塞的层次:
- 应用层阻塞:调用
read()/write()时阻塞 - 内核层阻塞:数据在内核缓冲区未就绪
- 网络层阻塞:数据包在网络中传输
- 应用层阻塞:调用
- 解决方案:
- 非阻塞I/O:立即返回,需要轮询检查
- I/O多路复用:
select/poll/epoll,同时监控多个socket - 异步I/O:发起I/O操作后立即返回,完成后通知
- 多线程/多进程:每个连接一个线程,阻塞不影响其他连接
283. IO模型有哪些
答案:
- 阻塞I/O:
- 调用I/O函数时线程阻塞,直到操作完成
- 最简单,但效率低(一个线程只能处理一个连接)
- 非阻塞I/O:
- 调用I/O函数时立即返回,需要轮询检查是否完成
- 避免线程阻塞,但轮询消耗CPU
- I/O多路复用:
- 使用
select/poll/epoll同时监控多个文件描述符 - 单个线程可以处理多个连接
- Linux下
epoll性能最好
- 使用
- 信号驱动I/O:
- 注册信号处理函数,数据就绪时通过信号通知
- 实际应用较少
- 异步I/O:
- 发起I/O操作后立即返回,操作完成后由系统通知
- 真正的异步,但实现复杂
- 对比:
| 模型 | 阻塞阶段 | 通知方式 | 效率 |
|---|---|---|---|
| 阻塞I/O | 等待数据、拷贝数据 | 无通知 | 低 |
| 非阻塞I/O | 拷贝数据 | 轮询检查 | 中 |
| I/O多路复用 | 拷贝数据 | 就绪事件 | 高 |
| 异步I/O | 无阻塞 | 完成通知 | 最高 |
284. 同步和异步的区别
答案:
- 同步I/O:调用者主动等待I/O操作完成
- 阻塞I/O、非阻塞I/O、I/O多路复用都是同步I/O
- “同步”指I/O操作的就绪状态需要调用者主动检查或等待
- 异步I/O:调用者发起I/O操作后立即返回,被动接收完成通知
- 操作完成后,系统通过回调、信号等方式通知调用者
- 关键区别:
- 数据就绪通知的时机:
- 同步:数据就绪后,需要调用者主动读取
- 异步:数据读取完成后,系统通知调用者
- 阻塞位置:
- 同步:在I/O操作过程中可能阻塞
- 异步:调用I/O函数后立即返回,不会阻塞
- 数据就绪通知的时机:
- 例子对比:cpp复制下载// 同步读取(阻塞) char buf[1024]; int n = read(fd, buf, sizeof(buf)); // 阻塞直到数据就绪 process_data(buf, n); // 异步读取(非阻塞) aio_read(&aiocb); // 立即返回 // … 做其他事情 // 数据读取完成后,通过信号或回调通知
285. 阻塞和非阻塞的区别
答案:
- 阻塞调用:
- 调用结果返回前,当前线程被挂起
- 调用线程只有在得到结果后才返回
- 如:默认的
socket.read()
- 非阻塞调用:
- 调用结果返回前,当前线程不会被挂起
- 无论是否立即得到结果,调用都会立即返回
- 如:设置
O_NONBLOCK标志后的socket.read()
- 区别对比:
| 特性 | 阻塞 | 非阻塞 |
|---|---|---|
| 调用返回 | 得到结果后才返回 | 立即返回 |
| 线程状态 | 可能被挂起 | 不会被挂起 |
| CPU使用 | 等待时不占用CPU | 需要轮询检查,可能浪费CPU |
| 编程复杂度 | 简单直观 | 相对复杂,需要处理返回状态 |
| 适用场景 | 连接数少,简单应用 | 高并发,需要高效处理多连接 |
- 代码示例:cpp复制下载// 阻塞读取 int n = read(fd, buffer, size); // 阻塞直到有数据 // 非阻塞读取 fcntl(fd, F_SETFL, O_NONBLOCK); // 设置非阻塞 int n = read(fd, buffer, size); // 立即返回 if (n == -1 && errno == EAGAIN) { // 数据未就绪,稍后重试 }
286. 同步,异步,阻塞,非阻塞的IO的区别
答案: 这是两个维度的组合,经常被混淆:
- 第一个维度:同步 vs 异步(关注结果通知机制)
- 第二个维度:阻塞 vs 非阻塞(关注等待状态)
四种组合:
- 同步阻塞:
- 调用I/O函数时阻塞等待,直到操作完成
- 例子:默认的
socket.read()
- 同步非阻塞:
- 调用I/O函数时立即返回,需要轮询检查是否完成
- 例子:非阻塞socket + 轮询
- 异步阻塞:
- 这种组合很少见,概念上矛盾
- 异步通常意味着非阻塞
- 异步非阻塞:
- 调用I/O函数后立即返回,操作完成后系统通知
- 例子:Linux的AIO、Windows的IOCP
正确理解:
- 同步/异步:关注的是消息通知机制
- 阻塞/非阻塞:关注的是等待状态
常见误区:
- 非阻塞I/O ≠ 异步I/O
- I/O多路复用(epoll)属于同步I/O,因为需要主动调用read/write
287. 到底什么是reactor
答案: Reactor模式是一种处理并发I/O的事件驱动设计模式。
- 核心组件:
- 事件多路分解器:如
epoll、select,等待多个I/O事件 - 事件分发器:将就绪的事件分发给对应的事件处理器
- 事件处理器:处理具体的I/O事件
- 事件多路分解器:如
- 工作流程:
- 应用程序向Reactor注册事件和对应的事件处理器
- Reactor调用事件多路分解器等待事件发生
- 当事件发生时,事件多路分解器返回就绪的事件
- 事件分发器调用相应的事件处理器进行处理
- 特点:
- 单线程事件循环:通常有一个主线程负责事件循环
- 非阻塞I/O:使用非阻塞socket
- 回调机制:通过回调函数处理事件
- 优点:
- 单线程可以处理大量连接
- 避免为每个连接创建线程的开销
- 编程模型清晰
- 在C++中的应用:
- Boost.Asio
- Muduo网络库
- Redis的网络模块
288. 什么是物理地址,什么是逻辑地址
答案:
- 逻辑地址(虚拟地址):
- 程序看到的地址空间,从0开始连续编址
- 每个进程有自己独立的逻辑地址空间
- 通过内存管理单元转换为物理地址
- 物理地址:
- 实际内存硬件上的地址
- 全局唯一,所有进程共享同一物理地址空间
- 由内存控制器直接使用
- 地址转换过程:
- CPU发出逻辑地址
- MMU通过页表查询物理地址
- 如果页表项有效,完成地址转换
- 如果页表项无效,触发页错误异常
- 操作系统处理页错误,加载缺失的页面
- 转换示例:text复制下载逻辑地址: 0x4000 (进程视角) ↓ MMU转换 (通过页表) 物理地址: 0x1234000 (内存硬件视角)
网络编程
289. 简述七层模型和四层模型
答案:
- OSI七层模型(理论模型):
- 物理层:比特流传输(网线、光纤、无线)
- 数据链路层:帧传输,差错检测(MAC地址)
- 网络层:路由选择,IP寻址(IP协议)
- 传输层:端到端通信(TCP、UDP)
- 会话层:建立、管理、终止会话
- 表示层:数据格式转换、加密解密
- 应用层:为用户提供网络服务(HTTP、FTP)
- TCP/IP四层模型(实际应用):
- 网络接口层:对应OSI的物理层+数据链路层
- 网际层:对应OSI的网络层(IP协议)
- 传输层:对应OSI的传输层(TCP、UDP)
- 应用层:对应OSI的会话层+表示层+应用层
- 对比:
- OSI模型更理论化,层次清晰但复杂
- TCP/IP模型更实用,是互联网的实际标准
- 现在常用五层模型(综合两者优点):
- 物理层、数据链路层、网络层、传输层、应用层
290. 请描述一下从输入URL到显示页面的全过程
答案:
- URL解析:浏览器解析URL,提取协议、域名、路径等信息
- DNS查询:将域名转换为IP地址
- 浏览器缓存 → 系统缓存 → 路由器缓存 → ISP DNS服务器 → 递归查询
- 建立TCP连接:与服务器进行三次握手
- SYN → SYN-ACK → ACK
- 发送HTTP请求:浏览器发送HTTP请求报文
- 请求方法、URL、协议版本、请求头、请求体
- 服务器处理:服务器处理请求,生成响应
- 静态资源直接返回,动态内容由应用服务器处理
- 服务器返回响应:服务器返回HTTP响应报文
- 状态码、响应头、响应体
- 浏览器解析渲染:
- 解析HTML:构建DOM树
- 解析CSS:构建CSSOM树
- 合并渲染树:DOM + CSSOM = 渲染树
- 布局:计算每个节点的位置和大小
- 绘制:将渲染树绘制到屏幕上
- 加载资源:解析过程中发现其他资源(图片、CSS、JS),重复3-6步
- 连接结束:四次挥手关闭TCP连接
291. 简述一下socket的编程流程
答案: TCP Socket编程流程:
- 服务器端:
- 创建socket:
socket(AF_INET, SOCK_STREAM, 0) - 绑定地址:
bind(sockfd, &addr, sizeof(addr)) - 监听连接:
listen(sockfd, backlog) - 接受连接:
accept(sockfd, &client_addr, &addr_len) - 数据传输:
read()/write()或recv()/send() - 关闭连接:
close(sockfd)
- 创建socket:
- 客户端:
- 创建socket:
socket(AF_INET, SOCK_STREAM, 0) - 连接服务器:
connect(sockfd, &server_addr, sizeof(server_addr)) - 数据传输:
read()/write()或recv()/send() - 关闭连接:
close(sockfd)
- 创建socket:
- UDP Socket编程(更简单):
- 服务器:socket → bind → recvfrom/sendto
- 客户端:socket → sendto/recvfrom
- 代码框架:c复制下载// TCP服务器示例 int server_fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr = {…}; bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)); listen(server_fd, 5); while (1) { int client_fd = accept(server_fd, NULL, NULL); // 处理客户端请求 close(client_fd); }
292. write阻塞的原因有哪些
答案: write()调用阻塞的常见原因:
- 发送缓冲区已满:
- TCP协议有发送缓冲区,如果缓冲区满,
write()会阻塞 - 等待对端ACK确认数据接收,释放缓冲区空间
- TCP协议有发送缓冲区,如果缓冲区满,
- 流量控制:
- TCP使用滑动窗口进行流量控制
- 对端通告的窗口大小为0时,发送方必须等待
- 网络拥塞:
- 网络状况差,数据包丢失或延迟
- TCP拥塞控制算法会减少发送速率
- 对端处理慢:
- 对端应用程序读取数据速度慢
- 导致对端接收缓冲区满,影响本端发送
- 管道破裂:
- 对端连接已关闭,但本端尝试写入
- 第一次写入收到RST,第二次写入触发SIGPIPE(默认终止进程)
- 解决方案:
- 使用非阻塞I/O + I/O多路复用
- 设置合适的socket缓冲区大小
- 优化网络环境和应用程序性能
293. 多路复用:select,poll,epoll的区别,epoll的底层是如何实现的
答案:1. 基本概念
I/O多路复用是一种同时监控多个文件描述符(fd)的机制,当某个fd就绪(可读、可写或异常)时,系统会通知进程进行相应的读写操作。常见的实现有select、poll和epoll。
2. select、poll、epoll的区别
| 特性 | select | poll | epoll |
|---|---|---|---|
| 时间复杂度 | O(n) | O(n) | O(1)(仅处理就绪的fd) |
| 最大连接数 | 有限(通常1024) | 无限制(基于系统资源) | 无限制(基于系统资源) |
| 工作模式 | 轮询所有fd | 轮询所有fd | 回调机制(仅通知就绪的fd) |
| fd拷贝方式 | 每次调用需从用户态拷贝fd集合到内核态 | 同select | 通过epoll_ctl注册fd,避免重复拷贝 |
| 触发模式 | 仅支持水平触发(LT) | 仅支持水平触发(LT) | 支持水平触发(LT)和边缘触发(ET) |
| 内核支持 | 几乎所有系统 | 几乎所有系统 | 仅Linux(其他系统有类似如kqueue) |
3. 详细对比
select
- 实现原理:
- 使用
fd_set(位图)存储fd,通过遍历所有fd检查就绪状态。 - 每次调用需要重置
fd_set,并重新从用户态拷贝到内核态。
- 使用
- 缺点:
- 最大fd数量受限(
FD_SETSIZE通常为1024)。 - 线性扫描所有fd,效率随fd数量增加而下降。
- 频繁的用户态-内核态拷贝开销。
- 最大fd数量受限(
poll
- 实现原理:
- 使用
pollfd结构体数组替代fd_set,解决了最大连接数限制。 - 与select类似,仍需遍历所有fd检查就绪状态。
- 使用
- 缺点:
- 效率随fd数量增加而下降(与select相同)。
- 大量fd时,用户态-内核态拷贝仍存在开销。
epoll
- 核心改进:
- 事件驱动:通过回调机制仅通知就绪的fd,无需遍历所有fd。
- 高效内存使用:使用
epoll_ctl注册fd,避免重复拷贝。 - 支持边缘触发(ET):仅在fd状态变化时通知,减少事件重复触发。
- 优点:
- 处理大量连接时性能显著优于select/poll。
- 支持水平触发(LT)和边缘触发(ET)模式。
4. epoll的底层实现
核心数据结构
- 红黑树(rbtree):
- 存储所有通过
epoll_ctl注册的fd,保证高效查找、插入和删除(时间复杂度O(log n))。
- 存储所有通过
- 就绪链表(ready list):
- 存储就绪的fd事件,当fd就绪时,内核将其添加到链表。
关键函数
epoll_create():- 创建一个
epoll实例,返回对应的文件描述符(epfd)。 - 内核初始化红黑树和就绪链表。
- 创建一个
epoll_ctl():- 向epfd添加、修改或删除监控的fd。
- 内核将fd插入红黑树,并为fd注册回调函数。
epoll_wait():- 阻塞等待就绪事件,返回就绪的fd数量。
- 内核检查就绪链表是否为空,若不为空则返回事件。
工作流程
- 初始化:
- 调用
epoll_create创建epfd,内核初始化红黑树和就绪链表。
- 调用
- 注册fd:
- 调用
epoll_ctl添加fd到红黑树,并设置回调函数(ep_poll_callback)。
- 调用
- 事件就绪:
- 当fd就绪时(如数据到达),触发回调函数。
- 回调函数将fd对应的事件添加到就绪链表。
- 获取事件:
epoll_wait检查就绪链表:- 若链表非空,拷贝事件到用户态并返回;
- 若链表为空,阻塞进程直到超时或事件就绪。
触发模式
- 水平触发(LT):
- 只要fd处于就绪状态,每次
epoll_wait都会通知该事件。 - 兼容性高,但可能重复触发(如数据未读完)。
- 只要fd处于就绪状态,每次
- 边缘触发(ET):
- 仅在fd状态变化时(如从无数据到有数据)通知一次。
- 需一次性处理所有数据,否则可能丢失事件。
5. 性能总结
- select/poll:适用于连接数少(<1000)的场景,跨平台兼容性好。
- epoll:适用于高并发场景(如Web服务器),性能随连接数增加仍保持稳定。
294. epoll边沿触发具体实现方式
答案:
- 边沿触发:只在状态变化时通知一次(如从不可读变为可读)
- 实现要求:
- 必须使用非阻塞socket
- 必须一次性读取所有可用数据
- 正确使用方式:c复制下载// 设置非阻塞 fcntl(fd, F_SETFL, O_NONBLOCK); // 边沿触发模式 struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; // 边沿触发 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 事件处理循环 while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].events & EPOLLIN) { // 必须循环读取,直到EAGAIN while (1) { ssize_t count = read(fd, buf, sizeof(buf)); if (count == -1) { if (errno == EAGAIN) break; // 数据读完 // 其他错误处理 } else if (count == 0) { // 连接关闭 break; } else { // 处理数据 process_data(buf, count); } } } } }
- 与水平触发的区别:
- 水平触发:只要条件满足就重复通知
- 边沿触发:只在状态变化时通知一次
- 边沿触发的优点:
- 减少事件通知次数,提高性能
- 避免”惊群”问题(多个进程/线程被同时唤醒)
295. LT和ET的区别,应用场景
答案:
- 水平触发:
- 只要文件描述符就绪(如可读),就会持续通知
- 应用程序可以多次调用read,直到数据读完
- 编程更简单,不容易遗漏事件
- 边沿触发:
- 只在状态变化时通知一次(如从不可读变为可读)
- 应用程序必须一次读取所有数据,直到EAGAIN
- 区别对比:
| 特性 | 水平触发 | 边沿触发 |
|---|---|---|
| 通知频率 | 条件满足就通知 | 状态变化时通知一次 |
| 数据读取 | 可以分多次读取 | 必须一次读取所有数据 |
| 编程复杂度 | 简单 | 复杂,容易遗漏事件 |
| 性能 | 可能重复通知 | 通知次数少,性能更好 |
| 适用场景 | 一般应用 | 高性能服务器 |
- 应用场景:
- 水平触发适用:
- 一般网络应用
- 需要简单编程模型的场景
- 对性能要求不极致的应用
- 边沿触发适用:
- 高性能服务器(如Nginx)
- 需要精确控制事件通知的场景
- 大量并发连接,需要减少系统调用的场景
- 水平触发适用:
296. 调用send函数发送数据不全怎么办
答案: send()可能由于各种原因无法一次性发送所有数据,需要正确处理:
- 原因分析:
- 发送缓冲区已满
- 网络拥塞
- 对端接收慢
- 解决方案:c复制下载// 方法1:循环发送直到所有数据发送完成 ssize_t send_all(int sockfd, const void* buf, size_t len) { size_t total_sent = 0; while (total_sent < len) { ssize_t sent = send(sockfd, (char*)buf + total_sent, len – total_sent, 0); if (sent == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 缓冲区满,需要等待可写事件 // 使用select/poll/epoll监控可写事件 continue; } else { return -1; // 其他错误 } } total_sent += sent; } return total_sent; } // 方法2:使用非阻塞I/O + I/O多路复用 // 注册EPOLLOUT事件,当socket可写时继续发送剩余数据
- 最佳实践:
- 总是检查
send()的返回值 - 处理
EAGAIN/EWOULDBLOCK错误 - 使用缓冲区管理待发送数据
- 配合I/O多路复用处理多个连接
- 总是检查
297. 1G的文件从A机器发送到B机器,怎么发(写代码实现)
答案: 发送大文件需要考虑效率、可靠性和内存使用:
c
复制下载
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define BUFFER_SIZE 8192 // 8KB缓冲区
#define PORT 8080
// 服务器端(接收文件)
void server_receive_file() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 5);
int client_fd = accept(server_fd, NULL, NULL);
// 接收文件名
char filename[256];
recv(client_fd, filename, sizeof(filename), 0);
int file_fd = open(filename, O_WRONLY | O_CREAT, 0644);
char buffer[BUFFER_SIZE];
ssize_t total_received = 0;
while (1) {
ssize_t received = recv(client_fd, buffer, sizeof(buffer), 0);
if (received <= 0) break;
write(file_fd, buffer, received);
total_received += received;
printf("Received: %zd bytes\n", total_received);
}
close(file_fd);
close(client_fd);
close(server_fd);
}
// 客户端(发送文件)
void client_send_file(const char* filename) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("服务器IP");
addr.sin_port = htons(PORT);
connect(sockfd, (struct sockaddr*)&addr, sizeof(addr));
// 发送文件名
send(sockfd, filename, strlen(filename) + 1, 0);
int file_fd = open(filename, O_RDONLY);
char buffer[BUFFER_SIZE];
ssize_t total_sent = 0;
while (1) {
ssize_t read_bytes = read(file_fd, buffer, sizeof(buffer));
if (read_bytes <= 0) break;
ssize_t sent = send(sockfd, buffer, read_bytes, 0);
if (sent != read_bytes) {
// 处理发送不完整的情况
lseek(file_fd, -(read_bytes - sent), SEEK_CUR);
}
total_sent += sent;
printf("Sent: %zd bytes\n", total_sent);
}
close(file_fd);
close(sockfd);
}
优化建议:
- 使用更大的缓冲区(如64KB)
- 使用非阻塞I/O + epoll处理多个连接
- 添加校验和验证文件完整性
- 支持断点续传
- 使用压缩减少传输量
298. 什么是TCP的粘包问题?怎么解决
答案:
- 粘包问题:TCP是流式协议,没有消息边界,多个消息可能被合并成一个TCP包发送,或在接收端一次读取到多个消息。
- 产生原因:
- Nagle算法:合并小包提高网络效率
- 接收缓冲区:可能一次读取到多个消息
- 网络延迟:多个消息可能同时到达
- 解决方案:
- 固定长度消息:
- 每个消息都是固定长度
- 简单但浪费空间,不够灵活
- 特殊分隔符:
- 用特殊字符(如
\n)分隔消息 - 需要转义处理,适用于文本协议
- 用特殊字符(如
- 消息头+消息体(最常用):
- 消息头包含消息体长度
- 先读取固定长度的消息头,再读取指定长度的消息体
- 应用层协议:如HTTP的Content-Length、chunked编码
299. Tcp和udp的区别
答案:
| 特性 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接 | 无连接 |
| 可靠性 | 可靠传输,保证数据顺序 | 不可靠传输,可能丢包乱序 |
| 流量控制 | 有(滑动窗口) | 无 |
| 拥塞控制 | 有(慢启动、拥塞避免等) | 无 |
| 首部开销 | 大(20字节) | 小(8字节) |
| 传输效率 | 相对较低 | 相对较高 |
| 应用场景 | 需要可靠传输(网页、邮件、文件) | 实时应用(视频、语音、游戏) |
| 数据边界 | 流式,无消息边界 | 数据报,有消息边界 |
- 选择依据:
- 用TCP:需要可靠传输,数据完整性重要
- 用UDP:实时性要求高,可以容忍少量丢包
300. tcp三次握手建立连接的过程,三次握手过程通信双方各自的状态
答案: 三次握手过程:
- 第一次握手(客户端 → 服务器):
- 客户端发送SYN=1, Seq=x
- 客户端状态:SYN_SENT
- 服务器状态:LISTEN
- 第二次握手(服务器 → 客户端):
- 服务器发送SYN=1, ACK=1, Seq=y, Ack=x+1
- 客户端状态:SYN_SENT
- 服务器状态:SYN_RCVD
- 第三次握手(客户端 → 服务器):
- 客户端发送ACK=1, Seq=x+1, Ack=y+1
- 客户端状态:ESTABLISHED
- 服务器状态:ESTABLISHED
状态转换:
text
复制下载
客户端: CLOSED → SYN_SENT → ESTABLISHED 服务器: CLOSED → LISTEN → SYN_RCVD → ESTABLISHED
为什么要三次握手:
- 确认双方收发能力:两次握手只能确认客户端→服务器通,服务器→客户端未确认
- 防止已失效连接请求:避免网络延迟的旧连接请求造成混淆
- 协商初始序列号,同步序列号
301. tcp四次挥手的过程,四次挥手过程中通信双方各自的状态
TCP使用四次挥手来可靠地终止一个连接。假设客户端主动发起关闭:
- 第一次挥手:
- 客户端发送FIN报文(FIN=1),进入
FIN_WAIT_1状态。
- 客户端发送FIN报文(FIN=1),进入
- 第二次挥手:
- 服务端收到FIN后,发送ACK报文作为应答,进入
CLOSE_WAIT状态。 - 客户端收到ACK后,进入
FIN_WAIT_2状态。
- 服务端收到FIN后,发送ACK报文作为应答,进入
- 第三次挥手:
- 服务端处理完剩余数据后,发送自己的FIN报文,进入
LAST_ACK状态。
- 服务端处理完剩余数据后,发送自己的FIN报文,进入
- 第四次挥手:
- 客户端收到服务端的FIN后,发送ACK报文,进入
TIME_WAIT状态,等待2MSL后进入CLOSED状态。 - 服务端收到ACK后,立即进入
CLOSED状态。
- 客户端收到服务端的FIN后,发送ACK报文,进入
状态变化总结:
- 主动关闭方(客户端):
ESTABLISHED->FIN_WAIT_1->FIN_WAIT_2->TIME_WAIT->CLOSED - 被动关闭方(服务端):
ESTABLISHED->CLOSE_WAIT->LAST_ACK->CLOSED
302. 简述一下tcp的超时机制,分类
TCP的超时机制主要是为了处理数据包丢失或确认丢失的情况,确保可靠性。
| 超时机制 | 描述 |
|---|---|
| 重传超时(RTO) | 发送一个数据段后启动定时器,如果超过RTO时间仍未收到ACK,则重传该数据段。RTO基于RTT动态计算。 |
| 坚持定时器 | 当接收方窗口为0时,发送方停止发送。坚持定时器周期性地向接收方查询窗口大小,防止因窗口更新报文丢失而导致连接死锁。 |
| 保活定时器 | 检测空闲连接的另一端是否还存在。如果一段时间内没有数据交换,会发送探针,多次无响应则关闭连接。 |
| TIME_WAIT 定时器 | 主动关闭连接后,保持TIME_WAIT状态2MSL时间,以确保最后的ACK能被对端收到,并让旧连接的报文在网络中消逝。 |
303. tcp通信过程的状态是如何变化的
TCP连接的生命周期通过状态机描述,主要状态变迁如下:
- 建立连接:
CLOSED-> (SYN_SENT/SYN_RCVD) ->ESTABLISHED - 数据传输:双方在
ESTABLISHED状态下进行全双工通信。 - 关闭连接:
- 主动关闭:
ESTABLISHED->FIN_WAIT_1->FIN_WAIT_2->TIME_WAIT->CLOSED - 被动关闭:
ESTABLISHED->CLOSE_WAIT->LAST_ACK->CLOSED - 同时关闭:
ESTABLISHED->FIN_WAIT_1->CLOSING->TIME_WAIT->CLOSED
- 主动关闭:
304. 从实用的角度来教,三次握手的真实目的
三次握手不仅仅是打招呼,其核心目的有三个:
- 确认双方的收发能力:通过SYN和ACK的交换,双方都确认了自己能发送、对方能接收,并且对方能发送、自己能接收。
- 同步初始序列号(ISN):交换ISN,为后续的数据传输提供起始点,用于数据排序和去重。
- 协商重要参数:在SYN报文的选项字段中协商参数,如最大报文段(MSS)、窗口缩放因子等。
305. 网络的七层模型,每一层的协议
OSI七层模型及其典型协议:
| 层次 | 功能 | 典型协议 |
|---|---|---|
| 7. 应用层 | 为用户提供网络服务接口 | HTTP, FTP, DNS, SMTP |
| 6. 表示层 | 数据格式转换、加密解密 | SSL/TLS, JPEG, ASCII |
| 5. 会话层 | 建立、管理、终止会话 | RPC, NetBIOS |
| 4. 传输层 | 提供进程到进程的通信 | TCP, UDP |
| 3. 网络层 | 提供主机到主机的通信和路由 | IP, ICMP, ARP |
| 2. 数据链路层 | 提供节点到节点的可靠传输 | Ethernet, PPP, MAC |
| 1. 物理层 | 定义电气、机械特性 | RJ45, 光纤, 无线电波 |
306. 为什么time_wait状态需要经过2msl才能返回到close状态
TIME_WAIT状态持续2MSL有两个主要原因:
- 可靠地终止连接:确保主动关闭方发送的最后一个ACK能到达被动关闭方。如果这个ACK丢失,被动关闭方会重传FIN。2MSL时间足以让这个重传的FIN到达,并让主动关闭方再次发送ACK。
- 让旧连接的报文在网络中消逝:2MSL时间确保了本次连接产生的所有报文都会从网络中消失,从而不会对后续具有相同四元组(源IP、源端口、目的IP、目的端口)的新连接造成数据混淆。
307. 如何根据ip获取对方的mac地址
通过ARP(地址解析协议)。
- 主机A想与同一局域网内的主机B通信,已知B的IP地址。
- A在本局域网内广播一个ARP请求包,内容为:“谁的IP地址是B的IP?请告诉你的MAC地址给我(A的MAC)”。
- 局域网内所有主机都会收到该请求,但只有主机B会识别出自己的IP,并向A发送一个单播ARP响应包,包含自己的MAC地址。
- A收到响应后,将B的IP-MAC对应关系存入自己的ARP缓存表,然后就可以进行数据链路层的帧封装和发送了。
308. proactor和reactor的区别和特点
两者都是处理高并发IO的设计模式。
| 特性 | Reactor(同步IO) | Proactor(异步IO) |
|---|---|---|
| 模式 | 应用程序向事件分离器注册读/写就绪事件。当socket可读或可写时,事件分离器通知应用程序,应用程序自己执行实际的读/写操作。 | 应用程序向事件分离器注册读/写完成事件,并提供一个缓冲区。操作系统完成整个IO操作后,事件分离器通知应用程序操作已完成。 |
| 比喻 | 快递到了,物业(Reactor)通知你(应用程序)下楼来取。 | 你把钥匙给物业(Proactor),快递到了,物业帮你开门把快递放到家里,然后通知你。 |
| 编程复杂度 | 相对较低。 | 相对较高,需要处理回调。 |
| 控制权 | 读写操作由应用线程完成,会阻塞线程。 | IO操作由操作系统完成,不阻塞应用线程,性能潜力更高。 |
309. 怎样加快大文件在网络中传输,根据滑动窗口与拥塞控制考虑
- 从滑动窗口角度:增大接收方的通告窗口大小,可以减少发送方等待ACK的次数,实现更大的“数据管道”,提高吞吐量。这通常需要调整系统内核参数。
- 从拥塞控制角度:
- 在稳定阶段,TCP使用拥塞避免算法,线性增加窗口,能较好地利用带宽。
- 使用具有更高效率的拥塞控制算法,如BBR,它不基于丢包而是基于测量带宽和RTT来调整发送速率,在高带宽、高延迟的网络中表现更好。
- 对于非实时要求的大文件,可以开启多个并行TCP连接(如迅雷、浏览器下载),每个连接独立进行拥塞控制,从而聚合带宽,但这可能对网络公平性有影响。
310. http和https的区别
| 特性 | HTTP | HTTPS |
|---|---|---|
| 协议 | 应用层协议 | HTTP over SSL/TLS |
| 默认端口 | 80 | 443 |
| 安全性 | 明文传输,不安全 | 加密传输,安全 |
| 工作原理 | 直接传输数据 | 先建立SSL/TLS安全连接,再加密传输数据 |
| 证书 | 不需要 | 需要由CA颁发的数字证书来验证服务器身份 |
| 性能 | 速度快,开销小 | 速度稍慢,因为加密解密有计算开销 |
| SEO | 无优势 | 搜索引擎会优先排名HTTPS网站 |
311. http有哪些常用的方法,http的端口号
- 常用方法:
GET:请求资源。POST:提交数据,通常用于创建新资源或触发处理。PUT:更新资源(整体替换)。PATCH:部分更新资源。DELETE:删除资源。HEAD:获取资源的元信息,不返回报文主体。OPTIONS:查询服务器支持的针对特定资源的方法。CONNECT:建立隧道,用于SSL/TLS代理。TRACE:追踪路径,用于诊断。
- HTTP端口号:默认是 80。
312. SSH基于TCP还是UDP?端口号
SSH基于TCP,端口号是22。
313. 讲一下WLAN
WLAN是无线局域网的缩写,它使用无线电波(而非电缆)在短距离内连接设备。最常见的WLAN技术是Wi-Fi,基于IEEE 802.11系列标准。
- 组成部分:无线路由器或接入点(AP)、无线网卡。
- 优点:移动性、布线方便。
- 挑战:安全性、信号干扰、速度稳定性。
314. 网卡的中断,网络方面遇到瓶颈怎么解决
- 网卡中断:当网卡接收到数据包时,会产生一个硬件中断信号给CPU。CPU暂停当前任务,执行中断处理程序(属于驱动程序的一部分)来将数据包从网卡缓冲区拷贝到内核内存。
- 网络瓶颈解决方案:
- NAPI:在高流量下,采用轮询而非每次包都中断,减少中断开销。
- 多队列网卡与RSS:将网络流量分散到多个CPU核心上处理。
- 中断亲和性:将特定网卡的中断绑定到特定的CPU核心。
- 协议栈优化:调整内核网络参数。
- 应用层优化:使用更高效的网络编程模型(如epoll)、减少数据拷贝。
- 硬件升级:升级网卡、增加带宽。
315. 什么时候会产生time_wait,如果系统出现大规模time_wait怎么处理
- 产生时机:在TCP连接中,主动关闭连接的一方会进入
TIME_WAIT状态。 - 大规模TIME_WAIT处理:
- 代码层面:确保由客户端主动关闭的连接改为由服务器端主动关闭(如果架构允许)。
- 使用长连接:减少TCP连接的建立和关闭次数。
- 调整内核参数:
net.ipv4.tcp_tw_reuse:允许将处于TIME_WAIT的socket用于新的TCP连接(仅作为客户端时安全)。net.ipv4.tcp_tw_recycle:(不推荐,已移除)net.ipv4.tcp_max_tw_buckets:限制系统中TIME_WAITsocket的最大数量。
316. TCP中何时会出现reset报文
- Reactor(同步IO)
317. 在TCP中调用read命令时,返回值大于0,等于0,小于0分别代表什么
- 返回值 > 0:成功读取到的字节数。
- 返回值 == 0:表示对端已经关闭了连接(收到了FIN)。
- 返回值 < 0:表示发生错误。需要检查
errno:EINTR:调用被信号中断,通常应重试。EAGAIN或EWOULDBLOCK:在非阻塞模式下,当前没有数据可读。- 其他错误:如
ECONNRESET(连接被对端重置)。
318. HTTP有哪些请求方式
同第311题。
319. GET请求和POST请求的区别
| 特性 | GET | POST |
|---|---|---|
| 语义 | 获取资源(幂等、安全) | 提交数据,可能修改服务器状态(非幂等、不安全) |
| 数据位置 | 通过URL的查询字符串传递 | 通过请求体传递 |
| 数据大小 | 受URL长度限制(通常2KB-8KB) | 理论上无限制,受服务器配置约束 |
| 安全性 | 参数在URL中明文显示,可被缓存、记录日志 | 相对安全(但HTTPS才是真正的安全) |
| 缓存/书签 | 可被缓存,可收藏为书签 | 通常不被缓存,不可收藏 |
| 后退/刷新 | 无害 | 浏览器会提示重新提交数据 |
320. 什么是强缓存和协商缓存
浏览器缓存机制的两个阶段:
- 强缓存:浏览器在请求资源前,先检查本地缓存。如果缓存未过期(通过
Cache-Control的max-age或Expires头判断),则直接使用缓存,不发送任何请求到服务器。状态码为200 (from cache)。 - 协商缓存:当强缓存失效时,浏览器会携带缓存标识(如
If-Modified-Since/Last-Modified或If-None-Match/ETag)向服务器发起请求。服务器检查资源是否变化。- 如果未变化,返回 304 Not Modified,浏览器继续使用缓存。
- 如果已变化,返回 200 OK 和新资源。
321. HTTP1.0和HTTP1.1的区别
| 特性 | HTTP/1.0 | HTTP/1.1 |
|---|---|---|
| 连接方式 | 默认非持久连接 | 默认持久连接(Keep-Alive) |
| Host头 | 非必需 | 必需,支持虚拟主机 |
| 缓存控制 | 主要使用Expires | 引入更精细的Cache-Control |
| 管道化 | 不支持 | 支持,但存在队头阻塞 |
| 范围请求 | 不支持 | 支持(Range头) |
| 错误通知 | – | 新增24个状态码(如100 Continue) |
322. HTTP2.0与HTTP1.1的区别
| 特性 | HTTP/1.1 | HTTP/2 |
|---|---|---|
| 协议格式 | 文本格式 | 二进制分帧 |
| 多路复用 | 不支持,存在队头阻塞 | 支持,一个连接上可交错传输多个消息 |
| 头部压缩 | 不支持,头部重复传输 | 使用HPACK算法压缩 |
| 服务器推送 | 不支持 | 服务器可主动推送资源 |
| 流优先级 | 不支持 | 支持设置请求优先级 |
323. HTTPS和HTTP有哪些区别
同第310题。
324. HTTPS工作原理
- TCP三次握手:建立TCP连接。
- TLS握手:
- Client Hello:客户端发送支持的TLS版本、加密套件列表、一个随机数。
- Server Hello:服务器选择TLS版本、加密套件,发送自己的数字证书和一个随机数。
- 证书验证:客户端验证服务器证书的合法性。
- 预主密钥:客户端生成预主密钥,用服务器公钥加密后发送给服务器。
- 会话密钥生成:双方使用随机数和预主密钥,独立计算出相同的会话密钥。
- 加密通信:后续的应用数据(HTTP报文)都使用会话密钥进行对称加密传输。
325. TCP和UDP的区别
| 特性 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接 | 无连接 |
| 可靠性 | 可靠,有确认、重传、排序机制 | 不可靠,尽最大努力交付 |
| 流量控制 | 有(滑动窗口) | 无 |
| 拥塞控制 | 有(慢启动、拥塞避免等) | 无 |
| 数据顺序 | 保证数据顺序 | 不保证顺序 |
| 速度 | 慢,开销大 | 快,开销小 |
| 数据边界 | 面向字节流,无边界 | 面向数据报,有边界 |
| 头部大小 | 20-60字节 | 8字节 |
| 应用场景 | 文件传输、邮件、Web浏览 | 视频流、语音、DNS查询、游戏 |
326. 三次握手的过程,为什么要进行三次握手
同第300题和第304题。
为什么是三次?
- 防止已失效的连接请求报文突然又传送到服务器,导致错误。两次握手无法防止这种情况,服务器会白白浪费资源。三次握手确保了双方的发信和收信能力都得到确认。
327. 四次挥手的过程,为什么要进行四次握手
同第301题。
为什么需要四次?
- 因为TCP连接是全双工的,每个方向必须单独关闭。一方发送FIN只表示它不再发送数据,但还可以接收数据。另一方需要先ACK这个FIN,然后在它自己也没有数据要发送时,再发送自己的FIN。因此需要两次独立的“FIN-ACK”过程。
328. TCP连接为如何保证可靠性
通过以下机制:
- 序列号和确认应答(ACK)。
- 超时重传。
- 数据校验和
- 流量控制(滑动窗口)。
- 拥塞控制(慢启动、拥塞避免、快速重传、快速恢复)。
329. 拥塞控制的实现机制
TCP拥塞控制包含四个主要算法:
- 慢启动:连接开始时,拥塞窗口指数增长。
- 拥塞避免:窗口增长到慢启动阈值后,线性增长。
- 快速重传:收到3个重复ACK时,立即重传丢失的报文。
- 快速恢复:在快速重传后,执行快速恢复,将窗口减半后进入拥塞避免阶段。
330. HTTP的keep_Alive是什么?TCP的keepalive和HTTP的Keep-Alive是一个东西吗
- HTTP Keep-Alive:指的是HTTP持久连接。允许在同一个TCP连接上发送和接收多个HTTP请求/响应。这是应用层的行为。
- TCP Keepalive:是TCP协议层的一个机制。当一个连接长时间空闲时,TCP会周期性地发送“保活”探测包,以检测对端是否还存活。这是传输层的行为。
它们是两个不同层次、目的不同的概念。
331. DNS查询过程
以查询www.example.com为例:
- 浏览器缓存 -> 操作系统缓存 -> 本地DNS服务器(递归查询)。
- 本地DNS服务器进行迭代查询:
- 问 根域名服务器:
.com的TLD服务器地址。 - 问 .com TLD服务器:
example.com的权威域名服务器地址。 - 问 example.com权威服务器:
www.example.com的IP地址。
- 问 根域名服务器:
- 本地DNS服务器将IP返回给客户端,并缓存结果。
332. DNS查询的两种方式
- 递归查询:客户端向本地DNS服务器发出查询,要求服务器必须返回最终的IP地址或错误信息。客户端只需问一次。
- 迭代查询:本地DNS服务器向根、TLD、权威服务器逐级查询,每一级服务器只返回下一级服务器的地址,由本地DNS服务器自己继续查询。服务器之间通常是迭代查询。
333. CDN是什么
CDN是内容分发网络。它通过将内容缓存到全球各地的边缘节点服务器上,使用户可以从地理上最近的节点获取所需内容。
- 作用:减少网络延迟、提高内容加载速度、减轻源站负载。
- 原理:通过DNS解析将用户请求导向离他最近的CDN节点。
334. cookie和sessions和token的区别是什么
| 特性 | Cookie | Session | Token(如JWT) |
|---|---|---|---|
| 存储位置 | 客户端浏览器 | 服务器端 | 客户端 |
| 安全性 | 较低,易被窃取 | 较高,信息在服务器 | 较高,但Token本身需防泄露 |
| 扩展性 | 好 | 差,服务器集群间同步复杂 | 好,无状态 |
| 跨域 | 受同源策略限制 | 依赖Cookie | 可放在请求头中,支持跨域 |
| 工作方式 | 服务器Set-Cookie,客户端自动携带 | 服务器生成Session ID通过Cookie传递,客户端携带ID | 服务器生成Token,客户端在Header中携带 |
335. TCP/IP七层模型中,每一层分别有什么协议
TCP/IP模型常被说成四层或五层。按五层模型:
| 层次 | 协议 |
|---|---|
| 应用层 | HTTP, FTP, DNS, SMTP, SSH |
| 传输层 | TCP, UDP |
| 网络层 | IP, ICMP, IGMP |
| 数据链路层 | Ethernet, PPP, ARP |
| 物理层 | IEEE 802.3等 |
336. TCP只进行两次握手会有什么问题
主要问题是已失效的连接请求报文突然又传送到服务器,导致错误。
- 场景:一个滞后的SYN包到达服务器,服务器误以为是新的连接请求并回应,如果只有两次握手,服务器就认为连接已建立,从而浪费资源。
- 三次握手解决:客户端不会确认这个陈旧的SYN-ACK,连接无法建立。
337. 连接队列和三次握手之间的关系
在服务器端,内核为每个监听套接字维护两个队列:
- 半连接队列(SYN队列):服务器收到SYN包,回复SYN-ACK后,连接放入此队列(状态
SYN_RCVD)。 - 全连接队列(Accept队列):服务器收到第三次握手的ACK后,连接从半连接队列移入此队列(状态
ESTABLISHED)。 - 应用程序调用
accept(),只是从全连接队列中取出一个已建立的连接。
338. 什么是TCP的连接
TCP连接是一个逻辑上的、端到端的通信通道。它由源IP、源端口、目的IP、目的端口这个四元组唯一标识。连接在通信前需要通过三次握手建立,在通信后通过四次挥手释放,保证了数据传输的可靠性。
339. 端口是用来标识什么的
端口号是一个16位的数字,用于在一台主机上标识一个具体的应用程序进程。IP地址标识了主机,而端口号标识了主机上的特定服务或程序。
340. WebSocket与HTTP有什么区别
第一部分:WebSocket 是什么?
简单来说,WebSocket 是一种网络通信协议,它允许在单个、长期的连接上进行全双工通信。
我们来拆解这个定义:
- 协议:就像 HTTP 是浏览器和服务器之间约定好的通信规则一样,WebSocket 也是一套规则。
- 单个、长期的连接:客户端(如浏览器)和服务器一旦通过 WebSocket 握手成功,就会建立一个持续的连接。这个连接在需要通信期间会一直保持打开,而不是像 HTTP 那样每次请求后都断开。
- 全双工:这是最关键的一点。它意味着客户端和服务器可以随时、独立地向对方发送数据,就像打电话一样,双方可以同时说话和收听。这与 HTTP 的“一问一答”模式有本质区别。
WebSocket 的设计目标就是为了解决 HTTP 在实时性要求高的场景下的低效问题。
第二部分:WebSocket 与 HTTP 的核心区别
为了更直观地理解,我们可以用一个比喻:
- HTTP:像 发送信件。
- 你写一封信(请求),寄出去。
- 邮局(网络)送达。
- 对方收到后,再写一封回信(响应),寄回来。
- 每次通信都需要重新建立一次连接,非常繁琐。
- WebSocket:像 打电话。
- 你先拨号(HTTP 握手请求)。
- 对方接听(握手响应)。
- 从此,连接建立,你们可以随时向对方说话,也可以同时说话,直到一方挂断。
下面是具体的技术对比表格:
| 特性 | HTTP | WebSocket |
|---|---|---|
| 通信模式 | 半双工(请求-响应) | 全双工(双向通信) |
| 连接生命周期 | 短连接:每次请求-响应后连接关闭。 | 长连接:握手成功后,连接保持打开,直到一方主动关闭。 |
| 通信发起方 | 只能由客户端发起请求。 | 服务器和客户端都可以主动向对方发送消息。 |
| 数据推送 | 不支持服务器主动推送。需要客户端轮询(效率低)。 | 支持服务器主动、实时地向客户端推送数据。 |
| 协议开销 | 大:每次请求和响应都包含完整的头部信息(Header)。 | 小:建立连接后,数据传输的包格式非常轻量,头部只有几字节。 |
| 适用场景 | 网页浏览、API 调用、表单提交等传统 Web 应用。 | 实时聊天、在线游戏、股票行情、协同编辑等实时应用。 |
第三部分:深入理解工作原理
HTTP 的工作流程(请求-响应模型)
- 客户端发起一个 HTTP 请求(例如,获取一个网页)。
- 服务器处理请求并返回一个 HTTP 响应。
- 连接断开。
- 如果客户端需要新数据,必须重复步骤 1-3。
对于实时数据,HTTP 的笨拙解决方案:
- 轮询:客户端每隔几秒就向服务器发一个 HTTP 请求问:“有新数据吗?” 不管服务器有没有新数据,都要回应。这会产生大量无效请求。
- 长轮询:客户端发一个请求,服务器如果没新数据,就把这个请求挂起,直到有数据或超时才返回。客户端收到响应后立即再发一个新的请求。这比普通轮询好一些,但仍然基于 HTTP,开销不小。
WebSocket 的工作流程(握手+持久连接)
WebSocket 连接建立过程巧妙地利用了 HTTP 来“升级”协议。
- HTTP 握手:
- 客户端发送一个特殊的 HTTP 请求,其头部包含
Connection: Upgrade和Upgrade: websocket等字段。意思是:“服务器,我想把我们的协议升级成 WebSocket。” - 服务器如果支持 WebSocket,会返回一个
HTTP 101 Switching Protocols的响应,表示:“同意升级协议。”
- 客户端发送一个特殊的 HTTP 请求,其头部包含
- 建立 WebSocket 连接:
- 握手成功后,之前的 HTTP 连接就被“升级”了。此时的通信不再走 HTTP 协议,而是使用 WebSocket 协议。
- 这个 TCP 连接会保持打开状态。
- 双向数据传输:
- 在此之后,客户端和服务器都可以随时、非常轻量地向这个连接上发送数据帧,无需额外的协议头开销。实现了真正的实时、双向通信。
- 连接关闭:
- 当一方(客户端或服务器)决定不再需要通信时,可以发送一个关闭帧来优雅地终止连接。
总结
- HTTP 是 Web 的基础,是为单向请求-响应而设计的,非常适合加载网页、提交表单等不要求高实时性的场景。
- WebSocket 是在 HTTP 基础上发展而来的补充协议,它通过一次 HTTP 握手升级连接,建立起一个持久化、低延迟、全双工的通道,专门用于解决实时双向通信的需求。
| 特性 | HTTP | WebSocket |
|---|---|---|
| 通信模式 | 半双工,请求-响应 | 全双工,双向通信 |
| 连接 | 短连接(HTTP/1.1可持久但仍是请求-响应) | 长连接,建立后持续打开 |
| 数据交换 | 客户端发起请求,服务器才能响应 | 服务器可以主动向客户端推送数据 |
| 头部开销 | 每次请求/响应都包含完整的HTTP头 | 建立连接时有握手,之后数据帧头很小 |
| 适用场景 | 传统Web页面加载、API调用 | 实时应用(聊天、游戏、股票行情) |
341. 服务端是如何解析HTTP请求的数据
- 读取请求行:解析方法(GET/POST)、URL、HTTP版本。
- 读取请求头:逐行读取直到空行,解析头字段(Host, Content-Type, Content-Length等)。
- 读取请求体:根据
Content-Length或Transfer-Encoding: chunked读取相应数据。 - 处理:根据解析出的信息进行路由和业务逻辑处理。
- 生成响应:生成状态行、响应头和响应体,发送回客户端。
342. TCP连接使用来解决什么问题的
TCP连接主要解决的是在不可靠的IP网络之上,提供可靠的、面向连接的、基于字节流的数据传输服务。它解决了数据丢失、乱序、重复、流量控制和网络拥塞等问题。
343. TCP初始序列号ISN怎么取值的
ISN不是从0或1开始的固定值。这样设计是为了安全,防止被猜测而遭受TCP序列号攻击。通常,ISN是基于一个随时间变化的计数器来生成的,这样每次连接的ISN都不同。
344. TCP三次握手时,发送SYN之后就宕机了会怎么样
- 客户端发送SYN后宕机:服务器回复SYN-ACK后收不到ACK,会进行重传,最终超时放弃连接。
- 影响:浪费服务器一些资源(半连接队列位置和重传),但通常有限制,影响可控。
345. 什么是SYN flood攻击
一种DDoS攻击。攻击者伪造大量不存在的源IP地址,向目标服务器发送TCP SYN包。服务器会为每个SYN分配资源并回复SYN-ACK,但永远收不到ACK(因为源IP是伪造的)。这会耗尽服务器的半连接队列资源。
- 防御:SYN Cookie、增加队列大小、使用防火墙过滤。
346. 除了四次挥手,还有什么方法断开连接
- 发送RST报文:一方可以直接发送RST复位报文,强制立即断开连接。这不是优雅关闭。
- 进程终止:当进程退出时,操作系统会关闭它打开的所有套接字。
347. TCP有超时重传为什么还需要快速重传
超时重传的等待时间(RTO)通常较长(至少200ms以上)。快速重传通过在收到3个重复ACK时就立即重传丢失的包,大大减少了重传的等待时间,提高了效率。超时重传是保障可靠性的最后手段。
348. TCP的SACK的引入是为了解决什么问题
解决多个数据包丢失时TCP重传效率低下的问题。SACK允许接收方在ACK中告知发送方自己已经成功接收的不连续的数据块。这样发送方就能知道具体哪些包丢了,从而一次性重传所有丢失的包。
349. TCP滑动窗口的作用是什么
- 流量控制:接收方通过通告窗口大小,防止发送方发送过多数据导致接收方缓冲区溢出。
- 提高信道利用率:允许发送方在收到确认之前连续发送多个数据包,将等待ACK的时间也用于数据传输。
350. 说说TCP拥塞控制的步骤
同第329题。
351. ARP和RARP分别是什么,有什么区别
- ARP:地址解析协议。根据IP地址获取MAC地址。用于同一局域网内。
- RARP:逆地址解析协议。根据MAC地址获取IP地址。常用于无盘工作站启动,现已被DHCP取代。
- 区别:功能相反。ARP是IP->MAC,RARP是MAC->IP。
352. JWT Token能说说吗
JWT是一种开放标准,用于在各方之间安全地传输信息作为JSON对象。
- 结构:
Header.Payload.SignatureHeader:令牌类型和签名算法。Payload:包含声明(用户ID、过期时间等)。Signature:用于验证消息未被篡改。
- 工作流程:登录后服务器生成JWT返回客户端。客户端后续请求在Header中携带JWT。服务器验证签名有效后,即可信任Payload中的信息。
353. 简单谈谈你对DNS的理解
DNS是互联网的“电话簿”,它将人类可读的域名翻译成机器可读的IP地址。它是一个分布式的、层次化的数据库系统,确保了互联网寻址的可扩展性和可靠性。
354. 简单谈谈你对CDN的理解
同第333题。
355. 常见的登录鉴权方式有哪些?各自的优缺点是?
- Session-Cookie:
- 优:技术成熟,服务器有完全控制权。
- 缺:服务器需存储Session,扩展性差。
- Token(如JWT):
- 优:无状态,易于扩展,支持跨域。
- 缺:Token一旦签发,在有效期内无法立即失效。
- OAuth 2.0 / OpenID Connect:
- 优:标准协议,适用于第三方授权登录,安全。
- 缺:实现相对复杂。
数据库
356. 数据库的事务是什么
数据库事务是一个不可分割的工作单元,它由一系列对数据库的操作组成。这些操作要么全部成功执行,要么全部不执行。事务是保证数据库一致性状态转移的基本机制。
357. 数据库事务有哪些特性
ACID特性:
- 原子性:事务中的所有操作是一个不可分割的整体。
- 一致性:事务执行前后,数据库都必须处于一致性状态。
- 隔离性:并发执行的事务之间互不干扰。
- 持久性:事务一旦提交,其对数据库的修改就是永久性的。
358. 事务的隔离级别有多少种,分别是什么
SQL标准定义了4种隔离级别,从低到高:
- 读未提交:存在脏读、不可重复读、幻读。
- 读已提交:解决脏读,存在不可重复读、幻读。
- 可重复读:解决脏读和不可重复读,可能存在幻读(InnoDB通过MVCC解决)。
- 串行化:最高级别,解决所有并发问题,但性能最低。
359. 不可重复读和幻读区别是什么?可以举个例子吗
- 不可重复读:指在一个事务内,多次读取同一行数据,结果不一致(因为被其他事务修改并提交了)。
- 例:事务A读工资为5000。事务B改工资为8000并提交。事务A再读,变成8000。
- 幻读:指在一个事务内,多次执行同一查询,返回的记录集合不同(因为其他事务插入或删除了符合查询条件的行并提交了)。
- 例:事务A查工资<10000的有10人。事务B插入一个工资5000的新员工并提交。事务A再查,变成11人。
核心区别:不可重复读针对数据的更新,幻读针对数据的新增或删除。
360. 什么是聚簇索引,什么是非聚簇索引
- 聚簇索引:表数据文件本身就是按主键组织的一个B+树索引。叶子节点包含了完整的行数据。一个表只有一个聚簇索引。
- 非聚簇索引(二级索引):叶子节点存储的不是行数据,而是对应行的主键值。通过非聚簇索引查找数据需要回表查询。
361. 数据库索引怎么用,适合什么场景,什么时候索引失效
- 如何使用:通过
CREATE INDEX语句创建。查询时数据库优化器自动判断是否使用。 - 适用场景:经常作为查询条件、排序、分组的列。
- 索引失效常见情况:
- 对索引列进行运算或函数操作。
- 使用
!=或<>。 - 使用
OR连接不同索引列的条件。 - 左模糊匹配(
LIKE '%abc')。 - 不符合最左前缀原则(复合索引)。
- 数据类型隐式转换。
362. 如何对索引进行优化
- 选择高选择性的列创建索引。
- 使用复合索引,注意最左前缀原则。
- 避免过多索引,影响写性能。
- 使用覆盖索引,避免回表。
- 定期
ANALYZE TABLE更新索引统计信息。
363. 创建索引一定能加快检索速度吗,为什么?
不一定。
- 能加速:对于大数据量的表,在经常查询的列上创建索引,可以减少扫描数据量。
- 不能加速甚至变慢:
- 小表全表扫描可能更快。
- 索引会增加INSERT/UPDATE/DELETE的开销。
- 查询需要返回大部分数据时,优化器可能选择全表扫描。
- 错误的索引可能不会被使用。
364. 为什么MYSQL索引要使用B+树,而不是B树或者红黑树
- vs 红黑树:B+树是多路平衡查找树,树高更低,减少磁盘I/O次数。
- vs B树:
- B+树非叶子节点只存键,不存数据,一个节点能容纳更多键,树高更矮。
- B+树所有叶子节点通过指针串联成有序链表,非常适合范围查询和全表扫描。
365. 你知道哪些数据库结构优化的手段
- 选择合适的数据类型。
- 范式化与反范式化:适当反范式化以减少JOIN。
- 垂直拆分:将不常用或大字段拆到另一张表。
- 水平拆分(分库分表)。
- 使用中间表存储统计结果。
366. B树和B+树区别
同第364题。
367. 怎么判断一个查询是否是高效率的
- 使用
EXPLAIN分析SQL执行计划,看type(访问类型)、key(使用的索引)、rows(预估扫描行数)。 - 查询响应时间是否可接受。
- 监控慢查询日志。
368. 如何优化查询语句
- 避免
SELECT *,只取需要的列。 - 避免在WHERE子句中对字段进行NULL值判断、函数操作。
- 使用
JOIN代替子查询(需实测)。 - 使用
UNION ALL代替UNION(如果不需要去重)。 - 合理使用索引。
369. MYSQL的约束有哪些
PRIMARY KEY(主键)UNIQUE(唯一)NOT NULL(非空)FOREIGN KEY(外键)CHECK(检查,MySQL 8.0.16后支持)DEFAULT(默认值)
370. inner join,left join,right join,outer join的区别
INNER JOIN:返回两个表中连接条件匹配的行。LEFT JOIN:返回左表所有行,右表无匹配则用NULL填充。RIGHT JOIN:返回右表所有行,左表无匹配则用NULL填充。FULL OUTER JOIN:返回左右两表的全部行,无匹配部分用NULL填充。(MySQL不支持)
371. mysql如何合并两个表
- 纵向合并(追加行):使用
UNION(去重)或UNION ALL(不去重,效率高)。sql复制下载SELECT col1, col2 FROM table1 UNION ALL SELECT col1, col2 FROM table2; - 横向合并(连接列):使用
JOIN。
372. 共享锁与独占锁
- 共享锁(读锁):一个事务加锁后,其他事务可以加共享锁读,但不能加独占锁写。
- 独占锁(写锁):一个事务加锁后,其他事务既不能加共享锁读,也不能加独占锁写。
373. 乐观锁和悲观锁
- 悲观锁:认为冲突概率高,先加锁再操作。数据库原生支持。适用于写多读少。
- 乐观锁:认为冲突概率低,不加锁,更新时判断数据是否被修改(通过版本号)。应用层实现。适用于读多写少。
374. 了解过存储过程吗
存储过程是预先编译好并存储在数据库中的一组SQL语句。
- 优点:减少网络传输、执行速度快、模块化。
- 缺点:调试复杂、移植性差、增加数据库负担。
375. 了解过数据库视图吗
视图是虚拟表,其内容由查询定义。它不存储数据,数据来自基表。
- 优点:简化复杂查询、增强数据安全性、逻辑数据独立性。
- 缺点:性能可能不如直接查询基表,更新可能受限。
376. MYSQL的端口号
默认端口号是3306。
377. Redis持久化方案
两种主要方案:
- RDB:在指定时间间隔生成数据集的时间点快照。
- AOF:记录所有写操作命令,重启时重新执行以恢复数据。
378. 两种持久化方式如何选择?
- RDB:
- 优点:文件紧凑,恢复快,适合灾难恢复。
- 缺点:可能丢失最后一次快照后的数据。
- AOF:
- 优点:数据更安全,最多丢1秒数据。
- 缺点:文件通常比RDB大,恢复慢。
- 选择:通常同时开启,用AOF保数据,用RDB做冷备。
379. 两种持久化方式能不能同时使用?如果能,redis重启时按照哪个文件的内容恢复数据?
可以同时使用。Redis重启时,如果AOF开启,优先使用AOF文件恢复数据。只有当AOF关闭时,才使用RDB文件。
380. 如果开启了aof方式,并且aof文件损坏,redis能否启动成功?
不能。Redis启动时会加载AOF文件,文件损坏则启动失败。
381. aof文件如果损坏,怎么处理?
- 使用
redis-check-aof --fix <filename.aof>工具修复。 - 如果修复不理想,从RDB快照恢复。
382. 如果只希望数据在服务器运行时存在该怎么做?
关闭所有持久化机制(不配置RDB的save规则,并将appendonly设置为no)。Redis将作为纯内存缓存,重启后数据丢失。
383. redis有哪五种数据类型?如何给每种数据类型进行添加数据?这五种都适合添加什么数据
- String:
SET key value。适合缓存、计数器。 - List:
LPUSH/RPUSH key value。适合消息队列、最新列表。 - Hash:
HSET key field value。适合存储对象。 - Set:
SADD key member。适合标签、共同好友。 - ZSet:
ZADD key score member。适合排行榜。
384. 如何关闭redis服务器?如何启动redis服务器?
- 关闭:在客户端执行
SHUTDOWN,或系统命令redis-cli shutdown。 - 启动:运行
redis-server /path/to/redis.conf。
385. 什么是持久化?两种持久化的概念与区别?优缺点?
同第377和378题。
386. redis的事务的三个阶段是什么?常用命令有哪些?是否具备原子性?
- 三个阶段:
MULTI(开始) -> 命令入队 ->EXEC(执行)。 - 常用命令:
MULTI,EXEC,DISCARD,WATCH。 - 原子性:不具备严格原子性。它保证命令按顺序执行且不被中断,但不提供回滚。某个命令出错,后续命令仍会执行。
387. 主从复制的概念是什么?方向是什么样的?
主从复制是指将一台Redis服务器(主节点)的数据,复制到其他Redis服务器(从节点)。复制是单向的,只能由主节点复制到从节点。用于数据冗余、读写分离、故障恢复。
388. 解释一下Redis
Redis是一个开源的、基于内存的、可可选持久化的键值对存储系统。它支持多种数据结构,并提供丰富的操作命令。因其极高的读写性能,常被用作缓存、消息队列、会话存储等。它通常被称为数据结构服务器。
389. 缓存雪崩解决方案
- 问题:大量缓存数据在同一时间大面积失效,请求直接落数据库。
- 解决方案:
- 设置不同的过期时间(加随机值)。
- 构建高可用的缓存集群(Sentinel/Cluster)。
- 使用互斥锁或队列控制数据库访问。
- 缓存永不过期,后台异步更新。
390. 缓存穿透解决方案
- 问题:查询一个数据库中根本不存在的数据,请求穿过缓存直达数据库。
- 解决方案:
- 缓存空对象:即使数据库查不到,也缓存空结果(设短过期时间)。
- 布隆过滤器:快速判断数据是否存在,不存在则直接返回。
391. 缓存击穿解决方案
- 问题:某个热点key在失效的瞬间,大量并发请求击穿缓存,直达数据库。
- 解决方案:
- 永不过期:对热点key不设过期时间,后台更新。
- 互斥锁:只有一个线程去加载数据,其他线程等待。
392. 缓存预热
系统上线后,提前将相关的缓存数据加载到缓存系统中。避免用户第一次请求时直接访问数据库,导致速度慢。
393. Redis常见的数据结构以及使用场景分别是什么
同第383题。
394. C++中的Map也是一种缓存数据结构,为什么不用Map,而选择Redis做缓存
- 存储位置与共享:C++
std::map是进程内缓存,无法多进程/多服务器共享。Redis是进程外缓存服务,可被所有应用服务器访问。 - 持久化:
std::map数据程序退出即消失。Redis支持持久化。 - 数据结构:Redis支持更丰富的数据结构和原子操作。
- 容量与扩展:
std::map受单机内存限制。Redis可集群扩展。 - 失效策略:Redis支持TTL等丰富策略。
395. Redis是如何部署的
- 单机模式:开发测试。
- 主从复制:一主多从,读写分离。
- 哨兵模式:在主从基础上实现自动故障转移(高可用)。
- 集群模式:数据分片,水平扩展和高可用。
396. Redis的有序集合底层实现是什么,如果让你实现,你会怎么实现
- 底层实现:跳跃表和哈希表的结合。哈希表实现O(1)的按成员查分值,跳跃表实现按分值排序和范围操作。
- 自己实现:类似,使用哈希表+跳表(或红黑树)。哈希表负责快速查找,跳表负责维护顺序。
397. 了解Redis的线程模型吗
Redis是单线程的(指处理网络请求和键值对操作的核心模块是单线程)。它使用I/O多路复用机制(如epoll)来并发处理大量客户端的连接。单线程避免了多线程的竞争和上下文切换。Redis 6.0引入了多线程处理网络I/O,但命令执行仍是单线程。
398. Redis失效时应该怎么处理,如果让你设计方案,你会怎么设计
- 失效处理:
- 故障转移:哨兵/集群自动切换。
- 降级:直接访问数据库,加锁防击垮。
- 快速恢复:重启并从持久化文件恢复。
- 设计方案:
- 高可用架构:生产环境必须用Sentinel或Cluster。
- 多级缓存:本地缓存+Redis。
- 熔断与降级:使用Hystrix等工具。
- 监控与告警。
399. 一条SQL查询语句是如何执行的
以MySQL为例:
- 连接器:管理连接,权限验证。
- 分析器:词法、语法分析。
- 优化器:生成执行计划。
- 执行器:调用存储引擎接口执行。
- 存储引擎:存储和提取数据(如InnoDB)。
400. MySQL的执行引擎有哪些,每个分别支持些啥
常见引擎:
- InnoDB:
- 支持:事务、行级锁、外键、MVCC。MySQL 5.5后默认引擎。
- MyISAM:
- 支持:表级锁、全文索引。
- 不支持:事务、行级锁、外键。
- Memory:数据存内存,速度快,重启丢失。
401. 说一下索引失效的场景
同第361题。常见场景包括:
- 对索引列进行运算或函数操作。
- 使用
!=或<>。 - 使用
OR连接不同索引列的条件。 - 左模糊匹配(
LIKE '%abc')。 - 不符合最左前缀原则(复合索引)。
- 数据类型隐式转换。
402. undo log,redo log,bin log有什么用
- undo log:回滚日志。用于事务回滚和MVCC,保证原子性。
- redo log:重做日志。用于崩溃恢复,保证持久性。记录的是物理修改。
- bin log:归档日志。用于主从复制和数据恢复。记录的是逻辑操作。
403. 什么是慢查询,原因是什么,可以怎么优化
- 慢查询:执行时间超过
long_query_time阈值的SQL查询。 - 原因:未用索引、全表扫描、锁等待、复杂查询等。
- 优化:使用
EXPLAIN分析、创建索引、优化SQL、分库分表。
404. Redis是单线程还是多线程的,为什么
- 核心网络I/O和数据操作:在Redis 6.0之前是纯单线程。6.0之后,网络I/O处理变成了多线程,但核心的命令解析和执行仍然是单线程。
- 为什么(核心部分保持单线程):避免竞争条件、简化实现、性能瓶颈通常在内存和网络而非CPU。
405. 缓存雪崩,击穿,穿透和解决办法
- 缓存雪崩:大量缓存同时失效 -> 设置不同过期时间、高可用集群。
- 缓存穿透:查询不存在的数据 -> 缓存空对象、布隆过滤器。
- 缓存击穿:热点key失效 -> 永不过期、互斥锁。
406. 如何保证数据库和缓存的一致性
常用策略是 Cache Aside Pattern:
- 读:先读缓存,未命中则读库并写缓存。
- 写:先更新数据库,再删除缓存。 还可配合延迟双删、设置缓存过期时间等策略实现最终一致性。
407. 设计模式 – 为什么用组合而不要用继承
- 继承缺点:破坏封装、耦合度高、类爆炸、行为在编译时确定。
- 组合优点:封装性好、耦合度低、更灵活(可在运行时改变行为)。符合组合/聚合复用原则。
408. 单例模式的构造函数,单例模式的创建过程,如何保证线程安全
- 构造函数:必须是私有的。
- 创建过程:通过静态方法(如
getInstance())获取唯一实例。 - 线程安全实现:
- 饿汉式:类加载时初始化。
- 懒汉式(DCL):双重检查锁定,需加
volatile。 - 静态内部类:利用类加载机制。
- 枚举:最简洁安全。
409. 如何使用单例模式,有什么注意事项
- 使用:通过
Singleton.getInstance()获取实例。 - 注意事项:
- 线程安全。
- 防止反射攻击(构造函数中判断实例是否存在)。
- 防止反序列化创建新对象(实现
readResolve方法)。 - 考虑类加载器的影响。
410. 如果用单例模式时创建了多个对象,如何定位问题
- 检查线程安全的实现是否正确(如DCL)。
- 检查是否有反射调用私有构造函数。
- 检查反序列化是否破坏了单例。
- 检查是否存在多个类加载器。
411. 请简述一下适配器模式
将一个类的接口转换成客户期望的另一个接口,使接口不兼容的类可以一起工作。
- 角色:目标接口、被适配者、适配器。
- 分类:类适配器(继承)、对象适配器(组合,更常用)。
412. 实现一个简单的观察者模式
观察者模式定义了一种一对多的依赖关系。当一个对象(主题)状态改变时,所有依赖它的对象(观察者)都会得到通知。
java
复制下载
// 主题接口
interface Subject {
void registerObserver(Observer o);
void removeObserver(Observer o);
void notifyObservers();
}
// 观察者接口
interface Observer {
void update(String message);
}
// 具体主题
class ConcreteSubject implements Subject {
private List<Observer> observers = new ArrayList<>();
private String state;
public void setState(String state) {
this.state = state;
notifyObservers();
}
// 实现注册、移除、通知方法...
}
// 具体观察者
class ConcreteObserver implements Observer {
private String name;
public ConcreteObserver(String name) { this.name = name; }
@Override
public void update(String message) {
System.out.println(name + " received: " + message);
}
}
413. 使用过的设计模式,应用场景,如何应用?阐述业务背景和应用方式
示例回答:
- 模式:工厂方法模式。
- 背景:支付系统需要支持多种支付渠道(支付宝、微信、银联)。
- 应用:
- 定义
PaymentHandler接口和pay()方法。 - 创建具体处理器类(
AlipayHandler,WechatPayHandler)。 - 创建
PaymentHandlerFactory,根据渠道类型返回对应的处理器实例。
- 定义
- 好处:将对象创建与使用分离,符合开闭原则,易于扩展。
414. 网盘项目 – 客户端发送消息给服务器,服务器端是如何解析的
- 定义应用层协议:包含消息头(类型、版本、长度)和消息体。
- 解决粘包:根据长度字段(固定头长或头中包含体长)来读取完整消息。
- 服务器解析:
- 读固定长度消息头 -> 解析消息类型和体长。
- 根据体长读完整消息体 -> 反序列化成业务对象(如登录请求)。
- 根据消息类型分发给对应的处理器。
415. 多个用户上传同一份文件该如何处理
使用秒传技术:
- 客户端计算文件哈希值(如MD5/SHA-1)。
- 上传前询问服务器该哈希值的文件是否存在。
- 若存在,服务器仅在数据库建立用户与文件的关联记录,无需存储文件内容。
- 若不存在,执行正常上传流程。
416. 秒传如何实现
同第415题。
417. 断点续传如何实现
- 客户端:
- 文件分片,记录已上传分片。
- 上传前询问服务器已接收的分片列表。
- 只上传缺失的分片。
- 服务器端:
- 支持从指定偏移量写入数据。
- 记录文件上传进度。
- 接收分片并写入文件指定位置。
- 全部分片完成后合并或更新状态。
418. 讲一下虚拟文件目录
为用户提供逻辑文件视图,与物理存储路径无关。
- 实现:在数据库维护元信息表(ID、父目录ID、名称、类型、所属用户、物理路径标识等)。
- 操作:用户的增删改查只更新数据库记录,不涉及物理文件移动,响应快。
419. 前后端的通信方式知道哪几种
- 短轮询:客户端定期询问服务器。
- 长轮询:服务器hold住连接,有数据或超时才返回。
- Server-Sent Events (SSE):服务器可单向推送数据给客户端。
- WebSocket:全双工双向通信。
420. 当你上传文件或更改文件时,如果出现问题,网络中断了,会不会导致数据库和文件对不上,怎么解决的
会。需保证操作原子性。
- 解决方案:
- 补偿机制/最终一致性:
- 记录操作状态(如“上传中”)。定时任务清理异常状态的数据。
- 采用软删除,先标记再物理删除,便于恢复。
- 操作日志:用于故障恢复和对账。
- 补偿机制/最终一致性:
421. 如何做token验证
- 登录成功,服务器生成Token(如JWT)返回客户端。
- 客户端存储Token(LocalStorage/Cookie)。
- 客户端请求时在Header(如
Authorization: Bearer <token>)中携带Token。 - 服务器端拦截器验证Token(签名、有效期),有效则放行,无效返回401。
422. 网盘项目的网络通信方式
- 大文件传输:直接使用TCP长连接(自定义协议或基于HTTP)。
- 控制命令(登录、列表):可使用基于TCP的自定义协议或RESTful API over HTTP。
423. 有考虑过多线程同时上传一个文件的问题吗
需要考虑并发写冲突。
- 解决方案:一个文件的一次上传会话应由一个客户端线程完成。若支持分块上传,不同块可并行,但同一块需顺序上传。服务器端需加锁或使用乐观锁机制。
424. md5的算法是自己实现的吗
通常不会。应使用成熟、安全的第三方库(如OpenSSL、Java的MessageDigest类)。自己实现可能存在漏洞或性能问题。
425. 文件如何和用户绑定
在数据库的用户文件表中建立关联。表结构包含:id, user_id, filename, virtual_path, file_hash, size, create_time等。通过user_id和virtual_path/filename唯一确定用户文件。
426. 连接使用的是长链接还是短链接
对于需要频繁交互的网盘项目,长连接是更好的选择,减少TCP连接建立/断开的开销。控制命令也可使用HTTP/1.1持久连接或HTTP/2。
427. 长链接socket的参数是怎么设置的
通过设置socket选项:
SO_KEEPALIVE:开启TCP保活机制。SO_SNDBUF/SO_RCVBUF:设置发送/接收缓冲区大小。TCP_NODELAY:禁用Nagle算法,减少小包延迟。SO_RCVTIMEO/SO_SNDTIMEO:设置超时。
428. 线程池是如何实现的
主要组成部分:
- 任务队列(阻塞队列):存放待执行任务。
- 工作线程集合:循环从队列取任务执行。
- 线程池管理器:创建、销毁线程,管理状态。
- 工作流程:
- 提交任务,若有空闲核心线程则立即执行。
- 若无,任务入队。
- 若队列满,创建新线程(直至达最大线程数)。
- 若线程数已达最大且队列满,执行拒绝策略。
429. 线程池在项目当中是怎么用的,分别有哪些线程,它们是怎么分工的
- 用法:创建
ThreadPoolExecutor,提交任务。 - 线程分工示例:
- 网络I/O线程(Netty boss/worker group):处理连接和数据读写。
- 业务逻辑线程池:执行耗时计算,防阻塞I/O线程。
- 文件I/O线程池:专处理磁盘读写。
- 定时任务线程池(
ScheduledThreadPoolExecutor):执行定时/延迟任务。
430. 线程池中的线程数目是否会随并发量动态增加
会的。取决于配置:
- 核心线程数:常驻线程数。
- 最大线程数:允许创建的最大线程数。
- 工作队列:存放待处理任务。 当任务提交速度 > 处理速度,且核心线程忙、队列满时,会创建新线程(直至最大线程数),动态增加。
431. 如何确定线程池中线程的状态
线程池不直接暴露内部线程状态。可间接了解:
- 监控指标:
getActiveCount()(活动线程数)、getQueue().size()(队列长)、getCompletedTaskCount()(已完成任务数)。 - JMX:通过Java Management Extensions监控MBean。
- 日志:在任务开始/结束处打日志。
432. 搜索引擎项目 – 一个网页的信息是通过什么形式存储的
以倒排索引和正排数据结合:
- 倒排索引:词项 -> 文档ID列表(含位置、权重等),用于快速定位。
- 正排数据:存储每个网页的原始信息(URL、标题、摘要、内容等),通过文档ID与倒排索引关联。
433. Simhash是什么,怎么使用的
用于快速计算文本相似度的局部敏感哈希算法,生成文本指纹。相似文本的Simhash值海明距离小。
- 使用步骤:
- 分词,赋权重(如TF-IDF)。
- 计算每个词的传统哈希(如MD5),转为定长二进制串。
- 加权累加:权重为正,哈希位为1加权重,为0减权重。
- 生成指纹:累加结果每位>0置1,否则置0。
- 应用:去重。计算新网页Simhash,与库中网页计算海明距离,小于阈值(如3)则认为重复。
434. 介绍一下倒排索引
搜索引擎核心数据结构。
- 正排索引:文档ID -> 文档内容(词列表)。如书目录。
- 倒排索引:词项 -> 出现该词的文档ID列表(倒排列表)。如书末索引。
- 倒排列表:含文档ID、词频、位置等,用于相关性排序。
- 优点:快速响应关键词查询。
435. 余弦相似算法
用于计算两个向量的夹角余弦值,衡量相似度。在搜索引擎中,将查询和文档表示为向量(如TF-IDF权重),计算余弦相似度进行排序。
- 公式:cosθ = (A·B) / (||A|| * ||B||)。值越近1越相似。
436. 任务队列是怎么实现的,阻塞还是非阻塞
- 实现:通常使用阻塞队列(如
LinkedBlockingQueue)。 - 阻塞 vs 非阻塞:
- 阻塞:队列空时消费者阻塞,队列满时生产者阻塞。能协调速度,避免CPU空转。
- 非阻塞:操作失败直接返回,需自旋重试,更复杂。
- 任务队列场景下,阻塞队列更常见、简单。
437. 负载均衡怎么做的
- DNS负载均衡:一个域名解析到多个IP。
- 硬件负载均衡:使用专用设备(如F5)。
- 软件负载均衡:使用Nginx、LVS等。
- 算法:轮询、加权轮询、最少连接、IP哈希等。
- 服务层:使用服务发现和客户端负载均衡(如Ribbon、Dubbo)。
438. 什么是最短编辑距离
两个字符串之间,由一个转换成另一个所需的最少编辑操作次数。允许操作:插入、删除、替换一个字符。
- 应用:拼写检查、模糊查询、DNA序列比对。
- 算法:动态规划。
439. 双缓存轮换是怎么做的,为什么不使用单缓存加锁
- 做法:维护两个缓存实例(CacheA, CacheB)。后台线程定时更新其中一个(如CacheB)。更新期间读请求访问旧缓存(CacheA)。更新完成后原子切换读请求到新缓存(CacheB)。然后更新CacheA,循环。
- vs 单缓存加锁:
- 双缓存:读操作基本无锁,性能极高。
- 单缓存加锁:更新缓存需加写锁,阻塞所有读请求,吞吐量下降。
440. LRU算法的原理,为什么使用LRU算法,还可以选用什么算法
- 原理:认为最近使用的数据将来概率更高。实现:哈希表+双向链表。访问数据时移到链表头。淘汰数据时淘汰链表尾。
- 为什么使用:实现相对简单,能较好反映访问局部性。
- 其他算法:
- LFU:最不经常使用。可能无法反应近期热点。
- FIFO:先进先出。简单但效果通常不如LRU。
- Random:随机淘汰。简单但不可预测。
441. 缓存和数据库是如何实现同步操作
同第406题。常用Cache Aside Pattern:更新数据库后删除缓存。配合重试、过期时间等手段。
442. 项目当中的任务队列具体会有哪些任务
根据项目类型:
- 爬虫项目:URL抓取、页面解析、数据存储任务。
- 网盘项目:生成缩略图、病毒扫描、文件转码、发送通知。
- 电商项目:取消超时订单、同步库存、优惠券过期、生成报表。
443. 搜索引擎项目用了几个进程几个线程池
示例架构:
- 进程:
- 爬虫进程(抓取网页)。
- 索引构建进程(解析网页、建倒排索引)。
- 搜索服务进程(接收查询、返回结果)。
- 管理/监控进程。
- 线程池:
- 爬虫进程:网络I/O线程池、页面解析线程池。
- 搜索服务进程:网络请求处理线程池、搜索计算线程池。
444. http项目 – 介绍下这个workflow编程范式是什么,怎么用的,以及这个框架能对项目起到什么作用
- 范式/框架介绍:一种异步、基于任务的编程范式。将复杂业务逻辑拆解成独立、可复用的任务(Task),通过依赖关系组装成工作流(Workflow)。
- 如何使用:定义任务逻辑和依赖,由框架调度器异步、高效执行。
- 作用:
- 高并发:异步非阻塞,提高吞吐量。
- 可维护性:逻辑清晰,易于测试。
- 复用性:任务可被多个工作流复用。
- 可扩展性:方便增改任务流程。
445. 有没有用什么二进制通信协议
在高性能C++后端中,常使用二进制协议:
- Protocol Buffers:Google出品,高效,语言中立。
- Thrift:Apache出品,功能强大,含完整RPC框架。
- MessagePack:类似JSON,但为二进制。
- 自定义协议:根据业务设计,通常含定长消息头和变长消息体。
