美团C++客户端一二面面经
来源:https://www.nowcoder.com/discuss/560937735280672768?sourceSSR=users
一面
实习:
略
1、线程和协程
线程:
- 定义:线程是操作系统能够进行运算调度的最小单位。它包含在进程之中,是进程中的实际运作单位。
- 并发性:线程是并发执行的,可以在同一进程中的多个线程之间共享相同的资源。
- 调度:由操作系统负责线程的调度和切换,切换线程的开销相对较大。
- 状态:线程有自己的堆栈,寄存器等状态信息。线程之间的通信通常通过共享内存或消息传递实现。
- 例子:多线程编程常见于使用线程池来加速任务执行,或者在图形用户界面(GUI)中使用一个线程处理用户输入,另一个线程处理界面刷新。
协程:
- 定义:协程是一种轻量级的线程,它不是被操作系统内核所管理,而是被编程语言的运行时环境或框架管理。
- 并发性:协程也是并发执行的,但它是由程序员控制的,而不是由操作系统调度。多个协程可以在同一个线程内运行。
- 调度:协程的调度是协作式的,由程序员手动控制协程的切换。因此,切换协程的开销相对较小。
- 状态:协程的状态可以被保存和恢复,协程之间的通信通常通过共享状态或者显式的消息传递实现。
- 例子:协程常见于异步编程模型中,例如使用Python的
asyncio库,或者在JavaScript中使用async/await语法。
对比:
- 切换开销:线程的切换由操作系统负责,开销相对较大;而协程的切换由程序员手动控制,开销较小。
- 并发性控制:线程的并发性由操作系统决定,而协程的并发性由程序员决定。
- 通信方式:线程通常通过共享内存或消息传递进行通信,而协程通常通过共享状态或显式的消息传递。
- 适用场景:线程通常适用于需要操作系统级别调度的场景,例如多核计算、I/O密集型任务;而协程适用于需要高度控制和轻量级并发的场景,例如异步编程、高并发网络服务器。
2、进程通信的方式是什么
- 管道:
- 描述:管道是一种半双工的通信机制,可以在两个相关的进程之间传递数据。
- 特点:管道有两种类型,一种是无名管道,只能在相关进程之间通信;另一种是命名管道(FIFO),可以在无关的进程之间通信。
- 适用场景:适用于具有亲缘关系的进程之间进行通信,比如父子进程之间。
- 消息队列:
- 描述:消息队列是一种通过消息进行进程通信的机制,消息可以具有不同的优先级。
- 特点:消息队列允许一个进程向另一个进程发送消息,发送和接收的进程不必同时存在。
- 适用场景:适用于不同进程之间需要进行异步通信的情况。
- 信号:
- 描述:信号是一种在进程之间传递简单消息的方式,用于通知接收进程发生了某个事件。
- 特点:信号可以用于进程之间的异步通信,例如在接收到某个信号时执行特定的处理函数。
- 适用场景:适用于进程之间需要发送简单消息进行通知的情况。
- 共享内存:
- 描述:共享内存允许多个进程共享同一块内存区域,其中的数据可以被所有共享该内存的进程访问。
- 特点:由于共享内存是直接对内存进行访问,因此读写速度较快,但需要进行同步操作以防止数据竞争。
- 适用场景:适用于需要高性能且进程之间需要频繁交换大量数据的情况。
- 套接字:
- 描述:套接字是一种通过网络进行进程通信的方式,可以在同一台机器上的进程之间通信,也可以在不同机器上的进程之间通信。
- 特点:提供了一种通用的、面向网络的通信方式,适用于分布式系统和网络编程。
- 适用场景:适用于需要跨越不同主机的进程通信的情况。
3、线程通信的方式是什么
- 互斥锁:
- 描述:互斥锁用于保护临界区,确保在同一时刻只有一个线程可以进入临界区。
- 机制:一个线程成功获取互斥锁后,其他线程需要等待锁的释放。这可以防止多个线程同时访问共享资源,确保数据的一致性。
- 适用场景:适用于需要对共享资源进行独占式访问的情况。
- 条件变量:
- 描述:条件变量用于在多个线程之间发送信号,通知其他线程某个条件的改变。
- 机制:一个线程可以通过条件变量等待某个条件的发生,而另一个线程可以通过条件变量发出信号,通知等待的线程条件已满足。
- 适用场景:适用于线程需要等待某个条件满足后再继续执行的情况。
- 信号量:
- 描述:信号量是一种计数器,用于控制对共享资源的访问。
- 机制:当一个线程访问共享资源时,信号量减1;当线程释放资源时,信号量加1。当信号量为0时,其他线程需要等待。
- 适用场景:适用于限制同时访问共享资源的线程数量的情况。
- 屏障:
- 描述:屏障用于让多个线程在同一点等待,直到所有线程都到达这个点。
- 机制:当线程达到屏障点时,它会等待其他线程也到达。只有当所有线程都到达时,它们才会一起继续执行。
- 适用场景:适用于需要线程同步的情况,例如在算法中需要多个阶段的计算。
- 读写锁:
- 描述:读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。
- 机制:当有线程正在写入时,其他线程不能进行读取或写入。当只有线程进行读取时,多个线程可以同时读取。
- 适用场景:适用于读取操作频繁、写入操作较少的情况,提高并发性能。
- 线程局部存储:
- 描述:线程局部存储用于为每个线程提供独立的变量存储空间,避免变量在多线程之间共享。
- 机制:每个线程都有自己的局部存储空间,对该空间的访问不会影响其他线程的局部存储。
- 适用场景:适用于需要在每个线程中保持独立状态的情况,例如保存线程特定的配置信息。
4、死锁的条件是什么
死锁(Deadlock)是在多线程或多进程环境下的一种资源争夺和竞争导致的一种阻塞状态,其中各个线程或进程都在等待彼此持有的资源,导致无法继续执行。死锁的发生通常需要满足以下四个必要条件,这些条件被称为死锁的必要条件:
- 互斥条件:
某个资源一次只能被一个进程或线程占用。如果某个资源已经被一个进程或线程占用,其他进程或线程必须等待。
- 占有且等待条件:
一个进程或线程可以持有一个资源,并等待获取其他进程或线程占有的资源。换句话说,一个进程或线程可以同时持有一些资源,同时等待获取其他资源。
- 非抢占条件:
已经占用资源的进程或线程不能被强行抢占,资源只能在进程主动释放的情况下才能被其他进程或线程获取。
- 循环等待条件:
一组进程或线程形成一个循环,每个进程或线程都在等待下一个进程或线程所持有的资源。
5、tcp和udp的差异
- 连接性:
- TCP:提供面向连接的服务,建立可靠的、双向的通信通道。通过三次握手建立连接,四次挥手断开连接。
- UDP:无连接的协议,每个数据包都是独立的,发送者和接收者之间没有持久的连接。
- 可靠性:
- TCP:提供可靠的、有序的数据传输。使用确认机制和重传机制来确保数据的可靠性,保证数据按顺序到达。
- UDP:不保证可靠性,数据包可能会丢失或无序,没有确认和重传机制。
- 数据量:
- TCP:面向字节流,没有固定的数据边界,适用于大量数据的可靠传输。
- UDP:数据包独立,有固定的大小,适用于少量数据的快速传输。
- 流量控制:
- TCP:通过滑动窗口等机制进行流量控制,防止发送者发送过多数据导致接收者无法处理。
- UDP:没有流量控制机制,发送者会一直发送数据,不考虑接收者的处理能力。
- 拥塞控制:
- TCP:有拥塞控制机制,通过调整发送速率来适应网络的拥塞情况。
- UDP:没有内建的拥塞控制,由应用层负责处理拥塞情况。
- 适用场景:
- TCP:适用于要求可靠传输、顺序传输的应用,如文件传输、电子邮件等。
- UDP:适用于实时性要求高、容忍少量丢失的应用,如音频、视频流等。
- 头部开销:
- TCP:头部开销较大,包含序号、确认号、窗口大小等信息。
- UDP:头部较小,只包含基本的源端口、目标端口、长度和校验和信息。
6、HTTP中get和post的差异
- 数据传递:
- GET:
- 数据通过URL的查询字符串(query string)传递,放在URL的后面,以
?开头,键值对之间用&连接。例如:http://example.com/resource?name=value&age=30。 - 数据长度有限制,因为URL的长度是有限制的。
- 数据通过URL的查询字符串(query string)传递,放在URL的后面,以
- POST:
- 数据通过请求体(Request Body)传递,不会在URL中显示。常用于传递大量数据或敏感信息。
- 数据长度较大,没有明确的长度限制。
- 安全性:
- GET:
- 因为数据附加在URL中,可能会被浏览器保存在历史记录、服务器日志等地方。
- 不适合传递敏感信息,如密码等。
- POST:
- 数据在请求体中,相对较安全,不会被保存在历史记录和服务器日志中。
- 适合传递敏感信息。
- 缓存处理:
- GET:
- 请求可被缓存,可以被浏览器缓存,适合幂等操作。
- 可以被书签保存,可以被浏览器历史记录记录。
- POST:
- 请求不能被缓存,每次请求都会生成新的请求。
- 不适合被书签保存或浏览器历史记录。
- 幂等性:
- GET:幂等,同样的请求会得到同样的结果,不会对服务器状态产生改变。
- POST:非幂等,同样的请求可能会产生不同的结果,可能对服务器状态产生改变。
- 可见性:
- GET:参数和数值对可以在URL中可见。
- POST:参数和数值对不会显示在URL中。
- 使用场景:
- GET:
- 适用于获取资源,浏览器刷新、后退按钮,以及被搜索引擎收录。
- 对服务器无副作用的请求。
- POST:
- 适用于提交表单,上传文件,以及包含敏感信息的请求。
- 对服务器有副作用的请求。
7、http如何实现断点续传,如果这个时候客户端收到一般就结束了的话
实现HTTP的断点续传通常需要借助HTTP协议中的Range头字段。Range头字段允许客户端指定从资源中获取的特定范围,这可以用于实现文件的部分下载,从而支持断点续传。
- 服务器端支持
Range头:
服务器需要能够解析并处理Range头字段。如果服务器支持范围请求,它会在响应中包含Accept-Ranges: bytes头。
- 客户端请求时使用
Range头:
客户端在发起请求时,可以通过Range头字段指定所需的数据范围。例如,Range: bytes=500-999表示请求资源的第501到1000字节。
- 服务器处理
Range请求:
服务器根据客户端请求的范围,返回相应范围的数据。响应的Content-Range头字段会指示返回的数据范围。
- 客户端处理部分响应:
客户端收到部分响应后,可以将这部分数据保存在本地,并记录已下载的范围。
- 续传时更新
Range头:
当客户端希望续传时,可以使用之前保存的已下载范围来更新Range头字段,请求未下载的部分。
如果在客户端收到部分响应后就结束,那么客户端在下次请求时可以根据已下载的数据范围选择是否发送Range头字段。如果客户端决定不发送Range头字段,服务器将返回整个资源,相当于重新下载。
一个简化的HTTP断点续传的示例:
GET /example.mp4 HTTP/1.1
Host: example.com
Range: bytes=500-999服务器响应:
HTTP/1.1 206 Partial Content
Content-Range: bytes 500-999/20000
Content-Length: 500
Content-Type: video/mp4
[...partial content...]客户端续传时的请求:
GET /example.mp4 HTTP/1.1
Host: example.com
Range: bytes=1000-19998、https可以被中间人劫持吗
HTTPS(Hypertext Transfer Protocol Secure)是一种通过加密和认证来保护数据传输安全的协议。通过使用 TLS(Transport Layer Security)或其前身 SSL(Secure Sockets Layer),HTTPS确保数据在客户端和服务器之间的传输是加密的,从而防止第三方窃听、篡改和伪装等攻击。
尽管HTTPS提供了一定的安全性,但在某些情况下,中间人攻击(Man-in-the-Middle Attack,简称MITM)仍然可能发生,尽管比HTTP更难实施。中间人攻击通常发生在通信的两端之间,攻击者可以截取、修改或注入通信数据,而通信的两端并不知情。
中间人攻击HTTPS的一些可能方式包括:
- 证书伪造:
攻击者可能尝试使用伪造的数字证书来冒充合法的网站,使用户误以为他们连接到了目标网站。这可以通过劫持网络流量,将自签名或由攻击者控制的证书提供给用户来实现。
- SSL/TLS剥离:
攻击者可能尝试剥离SSL/TLS保护,将加密的通信转换为不加密的通信。这通常发生在用户与攻击者之间建立了不加密的连接,而攻击者与目标服务器之间建立了加密连接。
- 中间人注入:
攻击者可能在传输过程中注入恶意内容,例如恶意脚本、广告或恶意软件。这可以通过劫持并修改传输的数据包来实现。
要减少中间人攻击的风险,可以采取以下措施:
- 使用受信任的证书:确保网站使用由受信任的证书颁发机构(CA)签发的证书,以防止证书伪造。
- HSTS(HTTP Strict Transport Security):启用HSTS可以确保浏览器只与网站建立加密连接,从而防止SSL/TLS剥离攻击。
- 公共Wi-Fi的注意:避免在不安全的公共Wi-Fi网络上访问敏感信息,因为这些网络更容易受到中间人攻击。
9、C++源码到可执行文件
- 预处理(Preprocessing): 在编译的第一阶段,源代码经过预处理器处理。预处理器执行以下操作:
- 移除注释:删除源代码中的注释行(以
//或/* */形式)。 - 展开宏:将源代码中定义的宏展开为其实际内容。
- 处理条件编译:根据条件编译指令(如
#ifdef、#ifndef、#if等)决定是否包含或排除特定代码块。
- 移除注释:删除源代码中的注释行(以
- 编译(Compilation): 在编译阶段,预处理后的源代码被编译器翻译成中间代码(通常是汇编语言或机器代码的一种表示形式),这个中间代码通常称为汇编代码(Assembly Code)。
- 汇编(Assembly): 汇编器将中间代码翻译成目标机器的汇编语言代码。这是一个与具体计算机架构相关的步骤。
- 链接(Linking): 在链接阶段,编译器会将源代码中使用的外部库(如标准库、自定义库)与程序的汇编代码进行链接,以创建可执行文件。链接器执行以下操作:
- 解析符号引用:将源代码中引用的函数或变量与其定义关联起来。
- 解析库依赖:确定程序需要的外部库,并将其链接到程序中。
- 生成可执行文件:将所有的汇编代码和库组合成一个可执行文件。
- 优化(Optimization)(可选): 一些编译器会在编译阶段执行代码优化,以提高程序的性能和效率。优化可以包括常量折叠、循环展开、内联函数等。
- 生成可执行文件(Executable): 最终,编译器将链接后的代码生成可执行文件,这个文件包含了计算机可以直接执行的指令。
- 执行程序: 用户可以运行生成的可执行文件,将程序加载到计算机的内存中,并开始执行。
10、动态链接和静态链接
- 静态链接(Static Linking):
在静态链接中,链接器在编译时将所有的代码和库都链接到最终的可执行文件中。这包括目标程序的所有依赖项,形成一个独立于系统的可执行文件。
步骤:
- 预处理: 处理源文件中的宏、包含文件等。
- 编译: 将源代码翻译成汇编代码。
- 汇编: 将汇编代码翻译成目标文件。
- 链接: 将目标文件和相关的库文件合并成一个可执行文件。
优点:
- 执行速度较快,因为所有的代码和库都已经被静态地链接在一起,不需要在运行时进行额外的加载和解析。
缺点:
- 占用的磁盘空间相对较大,因为每个可执行文件都包含了其依赖的库的副本。
- 需要重新编译和链接整个程序,对于库的更新需要重新生成整个可执行文件。
- 动态链接(Dynamic Linking):
在动态链接中,程序在运行时才链接所需的库,而不是在编译时将所有的代码和库链接到可执行文件中。动态链接产生的可执行文件包含有关如何在运行时加载和链接库的信息。
步骤:
- 预处理: 同静态链接一样,处理源文件中的宏、包含文件等。
- 编译: 将源代码翻译成汇编代码。
- 汇编: 将汇编代码翻译成目标文件。
- 部分链接(Static Linking): 部分链接生成一个包含所有目标文件的中间文件,但并不包含所有的库文件。
- 运行时链接(Dynamic Linking): 在运行时,操作系统的动态链接器加载和链接所需的共享库,形成最终的可执行文件。
优点:
- 节省磁盘空间,因为共享库被所有使用它的程序所共享。
- 允许库的更新,不需要重新编译整个程序。
缺点:
- 稍微增加了程序的启动时间,因为需要在运行时进行额外的加载和解析。
- 可能引入一些运行时的依赖性问题,如库的版本不一致等。
11、指针和引用的区别
- 定义和初始化:
- 指针: 指针是一个变量,其值为另一个变量的地址。使用
*操作符声明和访问指针。指针需要显式地初始化,可以为nullptr(空指针)。
int x = 10;
int* ptr = &x; // 初始化指向x的指针- 引用: 引用是一个别名,它是某个已存在变量的别名。使用
&操作符声明和访问引用。引用必须在声明时进行初始化,且初始化后不能再指向其他变量。
int y = 20;
int& ref = y; // 初始化引用,ref是y的别名- 语法和操作符:
- 指针: 使用
*操作符访问指针所指向的值,使用&操作符获取变量的地址。
int value = *ptr; // 获取ptr指向的值- 引用: 直接使用引用即可访问所引用变量的值,不需要额外的操作符。
int result = ref; // 获取ref引用的值- 空值(nullptr 或 NULL):
- 指针: 指针可以具有空值,通常使用nullptr(C++11及以上)或NULL来表示空指针。
int* ptr = nullptr;- 引用: 引用不可以为空,必须在初始化时指定引用的目标。
- 重新赋值:
- 指针: 可以重新指向不同的地址。
int a = 5, b = 10;
int* ptr = &a;
ptr = &b; // 可以重新指向b- 引用: 一旦初始化后,不能再引用其他变量。
int x = 5, y = 10;
int& ref = x;
// ref = &y; // 错误,引用不能重新赋值为其他变量- 存在性:
- 指针: 指针本身是一个对象,占用一定的内存空间。
- 引用: 引用本身并不占用额外的内存空间,它是原变量的别名。
- 作为函数参数:
- 指针: 可以传递指针给函数,使得函数能够修改指针所指向的值。
void modifyValue(int* ptr) {
*ptr = 42;
}
int main() {
int x = 10;
modifyValue(&x);
// x 的值现在为 42
return 0;
}- 引用: 可以传递引用给函数,同样可以修改引用所引用的值。
void modifyValue(int& ref) {
ref = 42;
}
int main() {
int y = 20;
modifyValue(y);
// y 的值现在为 42
return 0;
}12、vector的扩容原理
std::vector 是 C++ 标准库中的一个动态数组容器,它会在需要时动态地调整内部的存储空间。当元素数量达到当前容量时,std::vector 会触发扩容操作。
std::vector 扩容的一般原理如下:
- 分配更大的存储空间:
当元素数量达到当前容量时,std::vector 会分配更大的内存块来存储元素。通常,新的容量会是当前容量的一定倍数(通常是两倍)。
- 将元素移动到新的存储空间:
扩容后,std::vector 将元素从旧的存储空间复制或移动到新的存储空间。这个过程包括调用元素类型的移动构造函数或拷贝构造函数。
- 释放旧的存储空间:
扩容完成后,std::vector 会释放旧的存储空间。
这个过程确保了 std::vector 在扩容时能够保持元素的相对顺序,并且能够高效地进行扩容操作。由于扩容是一个相对昂贵的操作(需要分配新的内存、移动元素等),为了优化性能,std::vector 通常会选择适当的策略来决定何时以及以多大的幅度进行扩容。
13、代码题:
const int a;
int const a;
const int* a;
int* const a;这些差别是什么
const int a;
这声明了一个常量整数 a,其值不能被修改。一旦被赋值,它的值将不能再改变。
int const a;
这是与上述相同的声明方式,const 可以放在 int 的前面或后面,两者等效。都表示 a 是一个常量整数。
const int* a;
这声明了一个指向常量整数的指针 a。这意味着通过 a 不能修改指向的整数的值,但是可以改变 a 指向的其他整数。
int* const a;
这声明了一个指向整数的常量指针 a。这意味着可以通过 a 修改指向的整数的值,但是不能改变 a 指向的位置(即不能让 a 指向其他整数)。
int *a;
float b;
double c;
long long d;这些sizeof是多少
int *a;
指向整数的指针。sizeof(int*) 取决于系统的位数,通常为 4 或 8 字节。
float b;
单精度浮点数。sizeof(float) 通常为 4 字节。
double c;
双精度浮点数。sizeof(double) 通常为 8 字节。
long long d;
一个长长整数。sizeof(long long) 通常为 8 字节。
struct A{
int* a;
char b[10];
int c;
float d
};这个sizeof是多少
int* a;
指向整数的指针。sizeof(int*) 取决于系统的位数,通常为 4 或 8 字节。
char b[10];
包含 10 个字符的数组。由于 char 是 1 字节,该数组的大小为 10 字节。
int c;
整数。sizeof(int) 通常为 4 字节。
float d;
单精度浮点数。sizeof(float) 通常为 4 字节。
二面
1、写一下拷贝构造函数
拷贝构造函数是一个特殊的构造函数,用于在创建对象时,以同类型的另一个对象为模板来初始化它。
给个例子:
#include <iostream>
#include <cstring>
class MyString {
private:
char *str;
public:
// 构造函数
MyString(const char *s) {
if (s) {
str = new char[strlen(s) + 1];
strcpy(str, s);
} else {
str = nullptr;
}
}
// 拷贝构造函数
MyString(const MyString &other) {
if (other.str) {
str = new char[strlen(other.str) + 1];
strcpy(str, other.str);
} else {
str = nullptr;
}
}
// 析构函数
~MyString() {
delete[] str;
}
// 打印字符串
void print() const {
std::cout << (str ? str : "Empty") << std::endl;
}
};
int main() {
// 使用构造函数创建对象
MyString str1("Hello");
// 使用拷贝构造函数创建对象
MyString str2 = str1;
// 打印结果
std::cout << "str1: ";
str1.print();
std::cout << "str2: ";
str2.print();
return 0;
}2、写一下单例类
单例模式是一种设计模式,它确保一个类只有一个实例,并提供一个全局访问点。单例模式通常用于管理全局状态,配置或资源。
- 懒汉式(Lazy Initialization)单例模式:
懒汉式单例模式在需要时才创建单例实例。如果实例尚未创建,就创建并返回,否则直接返回现有的实例。这种方式在首次使用时才创建实例,延迟了对象的创建时间。
#include <iostream>
class LazySingleton {
private:
// 私有构造函数,防止外部创建实例
LazySingleton() {
std::cout << "LazySingleton instance created." << std::endl;
}
// 静态成员变量,用于保存唯一实例
static LazySingleton* instance;
public:
// 获取单例实例的静态方法
static LazySingleton* getInstance() {
if (!instance) {
instance = new LazySingleton();
}
return instance;
}
// 其他成员函数
void doSomething() {
std::cout << "LazySingleton is doing something." << std::endl;
}
};
// 初始化静态成员变量
LazySingleton* LazySingleton::instance = nullptr;
int main() {
// 获取单例实例
LazySingleton* instance1 = LazySingleton::getInstance();
LazySingleton* instance2 = LazySingleton::getInstance();
// 执行一些操作
instance1->doSomething();
instance2->doSomething();
// 输出结果应该表明 instance1 和 instance2 是同一个实例
return 0;
}- 饿汉式(Eager Initialization)单例模式:
饿汉式单例模式在类加载时就创建了单例实例。这样可以确保实例在首次使用之前就已经存在。饿汉式的缺点是在程序启动时就创建实例,可能会导致不必要的资源浪费。
#include <iostream>
class EagerSingleton {
private:
// 私有构造函数,防止外部创建实例
EagerSingleton() {
std::cout << "EagerSingleton instance created." << std::endl;
}
// 静态成员变量,用于保存唯一实例
static EagerSingleton* instance;
public:
// 获取单例实例的静态方法
static EagerSingleton* getInstance() {
return instance;
}
// 其他成员函数
void doSomething() {
std::cout << "EagerSingleton is doing something." << std::endl;
}
};
// 初始化静态成员变量
EagerSingleton* EagerSingleton::instance = new EagerSingleton();
int main() {
// 获取单例实例
EagerSingleton* instance1 = EagerSingleton::getInstance();
EagerSingleton* instance2 = EagerSingleton::getInstance();
// 执行一些操作
instance1->doSomething();
instance2->doSomething();
// 输出结果应该表明 instance1 和 instance2 是同一个实例
return 0;
}这两种单例模式各有优缺点,懒汉式节省了资源,但在首次使用时会稍微延迟,而饿汉式则在程序启动时就创建了实例,确保了实例的立即可用性。
题外话:还是第一次这样考C++基础的,不错
