迅雷服务器开发一面面经
1、对docker的理解?
docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的Linux机器上,也可以实现虚拟化,容器是完全使用沙箱机制,相互之间不会有任何接口。简言之,就是可以在Linux上镜像使用的这么一个容器。
特点:
- 轻量级:单机可以轻松支持上百Container,让各种个位数虚拟化的方案相形见绌。
- 快速就绪:一秒以内启动,即使是以资源快速就绪著称的青云IAAS也无法相比。
- 弱安全(容器隔离):Docker能够对多种OS资源进行隔离,但是它本质上依托于内核,因此所有的内核漏洞都是Docker的致命伤。VMware就不存在这个问题。
- 简化配置:构建一次后打包后就可以用作测试环境,也可以用作生产环境或与预生产环境,可以省去很多测试环节。比如一台服务器可以进行测试多个版本的测试,不用等待
- 动态扩容:对于运维来说,可以快速的进行扩容,减少原利用率
2、虚拟引擎跟docker这种容器引擎的区别?
- 架构差异
Docker容器是基于操作系统级虚拟化技术的解决方案。它利用Linux内核的命名空间和控制组特性,实现了资源隔离和轻量级的应用容器化。在Docker容器中,每个容器都运行在宿主机的操作系统上,并共享操作系统的内核,从而使得容器之间的隔离开销非常低。
虚拟引擎则是一种基于硬件级虚拟化技术的解决方案。它通过虚拟化软件(如VMware、VirtualBox等)创建一个完整的虚拟计算机环境,包括操作系统、应用程序和硬件资源。虚拟引擎在宿主机上运行一个独立的操作系统,并通过虚拟化软件进行管理和调度。
- 性能差异
由于Docker容器共享宿主机的操作系统内核,容器的启动速度非常快,通常只需要几秒钟的时间。此外,Docker容器的资源消耗也较低,因为它们与宿主机共享内核和硬件资源,无需额外的操作系统运行。
相比之下,虚拟引擎需要启动独立的操作系统,并模拟硬件资源,因此启动时间通常较长。而且,虚拟引擎需要占用较多的系统资源,包括内存、磁盘空间和计算资源。
- 部署方式差异
Docker容器使用镜像的方式进行部署。镜像是一个轻量级、可移植的打包格式,包含了应用程序及其运行环境的所有依赖。通过使用镜像,可以实现快速部署、扩展和迁移。
虚拟引擎则需要使用虚拟机镜像进行部署。虚拟引擎镜像是一个包含完整操作系统和应用程序的磁盘映像,它需要在虚拟机管理器中进行安装和配置。部署和迁移虚拟机需要较长的时间,并且需要额外的存储空间。
- 资源利用率差异
Docker容器具有较高的资源利用率。由于容器共享宿主机的内核和硬件资源,容器本身的体积相对较小,占用的存储空间较少。此外,容器可以动态调整资源配额,根据实际需求进行资源分配。
虚拟引擎的资源利用率较低。每个虚拟引擎都需要独立的操作系统和运行时环境,因此虚拟引擎的磁盘占用和内存消耗较高。同时,虚拟引擎的资源分配是静态的,需要预先配置好虚拟引擎的资源配额。
- 隔离性差异
Docker容器提供了一定程度的隔离性,但并不像虚拟引擎那样完全隔离。容器内的进程运行在宿主机的命名空间中,可以访问宿主机上的文件和网络资源。虽然容器之间是相互隔离的,但它们共享同一个内核,因此存在一定的安全风险和潜在的冲突可能性。
虚拟引擎提供了更强的隔离性。每个虚拟引擎运行在独立的虚拟环境中,具有自己的操作系统、网络栈和文件系统。虚拟引擎之间的进程无法直接访问宿主机上的资源,彼此之间的隔离性更高,有利于提高安全性和稳定性。
- 环境一致性差异
Docker容器可以实现环境的一致性。通过使用镜像,可以确保在不同的环境中运行相同的容器时,应用程序的行为和依赖关系保持一致。容器化的应用程序可以在开发、测试和生产环境中进行无缝部署,简化了应用程序的交付和维护。
虚拟引擎也可以实现环境的一致性,但相对来说更加复杂。由于虚拟引擎是完整的操作系统环境,需要在每个虚拟引擎中进行操作系统和应用程序的安装和配置。这增加了环境一致性的挑战,尤其是在跨不同的虚拟化平台和版本之间迁移时。
- 可移植性差异
Docker容器具有较高的可移植性。容器化的应用程序可以在不同的主机上运行,只要主机上安装了相同版本的Docker引擎。容器的可移植性使得应用程序的部署和迁移变得简单快捷,有利于构建跨平台的应用程序。
虚拟引擎的可移植性相对较低。由于虚拟引擎依赖于虚拟化软件和硬件资源,不同的虚拟化平台之间可能存在不兼容的情况。虚拟引擎的迁移需要考虑到不同平台的差异,增加了部署和维护的复杂性。
3、TCP为什么三次握手?
TCP建立连接时之所以只需要"三次握手",是因为在第二次"握手"过程中,服务器端发送给客户端的TCP报文是以SYN与ACK作为标志位的。SYN是请求连接标志,表示服务器端同意建立连接;ACK是确认报文,表示告诉客户端,服务器端收到了它的请求报文。
即SYN建立连接报文与ACK确认接收报文是在同一次"握手"当中传输的,所以"三次握手"不多也不少,正好让双方明确彼此信息互通。
4、如何识别TCP的唯一性?
对TCP而言在三次握手时的SYN标志会使用上一个ISN值,这个值是使用32位计数器,由0-4294967295,每一次连接都会分配到一个ISN值,连接双方对这个值会记录共识,假如这个值不一样,就说明了这个连接已超时或无效甚至是被人恶意攻击冒充连接。
5、TCP UDP区别?
- 连接性:
- TCP是面向连接的协议。在建立通信之前,TCP会建立一个连接,确保数据的可靠传输,然后在通信结束后关闭连接。这种连接性保证了数据的可靠性,但会引入一定的延迟。
- UDP是无连接的协议。它不会建立连接,直接将数据包发送到目标,不保证数据的可靠性,但具有低延迟的优势。
- 数据可靠性:
- TCP提供数据的可靠传输。它使用确认机制和重传来确保数据的完整性和可靠性。如果数据包在传输过程中丢失或损坏,TCP会重新发送丢失的数据。
- UDP不提供数据的可靠性。它将数据包发送出去,但不保证它们的到达。丢失、重复或无序的数据包在UDP中是常见的,应用程序需要自行处理。
- 流量控制:
- TCP具有流量控制机制,可以根据接收端的处理能力来调整数据的发送速率,以避免过载。
- UDP没有流量控制机制,发送方将数据包发送出去,不考虑接收方的处理速度,可能导致网络拥塞。
- 顺序性:
- TCP保持数据的顺序性。发送的数据包将按照发送顺序在接收端被重建。
- UDP不保证数据包的顺序性。数据包可能以不同的顺序到达接收端。
- 头部开销:
- TCP头部较大,包含许多控制信息,这增加了数据包的大小。
- UDP头部较小,只包含少量的必要信息。
- 适用场景:
- TCP适用于需要可靠数据传输的应用程序,如网页浏览、电子邮件、文件传输等。
- UDP适用于对数据传输延迟要求较高的应用程序,如音频和视频流、在线游戏等。
6、操作系统内存管理怎么实现的?
操作系统内存管理包括物理内存管理和虚拟内存管理。
- 物理内存管理:包括程序装入等概念、交换技术、连续分配管理方式和非连续分配管理方式(分页、分段、段页式)。
- 虚拟内存管理:虚拟内存管理包括虚拟内存概念、请求分页管理方式、页面置换算法、页面分配策略、工作集和抖动。
虚拟内存是现代操作系统普遍使用的一种技术。前面所讲的抽象满足了多进程的要求,但很多情况下,现有内存无法满足仅仅一个大进程的内存要求。物理内存不够用的情况下,如何解决呢?
覆盖overlays:在早期的操作系统曾使用覆盖技术来解决这个问题,将一个程序分为多个块,基本思想是先将块0加入内存,块0执行完后,将块1加入内存。依次往复,这个解决方案最大的问题是需要程序员去程序进行分块,这是一个费时费力让人痛苦不堪的过程。后来这个解决方案的修正版就是虚拟内存。
交换swapping:可以将暂时不能执行的程序(进程)送到外存中,从而获得空闲内存空间来装入新程序(进程),或读人保存在外存中而处于就绪状态的程序。
虚拟内存:虚拟内存的基本思想是,每个进程有用独立的逻辑地址空间,内存被分为大小相等的多个块,称为页(Page).每个页都是一段连续的地址。对于进程来看,逻辑上貌似有很多内存空间,其中一部分对应物理内存上的一块(称为页框,通常页和页框大小相等),还有一些没加载在内存中的对应在硬盘上。
虚拟内存与物理内存的关系?
当进程在虚拟内存中分配内存或执行指令时,操作系统负责将虚拟地址转换为物理地址。这个过程通常被称为地址映射。
操作系统使用页表(Page Table)或类似的数据结构来维护虚拟地址到物理地址的映射关系。
虚拟内存允许多个进程共享相同的物理内存,同时通过分页和分段等技术,允许操作系统将数据从物理内存中交换到磁盘上以释放内存资源。
当进程访问虚拟内存中的数据时,操作系统会根据页表将对应的物理内存数据加载到RAM中,或者在需要时从磁盘加载。
虚拟内存还提供了内存隔离和保护,一个进程不能直接访问其他进程的虚拟内存,从而增加了系统的稳定性和安全性。
7、内存压缩是怎么压缩的?
内存压缩技术的主要思想是将数据按照一定的算法压缩后存入压缩内存中,系统从压缩内存中找到压缩过的数据,将其解压后即可以供系统使用。这样既可以增加实际可用的内存空间,又可以减少页面置换所带来的开销,从而以较小的成本提高系统的整体性能。
内存压缩机制是在系统的存储层次中逻辑地加入一层——压缩内存层。系统在该层中以压缩的格式保存物理页面,当页面再次被系统引用时,解压该压缩页后,即可使用。我们将管理这一压缩内存层的相关硬件及软件的集合统称为内存压缩系统。内存压缩系统对于CPU、I/O设备、设备驱动以及应用软件来说是透明的,但是操作系统必须具有管理内存大小变化以及压缩比率变化的功能。
对于大多数的操作系统而言,要实现内存压缩,大部分体系结构都不需要改动。在标准的操作系统中,内存都是通过固定数目的物理页框(page frame)来描述的,由操作系统的VMM来管理。要支持内存压缩,OS要管理的实际内存大小和页框数目是基于内存的压缩比率来确定的。这里的实现内存是指操作系统可的内存大小,它与物理内存的关系如下:假设PM是物理内存,RM(t)是系统在t时刻的实际内存,而CR(t)是压缩比率,在给定时刻t可支持的最大实际内存为RM(t)=CR1(t)×PM。然而,由于应用程序的数据压缩率是不依赖于OS而动态变化的,未压缩的数据可能会耗尽物理内存,因此当物理内存接近耗尽时,操作系统必须采取行动来解决这个问题。
8、内存压缩具体有哪些算法?实际使用的是哪种?
- zSwap
zSwap是在memory与flash之间的一层“cache”,当内存需要swap出去磁盘的时候,先通过压缩放到zSwap中去,zSwap空间按需增长。达到一定程度后则会按照LRU的顺序(前提是使用的内存分配方法需要支持LRU)将就最旧的page解压写入磁盘swap device,之后将当前的page压缩写入zSwap。
- zRram
zRram即压缩的内存, 使用内存模拟block device的做法。实际不会写到块设备中去,只会压缩后写到模拟的块设备中,其实也就是还是在RAM中,只是通过压缩了。由于压缩和解压缩的速度远比读写IO好,因此在移动终端设备广泛被应用。zRam是基于RAM的block device, 一般swap priority会比较高。只有当其满,系统才会考虑其他的swap devices。当然这个优先级用户可以配置。
- zCache
zCache是oracle提出的一种实现文件页压缩技术,也是memory与block dev之间的一层“cache”,与zswap比较接近,但zcache目前压缩的是文件页,而zSwap和zRAM压缩是匿名页。
这些算法可以将原始数据块压缩成较小的文件,但是解压缩的工作量相对较大,会消耗相对较多的 CPU 资源。因此,在内存压缩方案中,我们需要综合考虑压缩比和解压缩速度等方面的因素。
在实现内存压缩时,还需要根据不同的数据类型和场景选择最适合的压缩算法。比如,在文本数据的压缩中,可以采用诸如 Huffman 编码、Lempel-Ziv 等算法;对于二进制数据(例如图片、音频等)则可以使用 PNG、JPEG 等图像编码标准进行压缩。同时,也有一些专门针对内存压缩的算法被开发出来,例如 LZ4、Snappy 等。
9、Linux中实际中使用的内存容量是否能大于实际的内存容量?
能,虚拟内存技术就是。
- 虚拟内存是操作系统为每个进程提供的一种抽象概念,它为每个进程分配一块连续的地址空间,通常是4GB(32位系统)或更大(64位系统)。
- 进程中的程序可以认为是运行在自己的独立虚拟地址空间中,这使得每个进程都有相同的地址范围,简化了程序的编写。
- 虚拟内存的主要目的是提供了一个抽象层,将程序员从底层物理内存的管理中解脱出来,同时允许更多的进程共享物理内存。
10、进程跟进程之间是共享内存的吗?
共享内存是系统出于多个进程之间通讯的考虑,而预留的的一块内存区。
共享内存允许两个或更多进程访问同一块内存,就如同 malloc() 函数向不同进程返回了指向同一个物理内存区域的指针。当一个程序想和另外一个程序通信的时候,那内存将会为这两个程序生成一块公共的内存区域。这块被两个进程分享的内存区域叫做共享内存。
因为所有进程共享同一块内存,共享内存在各种进程间通信方式中具有最高的效率。访问共享内存区域和访问进程独有的内存区域一样快,并不需要通过系统调用或者其它需要切入内核的过程来完成。同时它也避免了对数据的各种不必要的复制。
如果没有共享内存的概念,那一个进程不能存取另外一个进程的内存部分,因而导致共享数据或者通信失效。因为系统内核没有对访问共享内存进行同步,您必须提供自己的同步措施。
当一个进程想和另外一个进程通信的时候,它将按以下顺序运行:
- 获取mutex对象,锁定共享区域。
- 将要通信的数据写入共享区域。
- 释放mutex对象。
当一个进程从从这个区域读数据时候,它将重复同样的步骤,只是将第二步变成读取。
11、同一进程下多个线程访问内存空间怎么处理并发性问题?
- 共享内存:
- 多个线程可以访问和操作同一块共享内存区域。
- 线程通过读取和写入共享内存来实现数据共享。
- 在并发访问共享内存时,需要使用同步机制来确保数据的一致性和正确性。
- 互斥锁(Mutex):
- 互斥锁用于保护共享资源的访问,只允许一个线程访问共享资源,其他线程需要等待。
- 当一个线程获得互斥锁时,其他线程将被阻塞,直到该线程释放互斥锁。
- 信号量(Semaphore):
- 信号量用于控制多个线程对共享资源的访问权限。
- 信号量维护一个计数器,当计数器大于零时,允许线程访问共享资源;当计数器等于零时,线程需要等待。
- 线程可以通过增加或减少信号量的计数器来申请或释放共享资源的访问权限。
- 条件变量(Condition Variable):
- 条件变量用于线程间的通信和同步,可以让线程等待某个条件的满足后再继续执行。
- 线程可以通过条件变量来等待其他线程发出的通知,并在满足条件时被唤醒。
- 屏障(Barrier):
- 屏障用于同步多个线程的执行,确保这些线程在某个点上汇合并继续执行。
- 当线程达到屏障点时,需要等待其他线程也到达屏障点,然后才能一起继续执行。
12、进程创建的流程是什么?
许多操作系统都提供了专门的进程产生机制,比较典型的过程是:首先在内存新的地址空间里创建进程,然后读取可执行程序,装载到内存中执行。
Linux 系统创建线程并未使用上述经典过程,而是将创建过程拆分到两组独立的函数中执行:fork() 函数和 exec() 函数族。
基本流程是这样的:首先,fork() 函数拷贝当前进程创建子进程。产生的子进程与父进程的区别仅在与 PID 与 PPID 以及某些资源和统计量,例如挂起的信号等。准备好进程运行的地址空间后,exec() 函数族负责读取可执行程序,并将其加载到相应的位置开始执行。
Linux 系统创建进程使用的这两组函数效果与其他操作系统的经典进程创建方式效果是相似的,可能有读者会觉得这么做会让进程创建过于繁琐,其实不是的,Linux 这么做的其中一个原因是为了提高代码的复用率,这得益于 Linux 高度概括的抽象,无需再额外设计一套机制用于创建进程。
13、链表应用场景?
这里顺便讲一下数组和链表的区别。
- 内存分配:
- 数组:在编译时分配一块连续的内存空间,数组的大小通常是固定的,不能动态调整。
- 链表:在运行时按需分配内存,节点之间可以存储在不同的内存块中,链表的大小可以动态增长。
- 内存访问:
- 数组:由于内存连续,可以通过索引直接访问元素,访问速度较快。
- 链表:需要遍历链表来访问特定位置的元素,因此访问速度可能较慢。
- 插入和删除:
- 数组:在中间插入或删除元素通常需要将后续元素移动,时间复杂度为O(n),其中n是元素的数量。
- 链表:在中间插入或删除元素时,只需要调整节点的指针,时间复杂度为O(1)。
- 大小调整:
- 数组:通常需要创建一个新数组并将数据复制到新数组以调整大小。
- 链表:可以轻松地添加或删除节点以调整大小。
- 存储结构:
- 数组:线性结构,适用于随机访问元素。
- 链表:非线性结构,适用于插入和删除频繁的情况。
- 空间效率:
- 数组:通常需要分配足够大的内存,可能会浪费空间,但访问速度较快。
- 链表:根据需要分配内存,较灵活,但需要额外的指针存储节点间关系。
- 适用场景:
- 数组:适用于元素数量固定,需要频繁访问元素的情况。
- 链表:适用于元素数量不固定,需要频繁插入和删除元素的情况。
14、删除单向链表的倒数第n个结点?
思路:
- 使用两个指针,一个快指针
fast和一个慢指针slow,同时从链表的头节点开始遍历链表。 - 快指针
fast先向前移动 n+1 步,以确保fast和slow之间有 n 个节点的距离。 - 然后,同时移动
fast和slow,直到fast到达链表的末尾。 - 此时,
slow所指的节点就是倒数第 n 个节点的前一个节点。 - 删除倒数第 n 个节点即可。
参考代码:
#include <iostream>
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
ListNode* removeNthFromEnd(ListNode* head, int n) {
if (!head || n <= 0) {
return nullptr; // 链表为空或n值无效
}
ListNode* dummy = new ListNode(0); // 创建一个虚拟头节点
dummy->next = head;
ListNode* fast = dummy;
ListNode* slow = dummy;
// 快指针先向前移动n+1步
for (int i = 0; i < n + 1; ++i) {
if (fast) {
fast = fast->next;
} else {
return nullptr; // n大于链表长度,无法删除
}
}
// 快慢指针同时向前移动,直到快指针到达链表末尾
while (fast) {
fast = fast->next;
slow = slow->next;
}
// 删除倒数第n个节点
ListNode* toBeDeleted = slow->next;
slow->next = slow->next->next;
delete toBeDeleted;
return dummy->next; // 返回新的头节点
}
// 创建链表的函数
ListNode* createList(std::vector<int>& values) {
ListNode* dummy = new ListNode(0);
ListNode* current = dummy;
for (int val : values) {
current->next = new ListNode(val);
current = current->next;
}
return dummy->next;
}
int main() {
std::vector<int> values = {1, 2, 3, 4, 5};
ListNode* head = createList(values);
std::cout << "原链表:";
for (ListNode* curr = head; curr != nullptr; curr = curr->next) {
std::cout << curr->val << " ";
}
std::cout << std::endl;
int n = 2; // 要删除的倒数第n个节点
ListNode* result = removeNthFromEnd(head, n);
std::cout << "删除倒数第" << n << "个节点后的链表:";
for (ListNode* curr = result; curr != nullptr; curr = curr->next) {
std::cout << curr->val << " ";
}
std::cout << std::endl;
return 0;
}15、堆 栈区别?
- 分配方式:
- 栈:栈是一种自动分配和释放内存的数据结构,它遵循"后进先出"(LIFO)原则。当你声明一个局部变量时,该变量存储在栈上。函数的参数和局部变量也存储在栈上。栈的分配和释放是自动的,由编译器管理。
- 堆:堆是一种手动分配和释放内存的数据结构。在堆上分配内存需要使用
new或malloc等函数,释放内存则需要使用delete或free。堆上的内存不会自动释放,必须手动管理。
- 存储内容:
- 栈:栈主要存储局部变量、函数参数和函数调用的上下文。它的存储生命周期通常是有限的,当超出其作用域时,内存会自动释放。
- 堆:堆主要用于存储动态分配的对象和数据结构。它的存储生命周期没有那么明确,需要手动释放。
- 生命周期:
- 栈:栈上的变量生命周期与其作用域(通常是一个函数的执行)相对应。一旦超出作用域,栈上的变量将自动销毁。
- 堆:堆上的内存生命周期由程序员控制。在程序员显式释放内存之前,内存将一直存在。
- 分配速度:
- 栈:由于栈上的内存分配和释放是自动管理的,通常比堆更快。
- 堆:堆上的内存分配和释放需要较多的开销,通常比较慢。
- 大小限制:
- 栈:栈的大小通常受到限制,因为它由操作系统管理,可以很小,通常在几MB以内。
- 堆:堆的大小可以较大,受到系统资源的限制,通常比栈要大得多。
- 数据访问:
- 栈:栈上的数据访问速度较快,因为它是线性存储,访问局部变量通常只需要一次寻址操作。
- 堆:堆上的数据访问速度较慢,因为它是散乱存储,需要进行额外的寻址操作。
