先给大家说说我为什么一直要分享这个面经?
众所周知,我们可以根据面经来复盘自己的八股和反思自己在面试过程中没注意到的点,这样就会慢慢做得更好。
我们中的很多人,在学生时代可能没有一个很好的学习规划,就是那种学过就只是学过了,学没学懂那就另说了。。我呢,可以带着大家一起保持一种良好的习惯,给大家提供一种解决问题的思路。
今天给大家分享一位小伙伴的游戏服务端面经,要不还得是游戏公司,真的不简单。。。我也看了原题目,又多又难,主要还是难在一些游戏应用场景。对于一个校招生还是相当于难度的,当然薪资也不会少。
ps:面经题目为真实问题,答案是我本人结合自己以往所学知识给的建议答案,仅供参考!!!
1、常见出现的内存泄漏?
内存泄漏是指程序在动态分配内存后,没有释放或者访问不到这些内存块,从而导致这些内存块变得不可用,无法被系统回收。常见的内存泄漏情况:
1.动态内存分配未释放:使用new或malloc分配内存后,忘记使用delete或free释放内存。这会导致程序运行时不断占用内存,直到最终耗尽系统可用内存。
int *ptr = new int[100];
// 忘记释放内存
// delete[] ptr; // 正确释放内存
2.对象生命周期管理不当:在使用C++时,如果创建对象后忘记销毁它们,会导致对象的析构函数未被调用,从而导致资源泄漏,例如文件句柄、数据库连接等。
// 资源未释放
{
MyClass obj;
} // MyClass对象的析构函数未被调用
3.容器对象未清理:当使用容器(如std::vector、std::map)存储动态分配的对象时,容器的清理操作(clear或erase)未正确清理元素,导致对象的内存泄漏。
std::vector intPtrVector;
intPtrVector.push_back(new int);
// 忘记释放元素内存
// intPtrVector.clear(); // 清理容器
4.强引用循环:在使用智能指针时,如果存在循环引用,可能会导致对象无法被释放,从而造成内存泄漏。
class Node {
public:
std::shared_ptr next;
};
int main() {
std::shared_ptr node1 = std::make_shared();
std::shared_ptr node2 = std::make_shared();
node1->next = node2;
node2->next = node1; // 循环引用
// 对象无法释放
return 0;
}
5.不合理的缓存:应用程序可能会缓存大量数据,但未考虑到内存的限制。如果缓存不受控制,可能会导致内存泄漏。
std::map cache;
// 不断添加数据到缓存,没有删除操作
2、怎么样避免内存泄漏?
1.使用智能指针:智能指针(如std::shared_ptr、std::unique_ptr、std::weak_ptr)是C++中的工具,用于自动管理内存释放。它们会在不再需要时自动释放内存。
std::shared_ptr smartPtr = std::make_shared(42);
// 不需要手动释放内存
2.RAII(资源获取即初始化):使用RAII原则,确保在对象的构造函数中分配资源(如内存),并在析构函数中释放这些资源。这确保了在对象生命周期结束时资源会被正确释放。
class ResourceHandler {
public:
ResourceHandler() {
resource = new int[100];
}
~ResourceHandler() {
delete[] resource;
}
private:
int* resource;
};
3.使用容器类和标准库:C++标准库提供了许多容器类,它们会自动管理内存。使用std::vector、std::map等容器,可以减少手动内存分配和释放的需求。
std::vector numbers;
numbers.push_back(42); // 无需手动释放内存
4.遵循规则:避免在函数中返回指向局部变量的指针或引用,因为局部变量在函数退出后将不再有效。
int* createLocalVariable() {
int localVar = 42;
return &localVar; // 错误:局部变量的生命周期结束
}
5.明确释放资源:对于动态分配的资源,如内存或文件句柄,确保在不再需要时手动释放它们,避免忘记释放。
int* dynamicInt = new int;
// ...
delete dynamicInt; // 手动释放内存
3、介绍智能指针?
1.std::shared_ptr:允许多个智能指针共享同一块内存。每个std::shared_ptr都会跟踪被管理的对象的引用计数。当引用计数降为零时,内存将被自动释放。这有助于防止资源泄漏。
std::shared_ptr sharedPtr = std::make_shared(42);
2.std::unique_ptr:表示独占所有权,只能有一个std::unique_ptr指向特定内存区域。当std::unique_ptr离开其作用域时,它将自动释放内存。
std::unique_ptr uniquePtr = std::make_unique(42);
3.std::weak_ptr:是std::shared_ptr的弱引用,不会增加引用计数。它通常用于解决循环引用问题,允许在不增加引用计数的情况下检查std::shared_ptr是否仍然有效。
std::shared_ptr shared = std::make_shared(42);
std::weak_ptr weak = shared;
智能指针的优点:
自动内存管理:当智能指针超出范围或不再需要时,它们会自动释放所管理的内存,有助于避免内存泄漏。
减少人为错误:由于智能指针自动处理资源管理,因此减少了手动分配和释放内存所引发的错误。
更安全的多线程支持:std::shared_ptr提供了引用计数,有助于确保多个线程之间安全地共享对象。
需要注意的是:
避免循环引用:在使用std::shared_ptr时,循环引用可能导致内存泄漏。使用std::weak_ptr来打破循环引用。
选择适当的智能指针类型:根据情况选择std::shared_ptr、std::unique_ptr或std::weak_ptr,以符合内存管理需求。
4、内存泄漏怎么排查?
给出以下方法,仅供参考。
静态代码分析工具:使用静态代码分析工具,例如Valgrind(Linux)、Dr. Memory(Windows)、Clang Static Analyzer等,对代码进行静态分析,它们可以检测潜在的内存泄漏。
动态分析工具:使用内存分析工具,例如Valgrind的Memcheck、AddressSanitizer、LeakSanitizer,来运行程序并检测内存泄漏。这些工具会报告泄漏的内存块以及其分配位置。
手动检查:在代码中,确保每次分配内存后都有对应的释放操作。检查程序的每个代码路径,以确保没有条件下遗漏了内存释放。
重写析构函数:确保自定义类的析构函数中释放了所有已分配的资源,包括动态分配的内存、文件句柄等。使用RAII(资源获取即初始化)习惯可以帮助自动资源管理。
跟踪内存分配和释放:可以重载new和delete运算符,或使用C++11的operator new和operator delete函数来跟踪内存分配和释放,记录分配和释放的内存块。
日志记录:在关键点添加日志记录,例如在每次分配和释放内存时,记录相关信息,以便在内存泄漏问题出现时进行跟踪。
使用智能指针:使用C++智能指针(std::shared_ptr、std::unique_ptr)等自动内存管理工具来减少手动内存管理的复杂性。
内存泄漏检测库:一些第三方库提供了内存泄漏检测的功能,可以用于检测和分析内存泄漏问题。
逐步测试:逐步测试你的代码,特别是在大型应用程序中,逐一检查和验证每个模块,以确保没有内存泄漏。
代码审查:请同事或其他开发者审查你的代码,因为有时内存泄漏问题可能很难在单人开发中发现。
5、C++的垃圾回收了解过么?
C++不像一些其他编程语言(例如Java、C#)具有内置的垃圾回收机制。在C++中,内存管理主要由开发人员手动控制。有一些第三方库和工具可以帮助实现类似垃圾回收的功能,但它们不是C++标准的一部分。
RAII(Resource Acquisition Is Initialization):这是一种C++编程范式,它依赖于对象生命周期的管理。通过在对象的构造函数中分配资源(如内存或文件句柄)并在析构函数中释放这些资源,可以确保资源在对象生命周期结束时得到正确释放。RAII是一种手动的资源管理方法,但可以避免内存泄漏。
智能指针:C++11引入了智能指针,如std::shared_ptr和std::unique_ptr,它们允许自动管理内存,减少手动new和delete的需求。std::shared_ptr使用引用计数来跟踪共享对象的所有权,而std::unique_ptr在拥有独占对象所有权时提供更高效的内存管理。
第三方垃圾回收库:有一些第三方库,如Boehm-Demers-Weiser垃圾回收器(简称Boehm GC),提供了垃圾回收的功能。这些库通常通过运行时检查和自动内存回收来减少内存泄漏的风险。Boehm GC是一个开源库,可用于C++项目。
自定义内存管理:开发人员可以编写自定义的内存管理工具,以确保内存的正确释放。这可能涉及内存池、引用计数、对象生命周期追踪等技术。这种方法需要更多的工作,但可以提供更精细的控制。
相关视频推荐
5种内存泄漏检测方式,让你重新理解C++内存管理
从redis 7 个应用掌握 redis 的使用技巧
epoll的原理与使用,epoll比select/poll强在哪里?
Linux C/C++开发(后端/音视频/游戏/嵌入式/高性能网络/存储/基础架构/安全)
需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
6、C++11中右值引用?
它的引入主要是为了支持移动语义和完美转发,这些特性在C++中用于提高性能和编写更灵活的代码。
右值引用的语法形式如下:
T&&
其中,T是类型。右值引用用于表示对右值(rvalue)的引用,而左值引用用于表示对左值(lvalue)的引用。
右值是指那些临时的、无法被修改的、即将销毁的值,通常是表达式求值的结果。左值是具名的、可以被修改的、有持久性的值。
右值引用主要有两个用途:
支持移动语义:移动语义允许将资源(如内存)的所有权从一个对象转移到另一个对象,而不进行深层的复制。这对于提高性能非常有用,特别是在处理动态分配的内存时。通过右值引用,可以将资源的所有权从一个右值对象“窃取”并转移到新对象,而无需进行内存复制。例如,C++11引入了移动构造函数和移动赋值操作符,这些操作依赖于右值引用,允许对临时对象执行高效的资源移动操作。
完美转发:右值引用还允许完美转发(Perfect Forwarding),这是一种在函数调用中保留参数类型信息的技术。通过接受右值引用的参数,函数可以将参数转发到其他函数,而不会丧失原始参数的值类别(左值或右值)。完美转发对于泛型编程和编写通用库非常重要,因为它允许函数将参数按原样传递给其他函数,而无需知道参数的具体类型。
以下是一个示例,演示了右值引用的用法:
void processValue(int&& rvalue) {
// 使用右值引用
std::cout << "Processing rvalue: " << rvalue << std::endl;
}
int main() {
int x = 42;
processValue(std::move(x)); // 将左值 x 转为右值并传递
// 此时 x 的值可能已被修改或移动
return 0;
}
7、线程局部变量?
线程局部变量(Thread-Local Storage,TLS)是一种多线程编程中的机制,允许每个线程拥有其独立的变量实例,而不与其他线程共享。这些变量在不同的线程之间是相互独立的,每个线程都可以读取和修改其自己的线程局部变量,而不会影响其他线程的对应变量。
线程局部变量在多线程编程中非常有用,因为它们可以用于避免竞态条件(Race Conditions),减少锁的使用,并提高并发程序的性能。
在C++中,线程局部变量通常通过以下两种方式实现:
1.thread_local 关键字(C++11及更新版本):C++11引入了thread_local关键字,可以将变量声明为线程局部变量。这样的变量将独立于线程,每个线程都有自己的副本。示例如下:
thread_local int threadSpecificValue; // 定义线程局部变量
这个变量的生命周期与线程相同,当线程终止时,它将被销毁。
2.POSIX线程库的pthread_key_create和pthread_setspecific函数:在使用POSIX线程库的情况下,可以使用pthread_key_create函数创建线程局部变量的键,然后使用pthread_setspecific函数将特定的值与键相关联。
pthread_key_t key;
pthread_key_create(&key, nullptr); // 创建线程局部变量键
// 在每个线程中将值与键关联
int value = 42;
pthread_setspecific(key, &value);
线程局部变量的主要用途包括:
存储每个线程的状态信息,例如线程ID或线程特定的配置选项。
避免竞态条件,因为每个线程可以独立地访问和修改自己的线程局部变量,而不需要锁。
用于跟踪线程特定的资源,如线程池中的任务队列,每个线程拥有自己的任务队列。
8、介绍协程?
协程(Coroutines)是一种计算机程序组件,它允许函数在执行过程中暂停,保存当前状态,稍后恢复执行。协程可以看作是一种轻量级的线程,但与传统的线程不同,协程并不依赖于操作系统的线程管理,而是由程序员控制。协程的特点:
非抢占式调度:协程不会被操作系统或调度程序抢占执行权,而是由程序员显式地控制何时挂起和恢复协程的执行。这使得协程更轻量且不需要上下文切换的开销。
协作式多任务:协程通常是协作式多任务的一部分,协作多任务允许不同协程之间合作执行,而不是通过抢占式调度来控制执行顺序。
状态保存和恢复:协程可以在执行过程中暂停,并保存其状态,包括局部变量、指令指针等。稍后可以从保存的状态继续执行。
协程在编程中的应用:
异步编程:协程可以用于编写异步代码,处理I/O密集型任务而不会阻塞整个程序,提高效率。例如,C++中的协程和Python中的async/await都用于异步编程。
状态机:协程可以用于实现复杂的状态机,处理事件驱动的编程,例如编写编译器、解析器或网络通信。
生成器:协程可用于创建生成器函数,使其能够按需生成数据而不是一次性生成所有数据。
轻量级线程:协程可用于实现轻量级线程,允许编写更易于理解和管理的多线程代码。
9、为什么使用协程?
高效的多任务处理:协程允许程序员编写多任务应用程序,而无需线程或进程的开销。协程是一种轻量级的多任务机制,因此可以创建数百甚至数千个协程而不会显著增加系统开销。
简化异步编程:在异步编程中,经常需要处理回调函数和复杂的异步逻辑。协程通过使用async/await关键字(例如在Python或C#中)或使用co_await(在C++中)来简化异步编程。这使得异步代码更易于理解和维护。
易于编写事件驱动代码:协程使得编写事件驱动代码更容易。它们可以用于编写处理事件的状态机,从而更清晰地表达程序的逻辑。
降低并发编程复杂度:协程可以使并发编程更容易。它们允许编写同步风格的代码,但实际上是并发执行的。这可以减少共享数据和锁的需求,降低了并发编程中的错误几率。
资源管理:协程通常支持资源管理。当协程退出时,它可以自动关闭文件、数据库连接或其他资源,而无需显式处理。
提高性能:协程通常比线程或进程更高效,因为它们减少了上下文切换的开销。它们通常适用于处理大量的I/O密集型任务。
更清晰的代码:协程可以使代码更易于理解。通过将复杂的异步逻辑和状态机组织为协程,可以提高代码的可读性和可维护性。
10、字节对齐,好处是什么(对齐后减少换页)
主要目标是在存储数据结构时,将数据按照某种规则存放在内存中,以便提高访问速度、优化内存使用和硬件对齐要求。
好处:
提高内存访问速度:硬件架构通常要求数据按照特定的对齐规则存储,如果数据没有对齐,可能需要多次内存访问来获取完整的数据,从而降低了访问速度。字节对齐可以确保数据按照硬件对齐规则存储,减少了内存访问的次数,提高了程序的性能。
减小内存碎片:如果数据结构没有按照字节对齐存储,可能会导致内存碎片的产生。内存碎片会浪费大量的内存空间,降低内存的有效利用率。通过字节对齐,可以最大程度地减小内存碎片。
提高数据结构的移植性:不同的硬件架构和操作系统可能对数据结构的对齐要求不同。使用字节对齐可以增加数据结构在不同平台上的移植性,减少需要修改代码以适应不同平台的情况。
硬件操作的便捷性:某些硬件操作,如DMA(Direct Memory Access)或SSE指令集,要求数据结构满足特定的对齐要求。如果数据不满足这些要求,可能会导致硬件操作失败或效率低下。字节对齐可以确保数据满足这些硬件要求,从而提高了硬件操作的效率。
代码的可读性和维护性:字节对齐的数据结构通常更易于理解和维护。它们的布局更加清晰,开发者可以更容易地理解数据结构的组织方式。
11、基类的构造函数可以调用子类的虚函数么?
在C++中,基类的构造函数在执行期间不能调用子类的虚函数。这是因为在构造子类对象时,对象的构造顺序是从基类向子类进行构造的,而子类的虚函数需要在对象完全构造之后才能被调用。
虚函数的调用依赖于对象的虚函数表(vtable),而在基类构造函数执行期间,子类对象的构造尚未完成,虚函数表也不完整。因此,在基类构造函数中调用子类的虚函数是不安全的,因为这可能导致未定义的行为。
如果需要在子类的构造函数中执行特定于子类的操作,可以在子类构造函数中调用虚函数,因为在执行子类构造函数时,对象已经完全构造,虚函数表也是有效的。但要注意,应该谨慎地使用虚函数,确保在子类构造函数中调用虚函数时不会引发不希望的行为。
示例:
#include
class Base {
public:
Base() {
// 在基类构造函数中调用虚函数是不安全的
// 以下行为是不推荐的
// this->virtualFunction();
}
virtual void virtualFunction() {
std::cout << "Base::virtualFunction" << std::endl;
}
};
class Derived : public Base {
public:
Derived() {
// 在子类构造函数中调用虚函数是安全的
this->virtualFunction();
}
void virtualFunction() override {
std::cout << "Derived::virtualFunction" << std::endl;
}
};
int main() {
Derived derivedObj;
return 0;
}
示例中,基类构造函数中调用虚函数会导致基类版本的虚函数被调用,而在子类构造函数中调用虚函数时,会调用子类版本的虚函数。这是因为在子类构造函数执行时,对象已经是子类类型,虚函数表也是有效的。
12、unordered_map和map,unordered_set和set介绍,适用场景?
1.std::map 和 std::set:
有序容器:元素按照它们的键进行排序,这种排序对于一些应用非常有用。
基于红黑树:实现了自动平衡,插入和查找操作的时间复杂度为 O(log n)。
不适用于大规模数据:由于自动平衡的开销,对于大规模数据集来说,性能可能会受到限制。
适用场景:适用于要求有序性的情况,以及在数据规模较小的情况下。
2.std::unordered_map 和 std::unordered_set:
无序容器:元素的存储和访问顺序不受限制,它们使用哈希表实现。
基于哈希表:插入和查找操作通常为 O(1) 的平均时间复杂度。
不具备顺序性:元素按照哈希值分布,没有排序。
适用场景:适用于不需要有序性,但对于快速查找和插入操作性能要求较高的情况,尤其是在大规模数据集下。
13、了解红黑树么?
红黑树是一种自平衡的二叉查找树。
自平衡性:红黑树在插入和删除操作时会自动保持平衡,确保树的高度不会过大,从而保持了查找操作的时间复杂度为 O(log n)。
节点颜色:每个节点都带有一个颜色属性,可以是红色或黑色。
规则约束:红黑树必须满足一组规则,包括:
每个节点要么是红色,要么是黑色。
根节点必须是黑色。
每个叶子节点(NIL 节点,通常表示为空节点)都是黑色。
如果一个节点是红色,那么它的子节点必须是黑色(没有连续的红节点)。
从任一节点到其每个叶子的简单路径都包含相同数量的黑色节点(黑高度相等)。
插入和删除操作:插入和删除操作可能会导致树失去平衡,因此需要进行旋转和重新着色操作来维持平衡。
平衡性:红黑树的最长路径不会超过最短路径的两倍,因此保持了树的平衡性。
性能:红黑树的平均和最坏情况时间复杂度都为 O(log n),因此适用于大多数关联容器的实现,以及需要有序性和高性能的情况。
14、链表如何判断成环?
要判断链表是否成环,可以使用两个指针的追逐方法,也就是常说的快慢指针。
创建两个指针,一个称为快指针,每次移动两步,另一个称为慢指针,每次移动一步。
如果链表中不存在环,那么快指针将首先到达链表的末尾(即指向空节点)。
如果链表中存在环,快指针将在某个时刻追上慢指针,从而形成一个循环。
示例:
#include
// 定义链表节点结构
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
bool hasCycle(ListNode* head) {
if (head == nullptr) {
return false;
}
ListNode* slow = head;
ListNode* fast = head;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
return true; // 链表成环
}
}
return false; // 链表不成环
}
int main() {
// 创建一个示例链表
ListNode* head = new ListNode(1);
head->next = new ListNode(2);
head->next->next = new ListNode(3);
head->next->next->next = head; // 创建成环
bool hasCycleResult = hasCycle(head);
if (hasCycleResult) {
std::cout << "链表成环" << std::endl;
} else {
std::cout << "链表不成环" << std::endl;
}
// 释放链表内存,避免内存泄漏
delete head;
delete head->next;
delete head->next->next;
return 0;
}
这个算法的关键是使用两个指针,一个走得快,一个走得慢,如果链表成环,快指针最终会追上慢指针。这是一个非常有效的方法,具有线性时间复杂度 O(n),其中 n 是链表的长度。如果链表很长,成环点距离链表头部很远,这个算法也能够快速检测到环的存在。
15、索引为什么用B+树?
高效的范围查询:B+树非常适合范围查询,因为所有叶子节点都连接在一起,可以轻松地遍历整个范围。在数据库中,范围查询非常常见,如查找某个时间段内的所有记录,或查找某个区间内的数据。
有序性:B+树的内部节点包含关键字,这使得数据在磁盘上有序存储。这种有序性对于某些查询非常重要,如顺序读取或范围查询,因为它可以最大程度地减少磁盘I/O。
平衡性:B+树保持平衡,确保了查询效率始终稳定。这是与二叉查找树等数据结构相比的一个显著优势。平衡性保证了查找、插入和删除操作的时间复杂度都在O(log n)。
高度可扩展性:B+树是多叉树,可以容易地扩展到大规模的数据集。这使得它适用于现代数据库系统,可以存储海量数据。
支持磁盘存储:B+树适用于数据库等需要长期存储数据的场景。其结构可以轻松映射到磁盘上,以实现数据持久化。
支持高并发:B+树的读操作通常不需要锁定整棵树,这使得它更适合高并发的数据库系统。
索引扫描性能好:B+树支持前缀查找,这意味着你可以快速查找特定前缀的数据,这在某些应用中非常有用。
16、redis在游戏服务端的使用场景是什么?
缓存玩家数据:Redis可以用作玩家数据的缓存,以降低数据库负载。游戏通常需要频繁访问和更新玩家数据,如游戏状态、物品、金币等。将这些数据存储在Redis中可以大大加速对数据的访问,因为Redis内存数据库速度非常快。
分布式锁:多玩家游戏服务器通常需要处理并发请求。Redis的原子性操作和分布式锁可以用来确保关键操作的互斥性,防止多个玩家同时执行同一操作。
实时排行榜:Redis的有序集合(Sorted Sets)非常适合实现实时排行榜。游戏可以使用Redis来存储玩家的分数和排名,然后轻松地获取排行榜信息。
消息队列:Redis的发布/订阅(Pub/Sub)功能可以用作游戏服务器之间的消息队列,用于事件通知、广播消息等。这对于多人游戏中的协同和实时性非常重要。
在线状态跟踪:游戏服务器可以使用Redis来跟踪玩家的在线状态。当玩家登录或注销时,游戏服务器可以将其状态存储在Redis中,从而为其他玩家提供实时信息。
游戏会话管理:Redis可以用于管理游戏会话(session),跟踪玩家的登录状态和访问令牌。
配置管理:Redis可以用来存储游戏配置信息,如游戏规则、关卡数据、物品属性等。这使得可以在不停服的情况下动态调整游戏参数。
反作弊和日志记录:Redis可以用来存储游戏中的事件和日志数据,以帮助进行反作弊检测和分析游戏性能。
动态资源加载:游戏可以使用Redis来存储和管理动态加载的资源,如图片、声音和地图数据,以减少加载时间。
17、缓存穿透、缓存雪崩的解决方案?
缓存穿透:指的是针对某个不存在的键不断发起请求,导致请求不断穿透缓存层,直接访问数据库。这可能是因为恶意攻击或应用程序错误导致的。
布隆过滤器:布隆过滤器是一种用于快速检查某个元素是否在集合中的数据结构,它可以用于过滤掉缓存层的一些不存在的键,从而减少请求到数据库的压力。
缓存空值:如果某个请求查询到的数据为空,也可以将这个空结果缓存起来,但要设置适当的过期时间。
缓存雪崩:指的是因为缓存中大量键在同一时间过期,导致数据库负载激增。这可能是因为缓存层的键都设置了相同的过期时间。
随机过期时间:为了避免大量键在同一时间过期,可以为每个键设置一个随机的过期时间,使过期时间分散开。
设置备份缓存:在缓存层后面引入备份缓存,即使主缓存出现问题,备份缓存可以继续提供服务。备份缓存可以是另一套缓存服务器,或者使用本地磁盘缓存。
使用不同的过期策略:对于不同的键,可以根据其重要性设置不同的过期策略,一些重要的键可以设置较长的过期时间,而一些不那么重要的键可以设置较短的过期时间。
使用缓存预热:提前将数据加载到缓存中,避免在高并发时突然请求数据库。
使用限流和降级:在缓存出现问题时,可以使用限流和降级策略,以减轻系统负载。
18、TCP和UDP区别?
连接导向性:
TCP是面向连接的协议。在数据传输之前,它要求建立连接,以确保可靠的数据传输。它提供错误检测和纠正,以确保数据完整性和顺序性。
UDP是面向无连接的协议。它不需要建立连接,因此传输速度较快,但不提供与TCP一样的可靠性和顺序性。
数据完整性:
TCP提供数据完整性检查,确保数据在传输过程中不会损坏或丢失。如果出现错误,它会重新发送丢失的数据包。
UDP不提供数据完整性检查,因此数据可能在传输中损坏或丢失,而接收方无法检测或修复这些问题。
数据顺序性:
TCP确保数据包按照发送的顺序传输,接收方将按照相同的顺序重建数据。
UDP不提供数据包的顺序性,因此数据包可能以不同的顺序到达。
连接开销:
TCP建立连接需要进行三次握手和拆除连接需要进行四次挥手,这些额外的开销增加了网络延迟。
UDP没有连接建立和拆除的过程,因此开销较低,适用于实时通信和广播应用。
可靠性:
TCP提供可靠的数据传输,适用于需要确保数据不丢失和顺序正确的应用,如文件传输、电子邮件等。
UDP提供较低层次的可靠性,适用于实时应用,如音视频流、在线游戏等,其中速度和实时性更为重要。
用途:
TCP通常用于应用层协议,如HTTP、FTP等,以确保可靠的数据传输。
UDP通常用于实时流媒体、视频通话、在线游戏等需要快速传输的应用。
19、游戏服务端更适用于哪种协议,原因?
游戏服务端通常更适合使用UDP。这是因为游戏服务端的核心需求是实时性和低延迟,而UDP在这些方面具有优势,以下是一些原因:
低延迟和更快速的传输:UDP是面向无连接的协议,不需要建立和维护连接状态,因此没有TCP的三次握手和四次挥手的开销。这使得UDP在传输数据时速度更快,减少了传输的延迟,这对于实时游戏非常重要。
实时性:在游戏中,玩家的行动需要立即传送到服务器,然后广播到其他玩家。UDP支持快速的数据传输,使游戏的响应时间更短,更接近实时性。
适用于丢失数据:在某些情况下,游戏中的每一帧都是独立的,因此如果由于网络问题导致某些帧的数据包丢失,游戏可以继续进行,而无需等待丢失的数据包重新传输。这对于在线游戏来说很重要,因为玩家不能等待。
适应不稳定的网络:UDP不提供可靠性和数据完整性检查,这允许游戏在不稳定的网络环境下更好地适应数据包丢失或乱序。游戏服务器可以根据需要进行数据包丢失的处理,例如丢弃无关的数据或进行插值来平滑移动。
较低的开销:游戏通常需要频繁地发送小型数据包,UDP没有额外的连接开销,因此对带宽和服务器资源的使用更有效。
20、select、poll、epoll?
都是用于多路复用 I/O 的系统调用,通常在服务器编程中用于监听多个文件描述符的可读或可写状态。
select:
select 是最早引入的多路复用函数之一,适用于 Linux 和 Windows 等多个平台。
select 使用一个包含所有文件描述符的位图,通过遍历位图,它能够监视多个文件描述符,以确定哪些文件描述符已准备好进行 I/O 操作。
缺点是,位图的大小有限,通常被限制在 1024 或 2048 个描述符,这使得它不适合在高并发的情况下使用。
poll:
poll 是相对于 select 的改进,也适用于多个平台。
poll 使用一个文件描述符数组来监视多个文件描述符的状态,消除了 select 中位图大小有限的问题。但是 poll 仍然需要遍历整个数组,因此对于大量文件描述符的情况,性能仍然可能有限。
epoll:
epoll 是 Linux 特有的多路复用机制,从 2.6 内核版本开始引入。
epoll 是最灵活且性能最好的多路复用方法之一。它通过使用回调机制,只关注状态发生变化的文件描述符,而不需要遍历所有文件描述符。
epoll 可以同时监听大量的文件描述符,并且可以轻松处理高并发的情况。因此,它在大规模服务器编程中被广泛采用。
21、快排和堆排介绍一下?
1.快速排序 (Quick Sort):
快速排序是一种基于分治策略的排序算法。它选择一个元素作为"基准",然后将所有小于基准的元素移动到基准的左侧,大于基准的元素移动到基准的右侧。然后递归地对左右两侧的子数组进行排序。
快速排序是一种高效的排序算法,平均情况下时间复杂度为 O(n*log(n)),最坏情况下为 O(n^2)。
#include
#include
// 交换两个元素
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
// 选择基准,分割数组,返回基准的位置
int partition(std::vector& arr, int low, int high) {
int pivot = arr[low];
int i = low + 1;
for (int j = low + 1; j <= high; j++) {
if (arr[j] < pivot) {
swap(arr[i], arr[j]);
i++;
}
}
swap(arr[low], arr[i - 1]);
return i - 1;
}
// 快速排序
void quickSort(std::vector& arr, int low, int high) {
if (low < high) {
int pivotIdx = partition(arr, low, high);
quickSort(arr, low, pivotIdx - 1);
quickSort(arr, pivotIdx + 1, high);
}
}
int main() {
std::vector arr = {5, 2, 9, 3, 4, 6, 8, 7, 1};
int n = arr.size();
quickSort(arr, 0, n - 1);
std::cout << "Sorted array: ";
for (int i = 0; i < n; i++) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
return 0;
}
2.堆排序 (Heap Sort):
堆排序使用二叉堆数据结构来排序数组。它包括两个主要步骤:建立堆(通常是最大堆)和不断移除堆顶元素来得到排序后的数组。
堆排序的时间复杂度为 O(n*log(n)),并且具有稳定的性能。
#include
#include
// 下沉操作,用于维护堆的性质
void heapify(std::vector& arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
std::swap(arr[i], arr[largest]);
heapify(arr, n, largest);
}
}
// 堆排序
void heapSort(std::vector& arr) {
int n = arr.size();
// 建堆,从最后一个非叶子节点开始依次下沉
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 从堆顶依次取出最大值,放在数组末尾,再调整堆
for (int i = n - 1; i >= 0; i--) {
std::swap(arr[0], arr[i]);
heapify(arr, i, 0);
}
}
int main() {
std::vector arr = {5, 2, 9, 3, 4, 6, 8, 7, 1};
int n = arr.size();
heapSort(arr);
std::cout << "Sorted array: ";
for (int i = 0; i < n; i++) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
return 0;
}
22、线上怎么排查死锁?
监视工具:你需要使用系统监视工具来检测系统资源的使用情况。这些工具可以帮助你识别高负载、高内存使用或其他异常情况。
日志文件:检查应用程序的日志文件,特别是针对并发操作或锁资源的日志。这些日志文件可能会包含关于死锁的信息。
数据库死锁监视:如果你的应用程序与数据库交互,数据库管理系统通常提供了监视死锁的工具。这些工具可以帮助你识别数据库中的死锁情况。
堆栈跟踪:分析堆栈跟踪信息以确定应用程序中是否存在潜在的死锁。通常,线程在等待锁资源时会产生特定的堆栈跟踪。
资源监视:监视系统的资源使用情况,包括CPU、内存、磁盘和网络。如果系统资源用尽,可能会导致死锁。
代码审查:检查应用程序的源代码,特别是关于并发编程和锁资源的部分。确保你正确释放锁资源,避免在不同线程之间形成死锁条件。
死锁检测工具:一些编程语言和框架提供死锁检测工具,它们可以在运行时检测并报告死锁。
模拟测试:如果你可以重现死锁情况,可以使用模拟测试工具模拟并诊断死锁。
一旦你识别到死锁,下一步是解决死锁。通常的解决方法包括:
资源分配策略:优化资源分配策略,确保每个线程只请求它实际需要的资源,并尽早释放不再需要的资源。
死锁检测和恢复:使用死锁检测算法来检测死锁,然后采取措施来解除死锁。这可能包括终止一个或多个死锁的线程。
超时机制:为获取锁资源的操作设置超时机制,当等待时间过长时,线程可以取消锁请求。
资源预分配:如果可能,尽量避免在运行时动态分配资源。而是提前分配并保持资源。
使用无锁数据结构:无锁数据结构可以减少死锁的概率,因为它们不需要锁来保护共享资源。
23、gdb怎么查看堆栈信息?
基本步骤:
1.启动程序并进入GDB:
gdb your_program //使用这个命令启动你的程序,然后进入GDB。
2.运行程序:
run
3.程序执行时出现问题:等程序出现问题或你需要查看堆栈信息的时候,中断程序的执行。
4.查看堆栈信息:
1.Backtrace(bt)命令:可以使用backtrace或bt命令来查看完整的函数调用堆栈信息。
bt
2.查看特定帧:你可以使用frame命令查看特定帧的信息。例如,要查看第2帧的信息,可以输入:
frame 2
3.查看局部变量:一旦你在特定的帧中,可以使用info locals命令来查看局部变量的信息。
4.查看参数:使用info args命令来查看函数的参数。
5.查看上下文:使用info registers来查看寄存器状态。
5.继续执行:如果需要,你可以使用continue命令来继续程序的执行。
6.退出GDB:当你完成调试后,可以使用quit命令退出GDB。
24、怎么查看linux cpu情况?
要查看 Linux 系统的 CPU 情况,可以使用一些命令和工具,如top、htop、mpstat和vmstat。
1.top:top 命令是一个常用的交互式系统监视工具,它可以显示系统的实时信息,包括 CPU 使用情况、内存使用情况、进程列表等。在终端中运行 top 命令即可启动,然后你可以使用键盘上的不同快捷键来进行排序和筛选。
2.htop:htop 是 top 的改进版本,提供了更多的功能和交互性,如鼠标支持、更好的进程排序等。你可以通过在终端中运行 htop 命令来使用它。
3.mpstat:mpstat 命令用于显示多核 CPU 系统中每个 CPU 核心的性能统计信息。你可以运行以下命令来查看 CPU 使用情况:
mpstat -P ALL
4.vmstat:vmstat 命令提供了有关系统的多种信息,包括 CPU 使用情况、内存使用情况、I/O 等。要查看 CPU 使用情况,你可以运行以下命令:
vmstat 1
5.sar:sar(System Activity Reporter)工具是一个功能强大的系统性能监视工具,可以提供各种性能数据,包括 CPU 使用情况。要使用 sar,你需要安装 sysstat 包,并然后运行 sar 命令。
25、懒汉的单例模式有什么需要注意的?
懒汉式单例模式是一种常见的单例设计模式,它的主要特点是在第一次访问时才创建单例对象。这种延迟加载的方式使得在多线程环境下需要特别小心,以确保线程安全。
需要注意的是:
线程安全问题:在多线程环境中,多个线程可以同时进入单例对象的创建过程,导致创建多个实例。为了解决这个问题,你可以采用以下方法:
加锁:使用互斥锁来保护创建过程,确保只有一个线程可以创建实例。这种方式会引入锁的开销,但可以确保线程安全。
双重检查锁定(Double-Check Locking):这是一种减小锁开销的方式,只在第一次创建实例时加锁,之后检查锁定状态,如果已经创建,就不再加锁。注意,这需要特定的内存模型支持,C++11之后,使用关键字std::atomic可以实现双重检查锁定。
局部静态变量:使用C++11引入的局部静态变量,可以保证线程安全且懒汉式的单例。这是一种推荐的方式。
资源管理:在懒汉式单例中,实例创建延迟至首次使用,因此如果单例对象需要占用大量资源或者需要在销毁时进行资源释放,需要小心管理资源的生命周期。
多次初始化:如果你的懒汉式单例类允许多次初始化,即在已有实例的情况下可以再次初始化,需要注意在初始化时正确处理单例对象的状态。
以下是一个C++中懒汉式单例模式的示例(使用双重检查锁定和C++11局部静态变量):
class Singleton {
public:
static Singleton& GetInstance() {
static Singleton instance; // 局部静态变量确保线程安全的懒汉式
return instance;
}
void DoSomething() {
// 实现单例的操作
}
private:
Singleton() {
// 私有构造函数,防止外部实例化
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
int main() {
Singleton& instance = Singleton::GetInstance();
instance.DoSomething();
return 0;
}
26、常用的设计模式
单例模式(Singleton Pattern):
目的:确保一个类只有一个实例,并提供全局访问点。
使用场景:需要一个唯一的配置管理对象、线程池、缓存等。
工厂模式(Factory Pattern):
目的:定义一个创建对象的接口,但将具体类的实例化延迟到子类。
使用场景:需要根据不同情况创建不同类型的对象。
抽象工厂模式(Abstract Factory Pattern):
目的:提供一个创建一系列相关或相互依赖对象的接口,而无需指定其具体类。
使用场景:需要创建一组相关的对象,例如,创建不同操作系统的用户界面组件。
建造者模式(Builder Pattern):
目的:将一个复杂对象的构建与其表示分离,以便同样的构建过程可以创建不同的表示。
使用场景:构建复杂的对象,例如,创建包含多个部分的报表。
原型模式(Prototype Pattern):
目的:通过复制现有对象来创建新对象,而不是从头开始创建。
使用场景:当创建对象的代价昂贵时,通过复制现有对象来创建新对象可以提高性能。
适配器模式(Adapter Pattern):
目的:允许接口不兼容的类可以一起工作。
使用场景:将一个接口转换成另一个接口,以便与现有的代码或组件一起使用。
装饰器模式(Decorator Pattern):
目的:允许动态地将责任附加到对象,拓展功能。
使用场景:在不改变原始对象结构的情况下,通过添加新功能或属性来扩展对象。
观察者模式(Observer Pattern):
目的:定义对象之间的一对多依赖关系,以便当一个对象的状态发生变化时,其所有依赖对象都会收到通知并自动更新。
使用场景:当一个对象的状态变化需要通知其他对象时,例如事件处理系统。
策略模式(Strategy Pattern):
目的:定义一系列算法,将它们封装在一个对象中,然后根据需要动态切换算法。
使用场景:当需要在运行时选择算法的不同变体时。
模板方法模式(Template Method Pattern):
目的:定义算法骨架,但将一些步骤延迟到子类中实现。
使用场景:当算法的骨架是固定的,但其中一些具体步骤需要在子类中自定义时。