仅供个人参考学习交流使用,内容来自课本和网络 笔者@StuBoo
软件设计
Q1 面向对象的设计原则有哪些
- 单一职责原则 (Single Responsibility Principle - SRP):
意义:一个类应该有且仅有一个引起变化的原因。即,一个类应该只负责一项职责。
解释:将一个类的功能分解为更小的单元,每个单元负责一项特定的职责。这样做有助于降低类的复杂性,使得修改和维护更加容易。 - 开放/封闭原则 (Open/Closed Principle - OCP):
意义:软件实体应该对扩展开放,对修改关闭。可以通过添加新的代码来扩展系统的功能,而不是修改已有的代码。
解释:当需要添加新功能时,应该通过扩展现有代码而不是修改它来实现。这可以通过抽象和接口来实现,确保系统的稳定性和可维护性。 - 里氏替换原则 (Liskov Substitution Principle - LSP):
意义:所有引用基类的地方必须能够透明地使用其子类的对象。
解释:子类应该能够替代父类而不引起错误。使用继承时,确保子类能够完全符合父类的行为,不破坏系统的一致性。 - 依赖倒置原则 (Dependency Inversion Principle - DIP):
意义:高层模块不应该依赖于低层模块,两者都应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
解释:通过面向接口编程,使得高层模块和低层模块都依赖于抽象。这样可以减少模块间的耦合,提高系统的灵活性和可维护性。 - 接口隔离原则 (Interface Segregation Principle - ISP):
意义:不应该强迫一个类实现它并不需要的接口。客户端不应该被迫依赖于它们不使用的接口。
解释:将大接口拆分为更小、更具体的接口,确保每个类只需实现它需要的接口。这有助于避免类与不相关的接口产生耦合关系。 - 合成/聚合复用原则 (Composition Reuse Principle - CARP):
意义:首选使用合成/聚合,而不是继承来达到代码复用的目的。
解释:通过组合现有的对象来创建新的对象,而不是通过继承现有的对象。这可以减少类之间的依赖关系,提高系统的灵活性,并避免一些继承的问题,如过度耦合。
Q2 有那些常见的设计模式
- 单例模式 (Singleton Pattern):保证一个类仅有一个实例,并提供一个全局访问点。
- 工厂模式 (Factory Pattern):定义一个用于创建对象的接口,但让子类决定实例化哪个类。工厂方法使得一个类的实例化延迟到其子类。
- 抽象工厂模式 (Abstract Factory Pattern):提供一个接口,用于创建与产品家族相关或依赖的对象,而不需要指定具体类。
- 建造者模式 (Builder Pattern):将一个复杂对象的构建与其表示分离,使得同样的构建过程可以创建不同的表示。
- 原型模式 (Prototype Pattern):用于创建对象的种类,通过拷贝原型而非新建实例。
- 适配器模式 (Adapter Pattern):将一个类的接口转换成客户端期望的另一个接口。适配器让原本不兼容的类可以合作无间。
- 装饰器模式 (Decorator Pattern):动态地给一个对象添加一些额外的职责。就扩展功能而言,装饰器模式比生成子类更为灵活。
- 观察者模式 (Observer Pattern):定义对象间的一种一对多的依赖关系,使得当一个对象的状态改变时,所有依赖于它的对象都会得到通知并自动更新。
- 策略模式 (Strategy Pattern):定义一系列算法,将每个算法封装起来,并使它们可以互相替换。策略模式使得算法可以独立于使用它的客户端而变化。
- 模板方法模式 (Template Method Pattern):定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下重定义该算法的某些步骤。
Q3 解释单例模式的懒汉式和饿汉式,相关线程安全
懒汉式(Lazy Initialization):
在需要时才创建实例,如果实例不存在。懒汉式在第一次调用时才进行实例化。
#include <iostream>
#include <mutex>
class LazySingleton {
private:
static LazySingleton* instance;
static std::mutex mutex;
LazySingleton() {}
public:
static LazySingleton* getInstance() {
std::lock_guard<std::mutex> lock(mutex);
if (instance == nullptr) {
instance = new LazySingleton();
}
return instance;
}
};
LazySingleton* LazySingleton::instance = nullptr;
std::mutex LazySingleton::mutex;
int main() {
LazySingleton* lazyInstance1 = LazySingleton::getInstance();
LazySingleton* lazyInstance2 = LazySingleton::getInstance();
std::cout << (lazyInstance1 == lazyInstance2 ? "Same instance" : "Different instances") << std::endl;
return 0;
}
饿汉式(Eager Initialization):
在类加载时就创建实例,即使不使用。饿汉式在程序启动时就进行实例化。
#include <iostream>
#include <mutex>
class EagerSingleton {
private:
static EagerSingleton* instance;
static std::mutex mutex;
EagerSingleton() {}
public:
static EagerSingleton* getInstance() {
std::lock_guard<std::mutex> lock(mutex);
return instance;
}
};
EagerSingleton* EagerSingleton::instance = new EagerSingleton();
std::mutex EagerSingleton::mutex;
int main() {
EagerSingleton* eagerInstance1 = EagerSingleton::getInstance();
EagerSingleton* eagerInstance2 = EagerSingleton::getInstance();
std::cout << (eagerInstance1 == eagerInstance2 ? "Same instance" : "Different instances") << std::endl;
return 0;
}
懒汉式:延迟实例化,只有在需要时才创建实例。可能存在多线程安全性问题。
饿汉式:在程序启动时就创建实例。天生线程安全,但可能浪费资源,因为实例在整个程序周期内都存在。
线程安全保证:
懒汉式:可以通过加锁(如上述示例中的 std::mutex)来确保线程安全,但可能会降低性能。
饿汉式:天生线程安全,不需要额外的线程安全措施。
双重检查锁定(Double-Checked Locking):
在懒汉式中,为了提高性能,可以使用双重检查锁定来减少锁的开销。即在实例为null的情况下,再进行一次加锁检查。
#include <iostream>
#include <mutex>
class LazySingleton {
private:
static LazySingleton* instance;
static std::mutex mutex;
LazySingleton() {}
public:
static LazySingleton* getInstance() {
if (instance == nullptr) {
std::lock_guard<std::mutex> lock(mutex);
if (instance == nullptr) {
instance = new LazySingleton();
}
}
return instance;
}
};
LazySingleton* LazySingleton::instance = nullptr;
std::mutex LazySingleton::mutex;
int main() {
LazySingleton* lazyInstance1 = LazySingleton::getInstance();
LazySingleton* lazyInstance2 = LazySingleton::getInstance();
std::cout << (lazyInstance1 == lazyInstance2 ? "Same instance" : "Different instances") << std::endl;
return 0;
}
C++11及之后的版本对静态局部变量的初始化已经有了线程安全的保证。
在C++11及之后的标准中,通过标准中引入的一些语言特性,主要的机制是使用了“线程安全的函数级别的初始化”(Thread-safe function-level static initialization)。
在C++11之前,对于静态局部变量的初始化,存在竞态条件,即多个线程可能同时尝试初始化同一个静态局部变量,从而导致未定义的行为。
C++11之后的线程安全初始化:
- 控制流程:在第一次执行到静态局部变量的初始化语句时,会执行初始化,并且在初始化完成后,标记该静态局部变量为已初始化。
- 原子操作:在初始化过程中,使用原子操作确保只有一个线程执行初始化,其他线程在此期间等待。
- 双重检查:引入了一种双重检查机制,确保只有在静态局部变量尚未初始化的情况下才执行初始化。这样,已经初始化的线程无需再次执行初始化。
这样的机制在一定程度上提高了性能,因为只有在第一次需要使用静态局部变量时才进行初始化,而不是在程序启动时一次性初始化所有静态局部变量。这种线程安全的初始化机制是C++标准库中所规定的,编译器和运行时库会负责实现这一机制。
Q4 抽象工厂模式和工厂模式,应用了哪些设计原则
抽象工厂模式和工厂模式同属于创建型设计模式。
工厂模式(Factory Pattern):
- 主要原则: 工厂模式主要应用了工厂方法模式,其中定义了一个接口,但让子类决定实例化哪个类。这符合开放/封闭原则,允许添加新的产品类而不修改现有代码。
- 应用设计原则: 开放/封闭原则
抽象工厂模式(Abstract Factory Pattern):
- 主要原则: 抽象工厂模式应用了抽象工厂模式,其中提供一个接口,用于创建与产品家族相关或依赖的对象。这同样符合开放/封闭原则,并且遵循单一职责原则,因为每个具体工厂负责创建一族产品。
- 应用设计原则: 开放/封闭原则,单一职责原则
Q5 什么是适配器模式,什么是组合模式
适配器模式和组合模式都是面向对象设计模式,用于解决不同问题的设计需求。
适配器模式(Adapter Pattern):
定义:适配器模式是一种结构型设计模式,它允许接口不兼容的类能够一起工作。它充当两个不同接口之间的桥梁,允许它们协同工作而无需修改其源代码。
主要组成部分:
- 目标接口(Target Interface):客户端期望的接口。
- 适配器(Adapter):实现目标接口,并持有对适配者的引用,将适配者的接口转换为目标接口。
- 适配者(Adaptee):原本不兼容目标接口的类。
应用场景: 当系统中的一个类需要与另一个类协同工作,而它们之间的接口不兼容时,可以使用适配器模式。
组合模式(Composite Pattern):
定义:组合模式是一种结构型设计模式,它允许将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得客户端对单个对象和组合对象的使用具有一致性。
主要组成部分:
- 组件(Component):声明了组合对象和叶子对象的公共接口。
- 叶子(Leaf):没有子节点的对象。
- 组合(Composite):具有子节点的对象,可以包含叶子和其他组合。
应用场景: 当希望客户端能够一致地对待单个对象和组合对象时,可以使用组合模式。它常用于处理树形结构、图形结构或其他层次结构的问题。
两者的区别在于适配器模式解决的是接口不兼容的问题,充当两个不同接口之间的桥梁。
组合模式解决的是“部分-整体”的层次结构的表示问题,让客户端能够一致地对待单个对象和组合对象。
组合与继承
使用继承的场景:
- "是一个"关系: 当一个类是另一个类的特殊类型时,可以使用继承。例如,Cat是一个Animal。
- 代码重用: 继承允许在派生类中重用基类的代码,减少了重复编写相似代码的工作。
- 多态性: 继承支持多态性,子类可以替代基类的对象,提高了代码的灵活性。
- 覆盖和扩展: 子类可以覆盖(重写)基类的方法,同时也可以添加新的方法或属性。
使用组合的场景:
- "有一个"关系: 当一个对象包含另一个对象时,可以使用组合。例如,一个Car对象可能包含一个Engine对象。
- 灵活性: 组合通常比继承更灵活,因为它允许动态地改变对象的行为,而不是通过静态的类层次结构。
- 避免多重继承问题: 多重继承可能引入复杂性和歧义,而组合可以更清晰地表示对象之间的关系。
- 模块化: 组合支持更好的模块化,每个组件(对象)都是相对独立的,易于测试和维护。
在实际设计中,通常可以使用组合和继承相结合的方式,以充分发挥它们各自的优势。这种方式通常称为"组合优于继承"(Composition Over Inheritance)原则。通过组合,可以更灵活地构建对象,并且通过继承可以重用已有的代码。
示例场景:
假设有一个Bird类,它有一些通用的属性和方法。然后有Penguin和Eagle两个类,它们都是Bird的特殊类型,但它们也有一些独特的行为。在这种情况下,可以使用组合和继承的结合:
class Bird {
// 共享的属性和方法
};
class FlightBehavior {
// 定义飞行行为的接口
public:
virtual void fly() = 0;
};
class Penguin : public Bird {
// Penguin特有的属性和方法
};
class FlyingPenguin : public Penguin, public FlightBehavior {
// FlyingPenguin继承了Penguin的属性和方法,同时使用组合引入了飞行行为
public:
void fly() override {
// 实现飞行行为
}
C++语言基础
Q1 C和C++两者优缺点/区别,适合情况
C 语言特点:
- 优点:简洁、高效、可移植性强、面向过程、底层控制、资源利用高。
- 适用情况:嵌入式系统、操作系统、编译器、硬件驱动开发、对性能要求高的系统。
C++ 语言特点:
- 优点:面向对象、支持泛型编程、丰富的标准库、更高的抽象性、代码重用性高。
- 适用情况:大型项目开发、复杂软件系统、图形界面应用、游戏开发、科学计算、应用程序框架。
C 是一种过程化编程语言,不支持面向对象编程。它主要关注函数和过程的设计。C++ 是一种多范式编程语言,支持面向对象编程(OOP)。它在 C 的基础上引入了类和对象的概念,允许使用封装、继承和多态等面向对象的特性。
- 命名空间:C: 不支持命名空间。C++: 引入了命名空间(namespace)的概念,可以用来避免命名冲突,将代码组织成更模块化的结构。
- 标准模板库(STL):C++引入了标准模板库(STL),包括容器、算法和迭代器等,提供了一套通用的数据结构和算法。
- 异常处理:C++支持内建的异常处理机制,可以使用 try、catch 和 throw 来处理异常。
- 运算符重载:C++允许对类的运算符进行重载,使得用户可以自定义类的行为。
- 函数默认参数:C++支持函数默认参数,可以在函数声明时为参数提供默认值。
- 类型安全的枚举:C枚举类型不是类型安全的。C++14引入了类型安全的枚举(enum class)。
- const 关键字的扩展:C中const 主要用于定义常量。C++中const 用于定义常量,同时也可以用于类成员函数,表示该函数不修改对象的状态。
- 引用类型:C不支持引用。C++引入了引用,包括左值引用和右值引用(C++11)。
Q2 C++空类,编译器自动生成哪些函数?
当定义一个空类(无成员变量和成员函数)时,C++ 编译器可以自动生成特殊成员函数,有默认构造函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数和析构函数。这些函数被称为“合成函数”或“隐式函数”。
- 默认构造函数(Default Constructor):
当没有定义构造函数时,编译器会自动生成一个默认构造函数。它没有参数,并执行默认初始化。 - 拷贝构造函数(Copy Constructor):
编译器生成的拷贝构造函数用于按值传递、返回对象以及初始化新对象时。它会复制对象的成员变量值。 - 拷贝赋值函数(Copy Assignment Operator):
编译器生成的拷贝赋值函数用于对象之间的赋值操作。它会复制对象的成员变量值。 - 移动构造函数(Move Constructor):
如果没有显式定义移动构造函数,编译器也会生成默认的移动构造函数。它会将资源从一个对象“窃取”到另一个对象,通常用于支持移动语义。 - 移动赋值函数(Move Assignment Operator):
如果没有显式定义移动赋值函数,编译器也会生成默认的移动赋值函数。它会将资源从一个对象“窃取”到另一个对象,通常用于支持移动语义。 - 析构函数(Destructor):
如果没有定义析构函数,编译器会生成一个默认的析构函数,用于对象被销毁时清理资源。
示例:
#include <iostream>
class EmptyClass {
// 没有定义任何成员变量和成员函数
};
int main() {
// 实例化空类对象
EmptyClass obj1;
// 默认构造函数示例
EmptyClass obj2 = EmptyClass(); // 调用默认构造函数
// 拷贝构造函数示例
EmptyClass obj3 = obj1; // 调用拷贝构造函数
// 拷贝赋值函数示例
EmptyClass obj4;
obj4 = obj3; // 调用拷贝赋值函数
// 移动构造函数示例
EmptyClass obj5 = std::move(obj4); // 调用移动构造函数
// 移动赋值函数示例
EmptyClass obj6;
obj6 = std::move(obj5); // 调用移动赋值函数
return 0;
}
EmptyClass没有任何成员变量或成员函数,编译器生成默认构造函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数和析构函数会在示例中对对象进行实例化、赋值和销毁时自动调用。
Q3 对指针,字符数组求sizeof
指针的 sizeof:
int* ptr;
std::cout << "Size of pointer: " << sizeof(ptr) << " bytes" << std::endl;
对于指针,sizeof 返回的是指针本身的大小,通常是存储地址的字节数。在 32 位系统上是 4 字节;在 64 位上是 8 字节。
字符数组的 sizeof:
char arr[] = "Hello";
std::cout << "Size of char array: " << sizeof(arr) << " bytes" << std::endl;
对于字符数组,sizeof 返回的是整个数组的大小,包括末尾的空字符 \0。在这个例子中,数组 "Hello" 共有 6 个字符(包括末尾的空字符 \0),因此 sizeof(arr) 的结果通常是 6 字节。
Q4 vector内存扩容机制
内存分配与扩容原理:
动态内存分配:vector 使用动态内存分配来存储元素,默认情况下会分配一块初始大小的内存(例如 8 个元素大小)来存储元素。
扩容:vector 中的元素数量超过当前内存容量时,需要进行内存扩容。它通常会分配一块更大的内存空间,将原有的元素复制到新的内存区域中。
内存扩容策略:
vector 有两个重要的概念:capacity(容量)和 size(大小)。capacity 表示当前已分配的内存空间大小,size 表示当前 vector 中实际存储的元素个数。
成倍增长:vector 在进行内存扩容时采用成倍增长的策略,即每次扩容分配的内存大小是原来容量的两倍(这是一种常见的实现方式,但并非所有编译器都采用相同的策略)。
内存重新分配:vector 需要扩容时,会申请一块新的内存空间,将原有元素复制到新的内存空间中,然后释放原有的内存空间。
实现示例:
#include <iostream> #include <vector> int main() { std::vector<int> vec; std::cout << "Initial capacity: " << vec.capacity() << std::endl; // 打印初始容量 for (int i = 0; i < 10; ++i) { vec.push_back(i); // 添加元素 std::cout << "Size: " << vec.size() << ", Capacity: " << vec.capacity() << std::endl; // 打印当前大小和容量 } return 0; }
vec 是一个 vector 对象。随着元素的不断添加,size() 函数可以获得当前 vector 中元素的个数,而 capacity() 函数可以获取当前 vector 的内存容量。你会发现 capacity 在 size 达到 capacity 时进行了扩容操作,并且容量通常是成倍增长的。
Q5 实现vector的push_back()和remove()方法
创建一个动态数组,自己管理内存来模拟 vector 的函数:
#include <iostream>
template <typename T>
class MyVector {
private:
T* arr;
size_t capacity;
size_t size;
public:
MyVector() : arr(nullptr), capacity(0), size(0) {}
~MyVector() {
delete[] arr;
}
void push_back(const T& value) {
if (size >= capacity) {
if (capacity == 0) {
capacity = 1;
} else {
capacity *= 2;
}
T* new_arr = new T[capacity];
for (size_t i = 0; i < size; ++i) {
new_arr[i] = arr[i];
}
delete[] arr;
arr = new_arr;
}
arr[size++] = value;
}
void remove(const T& value) {
size_t index = 0;
for (; index < size; ++index) {
if (arr[index] == value) {
break;
}
}
if (index == size) {
std::cout << "Element not found in vector." << std::endl;
return;
}
for (size_t i = index; i < size - 1; ++i) {
arr[i] = arr[i + 1];
}
size--;
}
void print() const {
std::cout << "Vector elements: ";
for (size_t i = 0; i < size; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
}
};
int main() {
MyVector<int> vec;
vec.push_back(5);
vec.push_back(10);
vec.push_back(15);
vec.push_back(20);
vec.print();
vec.remove(10);
vec.print();
vec.remove(100); // Element not found in vector.
return 0;
}
Q6 智能指针解决内存泄漏
智能指针用于管理动态分配的内存,并帮助防止内存泄漏和悬挂指针(dangling pointers)等问题。
智能指针的主要原理包括以下几个方面:
- 自动内存管理: 智能指针通过包装原始指针,将内存资源的所有权和释放责任交给智能指针对象。当智能指针对象超出范围或不再需要时,其析构函数会被调用,自动释放指向的内存资源。
- 引用计数(Reference Counting): 某些智能指针(如 std::shared_ptr)使用引用计数来跟踪指向某个对象的指针数量。每次创建智能指针时,引用计数增加;当智能指针被销毁时,引用计数减少。当引用计数为零时,代表没有任何指针指向该对象,智能指针会自动释放对象所占用的内存。
- 所有权转移: 智能指针可以通过移动语义(move semantics)或复制语义(copy semantics)实现所有权的转移。例如,使用 std::move() 可以将一个对象的所有权从一个智能指针转移到另一个智能指针,避免多个指针管理同一块内存导致的问题。
- 避免悬挂指针: 智能指针在对象被释放后,会将指针设置为 null 或者空指针,防止悬挂指针的产生。
智能指针通过封装原始指针,实现了自动内存管理和所有权管理,提高了内存安全性,避免了内存泄漏和悬挂指针等问题。常见的智能指针包括 std::unique_ptr、std::shared_ptr 和 std::weak_ptr。
Q7 unique_ptr智能指针独占
- unique_ptr 是 C++ 标准库提供的一种智能指针,具有独占式所有权(exclusive ownership)的特性,确保在任何时刻只有一个 unique_ptr 实例可以指向所管理的对象。
- 这种独占性使得 unique_ptr 在管理动态分配的资源时非常有用,可以避免资源的多重所有权和悬挂指针等问题。
- unique_ptr 通过禁止复制操作、支持移动语义和析构函数释放资源的机制,保证了对动态分配资源的独占所有权,确保在任何时刻只有一个 unique_ptr 可以指向所管理的对象。
Q8 using关键字
在 C++ 中,using 关键字有多种用途和语法形式,主要用于命名空间的别名、类型别名和引入特定范围内的成员。
1.命名空间的别名:
// 命名空间的别名
namespace ns = some_namespace;
使用 using namespace 或 namespace ns = ... 可以创建一个新的命名空间别名,用于简化长命名空间的使用或提高代码的可读性。
2.类型别名:
// 类型别名
using new_name = existing_type;
using 可以用于创建类型别名,将已存在的类型命名为新的别名,提高代码的可读性和可维护性。
3.引入特定范围内的成员:
// 引入特定范围内的成员
using std::cout;
using std::endl;
using 可以用于引入特定范围内的成员,以便直接使用而无需在代码中指定完整的命名空间。
4.模板别名:
// 模板别名
template <typename T>
using my_container = std::vector<T>;
using 也可以用于创建模板别名,将模板实例化成新的别名,方便使用模板类。
Q9 move函数
在 C++11 及之后的标准中,std::move() 是一个位于 utility 头文件中的函数,用于将对象转移(移动)为右值引用,常用于移动语义的实现。
std::move() 的作用是将传入的对象转换为右值引用,表明对该对象的所有权可以被移动到其他对象中,而非复制。它并不实际搬迁数据,而是通过静态转换告诉编译器可以使用移动语义。移动语义允许在对象所有权转移时,避免深层拷贝,提高效率。
示例:
#include <utility>
#include <iostream>
#include <string>
int main() {
std::string str1 = "Hello, ";
std::string str2 = "world!";
// 使用 move 将 str2 转移给 str1
str1 = std::move(str2);
std::cout << "str1 after move: " << str1 << std::endl;
std::cout << "str2 after move: " << str2 << std::endl; // 注意:此处输出可能是空字符串或者未定义行为
return 0;
}
move() 使得 str2 的所有权转移到 str1,因此在输出 str2 之后可能会得到一个空字符串或者未定义的行为,因为其数据已经被转移。
move() 并不意味着对象的内容发生了移动,而是告诉编译器可以使用移动语义,以便更高效地处理对象的所有权转移。此函数主要用于支持移动语义,例如在容器元素的移动、资源管理类中实现移动构造函数和移动赋值操作符等场景。
Q10 gcc工作原理
GCC(GNU Compiler Collection)是一套由 GNU 开发的开源编译器集合,支持多种编程语言,包括 C、C++、Objective-C、Fortran、Ada 等。
- 预处理(Preprocessing): 首先,源代码经过预处理器处理,处理内容包括去除注释、宏展开、头文件包含等,生成一个经过预处理后的文件。
- 编译(Compilation): 预处理后的文件被送到编译器阶段,编译器将其翻译成汇编代码。在这个阶段,源代码被转换为特定硬件架构的汇编语言代码。
- 汇编(Assembly): 汇编器将汇编代码转换成机器可执行的二进制指令,即目标文件(Object File)。目标文件是二进制格式的文件,包含了编译后的代码、数据和符号表信息。
- 链接(Linking): 最后,链接器将目标文件与其他库文件(如标准库)合并,解析符号引用,生成最终的可执行文件或库文件。链接器会处理符号表,将不同目标文件中的函数、变量等符号引用解析为统一的地址,生成最终的可执行文件。
GCC 实现了多种优化、调试信息生成、不同平台的代码生成等功能,编译过程中会执行多个优化阶段,尝试提高生成代码的执行效率和性能。
Q11 内存动态分配 i++和++i的区别
i++(后缀递增):
i++ 是一种后缀递增操作符。它先将 i 的当前值返回,然后再将 i 的值加一。具体执行流程是,首先使用 i 的当前值进行表达式运算,然后将 i 的值加一。
int i = 5;
int a = i++; // a = 5, i = 6
++i(前缀递增):
++i 是一种前缀递增操作符。它先将 i 的值加一,然后再将新值返回。具体执行流程是,首先将 i 的值加一,然后使用新值进行表达式运算。
int i = 5;
int a = ++i; // a = 6, i = 6
前置++的效率要高于后者,因为前者不需要缓存值,以用来返回。
Q12 值传递与引用传递
值传递是将参数的实际值复制给函数的形式参数,函数内的操作不会影响原始值。
引用传递是将参数的地址传递给函数的形式参数,允许函数修改原始值。
值传递创建参数的副本,引用传递使用原始数据的引用,允许对原始数据进行更改。引用传递通常更高效,但可能意外地修改原始值。
Q13 c++11特性解释
- 智能指针(Smart Pointers):
智能指针(如 std::shared_ptr 和 std::unique_ptr)管理动态分配的内存。它们提供自动内存管理,避免内存泄漏,确保资源安全释放。 - Lambda 表达式(Lambda Expressions):
Lambda 表达式允许在函数内部定义匿名函数,简化代码和函数对象的编写,可用于实现更灵活的函数式编程。 - auto 关键字(Type Inference):
使用 auto 关键字可以让编译器推断变量类型,减少类型重复和增加代码可读性,提高编码效率。 - 移动语义(Move Semantics):
通过 std::move() 实现资源的所有权转移,减少不必要的复制操作,提高性能和效率。 - 范围-based for 循环(Range-based for loop):
提供一种简洁方式来遍历容器、数组和其他类似序列的对象,使代码更加清晰易读。
Q14 STL容器使用
vector(动态数组):
数据结构实现: 动态数组,支持动态扩展和随机访问。
适用场景: 需要高效的随机访问、在尾部进行插入和删除操作的场景。
list(双向链表):
数据结构实现: 双向链表,支持在头尾和任意位置进行插入和删除操作。
适用场景: 需要在中间位置频繁插入和删除元素的场景。
deque(双端队列):
数据结构实现: 由多个块组成的数组,支持在头尾进行快速插入和删除操作。
适用场景: 需要在头尾频繁进行插入和删除操作的场景。
queue(队列):
数据结构实现: 通常基于 deque 实现。
适用场景: 先进先出(FIFO)的场景,例如任务调度等。
stack(栈):
数据结构实现: 通常基于 deque 或 vector 实现。
适用场景: 后进先出(LIFO)的场景,例如表达式求值、深度优先搜索等。
set(集合):
数据结构实现: 红黑树,自动排序。
适用场景: 不允许重复元素且需要快速查找和插入的场景。
map(映射):
数据结构实现: 红黑树,基于键值对存储。
适用场景: 键值对的查找、插入和删除操作频繁的场景。
unordered_set(无序集合):
数据结构实现: 哈希表。
适用场景: 不需要自动排序,需要快速查找和插入的场景。
unordered_map(无序映射):
数据结构实现: 哈希表,基于键值对存储。
适用场景: 键值对的查找、插入和删除操作频繁的场景,不需要自动排序。
priority_queue(优先队列):
数据结构实现: 通常基于 vector 实现的最大堆或最小堆。
适用场景: 需要按照一定规则获取最大或最小元素的场景。
Q15 memset、memcpy和strcpy的区别是什么?
这三个函数在C/C++开发中用到的频率比较高
- memset函数常用于POD类型对象的初始化,一般第二个参数都是0,第三个参数是这段内存的长度
- memcoy函数需要注意的点是,函数传入的源位置和目标位置不能有重叠,否则这种操作引发的结果无法预知。如果你不能够确定传入的源位置和目标位置是否存在重叠,那么可以使用memmove函数代替memcoy
- strcpy存在另一个问题,那就是源字符串的长度可能大于目标区域的长度,导致目标区域内存被超写,造成不可预知的错误。当然也可以通过使用strncpy函数传入目标区域的大小-1,如果源字符串的长度小于目标区域的大小-1,则全部拷贝,否则最多只拷贝目标长度-1的内容,保证目标字符串以0结尾
链接:https://zhuanlan.zhihu.com/p/634480351
memset:
功能:用于设置一块内存的值为特定的字节。
用法:void memset(void ptr, int value, size_t num);
char buffer[10];
memset(buffer, 0, sizeof(buffer)); // 将 buffer 内的所有字节设置为 0
memcpy:
功能:用于在内存之间复制一块数据。
用法:void memcpy(void dest, const void* src, size_t num);
char source[] = "Hello";
char destination[10];
memcpy(destination, source, strlen(source) + 1); // 复制字符串到目标数组
strcpy:
功能:用于复制一个字符串到另一个字符串。
用法:char strcpy(char dest, const char* src);
char source[] = "Hello";
char destination[10];
strcpy(destination, source); // 复制字符串到目标数组
- memset 用于设置一块内存的值,通常用于初始化数组或清空内存。
- memcpy 用于在内存之间复制数据,可以用于复制任意数据块。
- strcpy 用于复制字符串,它会复制字符串中的字符直到遇到空字符 \0。
POD(Plain Old Data)是 C++ 中一种简单的数据类型,它具有以下特征:
- 只包含数据成员:POD 类型的对象只包含数据成员,没有任何成员函数(构造函数、析构函数、拷贝构造函数等)。
- 公有访问权限:所有数据成员都是公有的(public)。
- 没有虚函数:没有虚函数。
- 没有基类或继承:没有基类,也没有继承其他类。
- 标准布局:POD 类型的对象是标准布局的。标准布局类型是指对象中没有任何非静态数据成员之间存在虚拟函数、虚拟基类、非静态成员对象之一或多个的类。
- 可以使用 memcpy 进行位拷贝:POD 类型的对象可以使用 memcpy 进行位拷贝。
#include <iostream>
// 定义一个POD类型
struct Point {
int x;
int y;
};
int main() {
// 创建一个POD对象
Point p;
p.x = 10;
p.y = 20;
// 使用memcpy进行位拷贝
Point newP;
std::memcpy(&newP, &p, sizeof(Point));
// 输出新的POD对象的值
std::cout << "newP.x: " << newP.x << ", newP.y: " << newP.y << std::endl;
return 0;
}
C++11 引入了新的术语 "Trivial" 和 "Standard Layout" 来代替 "POD",更严格地定义了可移植性和行为。
Q16 深拷贝,浅拷贝
深拷贝(Deep Copy)和浅拷贝(Shallow Copy)是在编程中用于复制对象或数据的两种不同方式。
区别:
浅拷贝(Shallow Copy):
浅拷贝只复制对象的地址,而不是复制对象本身。新对象和原对象共享同一块内存地址。
修改一个对象的内容会影响到另一个对象,因为它们指向相同的内存地址。
class ShallowCopy {
public:
int* data;
ShallowCopy(const ShallowCopy& source) {
// 浅拷贝只复制指针,不分配新内存
data = source.data;
}
};
深拷贝(Deep Copy):
深拷贝会完全复制对象的内容,包括对象的值和指向的资源。新对象有自己独立的内存空间。
修改一个对象的内容不会影响到另一个对象,因为它们分别拥有独立的内存地址。
class DeepCopy {
public:
int* data;
DeepCopy(const DeepCopy& source) {
// 深拷贝分配新的内存,并复制数据
data = new int;
*data = *(source.data);
}
};
示例:
int main() {
int* arr = new int(5); // 创建动态分配的整数
ShallowCopy obj1;
obj1.data = arr;
ShallowCopy obj2 = obj1; // 浅拷贝
// 修改 obj1 的数据会影响到 obj2
*(obj1.data) = 10;
std::cout << *(obj2.data); // 输出结果为 10
// 释放内存
delete arr;
delete obj2.data;
return 0;
}
Q17 c++结构体内存对齐
成员对齐:
- 结构体的成员按照其自身的大小进行对齐,例如 char 通常是按照1字节对齐,int 可能是4字节对齐。
- 成员变量之间的间隔(填充字节)是为了保证每个成员变量的对齐。
对齐规则:
- 通常遵循最大成员对齐的原则,即结构体的对齐值等于其最大成员的大小。
- 如果结构体的大小不是最大成员大小的倍数,则会在最后一个成员之后填充字节以对齐整个结构体的大小。
struct MyStruct {
char a; // 大小为1字节
int b; // 大小为4字节
double c; // 大小为8字节
};
在上述示例中,MyStruct 结构体可能的对齐方式可能是:
char a; 按照1字节对齐,不需要填充字节。
int b; 通常按照4字节对齐,可能需要3字节填充字节。
double c; 通常按照8字节对齐,不需要填充字节。
因此,整个 MyStruct 结构体的大小可能是 16 字节(1 + 3(填充) + 4 + 8)。
结构体的内存对齐有助于提高程序运行效率,确保数据的正确对齐,避免因为对齐不当而导致的性能损失和内存访问错误。但不同的编译器和平台可能对结构体的内存对齐方式有所不同,可以使用编译器提供的特定的对齐指令进行控制。
Q18 继承多态的理解
继承
功能:继承是面向对象编程中一种类与类之间的关系,允许一个类(子类/派生类)获取另一个类(父类/基类)的属性和方法。
特性:
- 子类可以继承父类的属性和方法,通过继承可以实现代码的重用和扩展。
- 子类可以通过覆盖(重写)父类的方法或添加新方法来改变或增强父类的行为。
提供了层次化的类结构,可以通过派生出新的类来组织和管理代码,增强了代码的可维护性和扩展性。
多态
功能: 多态是指同一类方法在不同情况下具有不同的行为,是面向对象编程的一个重要特性。
特性:
- 允许不同类的对象对相同的消息(方法调用)做出不同的响应。
- 实现方式有静态多态(函数重载、运算符重载)和动态多态(基于继承的虚函数和接口)两种。
动态多态通过虚函数和继承实现,允许父类的指针或引用指向其子类的对象,在运行时根据对象的实际类型调用相应的方法。
Q19 const和static,inline和define
const 和 static 是 C++ 中的关键字,而 inline 和 #define 是用于定义宏和函数的预处理指令。
const 和 static 的区别
const:用于定义常量,表示一个变量在程序执行期间不可被修改。
- 声明常量:const int MAX_VALUE = 100;
- 在函数参数中表示不可修改:void func(const int x) {}
static:用于修改变量和函数的作用域。 - 修改变量的生命周期:static int count = 0;,静态变量存储在静态存储区,生命周期延长到整个程序运行期间。
- 修改函数的作用域:static void myFunction() {},静态函数只在声明它的文件内可见,无法通过链接器进行外部链接。
inline 和 define 的区别
inline:用于定义内联函数,建议编译器在调用处展开函数代码。
内联函数:inline int add(int a, int b) { return a + b; }
编译器会尝试将函数调用处直接替换为函数体,节省了函数调用的开销,但并非一定会内联展开。
#define:用于创建预处理宏,简单地进行文本替换。
宏定义:#define PI 3.14159
在预处理阶段直接将定义的宏名称替换为相应的文本,可能会导致代码可读性差和潜在错误,不进行类型检查。
- const 和 static 是关键字,分别用于定义常量和修改变量/函数的作用域和生命周期。
- inline 和 #define 是预处理指令,inline 建议编译器内联展开函数,而 #define 创建简单的文本替换宏定义。inline 保留了类型检查和作用域,而 #define 只是简单的文本替换,没有类型检查,可能引起潜在错误。
"顶层const" 和 "底层const" 是描述 C++ 中 const 修饰符的两个术语,用于区分 const 在不同的位置出现时的语义。
顶层const(Top-level const):
顶层const 指的是指针本身是一个常量。在指针声明中,const 出现在 * 的左边,表示指针指向的对象是常量。
顶层const 影响的是指针本身的常量性,而不是指针所指向的对象。
const int* ptr; // ptr 是一个指向常量整数的指针(顶层const)
int const* ptr; // 同样表示 ptr 是一个指向常量整数的指针
上述声明中,ptr 是一个指向常量整数的指针,可以通过 ptr 修改指针的值,但不能通过 *ptr 修改所指向的整数的值。
底层const(Low-level const):
底层const 指的是指针所指向的对象是一个常量。在指针声明中,const 出现在 * 的右边,表示指针指向的对象是常量。
底层const 影响的是指针所指向的对象的常量性,而不是指针本身的常量性。
int* const ptr; // ptr 是一个指向整数的常量指针(底层const)
上述声明中,ptr 是一个指向整数的常量指针,一旦初始化,不能再指向其他整数。
同时具备顶层const和底层const:
const int* const ptr; // ptr 是一个指向常量整数的常量指针
上述声明中,ptr 既不能通过 ptr 修改指针的值,也不能通过 * ptr 修改所指向的整数的值。
consteval和constinit(C++20)
如果传入的参数是编译时确定的,constexpr函数就会在编译时求值,反之则会在运行时求值。
constexpr int add(int a, int b)
{
return a+b;
}
constexpr int sum1 = add(1,2); //编译时求值
int a = 1, b = 2;
int sum2 = add(a,b); //运行时求值
当使用consteval修饰函数和变量时,如果不能在编译时求值,则编译错误。
consteval int add(int a, int b)
{
return a+b;
}
constexpr int sum1 = add(1,2); //编译通过,在编译时求值
int a = 1, b = 2;
int sum2 = add(a,b); //编译失败
constinit关键字用于声明一个变量为常量初始化变量。使用constinit可以保证静态变量的初始化顺序和线程安全性,从而避免竞争条件的发生。constinit关键字只能用于静态变量的初始化,不能用于动态变量的初始化。
Q20 C++调试工具使用,gdb的命令
在 C++ 开发中,常用的调试工具包括 GDB(GNU Debugger)、LLDB、Visual Studio Debugger(Windows)、Xcode Debugger(macOS)等。
用法:
启动 GDB:
在命令行中输入 gdb 后加上可执行文件的路径:gdb ./executable_file。
设置断点:
break 或 b:设置断点。例如,break filename.cpp:line_number 在指定文件和行设置断点。
delete:删除断点。delete 后可以跟断点编号或 delete 所有断点。
运行程序:
run 或 r:运行程序。
程序执行控制:
continue 或 c:继续执行程序。
next 或 n:执行下一行代码,不进入函数内部。
step 或 s:执行下一行代码,如果是函数则进入函数内部。
查看变量和调用栈:
print 或 p:打印变量值。例如,print variable_name。
info locals:查看当前作用域的局部变量。
backtrace 或 bt:查看调用栈。
查看源代码:
list 或 l:显示源代码。例如,list function_name。
frame 或 f:切换帧,显示当前执行位置的源代码。
观察内存:
x:检查内存内容。例如,x/num_format address。
设置 Watchpoint:
watch:设置监视点,当指定变量被修改时中断程序执行。
退出 GDB:
quit 或 q:退出 GDB。
Q21 MFC/QT
MFC(Microsoft Foundation Class)和 Qt 是两种不同的 C++ 应用程序框架,用于图形用户界面(GUI)开发。它们有以下主要区别:
平台和开源性:
MFC 是微软开发的,主要用于 Windows 平台,是 Windows 平台下的主要 GUI 应用程序开发框架。
Qt 是由挪威的 Digia 公司开发并开源,是跨平台的,支持 Windows、Linux、macOS 等多个操作系统,可用于开发跨平台的应用程序。
语言:
MFC 主要基于 C++,但在 C++ 的基础上添加了一些 Microsoft 特有的扩展和类库。
Qt 使用标准 C++,但提供了自己的一套类库和工具,用于实现跨平台的 GUI 应用程序。
功能和组件:
MFC 提供了许多 Windows 平台的原生控件和功能,但跨平台能力有限。
Qt 提供了丰富的控件库和功能,包括 GUI 控件、网络、多线程、XML 解析等,具有更广泛的跨平台能力和功能性。
Q22 IOCP的原理
IOCP(Input/Output Completion Ports)底层实现原理主要基于Windows操作系统的异步I/O模型。它使用完成端口(Completion Port)作为事件通知机制,通过与套接字关联的异步操作完成例程来处理I/O完成事件。操作系统维护一个线程池,等待I/O操作完成后调度线程执行完成例程。这种方式允许高并发处理多个套接字的异步I/O操作,提高了系统性能。IOCP的设计避免了传统同步I/O中线程阻塞的问题,充分利用异步I/O的优势,使得系统能够高效地处理大量并发I/O操作。
Q23 struct、class和union有哪些区别?
struct(结构体):
struct 中的成员默认是公有的(public)。
如果不显式指定,struct 中的成员默认是公有的。对象的默认构造函数: 如果没有显式提供构造函数,struct 的对象会有一个默认构造函数。
struct Point {
int x;
int y;
};
class(类):
class 中的成员默认是私有的(private)。
如果不显式指定,class 中的成员默认是私有的。对象的默认构造函数: 如果没有显式提供构造函数,class 的对象会有一个默认构造函数。
class Point {
int x;
int y;
public:
// 显式提供构造函数
Point(int xVal, int yVal) : x(xVal), y(yVal) {}
};
union(联合):
union 中的所有成员都具有相同的访问权限,与 struct 不同,union 中没有默认的访问权限。
union 中的所有成员都有相同的访问权限,取决于 union 的整体访问权限。对象的默认构造函数: union 没有默认构造函数。
union Data {
int i;
float f;
char c;
};
union 是 C++ 中的一种复合数据类型。
共享内存空间: union 中的所有成员共享同一块内存空间,它们的大小是相同的,起始地址也是相同的。
同一时间只能存储一个成员: union 只能同时存储其中的一个成员。存储一个成员后,切换到其他成员不会改变内存的地址,但会改变成员的值。
节省内存空间: union 可以用于节省内存,因为它只存储其中一个成员的值。这在某些情况下很有用,例如在结构体中有多个字段,但只需要使用其中的一个字段的值。
union Data {
int i;
float f;
char c;
};
共享内存: union 可以用于共享同一块内存空间的不同数据类型。这在某些特殊情况下,需要在不同的数据类型之间进行转换时很有用。
union Converter {
int intValue;
float floatValue;
};
位域: union 可以与位域一起使用,用于对一个整数的各个位进行分段的操作。
union BitField {
struct {
unsigned int bit1 : 1;
unsigned int bit2 : 1;
unsigned int bit3 : 1;
// ...
} bits;
unsigned int intValue;
};
位域允许在一个字节(或更大的存储单元)中以位为单位指定成员的大小,而 union 则允许共享同一块内存空间的不同数据类型。结合使用时,可以实现对一个整数的各个位进行分段的操作。
#include <iostream>
union BitFieldUnion {
struct {
unsigned int bit1 : 1;
unsigned int bit2 : 1;
unsigned int bit3 : 1;
// ... more bit fields
} bits;
unsigned int intValue;
};
int main() {
BitFieldUnion myUnion;
myUnion.intValue = 0; // 清空整数值
// 设置位域的值
myUnion.bits.bit1 = 1;
myUnion.bits.bit2 = 0;
myUnion.bits.bit3 = 1;
// 输出整数值
std::cout << "intValue: " << myUnion.intValue << std::endl;
return 0;
}
BitFieldUnion 包含了一个具有多个位域的结构体和一个整数。通过设置位域的值,可以实现对整数的不同位进行控制。这样的设计在对硬件寄存器进行位操作时很有用。位域的使用可能因为依赖于实现而不具有可移植性。
Q24 堆区申请内存
malloc 和 free:
malloc 和 free 是 C 语言中用于动态内存分配和释放的函数。
malloc 分配一块指定大小的内存,并返回指向分配内存的指针。如果分配失败,返回 NULL。
free 用于释放之前通过 malloc 分配的内存。
int *ptr = (int*)malloc(sizeof(int)); // 分配内存
free(ptr); // 释放内存
new 和 delete:
new 和 delete 是 C++ 中的运算符,用于动态分配和释放内存,并提供了更高级别的功能。
new 分配内存并调用对象的构造函数,返回指向分配内存的指针。
delete 用于释放通过 new 分配的内存,并调用对象的析构函数。
int *ptr = new int; // 分配内存并调用构造函数
delete ptr; // 释放内存并调用析构函数
allocate:
allocate 并非直接的 C++ 运算符,但是可以在 std::allocator 类中找到。allocator 提供了 allocate 和 deallocate 成员函数,用于动态分配和释放内存。
#include <memory>
int *ptr = std::allocator<int>().allocate(1); // 分配内存
std::allocator<int>().deallocate(ptr, 1); // 释放内存
malloc 和 free 是 C 语言的标准库函数,而 new 和 delete 是 C++ 中的运算符,提供了更丰富的功能,包括对象的构造和析构。allocator 是 C++ 标准库提供的一个泛型分配器类,可以用于分配和释放内存。
malloc 和 new 都用于动态分配内存,
malloc 是 C 语言标准库中的函数,其语法为:void* malloc(size_t size);
,返回一个指向分配内存的 void 指针。
new 是 C++ 中的运算符,有两种形式:new Type
和 new Type[N]
。new Type
用于分配单个对象的内存,而 new Type[N]
用于分配一个对象数组的内存。
// malloc 的用法
int *arr1 = (int*)malloc(5 * sizeof(int));
// new 的用法
int *arr2 = new int[5];
malloc 不了解所分配内存的类型,返回的是 void 指针。在使用时,需要进行类型转换。
new 在分配内存时会考虑对象的类型,返回的是指向正确类型的指针。不需要进行显式的类型转换。
// malloc 需要显式的类型转换
int *arr1 = (int*)malloc(5 * sizeof(int));
// new 不需要显式的类型转换
int *arr2 = new int[5];
malloc 只分配内存,不会调用对象的构造函数。
new 会调用对象的构造函数,确保对象被正确初始化。cpp// malloc 分配内存,但不调用构造函数
int *arr1 = (int*)malloc(5 * sizeof(int));
// new 分配内存,并调用每个对象的构造函数
int *arr2 = new int[5];
malloc 和 free 不提供对数组越界的检查。在使用时,需要注意数组的大小。
new 和 delete 会进行越界检查,确保不会访问或释放不存在的元素。
在C++11中我们可以使用以下方法使得new运算符不抛出异常,
int* p = new (std::nothrow) int(42);
if(p == nullptr)
{
//分配失败
}
new 更适用于 C++ 中对象的动态分配,因为它考虑了类型信息、构造和析构,并提供更方便的语法。
Q25 什么是cdel、stdcall、fastcall、thiscall
这是一些调用约定(Calling Convention)的术语,用于定义函数调用时参数的传递方式、寄存器的使用方式等。这些调用约定主要影响编程语言中函数的调用和返回方式。以下是其中几个常见的调用约定:
Cdecl(C调用约定):
Cdecl 是一种标准的调用约定,函数参数从右往左依次压入栈中,由调用方负责清理栈。
这是 C 语言中默认的调用约定,也是许多平台上默认的调用约定。
Stdcall(stdcall调用约定):
Stdcall 也是一种标准的调用约定,函数参数从右往左依次压入栈中,由被调用方负责清理栈。
主要用于 Win32 API 函数,是 Windows 平台上的默认调用约定。
Fastcall(fastcall调用约定):
Fastcall 是一种调用约定,使用寄存器来传递函数的一部分参数,可以提高函数调用的速度。
具体的寄存器分配可能因编译器和平台而异,通常用于优化性能要求较高的代码。
Thiscall(thiscall调用约定):
Thiscall 用于成员函数调用,将对象的指针作为隐含的第一个参数传递给成员函数。
对象指针通常存储在 ECX 寄存器中,而其他参数则按照 cdecl 的规则依次压入栈中。
在 C++ 中,默认情况下,非成员函数使用 cdecl 调用约定,而成员函数使用 thiscall 调用约定。
Q26 noexcept关键字
noexcept 关键字在移动构造函数和移动赋值操作符中的使用通常用于指示这些操作不会抛出异常。在 C++11 和之后的标准中,移动操作被设计为不抛出异常的操作,以便在进行性能优化时提供更多的信息。
性能优化:
移动语义的初衷是在对象之间转移资源而不是复制资源,通常涉及到指针或其他可以被移动的资源。
如果移动操作可能抛出异常,那么在发生异常时,程序可能处于不一致的状态,资源可能没有得到正确的转移,导致资源泄漏或不一致的状态。
容器优化:
标准库容器和算法在实现上通常会根据移动构造函数和移动赋值操作是否 noexcept 来进行性能优化。
如果容器的元素类型支持 noexcept 的移动操作,容器可以在某些情况下优化其内部结构。
异常安全性:
在 C++ 中,异常安全性是一个重要的设计考虑因素。如果移动操作可能抛出异常,那么在进行资源管理时需要更加谨慎,以确保在异常发生时能够正确处理资源。
class Example {
public:
// 移动构造函数使用 noexcept
Example(Example&& other) noexcept {
// 实现移动操作
}
// 移动赋值操作符使用 noexcept
Example& operator=(Example&& other) noexcept {
// 实现移动操作
return *this;
}
};
noexcept有助于提高程序的性能、容器的性能,并确保在异常发生时的可靠性。
27类的静态成员变量
静态成员变量的初始化:
- 静态成员变量通常需要在类的实现文件中进行定义和初始化。初始化通常在类外部进行:
// 类的头文件
class MyClass {
public:
static int staticVar;
};
// 类的实现文件
int MyClass::staticVar = 0;
访问权限:
- 静态成员变量可以是私有、受保护或公有的,与普通成员变量类似。它们的访问权限由它们的声明位置决定。
静态成员函数:
- 静态成员变量通常与静态成员函数一起使用。静态成员函数属于类而不是实例,可以访问类的静态成员变量,但不能访问非静态成员变量。
静态成员变量的生命周期:
- 静态成员变量的生命周期与程序的生命周期相同,它们在程序启动时被创建,在程序结束时被销毁。
静态成员变量的共享:
- 所有属于同一个类的对象共享相同的静态成员变量。这意味着对静态成员变量的修改会影响到所有类的实例。
静态成员变量的线程安全性:
- 静态成员变量的共享可能涉及到多线程访问。在多线程环境中,需要考虑采取适当的同步机制以确保对静态成员变量的并发访问是安全的。
静态成员变量的初始化顺序:
- 在 C++ 中,静态成员变量的初始化顺序可能受到编译单元的链接顺序的影响。在不同编译单元中的静态成员变量的初始化顺序不确定,可能导致初始化顺序的问题。
Q28 类的成员方法可以通过const符号重载吗?
这是一个很好的问题,估计很多人没有思考过。先说答案,底层const可以,而顶层const不可以。对吗?为什么?
2 / 2
ChatGPT
在C++中,类的成员方法可以通过 const 修饰进行重载。重载的方式分为底层 const 和顶层 const 两种情况
底层const重载:
底层 const 表示成员函数的 this 指针是常量指针,即不能修改成员变量的值。
底层 const 的重载是允许的,因为它对成员函数的影响仅在是否能够修改成员变量上。
class MyClass {
public:
void foo() const {
// 不能修改成员变量
}
void foo() {
// 可以修改成员变量
}
};
顶层const重载:
顶层 const 表示成员函数的返回值是常量,即不能修改返回值。
顶层 const 的重载是不允许的,因为C++不会通过返回值来区分函数。
class MyClass {
public:
int getValue() const {
return 42;
}
// 不允许使用顶层 const 重载
// const int getValue() {
// return 42;
// }
};
底层 const 重载是允许的,因为它影响的是成员函数内部是否能够修改成员变量,而顶层 const 重载是不允许的,因为C++不会通过返回值来区分函数。
Q29类的成员方法的引用限定符
在 C++11 中引入了成员方法的引用限定符(Reference Qualifiers)。引用限定符允许我们限定一个成员函数的调用方式,以确保它只能在特定类型的引用上调用。
引用限定符的语法是在成员函数声明或定义的参数列表后面添加 & 或 &&。其中,& 表示该成员函数只能在左值上调用,而 && 表示该成员函数只能在右值上调用。
class MyClass {
public:
// 成员函数的引用限定符
void foo() & {
// 只能在左值上调用
}
void bar() && {
// 只能在右值上调用
}
};
int main() {
MyClass obj;
// 调用 foo(),合法
obj.foo();
// 调用 bar(),合法
MyClass().bar();
// 下面两行代码将导致编译错误
// obj.bar(); // 错误,bar() 只能在右值上调用
// MyClass().foo(); // 错误,foo() 只能在左值上调用
return 0;
}
在上述例子中,foo() 成员函数使用 & 引用限定符,表示它只能在左值上调用。而 bar() 成员函数使用 && 引用限定符,表示它只能在右值上调用。
Q30 位运算
按位与(&)、按位或(|)、按位异或(^),按位取反(~)、左移(<<)、右移(>>)
优势有两点:1.速度快。2.节省寄存器/内存空间。
在C++中,如何处理int型负数最高位(是1)的左移或者右移?
不同编译器处理的方法不同。此操作在C++中属于未定义的行为。所以不要使用带符号的整数参加位运算。
如何判断一个数是不是2的整数次方?
使用这个数与这个数-1按位与,如果结果是0,则这个数是2的整数次方,否则不是。
bool is_power_of_two(unsigned int n)
{
return n & (n-1) == 0;
}
如何使用位运算交换两个数,而不能申请额外的空间?
可以使用异或操作,原理是一个数异或两次同一个数,结果等于原值。
void swap(unsigned int& a, unsigned int& b)
{
a = a ^ b; // a = a ^ b;
b = a ^ b; // b = a ^ b ^ b = a;
a = a ^ b; // a = a ^ b ^ a = b;
}
如何获取一个数字中的某一位是0 还是1?
把这个数字右移x位,然后与&1。
bool get_bit(unsigned int n, unsigned int x)
{
return 1 & (n >> x);
}
如何将一个数的某一位置成0/置成1,或取反?
将某一位置成0:
int clearBit(int num, int pos) {
return num & ~(1 << pos);
}
这个函数将 num 中的第 pos 位设置成0,其余位保持不变。
将某一位置成1:
int setBit(int num, int pos) {
return num | (1 << pos);
}
这个函数将 num 中的第 pos 位置成1,其余位保持不变。
某一位置取反:
int toggleBit(int num, int pos) {
return num ^ (1 << pos);
}
这个函数将 num 中的第 pos 位取反,其余位保持不变。
这里的 << 表示左移操作,& 表示按位与操作,| 表示按位或操作,~ 表示按位取反操作,^ 表示按位异或操作。
使用这些函数,你可以根据需要对某一位进行相应的操作。例如:
#include <iostream>
int main() {
int num = 5; // 二进制表示为 0101
// 将第2位设置成0
int result1 = clearBit(num, 1);
std::cout << result1 << std::endl; // 输出 1
// 将第3位设置成1
int result2 = setBit(num, 2);
std::cout << result2 << std::endl; // 输出 7
// 将第1位取反
int result3 = toggleBit(num, 0);
std::cout << result3 << std::endl; // 输出 4
return 0;
}
Q31 C++可执行体
链接:https://zhuanlan.zhihu.com/p/637162044
可调用对象包括函数、函数指针、类的静态方法、类的成员方法、仿函数、lambda表达式。
函数是一段代码,执行特定任务,接受参数并返回值。
int add(int a, int b)
{
return a + b;
}
函数指针是指向函数的指针。
int add(int a, int b)
{
return a + b;
}
using addptr = int(*)(int,int);
addptr ptr = &add; //函数指针
int sum = addptr(1,2);
类的静态方法和函数基本一致,只不过此静态方法属于整个类所有。而成员方法则只能通过类的实例来调用。
class Foo
{
public:
static int add(int a, int b) { return a + b;} //静态方法
int add2(int a, int b) { return a + b; } //成员方法
};
int sum1 = Foo::add(1,2);
Foo f;
int sum2 = f.add2(2,1);
仿函数是一个类或结构体,重载了()运算符。
struct Add
{
int operator()(int a, int b) { return a+ b;}
};
int sum = Add()(1,2);
lambda表达式在C++11时被引入,本质上是是一个匿名函数。
auto add = [](int a, int b) {return a + b;};
int sum = add(1,2);
//or
int a =1 , b = 2;
auto ladd = [a,b](){return a + b;};
int sum = ladd();
std::function,在C++11引入的,它的对象可以把以上所说的可执行体保存起来。
std::function<int(int,int)> add_fun;
add_fun = add;
add_fun = ptr;
add_fun = Foo::add;
add_fun =std::bind(&Foo::add2,&f,std::placeholders::_1,std::placeholders::_2);
add_fun = Add();
add_fun = ladd;
从性能上讲,函数、类的静态/成员方法(非虚方法)、仿函数和lambda表达式的性能基本相同,而函数指针和std::function的性能要差一些。因为函数指针和std::function的实例没有办法被内联。
Q32 多线程避免死锁
链接:https://zhuanlan.zhihu.com/p/641510924
在C++中,锁(Lock)是一种同步工具,用于保护共享资源,防止多个线程同时访问,从而避免数据竞争和不一致。
从种类上分,可以分为普通锁、读写锁、递归锁等种类。
从实现上分,可以分为互斥锁、自旋锁、信号量、条件变量等。
在C++11之前,C++便准层面并没有定义锁,锁的应用要依赖于平台。Linux下使用pthread库中的mutex;
#include <pthread.h>
pthread_mutex_t mutex_ = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&mutex_);
//被保护的区域
pthread_mutex_unlock(&mutex_);
C++11引入了std::mutex,统一了各个平台上互斥锁的使用:
#include <mutex>
std::mutex mutex_;
mutex_.lock();
//被保护的区域
mutex_.unlock();
pthread_mutex和std::mutex有没有非阻塞的api?
有的,分别是pthread_mutex_trylock()和try_lock(),当获取不到锁时这两者并不阻塞当前线程,而是立即返回。需要注意的是,当pthread_mutex_trylock()获取到锁时返回0,而std::mutex::try_lock()方法获取不到锁时返回false。
std::lock_guard和std::unique_lock有什么相同点和不同点?
相同点是两者都使用RAII(资源获取即初始化)技术实现的锁,支持自动上锁,自动解锁。
不同点主要包括三个方面:
- 灵活性:std::unqiue_lock的灵活性要高std::lock_gurad,std::unique_lock可以在任何时间解锁和锁定,而std::lock_guard在构造时锁定,在析构时解锁,不能手动控制。
- 所有权:std::unique_lock支持所有权转移,而std::lock_gurad不支持。
- 性能:由于std::unique_lock的灵活性更高,它的性能可能会稍微低一些。
实现一个lock_gurad
class lock_guard
{
explicit lock_guard(std::mutex& m):mutex_(m)
{
mutex_.lock();
}
~lock_guard()
{
mutex_unlock();
}
private:
std::mutex& mutex_;
};
当进程A持有锁1请求锁2,进程B持有锁2请求锁1时,两者都不会释放自己的锁,两者都需要对方的锁,就会造成死锁。当然现实中可能比这要复杂,但原理是相同的。
避免死锁主要从以下几个方面入手:
- 避免循环等待,如果需要在业务中获取不同的锁,保证所有业务按照相同的顺序获取锁。
- 使用超时锁,当锁超时时,自动释放锁。
- 使用try_lock,当锁被占用时,返回false并继续执行。
- 锁的粒度尽量要小,只保护竟态数据而不是整个流程。
adopt_lock_t/defer_lock_t/try_to_lock_t这三种类型的用法吗?
adopt_lock_t/defer_lock_t/try_to_lock_t都是空类,主要表示std::lock_gurad和std::unqiue_lock的默认构造中的操作:
adopt_lock_t:默认互斥量已被当前线程锁定,不使用lock()方法对互斥量加锁:
std::mutex mtx_;
mtx_.lock(); //lock
{
std::lock_guard<std::mutex> lock_(mtx_,std::adopt_lock); //这里默认当前线程已经对mtx_加过锁
...
}//unlock
defer_lock_t:虽然我拥有了std::mutex的引用,但是在构造函数中并不调用lock()方法对互斥量加锁:
std::mutex mtx_;
{
std::unique_lock<std::mutex> ulock_(mtx_,std::defer_lock); //这里并没有加锁
ulock_.lock();
if(ulock_.owns_lock())
{
//locked
}else
{
//unlocked
}
}//if locked,unlock
try_to_lock_t:在构造函数执行是并不是使用lock()方法加锁,而是使用try_lock()方法加锁:
std::mutex mtx_;
{
std::unique_lock<std::mutex> ulock_(mtx_,std::try_to_lock); //这里mtx_如果没有被锁定,则加锁成功,否则加锁失败
if(ulock_.owns_lock())
{
//locked
}else
{
//unlocked
}
}//if locked,unlock
adopt_lock_t可以用于std::lock_gurad和std::unique_lock,defer_lock_t/try_to_lock_t只能用于std::unique_lock。
Q33 std::array
链接:https://zhuanlan.zhihu.com/p/639498604
原生数组(非动态数组)和std::array
都在栈上开辟空间,初始化的时候需要提供数组长度,且长度不可改变。
区别是std::array
提供了安全的下标访问方法at,当下标越界时会抛出异常。
在当作参数传递时,原生数组会退化为指针,而std::array
会保留类型和长度信息。
空数组的长度应该是0,但是长度为0的std::array
对象的长度是1,因为它是空类。
如果一个类型的拷贝构造函数和拷贝赋值运算符是被删除的,可以使用std::array
存储它吗?
可以。只是不能传递这个std::array
对象,而只能传递这个对象的引用或指针。
array的性能更好,array的内存分配在栈上,编译时候确定需要在栈上开辟的空间。vector的元素存在堆上,需要开辟和释放堆内存。
但vector更灵活,如果能提前确定数据量,使用reserve函数一次性开辟空间,性能和array没有太大的差距。
array能在编译时候确定需要在栈上开辟的空间,请问array在编译时还能够做些什么?
比如给定一个array,我们可以在编译时求它所以元素的和。
#include <iostream>
#include <array>
constexpr int sum(auto arr)
{
int res = 0;
for (size_t i = 0; i < arr.size(); i++) res += arr[i];
return res;
}
int main(int argc, char const *argv[])
{
constexpr std::array arr = {1,2,3,4,5,6,7,8,9};
constexpr int res = sum(arr);
std::cout << res << std::endl;
return 0;
}
//g++ test.cpp -std=c++20
使用array实现编译期排序
#include <iostream>
#include <array>
constexpr auto sort(auto arr)
{
for ( int i = 0; i < arr.size() -1; i++)
{
for ( int j = 0; j < arr.size() - i -1; j++)
{
if (arr[j] < arr[j + 1])
{
auto tmp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tmp;
}
}
}
return arr;
}
int main(int argc, char const *argv[])
{
constexpr std::array arr {10.5, 28.6, 4.4, 23.8, 12.4, 3.8, 19.7, 17.5, 19.1, 0.6, 1.9, 1.5, 25.4, 5.4};
constexpr auto arr2 = sort(arr);
for (size_t i = 0; i < arr2.size(); i++)
{
std::cout << arr2[i] << std::endl;
}
return 0;
}
//g++ test2.cpp -std=c++20
使用了C++20的auto
参数类型自动推断特性,实现了类似于泛型的效果。arr可以是任何实现了constexpr opearator<
函数的类型。
Q34 以下代码的输出是什么?
链接:https://zhuanlan.zhihu.com/p/639218958
#include <iostream>
#include <list>
int main(int argc, char const *argv[])
{
std::list<int> li = {1,2,3,4,5,6};
for(auto it = li.begin(); it!= li.end(); ++it)
{
if(0 == *it % 2) li.erase(it);
}
for(auto& i : li) std::cout << i << " ";
std::cout << std::endl;
}
这里实际上会输出Segmentation fault
,原因是因为当从list
中erase
这个node
,这个node
的prev
和next
指针被清空,而++it
是通过当前的node
的next
指针去找下一个node
,解引用一个空指针,导致coredump
。
erase函数返回下一个有效迭代器,所以可以把if(0 == *it % 2) li.erase(it)
修改为if(0 == *it % 2) it = li.erase(it)
来解决这个问题。
Q35 类型转换
链接:https://zhuanlan.zhihu.com/p/638559078
C++11引入新的关键字规范了类型转换,引入四种新的类型转换,分别是static_cast、dynamic_cast、const_cast、和reinterpret_cast。
static_cast用途最广泛,除了后面三种类型转换外,其他的类型转换都能使用static_cast完成。
dynamic_cast主要用于运行时的从父类指针向子类指针转换,如果转换不成功则返回nullptr。
#include <iostream>
struct Base
{
virtual void fun() {}
};
struct Derived : public Base
{
virtual void fun() override {}
};
int main(int argc, char const *argv[])
{
Base* b1 = new Base;
Base* b2 = new Derived;
Derived* d1 = dynamic_cast<Derived*>(b1); //d1 == nullptr
Derived* d2 = dynamic_cast<Derived*>(b2); //d2 != nullptr
}
const_cast主要用于去除指针或引用类型的const属性。此操作可能会导致未定义的行为,所以需要慎用。
#include <iostream>
void function(const int& val)
{
int& v = const_cast<int&>(val);
v = 42;
}
int main(int argc, char const *argv[])
{
int val = 1024;
function(val);
std::cout << val << std::endl; //val == 42
}
//-----------------------------------------------
#include <iostream>
static constexpr int val_static = 1024;
void function(const int& val)
{
int& v = const_cast<int&>(val);
v = 42;
}
int main(int argc, char const *argv[])
{
function(val_static);
std::cout << val_static << std::endl;
}
// Segmentation fault
reinterpret_cast可以将指针或引用转换为任何类型的指针或引用。reinterpret_cast实现依赖于编译器和硬件,可能导致未定义的行为。
#include <iostream>
int main(int argc, char const *argv[])
{
int i = 42;
double d = 42.0;
long* l1 = reinterpret_cast<long*>(&i);
long* l2 = reinterpret_cast<long*>(&d);
std::cout << *l1 << std::endl; //*i1 == 42
std::cout << *l2 << std::endl; //*i2 == 4631107791820423168 X86_64 GCC 11.3
}
C++11为什么还要引入新的类型转换关键字?
主要有三点,更安全、更灵活、可读性更好。
隐式转换是指在表达式中自动进行的类型转换。比如int 和 double相加,会把int先转为double,然后再进行求和。
隐式转换的优势是代码简洁。但是有很大缺陷,有些情况隐式转换的结果和程序员的意图不一致,会导致难以发现的问题。所以在实际项目中一般会添加编译选项-Werror=conversion来禁止隐式转换。
explicit关键字也是禁止隐式转换的一个方式:
struct Foo
{
Foo(int i):val_(i){}
int val_;
};
struct Goo
{
explicit Goo(int i):val_(i){}
int val_;
};
void function1(Foo f){}
void function2(Goo g){}
int main(int argc, char const *argv[])
{
Foo f = 1024; //编译通过,可以把int类型转换成Foo
Goo g = 1024; //编译失败,不能把int类型转换成Goo
function1(42); //编译通过,可以把int类型转换成Foo
function2(42); //编译失败,不能把int类型转换成Goo
}
把一个自定义类型转换成一个int类型需要重载operator int()运算符:
#include <iostream>
struct Foo
{
Foo(double d):val_(d){}
double val_;
explicit operator int(){
return static_cast<int>(val_);
}
};
int main(int argc, char const *argv[])
{
Foo f(42.5);
int i = static_cast<int>(f);
std::cout << i << std::endl; //i == 42
}
Q36 malloc函数返回的类型是void*,在free的时候怎么知道这块内存的大小的呢?
在C语言中,malloc 函数返回的指针是 void* 类型,确实无法直接知道分配的内存大小。为了在释放内存时知道大小,通常需要进行一些额外的处理。
一种常见的方法是在分配内存时,分配的大小信息保存在分配的内存块之前。这样,在释放内存时,可以先获取保存的大小信息,然后再调用 free 函数释放内存。
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
// 在分配内存时,保存大小信息
void* my_malloc(size_t size) {
// 使用 uintptr_t 保证指针大小足够存储 size
size_t total_size = size + sizeof(size_t);
size_t* ptr = malloc(total_size);
if (ptr != NULL) {
// 将大小信息保存在分配的内存块之前
*ptr = size;
// 返回分配的内存块的起始地址(跳过大小信息部分)
return (void*)(ptr + 1);
} else {
return NULL; // 分配失败
}
}
// 在释放内存时,获取大小信息并释放内存
void my_free(void* ptr) {
if (ptr != NULL) {
// 回到大小信息的起始位置
size_t* size_ptr = (size_t*)ptr - 1;
// 获取保存的大小信息
size_t size = *size_ptr;
// 调用 free 释放内存
free(size_ptr);
}
}
int main() {
// 使用自定义的分配和释放函数
int* arr = (int*)my_malloc(5 * sizeof(int));
if (arr != NULL) {
// 使用分配的内存
for (int i = 0; i < 5; ++i) {
arr[i] = i;
}
// 使用自定义的释放函数
my_free(arr);
}
return 0;
}
my_malloc 函数在分配内存时,多分配了一些额外的空间保存了分配的大小信息。在 my_free 函数中,通过回到大小信息的起始位置,获取大小信息,然后调用 free 释放整个内存块。这种方式可以在释放内存时知道大小,但也增加了内存的开销。
不同的厂商实现的方式不尽相同。以ptmalloc为例,当使用malloc申请size = 16的内存时,malloc会从内存池中分配一块sizeof(chunk)+16长度的内存。chunk段保存了一些前后chunk的信息,也保存了这块内存的大小(16)。malloc函数返回的地址是0x00001000,而在free(0x00001000)时,free函数会用0x00001000减去特定值(sizeof(chunk)),得到chunk的起始地址,从chunk中获取这块内存真正的尺寸,从而完成free的任务。
Q37 移动语义
链接:https://zhuanlan.zhihu.com/p/637696565
移动构造函数的noexcept关键字能省略吗?
这里尽量不要省略。如果省略,编译器会推断是否会抛出异常。如果移动构造函数可能会抛出异常,则编译器不会将其标记为noexcept。当编译器不标记为noexcept时,为了保证程序的正确性,编译器可能会采用拷贝构造的方式实现移动构造,从而导致效率降低。
需要注意的是,如果标记了noexcept但在移动时抛出了异常,则程序会调用std::terminate()函数来终止运行。
知道std::move是如何实现的吗?
通过static_cast实现的,讲左值强行转换成右值,用来匹配移动语义而非拷贝。
template<typename T>
typename std::remove_reference<T>::type&& move(T&& t) { return static_cast<typename std::remove_reference<T>::type&&>(t);}
万能引用是如何实现的?
万能引用主要使用了引用折叠技术,
template<typename T>
void function(T&& t) { ...}
当T类型为左值时,&& & 被折叠为&, 当T类型为右值时,&& &&被折叠称为&&。以下是折叠规则:
& & -> &
& && -> &
&& & -> &
&& && -> &&
完美转发知道吗?
当我们需要在function中传递t参数时,如何保证它的左值或右值语义呢?这时候完美转发就登场了:
template<typename T>
void function2(T&& t2) {}
template<typename T>
void function(T&& t)
{
function2(t);
}
当传入的参数t的类型时右值时,由于引用折叠还是右值,此时的t虽然时一个右值引用,但t本身却是一个左值!这里非常的不好理解。
如果我们把t直接传入到function2,那么function2中的t2会被推导成左值,达不到我们的目标。
如果在调用function2时传入std::move(t),当t是右值时没有问题,但当t是左值时,把t移动到t2,t在外部不在能用。
这也不符合我们的预期。此时std::forward闪亮登场!
template<typename T>
void function2(T&& t2) {}
template<typename T>
void function(T&& t)
{
function2(std::forward<T&&>(t));
}
std::forward使用了编译时多态(SFINAE)技术,使得当参数t是左值是和右值是匹配不同的实现,完成返回不同类型引用的目的。以下是标准库的实现:
template <typename _Tp>
constexpr _Tp && forward(typename std::remove_reference<_Tp>::type &&__t) noexcept
{
return static_cast<_Tp &&>(__t);
}
template <typename _Tp>
constexpr typename std::remove_reference<_Tp>::type && move(_Tp &&__t) noexcept
{
return static_cast<typename std::remove_reference<_Tp>::type &&>(__t);
}
Q38 模板特化和偏特化
模板特化和偏特化是C++模板元编程中的两个重要概念,用于处理模板适用于不同类型的情况。下面详细解释这两个概念:
模板特化(Template Specialization):
模板特化允许为特定的类型提供定制的模板实现。当模板被实例化时,如果传入的类型与特化的类型匹配,编译器将选择使用特化版本而不是通用版本。
template <typename T>
class MyClass {
// 通用实现
};
template <>
class MyClass<int> {
// 针对int类型的特化实现
};
// 使用
MyClass<double> obj1; // 使用通用实现
MyClass<int> obj2; // 使用特化实现
使用template <>语法来声明特化版本。
可以为任何模板类或函数进行特化。
通常在头文件中提供通用模板,特化版本可以在其他文件中提供。
模板偏特化(Template Partial Specialization):
模板偏特化是对模板进行特化,但不是为特定类型提供完整的特化版本,而是为模板中的部分参数提供特化。通常用于处理模板参数的部分匹配的情况。
template <typename T, typename U>
class MyPair {
// 通用实现
};
// 针对指针类型的部分特化
template <typename T>
class MyPair<T, T*> {
// 针对指针类型的特化实现
};
// 使用
MyPair<int, double> obj1; // 使用通用实现
MyPair<int, int*> obj2; // 使用部分特化实现
使用template <typename T>
语法,但只提供部分类型。
通常用于处理特定类型或类型组合的情况,而不是所有可能的类型。
模板特化允许为特定类型提供完整的特化版本。
模板偏特化允许为模板参数的部分提供特化版本,通常用于处理部分匹配的情况。
Q39 gcc hello.c 这行命令具体的执行过程
执行 gcc hello.c 命令时,实际上涉及了一系列步骤,这包括了源代码的编译、汇编、链接等过程。
预处理(Preprocessing):
首先,源文件 hello.c 将会经过预处理器处理,展开所有的宏、处理条件编译指令等。生成一个经过预处理的文件,一般以 .i 或 .ii 为扩展名。
hello.c -o hello.i
编译(Compilation):
预处理后的文件将会被编译成汇编代码。这个阶段主要是将高级语言的代码翻译成汇编语言,但并没有生成可执行文件。
hello.i -o hello.s
如果是直接编译生成可执行文件而不生成汇编文件,可以使用以下命令:
hello.c -o hello.o
汇编(Assembling):
汇编器将汇编代码翻译成机器码,并生成一个目标文件。一般目标文件的扩展名为 .o 或 .obj。
hello.s -o hello.o
链接(Linking):
在链接阶段,将所有的目标文件和可能需要的库文件链接在一起,生成最终的可执行文件。在这一步,可能会涉及符号解析、地址重定向等操作。
hello.o -o hello
运行:
最终生成的可执行文件 hello 可以被运行。
总的来说,gcc hello.c 这个命令实际上执行了预处理、编译、汇编、链接这一系列步骤,将源代码转换为可执行文件。
Q40 动态链接库和静态链接库的区别
动态链接库(Dynamic Link Library,DLL)和静态链接库(Static Link Library,LIB)是两种不同的库文件
静态链接库(Static Link Library,LIB):
链接方式:在编译时,编译器将库的代码和程序的代码合并成一个可执行文件。这个过程称为静态链接。
文件格式:静态链接库通常以 .lib(Windows)或 .a(Unix/Linux)为扩展名。
加载时机:链接时被嵌入到可执行文件中,程序运行时不再需要外部库。
优点:不依赖外部库,程序独立运行。编译后的可执行文件较小。
缺点:更新库时需要重新编译程序。不同程序使用相同的库时,可能会导致代码冗余。
动态链接库(Dynamic Link Library,DLL)
链接方式:在编译时仅包含对库的引用,而在运行时由操作系统的动态链接器加载并链接。这个过程称为动态链接。
文件格式:动态链接库通常以 .dll(Windows)或 .so(Unix/Linux)为扩展名。
加载时机:在程序运行时,由操作系统的动态链接器将库加载到内存中,并与程序建立链接关系。
优点:可以实现共享库,多个程序可以共享同一份库的实例,节省内存。更新库时不需要重新编译程序。
缺点:程序运行时需要确保正确的库版本存在,否则可能会导致运行时错误。可执行文件较大,因为它只包含对库的引用。
静态链接库:编译时链接。链接到可执行文件中。不需要外部库在运行时。可执行文件较小,但可能有冗余代码。
动态链接库:运行时链接。在程序运行时由动态链接器加载。可实现库的共享,多个程序可以共享同一份库的实例。可执行文件较大,但节省内存并支持动态更新。
Q41 链接各个库有顺序的要求嘛?
在链接程序时,通常对库的链接顺序有一些要求,尤其是在使用静态链接库(.lib 或 .a)时。这是因为链接器按照从左到右的顺序处理库,解析符号的顺序可能会影响最终的链接结果。
静态链接库的链接顺序:
从左到右的顺序,链接器在处理库时,是从左到右的顺序逐个解析库中的符号。因此,如果符号在左边的库中被解析了,那么在右边的库中就不再解析。
依赖关系:如果库 A 依赖于库 B,那么库 A 应该放在库 B 的前面。这是因为链接器按照从左到右的顺序解析符号,如果库 A 在库 B 的前面,链接器会在库 A 中解析符号,然后在库 B 中解析符号。
myprogram myprogram.o -lA -lB -lC
示例中,-lA 应该在 -lB 和 -lC 的前面,因为程序可能依赖于库 A 中的符号。
动态链接库的链接顺序:
对于动态链接库(.dll 或 .so),通常不需要关心链接顺序,因为符号解析是在运行时由动态链接器处理的。动态链接库中的符号解析是按需进行的,而不是在链接阶段完成的。因此,动态链接库的链接顺序通常不是一个问题。
myprogram myprogram.o -lA -lB -lC
示例中,-lA、-lB 和 -lC 的顺序通常不会引起问题,因为符号解析是在运行时进行的。总的来说,对于静态链接库,需要考虑库之间的依赖关系和从左到右的链接顺序;而对于动态链接库,通常不需要过多关注链接顺序。
网络编程
Q1 c++网络编程socket通信
- Socket 是一种抽象层,提供了对网络通信的接口,通常位于传输层。它允许应用程序通过网络发送和接收数据。Socket 可以在不同层次的网络协议栈上实现,但通常在传输层上进行操作。
- 在 C++ 中,使用 socket 进行网络编程意味着你可以创建一个套接字(socket),然后将其绑定到特定的端口上以便监听和处理传入的连接请求,或者将其连接到远程主机的特定端口以进行通信。
- Socket 本身不直接与 HTTP 通信,因为 HTTP 是一个应用层协议,而 socket 位于更低的传输层。但是,可以使用 socket 在传输层上建立连接,然后在连接上发送 HTTP 请求和接收 HTTP 响应。在实际编程中,常见的是使用类库(如 libcurl、Boost.Asio 等)来简化基于 socket 的网络编程,这些库提供了更高层次的抽象和更易用的 API。
- 接收数据的层次通常仍然是通过 socket 来实现,通过读取来自网络的数据流。在使用 socket 进行网络通信时,接收数据的过程通常涉及使用 socket API 中的 recv() 或类似函数来接收数据。数据到达时,它会被接收到 socket 缓冲区,应用程序可以从缓冲区中读取数据进行处理。
Q2 创建一个socket后,内核态和用户态会发生什么
创建一个套接字后,涉及到内核态和用户态的一系列操作。首先,用户态的应用程序通过系统调用(如 socket、bind、listen 等)向操作系统内核发出请求。内核会进行以下主要步骤:
- 用户态 -> 内核态切换: 应用程序通过系统调用请求进入内核态,执行相应的套接字创建、绑定等操作。
- 套接字创建: 内核分配一个文件描述符(整数),用于标识套接字。同时,内核创建套接字的数据结构,并分配一些资源用于管理套接字的状态。
- 用户态 -> 内核态切换: 内核执行完毕后,将控制权返回给用户态应用程序。
- 套接字绑定和监听: 应用程序调用 bind 将套接字与地址绑定,然后调用 listen 开始监听连接。这些操作涉及内核更新套接字状态和设置相应的参数。
- 用户态 -> 内核态切换: 用户态应用程序继续等待连接请求时,可能会调用 accept。这会触发一次用户态到内核态的切换。
- 等待连接: 内核维护了一个等待连接的队列,并在有连接请求时,将连接信息返回给应用程序。此时,应用程序可以继续执行后续的通信操作。
整个过程中,内核负责管理套接字的状态、资源分配和网络协议栈的操作,而用户态应用程序则通过系统调用来发起请求和处理返回结果。这样的设计实现了用户程序和底层网络协议栈的分离,保证了安全性和可维护性。
Q3 accept函数什么时候拿到客户端的fd
在服务器端使用 accept 函数时,当有客户端发起连接请求并且连接成功时,accept 函数会返回一个新的套接字描述符(file descriptor),该描述符用于后续与该客户端的通信。
accept 函数会阻塞等待客户端的连接请求。一旦有客户端连接请求到达,accept 就会返回,返回的套接字描述符就是用于与该客户端进行通信的描述符。
操作系统
Q1 reactor和proactor区别
Reactor 和 Proactor 都是用于处理异步 I/O 的设计模式,通常用于构建高性能的并发网络应用程序,在处理异步 I/O 时有一些不同之处:
- Reactor 模式:
Reactor 模式基于事件驱动,其核心思想是一个事件循环(event loop)。在这个模式下,应用程序将 I/O 操作(如读取、写入等)交给一个中央事件处理器(称为 Reactor)处理。Reactor 负责监视所有的 I/O 事件,并分发这些事件到合适的处理程序(又称为事件处理器或回调)进行处理。
在 Reactor 模式中,当一个 I/O 操作完成时,应用程序必须主动询问操作是否已经完成,然后才能继续进行下一步操作。 - Proactor 模式:
Proactor 模式也是一种事件驱动模式,但它更进一步地封装了异步操作。在 Proactor 模式中,应用程序发出一个异步操作请求(如读取或写入),然后继续进行其他工作。操作完成后,系统会通知应用程序,告知操作已完成,可以处理结果了。
这意味着 Proactor 模式下,应用程序发起了一个异步操作后,可以继续执行其他任务,操作完成后会接收到通知,不需要显式地等待操作完成。
Reactor 和 Proactor 都是处理异步 I/O 操作的模式,但它们的主要区别在于异步操作的处理方式:Reactor 模式需要应用程序主动查询操作是否完成,而 Proactor 模式则是在操作完成后异步通知应用程序。
Q2 为什么要有主线程/从线程
原因:
- 并发处理: 多线程允许程序同时执行多个任务,这样可以充分利用多核处理器的性能优势,提高程序的执行效率。主线程通常是程序的起点,而子线程则是由主线程创建的辅助线程,用于执行一些独立的任务。
- 任务分解: 主线程可以将任务分配给多个子线程来同时执行,从而提高整体的处理能力。这样可以将复杂的任务分解成多个小的子任务,每个子线程负责执行其中一个子任务。
- 提高响应性: 在一些需要同时进行多项工作的情况下,使用多线程可以保持程序的响应性。例如,在图形用户界面(GUI)应用中,主线程用于处理用户交互事件,而子线程可以用于执行耗时的计算或I/O操作,以避免阻塞主线程导致界面卡顿。
- 并发控制: 多线程也为并发控制提供了一种方式。通过使用线程同步机制(如互斥锁、信号量、条件变量等),可以确保多个线程安全地访问共享资源,避免出现竞态条件(Race Condition)等并发问题。
- 任务分配和负载均衡: 在一些需要大量计算的场景下,可以将任务分配到多个子线程上,以实现负载均衡,从而更好地利用系统资源。
主线程和子线程的存在可以提高程序的并发性、响应性和效率,允许程序同时执行多个任务,从而更好地利用计算机系统的资源。
Q3 有限状态机
有限状态机(Finite State Machine,FSM)是一种抽象的计算模型,用于描述对象或系统在不同状态之间的转换和行为。它可以用来建模和解决许多问题,从硬件设计到软件开发都有广泛的应用。
描述:
有限状态机由一组状态、事件和转换组成:
状态(State): 系统或对象可以处于的某个状态。例如,对于一个自动售货机,可能有“待命”、“选择商品”、“投币”、“出售商品”等状态。
事件(Event): 引起状态转换的触发器。例如,用户投入硬币、选择商品等。
转换(Transition): 由事件触发引起的状态变化。从一个状态到另一个状态的跃迁。当某个事件发生时,有限状态机会根据当前状态执行相应的动作,并可能转移到另一个状态。
原理:
有限状态机可以通过不同的表示方式实现,包括状态图(State Diagram)、状态表(State Table)、状态转换图等。其中,状态图通常用图形化形式展示状态之间的转换关系,以状态节点和转换箭头的形式展示。
有限状态机可以分为两种主要类型:
确定性有限状态机(Deterministic Finite Automaton,DFA): 对于给定的输入,只有一种可能的转换路径。在任何给定时间,DFA只处于一个状态。
非确定性有限状态机(Nondeterministic Finite Automaton,NFA): 对于给定的输入,可能有多种转换路径。在任何给定时间,NFA可以处于多个状态。
有限状态机有多种应用:
- 模型建模: 用于描述对象或系统的行为和状态转换,例如计算机网络通信协议、编译器的词法分析和语法分析、控制系统等。
- 软件开发: 在软件开发中,有限状态机可以用于实现状态驱动的逻辑,例如游戏开发中的角色状态、自动机器人、工作流程等。
- 硬件设计: 在数字电路设计中,有限状态机被广泛用于描述电路的控制逻辑。
- 行为设计: 用于描述和实现系统的特定行为模式,例如状态模式、行为树等。
有限状态机可以帮助理解和描述系统的行为,提供了清晰且有效的方式来管理和控制复杂系统的状态和转换。
Q4 select,poll,epoll区别
select、poll 和 epoll 都是用于 I/O 多路复用的机制,允许在单个线程中处理多个 I/O 事件:
select:
select 是最古老的多路复用机制,适用于所有平台。
有文件描述符数量限制,一般为 1024。
每次调用 select 都需要将文件描述符集合从用户态拷贝到内核态,效率可能较低。
对文件描述符进行线性扫描,当文件描述符数量较多时性能下降明显。
poll:
poll 也是一种多路复用机制,不同于 select,没有文件描述符数量限制。
每次调用 poll 需要将文件描述符集合拷贝到内核态,效率与文件描述符数量成线性关系。
epoll:
epoll 是 Linux 特有的高性能多路复用机制。
使用事件通知的方式避免了 select 和 poll 中的线性扫描问题,可以监听大量文件描述符。
epoll 使用基于事件的就绪通知,只返回已就绪的文件描述符,效率更高。
支持水平触发(LT)和边缘触发(ET)两种模式。
epoll 在处理大量文件描述符时性能更好,避免了 select 和 poll 中的性能瓶颈问题,特别适用于高并发的网络应用。但epoll 只能在 Linux 环境下使用。select 和 poll 有更广泛的跨平台兼容性。
Q5 进程与线程,进程状态以及转换
进程与线程的区别:
进程(Process):
独立执行的程序实例,有独立的地址空间,包含代码、数据和资源。
拥有独立的堆和栈,进程间通信开销大。
资源分配由操作系统负责,进程间相互独立。
线程(Thread):
进程的一部分,与同一进程的其他线程共享地址空间和资源。
共享相同的堆和栈,线程间通信开销小。
线程被视为轻量级进程,创建销毁速度快,切换开销小。
进程的状态及转换:
在典型的操作系统中,进程通常经历以下几种状态:
创建(Created): 进程正在创建中,尚未被调度执行。
就绪(Ready): 进程已准备好执行,等待分配 CPU 时间片。
运行(Running): 进程正在 CPU 上执行。
阻塞(Blocked): 进程暂时无法执行,等待某种事件发生,如等待 I/O 完成或资源可用。
终止(Terminated): 进程执行完毕或被手动终止。
进程在这些状态之间转换,通常的转换关系为:
创建状态 -> 就绪状态:进程被创建后,准备好执行。
就绪状态 -> 运行状态:进程被分配 CPU 时间片并执行。
运行状态 -> 就绪状态:时间片用完或等待事件发生。
运行状态 -> 阻塞状态:等待某些事件的完成或资源的释放。
阻塞状态 -> 就绪状态:等待的事件发生或资源可用。
Q6 孤儿进程,僵尸进程,守护进程
孤儿进程(Orphan Process):
当父进程结束或者意外终止时,子进程可能成为孤儿进程。孤儿进程将由操作系统的进程管理模块接管,它们的父进程会被系统指定为 init 进程(PID 为1)。
孤儿进程不再有父进程,因此由操作系统负责收养和管理。
僵尸进程(Zombie Process):
当一个进程执行完成后,其父进程却没有调用 wait() 或 waitpid() 等系统调用来获取子进程的退出状态,子进程的进程描述符依然保留在系统中,此时子进程成为僵尸进程。
僵尸进程不占用内存资源,但会占用进程号和部分系统资源,应当由父进程调用 wait() 等来释放僵尸进程。
守护进程(Daemon Process):
在 UNIX 或类 UNIX 系统中运行的一种特殊进程,通常在后台运行,没有控制终端。
守护进程通常被设计用来执行特定的系统任务,例如监听服务请求、周期性任务等。它们通常在系统启动时启动,并一直运行直到系统关闭。
Q7 进程间通信方式
进程间通信(Inter-Process Communication,IPC)是指不同进程之间交换数据或信息的机制。在操作系统中,有多种方式实现进程间通信:
管道(Pipes):
单向通信管道,通常用于具有亲缘关系的父子进程间通信。
包括匿名管道(只能在相关进程间使用)和命名管道(允许无关的进程之间通信)。
消息队列(Message Queues):
通过消息队列传递数据,允许进程按照一定顺序收发消息。
提供了一种异步通信方式,不同进程之间通过消息传递进行通信。
共享内存(Shared Memory):
允许多个进程访问同一块物理内存,因此进程间的数据共享效率高。
需要额外的同步机制来避免数据冲突和一致性问题。
信号量(Semaphores):
用于进程间同步和互斥的机制,可用于控制多个进程对共享资源的访问。
通过对信号量进行操作来实现对共享资源的访问控制。
套接字(Sockets):
在网络编程中常用,也可以在本地进程间进行通信。
提供了一种灵活、通用的进程间通信机制,允许通过网络协议或本地接口进行数据交换。
Q8 死锁
死锁(Deadlock)是指在多个进程或线程中,每个进程/线程都在等待其他进程/线程释放资源或者完成某个动作,导致所有进程/线程都无法继续执行,从而形成一种僵持状态的现象。
死锁产生的条件通常包括以下四个要素,也称为死锁的必要条件:
互斥条件(Mutual Exclusion): 至少有一个资源是独占的,一次只能被一个进程/线程占用。
不可抢占条件(Hold and Wait): 进程/线程持有至少一个资源,并且在等待获取其他进程/线程持有的资源。
循环等待条件(Circular Wait): 进程/线程之间存在一种循环等待资源的关系,即进程/线程之间形成一个环路,每个进程/线程都在等待下一个进程/线程所持有的资源。
资源不足条件(Resource Depletion): 资源无法无限地分配,可能会出现多个进程/线程因为无法获取所需的资源而等待,从而形成死锁。
死锁的产生是因为进程/线程在资源竞争时出现相互等待的情况,每个进程/线程都在等待其他进程/线程释放资源,导致所有进程/线程都无法继续执行下去。
为了预防死锁,可以采用以下方法:
破坏死锁产生的必要条件:
确保资源的互斥性不会导致死锁。
避免进程持有一个资源时继续等待其他资源。
尽量避免循环等待。
死锁检测和解除:
检测系统中的死锁情况,并采取相应的措施解除死锁。
采用超时、资源剥夺等方式解除死锁。
资源分配策略:
预先分配资源,避免在执行过程中产生死锁。
使用银行家算法等资源分配算法进行资源管理。
Q9 乐观锁/悲观锁实现
悲观锁是一种并发控制的机制,它假设在并发访问时会发生冲突,因此在进行读写操作前,会直接对共享资源进行加锁,以防止其他线程同时访问,保证数据的一致性。常见的实现方式包括:
互斥锁(Mutex):
使用互斥锁来实现悲观锁,通过锁定共享资源来确保同一时间只有一个线程可以访问该资源。
在读写操作前,使用互斥锁进行加锁操作,访问结束后释放锁。
#include <mutex>
std::mutex mtx;
void criticalSection() {
mtx.lock(); // 加锁
// 访问共享资源的代码
mtx.unlock(); // 解锁
}
读写锁(Read-Write Lock):
读写锁允许多个线程同时读取共享资源,但当有写操作时需要互斥独占。
读操作时使用读锁,写操作时使用写锁,读锁与写锁之间互斥。
#include <shared_mutex>
std::shared_mutex rwMutex;
void readOperation() {
std::shared_lock<std::shared_mutex> lock(rwMutex); // 读锁
// 读取共享资源的代码
}
void writeOperation() {
std::unique_lock<std::shared_mutex> lock(rwMutex); // 写锁
// 写入共享资源的代码
}
悲观锁在操作共享资源时会频繁加锁和解锁,可能导致线程阻塞等待锁释放,降低系统的并发性能。因此,在使用悲观锁时需要注意锁的粒度和加锁时间,尽量减少锁的持有时间,避免长时间阻塞其他线程的访问。
悲观锁适用于对数据更新频繁、对数据一致性要求高的场景,能够确保数据在任何时刻都是正确的,但也会带来一定的性能开销。
乐观锁是一种并发控制的机制,它假设在大多数情况下,不会发生并发冲突,因此在进行并发操作时,不会立即对共享资源进行加锁,而是在更新操作前进行一次检查。常见的实现方式包括以下几种:
版本号或时间戳:
给数据行增加一个版本号或时间戳字段,每次更新数据时增加版本号或更新时间戳。
在进行更新操作前,先检查当前数据行的版本号或时间戳是否与自己持有的相同,若相同则允许更新,否则认为数据已经被其他进程修改,需要回滚或进行其他处理。
CAS(Compare and Swap)操作:
使用原子操作进行比较并替换的方式。例如,在多线程环境下,使用 compare_exchange_weak() 或 compare_exchange_strong() 等原子操作来比较当前值和预期值,若相等则替换为新值。
在乐观锁中,先读取当前值并保存,然后尝试使用 CAS 操作进行更新,如果比较并替换的操作失败,表示其他线程已经修改了数据,需要进行相应的处理。
乐观锁通常适用于读操作远远多于写操作的情况,因为它不会立即加锁,可以减少锁的争用,提高并发性能。但是,乐观锁在更新操作时需要做额外的版本号或时间戳检查或CAS操作,如果并发冲突较为频繁,可能导致不断的重试,增加了系统的开销。
悲观锁假设会有并发冲突,直接对共享资源进行加锁,以确保数据的一致性,适用于写操作频繁的场景。乐观锁则更适用于读操作较多、并发冲突较少的场景。
Q10 并发/并行
"并发"和"并行"是与多任务处理相关的概念,但有着不同的含义:
并发(Concurrency):
指的是系统同时处理多个任务的能力,这些任务可能在一段时间内交替执行,共享系统资源。并发并不一定意味着同时执行,而是在一段时间内有多个任务在执行。
示例:多个程序同时运行,操作系统轮流分配 CPU 时间片给它们,看起来好像是同时运行的。
并行(Parallelism):
指的是系统同时执行多个任务的能力,这些任务真正同时进行,每个任务在不同的处理器核心或计算单元上执行。
示例:多个线程同时在多个处理器核心上执行不同的任务,每个任务都在同一时刻进行。
"并发"强调的是任务的交替执行,多个任务在同一时间段内交替执行,而"并行"强调的是同时执行,多个任务在同一时刻同时进行。
在实际应用中,通过并发和并行可以提高系统的性能和响应能力,但并发和并行的实现需要考虑到系统的硬件架构、资源管理、线程同步和通信等问题。
Q11 什么是大端字节,什么是小端字节?如何转换字节序?float和double有字节序的概念嘛?
在计算机系统中,字节序(Endianess)指的是在多字节数据类型(如整数、浮点数)在存储时字节的排列顺序。
有两种主要的字节序:大端字节序(Big Endian)和小端字节序(Little Endian)。
大端字节序(Big Endian):
在大端字节序中,数据的高字节存储在低地址,而低字节存储在高地址。这意味着对于多字节的数据类型,其最高有效字节(Most Significant Byte,MSB)存储在起始地址。
小端字节序(Little Endian):
在小端字节序中,数据的低字节存储在低地址,而高字节存储在高地址。这意味着对于多字节的数据类型,其最低有效字节(Least Significant Byte,LSB)存储在起始地址。
如何转换字节序:在C/C++中,可以使用以下方式进行字节序的转换:
大端到小端的转换:
#include <cstdint>
uint16_t bigToLittleEndian(uint16_t x) {
return ((x & 0xFF00) >> 8) | ((x & 0x00FF) << 8);
}
uint32_t bigToLittleEndian(uint32_t x) {
return ((x & 0xFF000000) >> 24) | ((x & 0x00FF0000) >> 8) |
((x & 0x0000FF00) << 8) | ((x & 0x000000FF) << 24);
}
小端到大端的转换:
#include <cstdint>
uint16_t littleToBigEndian(uint16_t x) {
return ((x & 0xFF00) >> 8) | ((x & 0x00FF) << 8);
}
uint32_t littleToBigEndian(uint32_t x) {
return ((x & 0xFF000000) >> 24) | ((x & 0x00FF0000) >> 8) |
((x & 0x0000FF00) << 8) | ((x & 0x000000FF) << 24);
}
float和double的字节序:对于浮点数类型(float和double),它们同样有字节序的概念。在IEEE 754浮点数标准中,浮点数的表示方式是规定好的,因此字节序的问题相对较小。
如果在不同字节序的系统之间传递二进制数据,需要确保字节序正确,否则解释的结果可能会不同。在实践中,使用网络字节序(通常是大端字节序)作为标准,以确保跨平台的一致性。
Q12 信号和信号量的区别是什么?
信号(Signal)和信号量(Semaphore)是两个在计算机科学领域中用于进程间通信和同步的概念。
信号(Signal):
定义:信号是一种异步通知机制,用于在进程之间或在操作系统内核和进程之间传递消息。信号是一种轻量级的通信方式,通常用于处理异步事件,如中断、错误、用户输入等。
特点:
信号是由操作系统或进程内部产生的事件。信号是异步的,即可以在任何时刻发生。信号通常用于通知进程某个事件的发生,进程可以注册信号处理函数来响应信号。
用途:
处理中断:例如,SIGINT 表示终端中断,通常由用户按下 Ctrl+C 产生。错误通知:例如,SIGSEGV 表示段错误,发生在非法内存访问时。
进程间通信:通过信号可以进行简单的进程间通信,但并不适合用于大量数据的传递。
信号量(Semaphore):
定义:信号量是一种计数器,用于实现对资源的访问控制,主要用于同步多个进程或线程对共享资源的访问。信号量是一种更为复杂的同步机制,提供了对资源的计数和等待/唤醒操作。
特点:
信号量是由应用程序创建和管理的。信号量通常用于同步多个进程或线程对共享资源的访问,防止竞争条件。信号量的值可以增加或减少,允许多个进程或线程同时访问某个资源。
用途:
同步进程:通过信号量可以实现进程之间的同步,确保它们按照特定的顺序执行。互斥访问:信号量可以用于保护共享资源,防止多个进程或线程同时访问。
进程间通信:通过信号量可以进行更复杂的进程间通信,包括传递信息和同步操作。
总结:
信号主要用于异步通知,处理事件,而信号量主要用于同步访问共享资源,控制进程或线程的访问顺序。
信号是由操作系统或进程内部产生的,而信号量是应用程序创建和管理的。
信号通常是一个瞬时的事件,而信号量是一个计数器,可以用于长时间的同步和资源管理。
Q13 虚拟内存是如何映射到物理内存的?了解分页内存管理吗?
虚拟内存是一种计算机系统的内存管理技术,它使得程序认为它拥有连续的、私有的内存空间,而实际上,这个内存空间可能是分散在物理内存中的。
分页内存管理是虚拟内存实现的一种常见方式,
虚拟内存:
虚拟内存是一种内存抽象技术,它为每个运行的程序提供了一个独立且连续的地址空间,称为虚拟地址空间。虚拟地址空间的大小通常远远大于物理内存的大小,这使得系统能够运行更多的程序并更灵活地管理内存。
虚拟内存的主要优势包括:
允许程序使用比实际物理内存更大的地址空间。提供了内存保护和隔离的机制。简化了内存管理和进程间通信。
分页内存管理:
分页内存管理是虚拟内存的一种实现方式,它将虚拟内存和物理内存划分成固定大小的页面(Page)。通常,一个页面的大小为2的幂次方(例如4KB或8KB)。
操作系统维护一个页面表(Page Table),其中记录了虚拟页面到物理页面的映射关系。
映射过程:
- 分割虚拟地址空间:虚拟地址空间被划分成大小相同的页面。
- 分割物理地址空间:物理内存也被划分成相同大小的页面。
- 建立映射关系:操作系统通过页面表将虚拟页面映射到物理页面。
- 访问页面:当程序访问虚拟页面时,操作系统通过页面表找到对应的物理页面。
- 页面表:页面表是一种数据结构,它记录了虚拟页面和物理页面之间的映射关系。每个进程都有自己的页面表,操作系统负责维护和更新页面表。
- 页面置换:由于虚拟内存远大于物理内存,不是所有的虚拟页面都能同时驻留在物理内存中。当程序访问一个未在物理内存中的虚拟页面时,就会发生缺页中断。此时,操作系统需要选择一个物理页面将其加载到内存中,可能需要进行页面置换。
优势:
- 灵活性:允许程序的地址空间比物理内存大。
- 隔离性:不同的进程有各自独立的虚拟地址空间。
- 共享:多个进程可以共享相同的物理页面。
- 内存保护:页面表可以设置权限,实现对内存的保护。
Q14 什么是上下文切换,操作系统是怎么做的上下文切换?
上下文切换是操作系统在多任务(多进程或多线程)环境下,从一个任务切换到另一个任务时,保存当前任务的执行状态(上下文),并加载下一个任务的执行状态的过程。
上下文切换是实现多任务调度的重要机制,确保多个任务能够交替执行。
上下文切换的主要目的是保存和恢复任务的执行环境,包括寄存器的值、程序计数器、内存页表等。这样,当任务重新执行时,它可以在切换前的状态下继续执行。
操作系统如何进行上下文切换:
- 保存当前任务的上下文:
- 操作系统首先保存当前任务的执行状态。这涉及到保存寄存器的值、程序计数器(指向下一条指令的地址)、栈指针等。这些信息通常保存在任务的内核栈上。
- 选择下一个任务:
- 操作系统从就绪队列中选择下一个要执行的任务。这个选择可能基于调度算法,例如先来先服务、轮转法、优先级调度等。
- 恢复下一个任务的上下文:
- 操作系统根据选择的下一个任务,恢复其保存的上下文。这包括将寄存器的值、程序计数器、栈指针等还原到任务切换前的状态。
- 切换到用户态:
- 如果上下文切换是从内核态到用户态,操作系统将控制权交给用户程序。这可能涉及设置特权级别和段选择器等。
上下文切换的代价相对较高,因为它需要保存和恢复大量的状态信息,并且涉及内核态和用户态的切换。因此,合理的调度算法和减少上下文切换的频率对系统性能至关重要。
上下文切换通常发生在以下情况下:
- 任务的时间片用完,需要切换到下一个任务。
- 任务被中断,需要处理中断并切换到相应的中断处理程序。
- 任务主动让出CPU,例如在I/O操作中等待数据。
- 多核处理器上,任务在不同核之间切换。
- 为了减少上下文切换的开销,操作系统设计时会采用一些优化措施,如就绪队列的设计、调度算法的优化、线程和进程的设计等。
Q15 进程虚拟空间是怎么布局的?
进程的虚拟空间布局是指操作系统在每个进程中给予其虚拟地址空间的不同区域的划分和用途。
虚拟地址空间通常被划分为多个段,每个段用于存放不同类型的数据和代码。下面是典型的进程虚拟空间布局:
- 文本段(Text Segment):起始地址: 通常位于虚拟地址空间的低地址部分。用途: 存放程序的可执行指令,即代码段。特点: 该段通常是只读的,防止程序意外修改其自身的指令。
- 数据段(Data Segment):起始地址: 紧随文本段之后。用途: 存放全局变量和静态变量。特点: 包括初始化的全局变量和静态变量。
- 堆(Heap):起始地址: 通常在数据段的末尾,向高地址扩展。用途: 动态分配的内存,例如使用 malloc 或 new 分配的内存。特点: 可以动态扩展和收缩,由程序员手动管理。
- 栈(Stack):起始地址: 通常在虚拟地址空间的高地址部分。用途: 存放函数的局部变量、函数调用的返回地址等。特点: 栈是一种后进先出(LIFO)的数据结构,栈帧随着函数调用和返回而动态增减。
- 共享库区域(Shared Libraries):起始地址: 通常位于虚拟地址空间的中间位置。用途: 存放共享库(动态链接库)的代码和数据。特点: 多个进程可以共享同一个共享库的实例,以节省内存。
- 内核空间(Kernel Space):起始地址: 位于虚拟地址空间的最高地址部分。用途: 存放操作系统内核的代码和数据结构。特点: 只有在内核态运行时才能访问。
不同的操作系统和体系结构可能有一些差异,但上述布局是一个通用的概念。虚拟地址空间的具体大小和布局可能取决于操作系统和编译器的设计。
Q16 操作系统是怎么进行进程管理的?
操作系统进行进程管理是为了有效地调度和协调多个进程的执行,以实现并发和多任务。进程管理包括创建、调度、终止、同步和通信等多个方面。
操作系统进行进程管理的主要任务和机制:
- 进程创建:创建新进程: 当一个新的程序被执行,或者一个已存在的进程创建了一个新的子进程,操作系统需要为新进程分配资源,包括内存空间、文件描述符等。初始化进程控制块(PCB): 操作系统为每个进程维护一个进程控制块,其中包含了进程的各种信息,如程序计数器、寄存器状态、内存分配信息等。建立地址空间: 操作系统为新进程建立独立的虚拟地址空间,确保进程之间相互隔离。
- 进程调度:调度算法: 操作系统使用调度算法决定哪个进程获得CPU的使用权,以实现公平性、优先级等调度策略。上下文切换: 当操作系统决定切换到另一个进程时,它会保存当前进程的上下文(寄存器、程序计数器等),并加载下一个进程的上下文。
- 进程终止:正常终止: 进程完成它的工作,主动调用系统调用退出。异常终止: 进程遇到错误,导致异常终止,操作系统负责回收资源。被其他进程终止: 父进程终止,导致子进程被终止。
- 进程同步和通信:临界区保护: 防止多个进程同时访问共享资源,通过临界区保护来避免竞态条件。互斥量、信号量: 提供用于同步的机制,确保进程按照某种规定的顺序执行。进程通信: 提供进程之间通信的机制,如管道、消息队列、共享内存等。
- 进程状态管理:就绪态、运行态、阻塞态: 进程可能处于就绪态、运行态或阻塞态,操作系统需要管理进程在这些状态之间的转换。进程挂起: 当一个进程处于等待某个事件的状态时,操作系统可以将其挂起,节省资源。
- 进程优先级和调度策略:进程优先级: 操作系统可以为不同的进程分配不同的优先级,以影响调度算法的决策。调度策略: 操作系统可以使用不同的调度策略,如先来先服务、时间片轮转、优先级调度等。
- 进程间通信:管道、消息队列、共享内存: 用于不同进程之间进行通信和数据传递。信号、事件: 提供了一种轻量级的进程通信方式,用于通知和处理异步事件。进程管理是操作系统中的一个核心功能,其目标是提供高效、公平、安全的进程调度和通信机制,使得系统能够同时运行多个任务。不同的操作系统可能有不同的实现方式和策略。
Q17 操作系统是如何做到进程阻塞的?
操作系统通过一些机制和系统调用实现进程阻塞。
进程阻塞是指进程由于某些原因无法继续执行,需要暂时挂起等待某个条件的满足。
操作系统实现进程阻塞的主要方式:
- 系统调用:等待型系统调用: 操作系统提供了一些等待型的系统调用,例如等待输入、等待信号等。当进程调用这些系统调用时,如果条件未满足,进程可能被阻塞。
- 同步原语和互斥机制:互斥量和信号量: 进程在访问共享资源时,可能会使用互斥量或信号量来进行同步。当资源被其他进程占用时,当前进程可能会被阻塞,直到资源可用。
- 等待队列:进程等待队列: 操作系统维护一个等待队列,当进程需要等待某个条件满足时,它将被加入等待队列,等待条件满足时再被唤醒。这可以通过系统调用、信号、互斥量等实现。
- 管道和消息队列:进程通信: 使用管道、消息队列等进程通信机制时,进程在尝试读取数据时可能被阻塞,直到有数据可读。
- 中断和信号:信号: 进程可以通过注册信号处理函数来响应异步事件。当某个信号发生时,进程可能被阻塞,并等待信号处理函数执行完毕。
- 文件和设备的 I/O 阻塞:I/O 阻塞: 当进程执行文件或设备的输入输出操作时,如果操作无法立即完成(例如等待数据准备就绪或设备响应),进程可能会被阻塞。
- 休眠状态:休眠状态: 在一些情况下,进程可能被操作系统主动置于休眠状态,等待某个事件的发生。例如,当进程调用 sleep 系统调用时,它会被阻塞一段时间。
- 进程间通信:阻塞式进程通信: 在进程间通信时,如果采用阻塞式的方式,发送进程可能会在等待接收进程响应时被阻塞。
阻塞的目的是为了有效地协调进程之间的执行,以及实现同步和互斥等多任务处理的需求。
Q18 线程是如何实现的?
线程是进程中的一个执行单元,它与其他线程共享同一进程的资源,包括代码段、数据段和打开的文件等。线程的实现通常由操作系统提供支持,下面简要介绍线程的实现方式:
- 用户级线程和内核级线程:用户级线程: 线程的管理完全由用户空间的线程库(用户态线程库)实现,操作系统对线程的存在一无所知。用户级线程的切换不涉及内核态的切换,因此开销较小。但由于用户级线程不受操作系统的直接管理,可能无法充分利用多核处理器。内核级线程: 线程的创建、调度和销毁由操作系统的内核管理,线程切换涉及到内核态和用户态的切换。内核级线程能够更好地利用多核处理器,但线程切换的开销相对较大。
- 线程创建和销毁:线程创建: 操作系统提供系统调用或线程库提供API来创建线程。创建线程时,操作系统分配线程所需的资源,如堆栈空间、寄存器等。线程销毁: 线程执行完任务或被主动终止时,会被销毁。资源的释放由操作系统负责,包括回收堆栈空间、释放其他分配的资源。
- 线程调度:线程调度: 操作系统负责调度线程的执行。调度算法决定了哪个线程在何时执行。调度可能基于优先级、时间片轮转、先来先服务等策略。线程切换: 当线程切换时,操作系统会保存当前线程的上下文(寄存器状态等),并加载下一个线程的上下文,实现线程的切换。
- 同步和互斥:同步机制: 操作系统提供同步机制,如互斥量、信号量、条件变量等,用于确保线程之间的同步执行。互斥操作: 通过互斥机制,操作系统可以保证多个线程不会同时访问共享资源,避免数据竞争和一致性问题。
- 用户态线程库:线程库: 为了提供更高层次的线程抽象,通常会有用户态的线程库。这些库提供线程的创建、调度、同步等功能,而具体的线程管理由库来实现。
- 线程间通信:线程通信: 操作系统提供线程间通信的机制,如管道、消息队列、共享内存等,用于在线程之间传递数据。总体而言,线程的实现涉及到操作系统的内核支持和用户态线程库。操作系统提供了创建、调度、同步等基本的线程管理功能,而用户态线程库则提供更高层次的接口,以方便开发人员使用线程。线程的实现方式可能有一定的差异,具体取决于操作系统的设计和支持。
Q19 锁的性能开销,锁的实现原理?
锁是多线程编程中用于实现同步和互斥的机制,用于保护共享资源免受并发访问的影响。锁的性能开销主要取决于锁的实现方式:
- 自旋锁(Spin Lock):自旋锁是一种忙等待的锁,线程在获取锁失败时会一直循环检查,直到成功。自旋锁的性能开销较低,适用于锁被持有的时间较短的情况。自旋锁通常使用原子操作(比如 CAS 操作)来实现。当一个线程尝试获取锁时,它会不断尝试修改锁的状态,直到成功。如果锁已被其他线程持有,尝试获取锁的线程会一直自旋等待。
- 互斥锁(Mutex):互斥锁是一种阻塞锁,线程在获取锁失败时会被阻塞,直到锁可用。互斥锁的性能开销较高,尤其在锁的竞争激烈时。互斥锁的实现通常涉及内核态的系统调用。当一个线程请求锁时,如果锁已被其他线程持有,线程会进入阻塞状态。当持有锁的线程释放锁时,等待的线程被唤醒。
- 读写锁(Read-Write Lock):读写锁允许多个线程同时读取共享资源,但只允许一个线程写入。读写锁的性能开销相对较低,适用于读操作频繁的场景。读写锁的实现也通常涉及内核态的系统调用。读操作时,多个线程可以同时持有读锁,写操作时,只有一个线程能够持有写锁。
- 条件变量(Condition Variable):条件变量通常与互斥锁一起使用,用于在特定条件下线程的等待和唤醒。条件变量的性能开销相对较高,因为它通常需要进行线程的阻塞和唤醒操作。条件变量的实现也依赖于内核态的系统调用。等待条件时,线程会释放互斥锁并进入阻塞状态,当条件满足时,线程被唤醒并重新获取互斥锁。
- 递归锁(Recursive Lock):递归锁允许同一线程多次获取同一个锁,但需要确保释放相同次数。递归锁的性能开销相对较高,因为需要维护额外的状态信息。递归锁的实现通常与互斥锁类似,但需要记录锁的持有次数。当线程请求锁时,如果当前线程已经持有锁,则增加持有次数,释放锁时减少持有次数。锁的性能开销与实现方式密切相关,选择合适的锁取决于应用程序的需求和并发访问的模式。在实际应用中,综合考虑锁的开销、竞争情况以及锁的粒度等因素,进行合理的锁选择和使用。
Q20 线程之间私有和共享的资源有哪些?
在多线程编程中,线程之间可以拥有私有资源和共享资源。这些资源的性质决定了线程之间的交互方式和需要考虑的同步机制。
私有资源:
- 线程私有栈(Thread-specific Stack): 每个线程通常都拥有自己的栈空间,用于存储局部变量、函数调用信息等。栈是线程私有的,不同线程的栈相互独立。
- 线程私有寄存器(Thread-specific Registers): 寄存器是 CPU 中的存储单元,用于临时存储数据。不同线程在运行时会使用不同的寄存器,因此寄存器可以看作是线程私有的。
- 线程私有变量(Thread-specific Variables): 在一些编程语言和运行环境中,可以使用线程私有变量来存储每个线程独有的数据。
共享资源:
- 堆内存(Heap Memory): 所有线程共享进程的堆内存,可以通过动态分配内存(例如 malloc 或 new)来获取共享的堆内存。
- 全局变量(Global Variables): 进程中的全局变量是线程之间共享的,因此需要通过同步机制来确保对全局变量的访问是安全的。
- 静态变量(Static Variables): 进程中的静态变量(包括静态全局变量和局部静态变量)也是线程之间共享的,需要注意同步。
- 文件描述符(File Descriptors): 在一些操作系统中,文件描述符是进程级别的,因此在多线程环境中需要注意对文件的共享访问。
- 共享库(Shared Libraries): 进程加载的共享库通常是线程共享的,因此在多线程环境中需要考虑共享库的线程安全性。
- 网络连接(Network Connections): 多个线程可以共享同一网络连接,需要通过同步机制确保对网络连接的安全访问。
- 进程空间(Process Space): 进程的地址空间是线程共享的,因此代码段、数据段等都是多个线程共享的。
在使用共享资源时,需要特别注意线程安全性,避免竞态条件和数据不一致的问题。采用同步机制(如锁、信号量、互斥量等)可以确保对共享资源的安全访问。私有资源的存在使得每个线程都有独立的工作空间,不会直接影响其他线程的执行。
数据库
Q1 数据库四大特性,四种隔离安全机制
数据库的四大特性是:
原子性(Atomicity): 原子性指的是事务中的所有操作要么全部成功执行,要么全部失败回滚,不存在部分执行的情况。在事务执行过程中,如果发生错误或者异常,会回滚到事务开始前的状态,保证数据的一致性。
一致性(Consistency): 一致性指的是事务执行前后,数据库从一个一致性状态转变到另一个一致性状态,不会破坏数据库的完整性约束和业务规则。即使在发生错误或异常时,数据库也能保持数据的完整性。
隔离性(Isolation): 隔离性指的是多个事务并发执行时,每个事务的操作应该与其他事务隔离,互不干扰。事务之间的执行应该相互独立,避免互相影响,以确保数据的完整性和一致性。
持久性(Durability): 持久性指的是一旦事务被提交,其对数据库中数据的修改就是永久性的,即使系统发生故障或重启,修改的数据也不会丢失。
四种隔离安全机制(隔离级别)是:
读未提交(Read Uncommitted): 允许一个事务读取另一个未提交事务的数据。这种隔离级别存在脏读(Dirty Read)问题,可能导致读取到未提交事务的数据,影响数据的一致性。
读提交(Read Committed): 允许事务只能读取到已经提交的数据。解决了脏读问题,但依然存在不可重复读(Non-Repeatable Read)问题,即在一个事务内,多次读取同一数据可能得到不同结果。
可重复读(Repeatable Read): 在一个事务内多次读取同一数据时,保证多次读取结果一致。解决了不可重复读问题,但依然存在幻读(Phantom Read)问题,即在一个事务内多次读取同一范围的数据可能出现新增或删除的情况。
串行化(Serializable): 最高的隔离级别,确保每个事务都独立执行,彼此之间没有交叉。避免了脏读、不可重复读和幻读的问题,但效率较低,因为串行化执行会导致并发性能下降。
Q2 MySQL引擎/数据库分类
MySQL 提供了多种存储引擎(Storage Engine),不同的存储引擎拥有各自的特性和适用场景。常见的 MySQL 存储引擎包括:
InnoDB:
默认的 MySQL 存储引擎,支持事务和行级锁定,提供了较高的数据完整性和并发性能。
适用于大多数应用场景,特别是需要事务支持和较好的数据一致性的应用。
MyISAM:
不支持事务和行级锁定,但提供了较高的性能和压缩性。
适用于读操作频繁、写操作相对较少的应用场景,如数据仓库和只读数据。
MEMORY:
将表存储在内存中,提供了快速的读写速度,但数据不是持久化的。
适用于需要快速访问和临时存储数据的应用,但不适用于大量数据或长期存储。
NDB Cluster:
适用于集群环境,提供了高可用性、高扩展性和分布式存储能力。
适用于大型数据的高可用集群架构,例如需要高性能和大容量的数据存储。
数据库通常可以根据其应用和功能进行分类:
关系型数据库(RDBMS):
数据以表格的形式组织,采用 SQL(Structured Query Language)作为查询语言,例如 MySQL、PostgreSQL、Oracle 等。
非关系型数据库(NoSQL):
数据不以关系模型(表格)存储,而是以键值对、文档、图形或列族等形式存储,例如 MongoDB、Redis、Cassandra 等。
面向对象数据库(OODBMS):
数据以对象的形式进行存储和操作,直接支持面向对象的概念,例如 ObjectDB、db4o 等。
计算机网络
Q1 TCP/UDP的选择
- 选择 TCP:对于需要可靠数据传输和确保数据顺序的应用,如文件传输、网页访问。TCP提供错误检测、流量控制和重传机制。
选择 UDP:对于实时性要求高、对数据传输延迟要求较低的应用,如音视频流、在线游戏。UDP传输速度快且没有连接的开销,但不保证数据可靠性和顺序性。
Q2 3次握手,4次挥手
次握手(建立连接):
客户端发送 SYN: 客户端向服务器发送一个标志位为 SYN(同步序列编号)的数据包,表示请求建立连接。客户端将自己的初始序列号(sequence number)设定为一个随机数 A。
服务器回应 SYN + ACK: 服务器收到 SYN 数据包后,如果同意建立连接,则会发送一个带有 SYN/ACK 标志的数据包作为响应,表示确认客户端的请求,并表示自己也准备好了。服务器会将自己的初始序列号设定为另一个随机数 B,并确认收到了客户端发来的 SYN(确认号为 A+1)。
客户端发送 ACK: 客户端收到服务器的 SYN/ACK 数据包后,向服务器发送一个确认报文,确认收到服务器的响应。这个确认报文的序列号设定为 A+1,确认号为 B+1。连接建立完成,双方可以开始进行数据传输。
4 次挥手(终止连接):
客户端发送 FIN: 当客户端想要关闭连接时,它发送一个 FIN(终止连接)标志的数据包给服务器,表示客户端不再发送数据。客户端进入 FIN_WAIT_1 状态。
服务器回应 ACK: 服务器收到客户端的 FIN 后,会发送一个 ACK 确认报文,表示收到了客户端的请求,但仍然允许数据传输。服务器进入 CLOSE_WAIT 状态。
服务器发送 FIN: 当服务器确定数据传输结束后,会发送一个 FIN 给客户端,表示服务器也准备关闭连接。服务器进入 LAST_ACK 状态。
客户端回应 ACK: 客户端收到服务器的 FIN 后,发送一个确认报文,表示已经收到了服务器的关闭请求。客户端进入 TIME_WAIT 状态,等待一段时间后才会关闭连接。服务器收到这个 ACK 后,关闭连接,进入 CLOSED 状态。
- 避免重复建立连接: 三次握手确保客户端和服务器之间的通信双方都愿意建立连接,并且防止因网络延迟或数据包丢失等原因导致的重复连接建立。
- 避免半开连接的产生: 三次握手确保双方的序列号和确认号同步,确保连接的可靠建立。四次挥手中的最后一个 ACK 确认了双方都已经完成了数据传输,避免了半开连接状态的出现。
- 确保数据的可靠传输: TCP 的三次握手确保了双方都已经同步了初始序列号和确认号,建立了可靠的连接后再进行数据传输,保证数据的可靠性和完整性。
- 允许双方有足够时间准备和关闭连接: 四次挥手中的 ACK 和 FIN 的交换允许双方在关闭连接前完成数据的传输和接收,并通知对方自己已经准备好关闭连接,避免了数据丢失和不完整的情况。
Q3 计算机网络TCP分包和解包
TCP 分包过程:
数据块划分: 应用层的数据被划分为适当大小的数据块(段),以便在网络上传输。
添加首部信息: 每个数据块被加上 TCP 首部信息(包括序列号、确认号、控制标志等),形成 TCP 段。TCP 首部信息中包含了控制信息,用于管理传输过程和数据重组。
发送数据: 段被发送到网络传输层(TCP 层),通过网络传输到目的地。
TCP 解包过程:
接收数据: 接收端收到传输过来的 TCP 段。
重新组装数据: 接收端的 TCP 层根据序列号和确认号等信息,将接收到的 TCP 段按序重新组装成完整的数据块。
传递给应用层: 当 TCP 接收到所有的数据块并按顺序重新组装后,数据被传递给接收端的应用层处理。
Q4 http/https的区别
HTTP(Hypertext Transfer Protocol)和 HTTPS(Hypertext Transfer Protocol Secure)
安全性:
HTTP 不加密数据传输,信息以明文形式传输,容易被窃听和篡改。
HTTPS 则通过 SSL/TLS 协议对数据进行加密传输,确保数据传输的安全性和隐私性。
加密方式:
HTTP 使用 TCP(端口号80)作为传输协议,数据传输是明文的。
HTTPS 在 HTTP 的基础上使用了 SSL/TLS 协议(通常使用端口443),通过公钥加密传输,保证了数据的机密性和完整性。
证书认证:
HTTPS 使用了数字证书对通信双方进行认证,验证服务器的身份,防止中间人攻击,确保通信安全。
HTTP 不涉及数字证书的验证,无法验证通信双方的真实身份,存在安全风险。
连接方式:
HTTP 是无状态的协议,每个请求和响应之间相互独立,不保留状态信息。
HTTPS 与 HTTP 相同,也是无状态的,每个请求和响应之间相互独立。
HTTPS 是在 HTTP 的基础上加入了 SSL/TLS 加密机制的安全版本。HTTPS 可以保护数据的隐私性和完整性,防止窃听和篡改,适用于对数据传输安全性有要求的场景,如网上支付、登录、个人隐私信息传输等。
Q5 七层网络参考模型
七层网络参考模型指的是 OSI(Open Systems Interconnection)参考模型,它将计算机网络体系结构划分为七个抽象层,每个层都有特定的功能和责任,用于描述网络中数据的传输和处理过程。这些层从底层到顶层依次为:
物理层(Physical Layer):
最底层的层级,负责在物理介质上传输原始比特流,定义了数据传输所使用的物理媒介和接口标准,如电压、光信号等。
数据链路层(Data Link Layer):
负责点对点之间的可靠数据传输,处理帧的传输、错误检测和纠错,定义了数据的帧格式和链路控制协议。
网络层(Network Layer):
提供了数据包在不同网络间的路由和转发功能,负责网络间的数据传输,处理数据包的寻址和路由选择。
传输层(Transport Layer):
提供端到端的通信服务,负责数据的可靠传输和错误恢复,处理数据流的分段、重组和流量控制,例如 TCP 和 UDP 协议。
会话层(Session Layer):
管理不同应用程序之间的对话和通信会话,确保数据传输的安全和一致性。
表示层(Presentation Layer):
负责数据的格式化、编码和加密,确保数据格式在不同系统之间的兼容性和可识别性。
应用层(Application Layer):
最高层的层级,提供各种应用程序和用户接口,向用户提供特定的网络服务,如 HTTP、FTP、SMTP 等。
数据结构与算法
Q1 c++优先队列实现
C++ 标准库中的 std::priority_queue 是一个基于堆(Heap)数据结构实现的优先队列。
默认情况下,std::priority_queue 使用 std::vector 作为底层容器,并使用默认的 std::less 比较器来实现最大堆(Max Heap)。最大堆的特性是根节点的值大于其子节点的值,因此在最大堆中,队首元素(优先级最高)是整个队列中最大的元素。
优先队列的实现基于堆的性质,支持插入和弹出操作,并保持队列中的元素是按照优先级顺序进行排列的。
未完待续
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。