小米C++开发岗一面面经
网络部分
来源:https://www.nowcoder.com/discuss/395324470116851712?sourceSSR=users
1、TCP通信的连接过程?
- 第一次握手: 客户端发送一个TCP报文段,其中包含一个SYN(同步)标志位,以及客户端的初始序列号(ISN,Initial Sequence Number)。这表示客户端请求建立连接,并设置了初始序列号,以便后续的数据传输。
- 第二次握手: 服务器接收到客户端的SYN后,确认收到,并发送回一个带有SYN和ACK(确认)标志位的报文段。服务器也会为自己设置一个初始序列号。
- 第三次握手: 客户端接收到服务器的SYN-ACK后,确认收到,并发送一个ACK标志位的报文段。这个报文段表示连接已经建立,双方可以开始进行数据传输。
此时,TCP连接已经建立,双方可以安全地传输数据。
1.1、三次握手可以减到两次吗?
在标准的 TCP 协议中,三次握手是用来建立连接的过程,它的设计是为了确保双方都能够正常接收对方的数据。因此,如果要保证连接的可靠性,三次握手是必要的,无法减少到两次。
1.2、TCP首部序号的主要作用?
TCP(传输控制协议)首部中的序号字段主要用于实现 TCP 的可靠性传输和流量控制。TCP 使用序号来标识每个发送的数据段,并且通过序号来实现以下功能:
- 顺序交付:TCP 通过序号确保接收方按照发送方发送数据的顺序进行接收和重组。每个数据段都有一个序号,接收方根据序号来确定数据段的顺序,以便正确地重组数据流。
- 重传机制:TCP 使用序号来实现重传机制。发送方在发送数据段时会为每个数据段分配一个序号,接收方收到数据后会发送确认(ACK)消息,确认收到的最后一个连续的数据段。如果发送方在一定时间内未收到确认消息,就会认为数据丢失,然后重新发送该数据段。
- 流量控制:TCP 使用序号来实现流量控制,即控制发送方发送数据的速率,以避免网络拥塞。接收方会通过确认消息告诉发送方自己的接收窗口大小(即可以接收的数据量),发送方根据接收窗口大小来调整发送数据的速率。
1.3、既然说了滑动窗口,简单介绍一下?
滑动窗口是 TCP 协议中用于流量控制和可靠传输的重要机制之一。它是通过动态调整发送方和接收方之间的数据传输窗口大小来实现的。
在 TCP 中,发送方和接收方都有一个接收窗口(Receiver Window)和发送窗口(Sender Window)。发送窗口指的是发送方可以发送的数据量,而接收窗口指的是接收方可以接收的数据量。
滑动窗口的工作方式如下:
- 初始状态:在建立 TCP 连接时,发送窗口和接收窗口的初始大小由双方协商确定,通常取决于网络条件和双方的处理能力。
- 动态调整:在数据传输过程中,发送方和接收方会根据接收方发送的确认消息(ACK)和窗口更新消息来动态调整窗口大小。接收方通过 ACK 消息告知发送方自己当前的接收窗口大小,而发送方根据接收方的窗口大小来调整自己的发送窗口大小。
- 流量控制:接收方的窗口大小可以用来实现流量控制。如果接收方的窗口大小变小,说明接收方的处理能力有限或者网络拥塞,发送方会相应地减小自己的发送窗口,以避免发送过多的数据导致丢包或者拥塞。反之,如果接收方的窗口大小变大,发送方可以增大自己的发送窗口,以提高传输效率。
- 顺序交付:通过滑动窗口机制,TCP 可以实现顺序交付。发送方和接收方通过窗口大小来控制数据的发送和接收顺序,从而保证数据的正确顺序传输。
1.4、请求和确认报文配合的机制,是等到确认报文返回了再发送下一个数据还是一直发等延迟确认?
在 TCP 中,发送方发送数据后,并不是一直等待确认报文返回后再发送下一个数据,也不是一直发送而不管接收方是否立即返回确认报文。实际上,TCP 使用了一种称为延迟确认(Delayed ACK)的机制来优化网络性能。
延迟确认的机制如下:
- 发送方发送数据:发送方发送数据后,并不立即等待确认报文返回,而是继续发送其他数据或者等待一段时间。
- 接收方收到数据:接收方收到数据后,不会立即发送确认报文,而是等待一段时间。这段时间通常很短,一般为几毫秒。
- 延迟发送确认:如果在这段时间内接收方有需要发送数据的情况,它会将确认报文与数据报文一起发送,这样可以减少网络上的确认报文数量,提高网络利用率。
- 定时发送确认:如果在延迟确认的时间内接收方没有需要发送的数据,那么它会在超时后发送一个确认报文,以确保发送方知道数据已经被正确接收。
延迟确认的机制可以减少网络上的确认报文数量,从而减少网络的负载,并且可以提高网络的吞吐量。然而,延迟确认也可能会引入一定的延迟,因为发送方在等待确认时可能会出现一定的等待时间。
2、浏览器里面输入baidu.com,TCP/IP协议栈中大概用到了哪几个协议?
- DNS 协议:首先,浏览器会将域名
baidu.com转换为对应的 IP 地址。这个转换过程涉及 DNS 协议,它负责将域名解析为 IP 地址,以便进行后续的通信。 - HTTP 协议:一旦浏览器获得了百度服务器的 IP 地址,它会使用 HTTP 协议来与服务器建立连接,并发送 HTTP 请求。HTTP 协议是用于在客户端和服务器之间传输超文本的应用层协议,它定义了客户端和服务器之间的通信格式和规则。
- TCP 协议:在 HTTP 请求中,通常使用 TCP 协议来建立可靠的连接。TCP 负责在网络上可靠地传输数据,它提供了数据的分段、传输控制、错误检测和重传等功能,以确保数据能够安全地从客户端发送到服务器。
- IP 协议:TCP 协议依赖于 IP 协议来进行数据包的传输。IP 协议负责在网络上寻址和路由,它将数据包从源地址传输到目标地址,同时负责分片和重组数据包。
- ARP 协议:如果目标 IP 地址在本地网络上,那么发送方需要使用 ARP 协议来将 IP 地址解析为对应的 MAC 地址,以便将数据包发送到正确的网络接口。
3、Linux如何把网络包发送出去?
- 构建数据包:首先,需要构建要发送的网络数据包。这通常涉及设置数据包的目标 IP 地址、源 IP 地址、目标端口号、源端口号以及数据内容等信息,具体构建方式可以使用相关的网络编程库或者系统调用来实现。
- 选择网络接口:接下来,需要选择用于发送数据包的网络接口。Linux 系统中的网络接口通常由网络设备(如网卡)表示,每个网络设备都有一个唯一的标识符(通常是接口名或者设备文件名)。
- 发送数据包:一旦构建了数据包并选择了网络接口,就可以使用系统调用(如
sendto或者write)将数据包发送到指定的目标地址。发送数据包的系统调用通常会指定目标地址和目标端口号,以及要发送的数据内容。 - 数据包在网络中传输:一旦数据包被发送到网络接口,Linux 内核会根据路由表等信息决定如何将数据包发送到目标主机。这可能涉及到 ARP 协议的使用(如果目标主机在本地网络中),以及 IP 协议和其他路由协议的处理。
- 数据包到达目标主机:最终,数据包会通过网络传输到达目标主机。目标主机的网络协议栈会根据目标端口号等信息将数据包交给相应的应用程序处理。
C/C++基础
1、变量类型及变量的存储位置及生命周期?
- 基本数据类型变量:比如
int、float、char等,它们通常被存储在栈(stack)中,它们的生命周期与所在的作用域(scope)相关联。当超出作用域时,这些变量会自动被销毁。 - 复合数据类型变量:比如数组、结构体、类等,它们的存储位置和生命周期取决于它们的定义方式。如果在函数内部定义,它们通常也会被存储在栈中,并且具有与作用域相关的生命周期。如果在全局作用域或者静态存储区定义,它们会被存储在静态存储区(static storage area)中,其生命周期与程序的运行周期相同。
- 指针类型变量:指针变量存储的是内存地址,它们的类型决定了指针所指向的数据类型。指针变量本身通常被存储在栈中,但指针所指向的数据可以位于堆(heap)或者静态存储区中,其生命周期取决于所指向的数据的存储位置和生命周期。
2、静态变量和全局变量的区别?
- 作用域:
- 全局变量:全局变量是定义在函数外部的变量,在整个程序中都是可见的,可以被程序中的任何函数访问。
- 静态变量:静态变量可以定义在函数内部或者全局作用域中。如果在函数内部定义,它们只在定义它们的函数中可见,称为局部静态变量;如果在全局作用域中定义,它们在整个程序中都是可见的,但是只能在定义它们的文件中访问,称为文件静态变量。
- 生命周期:
- 全局变量:全局变量的生命周期与程序的生命周期相同,它们在程序启动时被创建,在程序结束时被销毁。
- 静态变量:静态变量的生命周期与程序的生命周期相同,无论它们是局部静态变量还是文件静态变量,都在程序启动时被创建,在程序结束时被销毁。
- 存储位置:
- 全局变量:全局变量通常存储在静态存储区(static storage area)中,它们在程序启动时分配内存,在程序结束时释放内存。
- 静态变量:静态变量的存储位置取决于它们的定义方式。局部静态变量通常也存储在静态存储区中,而文件静态变量也存储在静态存储区中,但只能在定义它们的文件中访问。
- 初始化:
- 全局变量:全局变量可以在定义时进行初始化,如果没有显式初始化,则会被默认初始化为相应类型的零值。
- 静态变量:静态变量可以在定义时进行初始化,如果没有显式初始化,则会被默认初始化为相应类型的零值。局部静态变量只在第一次进入定义它们的函数时初始化,之后的函数调用不会重新初始化它们。
3、局部变量存储的位置?
局部变量(Local variable)通常存储在栈(Stack)中。栈是一种后进先出(LIFO)的数据结构,用于存储函数调用期间的局部变量、函数参数、返回地址等信息。
当一个函数被调用时,会在栈上分配一段内存用于存储该函数的局部变量。当函数执行完毕时,这些局部变量所占用的内存空间会被自动释放,这是因为栈的特性决定了函数的生命周期与函数的调用周期相对应。
局部变量的存储位置和生命周期受限于函数的作用域和执行流程,它们只在函数内部可见,出了函数的作用域就无法访问。每次函数调用都会创建一个新的栈帧(stack frame),其中包含了该函数的局部变量、参数等信息,函数返回时栈帧会被销毁,局部变量也随之消失。
4、#define和const int定义宏的区别,你更倾向于哪种?
这里说说这两种的区别吧。
- 作用域:
#define:#define是预处理器指令,它在预处理阶段将文本替换为指定的内容,没有作用域的概念,替换发生在代码编译之前。const int:const int是 C++ 中的关键字,用于定义具有作用域的常量,其作用域通常与其定义的位置相关联。
- 类型安全:
#define:#define定义的常量没有类型,它们只是简单的文本替换,可能导致类型不安全的问题。const int:const int定义的常量是有类型的,编译器会对其进行类型检查,可以提供类型安全的保证。
- 调试和可读性:
#define:#define定义的常量只是简单的文本替换,可能会导致调试时的困难和可读性不佳。const int:const int定义的常量具有更好的调试性和可读性,因为它们是真正的变量,有类型信息,可以在代码中进行正常的引用和使用。
5、看代码,改错
// 一阶段
char* get_buffer(const int size)
{
char a[size];
return a;
}
// 二阶段
char* get_buffer()
{
static int char a[10];
return a;
}一阶段:
get_buffer 函数试图返回一个指向局部变量 a 的指针,这是不安全的,因为当函数返回后,a 的生命周期结束,指向它的指针将变成悬空指针(dangling pointer),访问它将导致未定义的行为。
为了修正这个问题,你可以将 a 定义为动态分配的数组,这样它的生命周期将延长到其所在的作用域结束。
char* get_buffer(const int size)
{
char* a = new char[size]; // 使用动态内存分配
return a;
}使用了 new 运算符来动态分配大小为 size 的字符数组,并返回了指向这个动态数组的指针。调用者在使用完返回的指针后,应该使用 delete[] 运算符来释放动态分配的内存,以避免内存泄漏。
二阶段:
a 被定义为 static int char a[10];,这有两个问题:
int和char类型修饰符的位置错误,应该是char在前面,int在后面,应该是static char a[10];。static修饰符使得a成为静态变量,它的生命周期延长到程序的整个运行周期。但是由于a是一个数组,而且在函数内部定义,因此它的作用域仍然是在函数内部,不能直接返回其指针。
可以将 a 定义为 static,并且将其声明为 static char a[10]; 放在函数外部,这样它就成为了一个全局的静态数组,并且可以在函数外部访问。
static char a[10];
char* get_buffer()
{
return a;
}6、提出需求,返回一个指定字节大小的内存,函数返回值代表成功与否,0代表成功,1代表失败
int get_mem(char **a, int size)
{
*a = new char[size];
if ((*a) != nullptr)
{
return 0; // success
}
else
{
return 1; // failed
}
}首先介绍思路,根据malloc或者new的返回值或者异常进行代码编写。
首先通过 new 运算符动态分配了 size 个字节的内存,并将指针 *a 指向这块内存。然后我们检查 *a 是否为 nullptr,如果不是,则说明内存分配成功,返回 0;如果是,则说明内存分配失败,返回 1。
7、构造函数中有没有什么禁忌的操作,什么操作不能在构造函数里做?
- 调用虚函数:在构造函数中调用虚函数可能会导致意想不到的行为,因为在构造函数执行期间,对象的派生类部分还没有初始化完成,此时调用虚函数可能会导致调用的是基类的实现,而不是派生类的实现。
- 抛出异常:如果在构造函数中抛出异常,那么对象的析构函数将不会被调用,这可能导致资源泄漏或者其他问题。
- 使用未初始化的成员变量:构造函数负责初始化对象的成员变量,如果在构造函数中使用未初始化的成员变量,可能会导致未定义的行为。
- 访问其他未完成构造的对象:在构造函数中访问其他未完成构造的对象可能会导致不确定的行为,因为对象的构造顺序并不总是确定的。
- 执行复杂的逻辑:构造函数应该尽量保持简单,不应该执行过于复杂的逻辑或者耗时的操作,以确保对象的正确初始化和高效构造。
8、构造函数产生异常如何处理,你在构造函数中分配内存会出现异常吗?如何处理?
在 C++ 中,如果构造函数产生异常,会导致对象的析构函数不会被调用,从而可能导致资源泄漏或者其他问题。因此,在构造函数中需要特别小心地处理可能导致异常的情况。
如果在构造函数中进行了内存分配操作(比如使用 new 运算符),并且这些操作可能会抛出异常,那么可以通过以下方式来处理:
- 使用智能指针: 推荐使用智能指针(如
std::unique_ptr、std::shared_ptr等)来管理动态分配的内存,这样可以避免手动释放内存的问题。智能指针会在对象生命周期结束时自动释放内存,即使构造函数中抛出异常,也可以保证资源的正确释放。 - 使用 RAII(资源获取即初始化): 通过 RAII 的方式管理资源,在对象的构造函数中进行资源分配,并在对象的析构函数中进行资源释放。这样可以确保资源在对象生命周期结束时被正确释放,即使构造函数中抛出异常。
- 在构造函数中捕获异常并处理: 如果无法使用智能指针或者 RAII,可以在构造函数中捕获可能抛出的异常,并在适当的地方进行处理或者回滚操作。这种方式需要特别小心,确保异常被正确地处理,不会导致资源泄漏或者对象状态不一致的问题。
给个简单的示例,演示了如何在构造函数中使用智能指针来管理动态分配的内存,并处理可能抛出的异常:
#include <memory>
#include <iostream>
class MyClass {
public:
MyClass() {
try {
// 在构造函数中使用智能指针管理动态分配的内存
ptr = std::make_unique<int[]>(10);
// 这里可以进行其他初始化操作
} catch (const std::bad_alloc& e) {
// 处理内存分配失败的情况
std::cerr << "Memory allocation failed: " << e.what() << std::endl;
// 可以在这里进行清理操作,或者重新抛出异常
throw; // 重新抛出异常
}
}
private:
std::unique_ptr<int[]> ptr;
};
int main() {
try {
MyClass obj; // 在对象创建时可能抛出异常
} catch (const std::exception& e) {
// 处理构造函数中抛出的异常
std::cerr << "Exception caught: " << e.what() << std::endl;
}
return 0;
}9、构造函数中进行文件操作,有没有风险,打开文件这种?
在构造函数中进行文件操作(比如打开文件)是有一定风险的,需要特别小心处理。这是因为构造函数在对象被创建时就会被调用,在构造函数中进行的文件操作可能会导致一些问题,比如:
- 资源泄漏: 如果在构造函数中打开文件,并且在析构函数中关闭文件,那么在构造函数中发生异常时,析构函数可能不会被调用,导致文件句柄未被关闭,从而造成文件资源泄漏。
- 异常安全性: 如果在构造函数中发生异常,对象可能无法成功创建,但是由于对象已经部分构造完成,可能会导致对象处于不一致的状态。这可能会导致程序的不确定行为。
- 性能问题: 如果在构造函数中进行了耗时的文件操作,可能会影响对象的创建性能,导致对象创建时间过长。
为了避免这些问题,可以考虑以下几点:
- 使用 RAII(资源获取即初始化): 在 C++ 中,可以使用 RAII 来管理资源,比如使用智能指针管理动态分配的内存,或者使用文件流对象来管理文件的打开和关闭操作。这样可以确保资源在对象生命周期结束时被正确释放,避免资源泄漏和不一致状态的问题。
- 避免在构造函数中进行复杂的操作: 构造函数应该尽量保持简单,不应该进行过于复杂或者耗时的操作,以提高对象的创建性能和稳定性。
- 使用异常安全的代码: 如果在构造函数中进行了可能抛出异常的操作,需要确保代码具有异常安全性,即使在发生异常时也能够保证程序状态的一致性。
10、什么情况下析构函数需要为虚函数?
在 C++ 中,当一个类可能会被继承,并且使用基类指针或引用指向派生类对象时,通常需要将析构函数声明为虚函数。这样做的主要原因是为了确保在使用多态时能够正确地销毁对象,避免内存泄漏和未定义行为。
具体来说,当满足以下条件时,析构函数应该声明为虚函数:
- 存在继承关系: 如果一个类可能会被其他类继承,那么它的析构函数通常应该声明为虚函数。
- 使用基类指针或引用指向派生类对象: 当使用基类指针或引用指向派生类对象,并通过基类指针或引用调用析构函数时,如果析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类中的资源没有被正确释放,造成资源泄漏或者未定义行为。
- 希望正确地销毁对象: 如果希望在使用多态时能够正确地销毁对象,保证派生类中的资源得到正确释放,那么应该将基类的析构函数声明为虚函数。
11、你聊到了虚表和虚指针?
当一个类中包含虚函数时,C++ 编译器会为该类生成一个虚表(virtual table),也称为虚函数表。虚表是一个存储指向虚函数的指针的数组,其中每个指针都指向对应虚函数的实际实现。这个虚表是在编译阶段创建的,每个类只有一个虚表,其中存储了该类所有虚函数的地址。
在使用多态(Polymorphism)时,当基类指针或引用指向派生类对象时,通过这个基类指针或引用调用虚函数时,实际上是通过虚表来确定要调用的函数。编译器会根据对象的实际类型,在运行时动态地查找虚表,找到对应的虚函数并调用之。
为了支持动态绑定(Dynamic Binding),每个对象都包含一个指向其虚表的指针,这个指针被称为虚指针(vptr)。虚指针在对象的布局中通常位于对象的起始位置(或者其他位置,具体取决于编译器和平台)。当对象被创建时,虚指针会被初始化为指向其类的虚表。这样,在调用虚函数时,可以通过对象的虚指针找到对应的虚表,并在虚表中查找要调用的函数。
12、定义宏求两个变量的和
定义一个宏:
#define SUM(x, y) ((x) + (y))这个宏接受两个参数 x 和 y,并返回它们的和。在使用时,你可以像下面这样使用宏:
int a = 5;
int b = 10;
int result = SUM(a, b); // result 的值为 15SUM(a, b) 会被展开为 ((a) + (b)),最终得到 5 + 10,结果为 15。
13、写个函数指针,调用这个函数?
给个例子:
#include <iostream>
// 定义一个函数,用于计算两个整数的和
int add(int a, int b) {
return a + b;
}
int main() {
// 定义一个函数指针,指向 add 函数的地址
int (*ptr)(int, int) = &add;
// 使用函数指针调用 add 函数,并输出结果
int result = (*ptr)(5, 10);
std::cout << "The sum is: " << result << std::endl;
return 0;
}首先定义了一个函数 add,用于计算两个整数的和。然后在 main 函数中,我们定义了一个函数指针 ptr,并将其指向 add 函数的地址。接下来,我们使用函数指针 ptr 来调用 add 函数,并将结果存储在 result 变量中。最后,我们输出了结果。
14、结构体对齐的问题?
在 C/C++ 中,结构体(struct)的对齐(alignment)是指编译器如何在内存中排列结构体的成员变量以及如何分配内存空间。结构体对齐的目的是为了提高内存访问的效率,尤其是在处理大量数据的时候。
结构体对齐的规则通常由编译器和编译选项来决定,不同的编译器可能会有不同的默认对齐方式。一般来说,结构体的对齐规则包括以下几个方面:
- 成员变量的对齐规则: 结构体的成员变量通常会按照其自身的对齐要求进行排列。比如,一个
char类型的变量通常只需要按字节对齐,而一个int类型的变量可能需要按照其大小(通常是 4 字节或 8 字节)对齐。 - 结构体整体的对齐规则: 结构体的整体对齐通常是按照其最大对齐要求的成员变量来决定的。也就是说,结构体的对齐大小通常是其成员变量中最大的对齐要求的大小。
- 对齐指令和属性: 一些编译器提供了对齐指令或者属性,可以用来控制结构体的对齐方式。比如,
#pragma pack指令可以用来设置结构体的对齐方式。 - 填充字节: 为了满足对齐要求,编译器可能会在结构体的成员变量之间插入一些填充字节,使得每个成员变量的起始地址都满足其对齐要求。
15、构造函数初始化,函数体中初始化和成员列表初始化的区别?
在 C++ 中,可以使用成员初始化列表(member initializer list)或者在构造函数的函数体中对类的成员变量进行初始化。这两种方式都可以用来初始化类的成员变量,但它们有一些区别。
- 成员初始化列表:
- 成员初始化列表是在构造函数的参数列表后面使用冒号(:)来初始化类的成员变量。
- 它可以在类的构造函数中对所有成员变量进行初始化。
- 成员初始化列表的执行顺序与成员变量在类中的声明顺序一致,而不是与它们在成员初始化列表中的顺序一致。
- 优点是可以避免不必要的默认构造和赋值操作,提高了代码的执行效率。
- 构造函数体中初始化:
- 在构造函数的函数体中,可以使用赋值操作符(=)来对成员变量进行初始化。
- 这种方式可以在构造函数的函数体中对成员变量进行更加复杂的初始化操作,比如需要根据其他参数计算出初始值的情况。
- 缺点是可能会导致不必要的默认构造和赋值操作,降低了代码的执行效率。
16、指针和引用的区别?
- 语法和操作符:
- 指针使用
*来声明和解引用,使用&来获取地址。 - 引用使用
&来声明,没有解引用操作符,因为引用本身就是一个别名。
- 指针使用
- 内存分配:
- 指针是一个独立的对象,它存储了另一个对象的地址。
- 引用是被绑定到另一个对象的别名,它并不占据额外的内存空间。
- 空值(Null):
- 指针可以是空指针(nullptr),表示不指向任何有效的对象。
- 引用必须在声明时被初始化,并且不能在后续的使用中改变引用的目标对象,因此不存在空引用。
- 重新赋值:
- 指针可以被重新赋值指向不同的对象。
- 引用在声明后不能改变其绑定的对象,一旦引用被初始化,它将一直引用同一个对象。
- 用法和语义:
- 指针通常用于需要动态分配内存、可选地指向不同对象或者需要在运行时改变指向的情况。
- 引用通常用于函数参数传递、返回值、避免复制大型对象等场景,它提供了更直观的语义和更安全的行为。
17、引用的底层是什么?一个程序里十万个引用和十万个指针消耗内存是一样的?
在 C++ 中,引用的底层实现通常是通过指针来实现的,但是在代码编译和执行过程中,编译器会对引用进行一些优化,使得在使用上表现得更加像一个常规的变量别名,而不是一个指针。
具体来说,对于一个引用,编译器在生成对应的机器码时,可能会将其实现为一个指针,但是在编程中使用引用时,它的行为更接近于一个常规的变量别名,而不需要像指针一样进行显式的解引用操作。
由于引用在语义上更接近于变量别名,因此在使用引用时不需要像指针一样考虑指针的操作(比如取地址、解引用等),这使得引用在某些情况下更加方便和直观。同时,引用也具有一些限制,比如在声明时必须初始化,并且不能在后续的使用中改变引用的目标对象,这些限制也使得引用在使用上更加安全。
在大多数现代计算机架构中,一个指针通常占据与机器的地址长度相同的内存空间,比如在 32 位系统上,一个指针通常占据 4 个字节;在 64 位系统上,一个指针通常占据 8 个字节。同样地,一个引用也通常占据与指针相同的内存空间。
因此,如果一个程序里有十万个引用和十万个指针,它们的内存消耗在理论上应该是相同的。
然而,在实际情况中,不同的编译器和优化设置可能会对内存消耗产生一些微小的差异,例如编译器可能会对一些引用进行优化,使得它们不需要额外的内存空间来存储地址,但这种差异通常是微不足道的。
操作系统
1、进程上下文的概念?
在操作系统中,进程上下文(Process Context)是指操作系统为了管理和执行进程而保存的一组状态信息的集合。这些状态信息包括了进程的执行环境、资源使用情况、以及当前的执行状态等,它们共同描述了一个进程在某一时刻的完整状态。
进程上下文通常包括以下内容:
- 处理器状态:这包括了处理器寄存器的内容,如程序计数器(PC)、堆栈指针(SP)、通用寄存器(如 AX、BX、CX、DX 等)等。这些寄存器记录了进程当前的执行位置、栈的状态以及其他临时数据。
- 内存管理信息:这包括了进程的地址空间布局、页表信息、内存映射关系等,用于描述进程的内存使用情况以及操作系统对其内存的管理方式。
- 打开文件描述符表:这是一个记录了进程打开的文件和网络连接的表格,用于描述进程当前的文件和网络状态。
- 信号处理器状态:这包括了进程注册的信号处理器(Signal Handler)以及当前等待处理的信号,用于描述进程对信号的处理方式和当前的信号状态。
- 调度信息:这包括了进程的调度参数、优先级等信息,用于描述操作系统对进程调度的方式和策略。
2、什么时候会触发进程上下文切换?
进程上下文切换是操作系统进行多任务处理时的重要概念,它指的是在一个 CPU 上从一个进程切换到另一个进程时,需要保存当前进程的状态并加载另一个进程的状态的过程。进程上下文切换通常发生在以下几种情况下:
- 时间片耗尽: 当一个进程的时间片(Time Slice)用尽时,操作系统会暂停当前进程的执行,并将 CPU 资源分配给下一个需要执行的进程,这时就会发生进程上下文切换。
- 阻塞和唤醒: 当一个进程因为等待某种事件(比如 I/O 操作、信号等)而被阻塞时,操作系统会暂停该进程的执行,并将 CPU 资源分配给其他可以执行的进程。当等待的事件发生时,操作系统会唤醒被阻塞的进程,并在合适的时机进行进程上下文切换,以便该进程可以继续执行。
- 多核调度: 在多核处理器系统中,操作系统可以同时在多个核上执行多个进程,这时会涉及到进程之间的调度和切换,需要进行进程上下文切换来实现多个进程的并发执行。
- 中断处理: 当 CPU 接收到外部硬件设备发来的中断信号时,操作系统会根据中断类型进行相应的处理,可能需要切换到中断处理程序来处理中断事件,这也会导致进程上下文的切换。
3、进程间通信方式中,哪种你用的最多?
- 管道:管道是一种半双工的通信方式,用于在相关进程之间进行通信。它分为匿名管道和命名管道两种。匿名管道通常用于具有亲缘关系的父子进程之间进行通信,而命名管道则可以在无关联的进程之间进行通信。
- 命名管道:命名管道是一种特殊的文件,可以在无关联的进程之间进行通信。与匿名管道不同,命名管道使用文件系统作为中介,因此可以用于不相关的进程之间进行通信。
- 消息队列:消息队列是一种通过消息传递进行通信的机制,它允许一个进程向另一个进程发送数据。消息队列通常由操作系统内核维护,不同进程可以通过消息队列进行通信。
- 信号量:信号量是一种用于控制对共享资源的访问的机制,它可以用于多个进程之间的同步和互斥。通过信号量可以实现进程之间的协调和通信。
- 共享内存:共享内存是一种允许多个进程共享同一块物理内存的机制,它可以实现进程之间高效的数据共享,但需要通过其他方式来实现进程同步。
- 套接字:套接字是一种通用的进程间通信机制,通常用于网络编程中,但也可以用于在同一台计算机上不同进程之间进行通信。
- 信号:信号是一种异步的通信机制,用于通知进程发生了某个事件。进程可以通过注册信号处理函数来处理收到的信号。
进程A和进程B如何建立联系?
进程 A 和进程 B 可以通过多种方式建立联系和进行通信,具体的选择取决于它们之间需要交换的数据类型、通信的性质以及系统的特性。以下是一些常见的建立联系和通信的方式:
- 管道(Pipe): 如果进程 A 和进程 B 是具有亲缘关系的父子进程,可以使用管道进行通信。管道可以在父子进程之间传递数据,它是一种半双工的通信方式,数据只能在一个方向上传输。
- 命名管道(FIFO): 如果进程 A 和进程 B 是无关联的进程,可以使用命名管道进行通信。命名管道是一种特殊的文件,允许不相关的进程之间进行通信。
- 消息队列(Message Queue): 如果进程 A 和进程 B 需要通过消息传递进行通信,可以使用消息队列。消息队列允许一个进程向另一个进程发送消息,它是一种异步的通信方式。
- 信号量(Semaphore): 如果进程 A 和进程 B 需要进行进程间的同步和互斥控制,可以使用信号量。信号量可以用于控制对共享资源的访问,实现进程间的协调。
- 共享内存(Shared Memory): 如果进程 A 和进程 B 需要共享大量的数据,可以使用共享内存。共享内存允许多个进程共享同一块物理内存,可以实现高效的数据共享。
- 套接字(Socket): 如果进程 A 和进程 B 需要在不同的主机上进行通信,可以使用套接字。套接字是一种通用的网络通信方式,可以在网络中的不同主机上的进程之间进行通信。
- 文件: 进程 A 和进程 B 可以通过文件进行通信,其中一个进程写入文件,另一个进程读取文件。这种方式适用于需要持久化存储数据的场景。
内存映射是基于什么实现的?
内存映射(Memory Mapping)是一种让文件或者其他对象(比如设备)映射到进程地址空间的机制,使得进程可以直接访问这些对象,而无需进行繁琐的读写操作。内存映射是基于操作系统提供的虚拟内存管理机制实现的。
在内存映射中,操作系统会将文件或者其他对象的一部分或全部内容映射到进程的虚拟地址空间中,使得这部分内容在逻辑上变成了进程的一部分内存,进程可以像访问内存一样访问这些内容。当进程对这些映射区域进行读写操作时,实际上是在访问文件或者其他对象,而操作系统会负责将这些操作转换为对底层对象的读写操作。
内存映射通常通过以下步骤实现:
- 创建映射区域:进程通过调用操作系统提供的接口(比如
mmap函数)来请求创建一个映射区域,指定要映射的对象(比如文件)、映射的起始位置、映射的大小等参数。 - 建立映射关系:操作系统会根据进程的请求,在进程的虚拟地址空间中创建一个映射区域,并将指定的对象内容映射到这个区域中。
- 访问映射区域:进程可以通过指针访问映射区域中的内容,就像访问普通的内存一样。当进程对映射区域进行读写操作时,操作系统会负责将这些操作转换为对底层对象的读写操作。
- 撤销映射关系:当不再需要映射区域时,进程可以通过调用操作系统提供的接口(比如
munmap函数)来撤销映射关系,释放相应的资源。
4、进程A和进程B对于共享内存之间通过什么共同管理?
当进程 A 和进程 B 需要共享内存时,它们之间需要通过一些机制来共同管理共享内存的访问和使用。这些机制通常包括以下几个方面:
- 内存映射描述符:内存映射描述符是操作系统提供的用于管理共享内存的数据结构,它包含了共享内存的起始地址、大小等信息。进程 A 和进程 B 都需要通过内存映射描述符来访问共享内存,操作系统会根据这些描述符来管理共享内存的访问权限和使用情况。
- 同步机制:进程 A 和进程 B 需要通过同步机制来确保在对共享内存进行读写操作时不会发生冲突。常见的同步机制包括信号量(Semaphore)、互斥锁(Mutex)等,它们可以用来实现进程间的互斥访问共享内存,防止多个进程同时修改共享内存导致的数据不一致问题。
- 通信协议:进程 A 和进程 B 需要通过一定的通信协议来约定共享内存的使用方式和数据格式。通信协议可以包括数据的格式、读写的顺序、数据的同步方式等,它们可以帮助进程 A 和进程 B 在共享内存的使用过程中进行有效的沟通和协作。
- 错误处理机制:进程 A 和进程 B 需要共同管理共享内存的错误处理机制,包括对内存访问权限的检查、对异常情况的处理等。在共享内存的使用过程中,可能会出现各种错误情况,进程 A 和进程 B 需要通过错误处理机制来保证共享内存的安全使用。
5、共享内存怎么创建?
在 Unix/Linux 系统中,可以使用 shmget 函数来创建共享内存。该函数的原型如下:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);其中:
key是用于标识共享内存的键值,可以使用ftok函数生成一个键值。size是共享内存的大小,以字节为单位。shmflg是创建共享内存的标志,可以是IPC_CREAT表示如果不存在则创建共享内存,或者与其他标志进行位运算组合。
成功创建共享内存后,shmget 函数将返回一个共享内存标识符(即共享内存的 ID),该标识符可以用于后续的共享内存操作。
以下是一个简单的示例,演示了如何使用 shmget 函数创建共享内存:
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
int main() {
key_t key = ftok("/tmp", 'A'); // 生成键值
int size = 1024; // 共享内存大小,以字节为单位
int shmflg = IPC_CREAT | 0666; // 创建共享内存的标志
// 创建共享内存
int shmid = shmget(key, size, shmflg);
if (shmid == -1) {
perror("shmget");
return 1;
}
printf("Shared memory created with ID: %d\n", shmid);
return 0;
}6、系统调用的过程?
系统调用是用户空间程序与操作系统内核之间进行交互的一种机制,用于让用户空间程序能够访问操作系统提供的服务和资源。系统调用的过程一般包括以下几个步骤:
- 触发系统调用:用户空间程序通过调用标准库函数(如
read、write、open等)或者汇编指令(如int 0x80或syscall)来请求操作系统执行特定的操作,比如打开文件、读取数据等。 - 切换到内核模式:当用户空间程序触发系统调用时,CPU 会从用户态切换到内核态,这涉及到特权级别的切换(比如从用户态切换到内核态需要从用户态的特权级别切换到内核态的特权级别)。
- 系统调用号识别:操作系统内核根据系统调用号(每个系统调用都有一个唯一的编号)来识别用户程序请求的具体操作。
- 参数传递:用户程序需要将系统调用所需的参数传递给内核。这些参数通常通过寄存器传递,或者存放在特定的内存位置中,以便内核能够获取到这些参数。
- 执行系统调用:内核根据系统调用号和参数执行相应的操作,比如打开文件、读取数据等。在执行过程中,内核可能会访问硬件设备、修改进程的状态等。
- 返回结果:内核执行完系统调用后,将结果返回给用户程序。这通常是通过寄存器返回的,或者将结果存放在用户程序提供的内存空间中。
- 恢复用户态:内核将 CPU 从内核态切换回用户态,用户程序可以继续执行后续的指令。
