后端开发面经系列 -- 哔哩哔哩C++后端一面

B站C++后端开发一面

公众号:阿Q技术站

来源:https://www.nowcoder.com/discuss/550638808786661376

1、MySQL默认16KB的页大小会不会有什么问题?为什么使用16KB作为页面的默认大小?

MySQL默认的页大小(或称为数据页、表空间页)为16KB。这是InnoDB存储引擎的默认页大小,而不是MySQL服务器本身的默认页大小。这个大小通常是合理的,但对于某些特定的应用场景,可能需要不同的页大小。

MySQL使用16KB作为默认页大小以及潜在问题的原因:

  1. 性能优化:较大的页大小可以减少磁盘I/O次数,因为每个页通常包含多行数据,这有助于提高读取和写入性能。较大的页还有助于减少索引占用的空间。
  2. 多种大小支持:虽然16KB是默认页大小,但InnoDB存储引擎实际上支持多个页大小。可以使用不同的页大小来优化不同类型的表或应用,因为不同的页大小可能适用于不同的工作负载。
  3. 大容量数据:对于大容量数据表,较大的页大小可以减少页的数量,从而降低内存开销,提高性能。

尽管16KB的页大小对于许多应用是合理的,但并不是所有情况下都适用。在某些情况下,较小的页大小可能更为合适,例如针对低内存系统或需要更高精度的应用。对于这些情况,你可以配置MySQL的InnoDB存储引擎以使用不同的页大小。

2、操作系统大页了解吗?

它允许操作系统使用较大的页面(通常是传统页面大小的几倍)来管理物理内存和虚拟内存。大页有助于提高内存访问的性能和减少操作系统内存管理开销。

特点和优点:

  1. 较小的页 vs. 大页:传统操作系统使用较小的页面(通常为4KB),这些小页面用于虚拟内存和物理内存之间的映射。大页允许操作系统使用更大的页面,通常是2MB或更多,以减少页面表的条目数量。
  2. 减少页面表大小:通过使用大页,操作系统可以减少需要维护的页面表的大小,因为每个大页只需要一个表项而不是多个小页的表项。这减少了内存访问时需要查找页面表的开销。
  3. 减少TLB缓存失效:大页可以减少TLB缓存失效的机会,因为更多的虚拟地址范围可以映射到一个大页中。这有助于提高内存访问的速度,特别是对于大型数据集的访问。
  4. 提高性能:大页通常提高了内存访问的性能,尤其是对于需要频繁访问大数据集的应用程序,如数据库服务器和科学计算应用。

使用大页的注意事项:

  1. 硬件支持: 大页需要硬件的支持,包括处理器和内存管理单元(MMU)的支持。不是所有的硬件都支持大页。
  2. 操作系统配置: 操作系统需要正确配置以启用大页支持,并分配大页内存。不同操作系统可能有不同的配置方法。
  3. 应用程序支持: 应用程序需要明确支持大页。一些应用程序可能需要特殊的配置或代码更改,以利用大页的优势。

3、虚拟地址如何转化成物理地址?TLB了解吗?

虚拟地址到物理地址的转换过程:

  1. 虚拟地址生成: 当应用程序中的进程需要访问内存中的数据时,它会生成虚拟地址。
  2. 分页机制: 操作系统通常使用分页机制将虚拟地址划分为固定大小的页面(通常为4KB)。这些页面被映射到物理内存中的页面框(页框)。
  3. 页表查找: 虚拟地址中的页面号用于查找页表,页表存储了虚拟地址到物理地址的映射关系。操作系统将虚拟地址的页面号映射到物理地址的页面框号。
  4. 偏移量计算: 虚拟地址中的偏移量用于计算物理地址中的偏移量。
  5. 物理地址生成: 通过将页面框号和偏移量组合,可以生成物理地址。
  6. 内存访问: 生成的物理地址用于访问物理内存中的数据。

TLB:

TLB是一种硬件缓存,它用于存储虚拟地址到物理地址的映射关系,以提高地址转换的速度。TLB通常是一种关联数组,可以在一个时钟周期内进行查找。

TLB的工作方式如下:

  1. 当CPU生成虚拟地址时,它首先查找TLB,看是否已经存在虚拟地址到物理地址的映射。如果存在,CPU可以直接从TLB中获取物理地址,从而避免了访问页表的开销。
  2. 如果TLB中没有找到映射,CPU将请求操作系统进行页表查找,以找到虚拟地址到物理地址的映射。一旦找到映射,操作系统将其存储在TLB中,以便下次访问相同虚拟地址时可以更快地进行转换。

TLB具有有限的大小,因此它通常只能存储部分虚拟地址到物理地址的映射。如果发生TLB未命中(TLB miss),则需要进行额外的页表查找,这可能需要更多的时钟周期。因此,优化TLB的使用对于提高内存访问速度非常重要。

4、有一个程序频繁访问操作系统很多页面,导致TLB miss率比较高,怎么优化?

如果一个程序频繁访问操作系统中的很多页面,导致TLB的命中率低,可以采取以下优化措施来提高TLB的性能:

  1. 局部性原则: 优化算法和数据结构,以便程序在内存访问时表现出更好的局部性。这包括时间局部性和空间局部性。尽量让程序在访问内存时,访问附近的地址,以减小TLB未命中的概率。
  2. 增加页表项的大小: 如果可能的话,增加操作系统页表中每个页表项的大小,以容纳更多的虚拟地址到物理地址的映射。这样,更多的映射可以存储在TLB中,提高了命中率。
  3. 使用大页面: 如果硬件和操作系统支持,使用大页面(大页)可以减少页表项的数量,从而减小TLB未命中的概率。大页面通常是传统页面大小的多倍。
  4. 改进数据结构: 确保程序的数据结构和算法在内存访问时尽量减小缓存未命中。例如,可以使用紧凑的数据结构,减少指针跳转和随机内存访问。
  5. 预取数据: 使用预取技术,提前加载可能访问的数据到TLB,以减少未命中的概率。这可以通过硬件预取、编译器优化或手动预取指令来实现。
  6. 增加TLB的大小: 如果硬件支持,增加TLB的大小可以容纳更多的虚拟地址到物理地址的映射。这可以通过升级硬件来实现,但可能需要考虑成本和兼容性问题。
  7. TLB管理策略: 使用合适的TLB管理策略,如LRU(最近最少使用)或LFU(最不常用)来替换TLB中的旧条目。这有助于更好地利用有限的TLB空间。
  8. 减小程序的工作集: 如果程序访问的页面太多,可以考虑减小程序的工作集,只保留必要的数据和代码。这有助于降低内存访问的复杂性。
  9. 考虑多线程并发: 在多线程应用中,不同线程之间的内存访问可能导致TLB冲突。优化线程间的内存访问模式可以改善TLB性能。

5、map和b+树,从内存访问的角度,哪个效率比较高?

map底层是红黑树实现,所以它们两个的比较也就是红黑树和B+树的比较。

从内存访问的角度来看,B+树通常在大规模数据存储和范围查询操作时效率更高,而红黑树在单个键查找操作和小规模数据集上效率更高。这是因为它们的内部结构和数据访问模式不同。

B+树:

  • B+树是一种多叉树,通常用于数据库索引和文件系统的元数据管理。
  • B+树的节点通常较小,可以包含多个键值对。
  • B+树的节点是有序的,支持范围查询和范围遍历操作。
  • B+树的高度相对较低,通常需要较少的磁盘或内存访问来查找或遍历数据。

在内存访问方面,B+树在范围查询和范围遍历时效率较高,因为它可以按顺序访问节点。然而,在单个键查找操作中,可能需要遍历树的高度来找到目标数据。

红黑树:

  • 红黑树是一种自平衡的二叉搜索树,通常用于实现数据结构如std::map
  • 红黑树的节点相对较小,通常包含一个键值对。
  • 红黑树是二叉搜索树,不支持范围查询,它的查找、插入和删除操作平均时间复杂度为O(log n)。

在内存访问方面,红黑树在单个键查找操作上通常效率更高,因为它是二叉搜索树,而查找操作的平均时间复杂度为O(log n)。然而,红黑树不适合范围查询和范围遍历操作。

如果你需要处理大规模数据集或需要支持范围查询,B+树通常更适合。如果你主要进行单个键的查找操作或处理小规模数据集,红黑树可能更适合。

6、操作系统页大小是多大?为什么用4KB的大小?

操作系统中的页大小通常是4KB,尽管它在不同的计算机体系结构和操作系统中也可能有所不同。

原因:

  1. 经济因素:4KB页大小是一种经济和高效的选择。较小的页大小意味着更多的页表项,从而需要更多的内存来存储页表,而较大的页大小可能导致内部碎片。4KB的页大小通常在平衡内存管理效率和内存开销之间。
  2. 灵活性:较小的页大小使操作系统更灵活,能够更好地适应各种应用程序和工作负载。它可以更好地满足不同应用程序的内存分配需求。
  3. 页面置换效率:较小的页面允许更细粒度的页面置换。当操作系统需要将页面从内存中移出到磁盘以腾出空间时,使用小页面可以减小页面置换开销。大页可能导致不必要的数据移动,因为整个大页需要移动,而不仅是其中一部分。
  4. 内存碎片:较小的页面大小有助于减少内部碎片。如果页大小太大,会浪费更多的内存,因为无法完全填充每个页面,导致浪费。

尽管4KB页大小在许多系统中是常见的选择,但在某些特殊应用中,可以使用不同的页大小。例如,某些系统支持大页面(如2MB或4MB),以减少页表的大小和内存开销,适用于某些高性能计算工作负载。

7、操作系统申请大于4KB的页面的流程?

  1. 请求分配大页面: 应用程序或内核需要大于4KB的页面时,它会向操作系统发出请求。这个请求通常包括所需页面的大小和数量。
  2. 内核响应: 操作系统内核接收到请求后,会检查是否有足够的大页面可供分配。如果可用,操作系统将继续分配页面。
  3. 分配页面: 操作系统会在内存中找到足够的物理内存页框来容纳所需的大页面。这些页框通常是物理内存中的连续区域。操作系统会将这些页框分配给应用程序或内核,并维护有关分配的元数据信息。
  4. 虚拟地址映射: 一旦大页面分配完成,操作系统将建立虚拟地址到物理地址的映射,以便应用程序可以访问这些页面。这个映射通常包括更新页表或页目录,具体取决于操作系统的内存管理机制。
  5. 使用大页面: 应用程序可以开始使用分配的大页面。这些页面通常用于存储大型数据结构或执行需要大量内存的任务。
  6. 释放大页面: 当应用程序不再需要分配的大页面时,它可以通知操作系统释放这些页面。操作系统将页面标记为可用,并在需要时用于后续分配。

但是需要注意的是,大页面的分配通常需要硬件和操作系统的支持。不是所有的硬件和操作系统都支持大页面,因此可用性取决于特定的系统配置。此外,分配大页面通常会引入内存浪费,因为无法将这些页面细分成小的页面,从而导致内部碎片。

8、手撕:LRU?

问题描述:

请你设计并实现一个满足LRU (最近最少使用) 缓存约束的数据结构。

实现 LRUCache 类:

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。

函数 getput 必须以 O(1) 的平均时间复杂度运行。

思路:

  1. 首先,我们需要定义一个双向链表节点,用于存储键值对。这个节点包括键(key)和值(value),以及指向前一个节点(prev)和后一个节点(next)的指针。
  2. 我们需要维护一个哈希表(unordered_map),用于快速查找键对应的节点。哈希表的键是键(key),值是指向节点的指针。
  3. 双向链表的头部表示最近访问的节点,尾部表示最久未使用的节点。每次访问一个节点时,我们需要将它移动到链表头部。
  4. 当插入一个新的键值对时,如果缓存已满,我们需要移除链表尾部的节点,即最久未使用的节点,并从哈希表中删除它。
  5. 通过上述方法,我们可以实现以下操作:
    • get(key):如果键存在于缓存中,首先从哈希表中查找该键,然后将访问的节点移动到链表头部,并返回值;如果键不存在,返回-1。
    • put(key, value):如果键已存在,更新值并将节点移动到链表头部;如果键不存在,需要插入新的节点。如果缓存已满,需要移除最久未使用的节点,然后插入新节点到链表头部。

参考代码:

#include <unordered_map>
using namespace std;

class LRUCache {
public:
    LRUCache(int capacity) {
        capacity_ = capacity;
    }

    int get(int key) {
        // 如果键存在于缓存中
        if (cache_.find(key) != cache_.end()) {
            // 将访问的节点移动到链表头部,表示最近访问
            moveToFront(key);
            return cache_[key]->value;
        }
        return -1; // 如果键不存在,返回-1
    }

    void put(int key, int value) {
        // 如果键已存在,更新值并将节点移动到链表头部
        if (cache_.find(key) != cache_.end()) {
            cache_[key]->value = value;
            moveToFront(key);
        } else {
            // 如果键不存在,需要插入新的节点
            if (cache_.size() >= capacity_) {
                // 如果缓存已满,需要移除最久未使用的节点
                evictLast();
            }
            Node* newNode = new Node(key, value);
            cache_[key] = newNode;
            addToFront(newNode);
        }
    }

private:
    struct Node {
        int key;
        int value;
        Node* prev;
        Node* next;
        Node(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {}
    };

    int capacity_;
    unordered_map<int, Node*> cache_;
    Node* head_ = nullptr;
    Node* tail_ = nullptr;

    // 辅助函数:将节点移动到链表头部
    void moveToFront(int key) {
        Node* node = cache_[key];
        if (node == head_) {
            return;
        }
        if (node == tail_) {
            tail_ = tail_->prev;
            tail_->next = nullptr;
        } else {
            node->prev->next = node->next;
            node->next->prev = node->prev;
        }
        node->next = head_;
        head_->prev = node;
        head_ = node;
    }

    // 辅助函数:将节点插入到链表头部
    void addToFront(Node* node) {
        if (!head_) {
            head_ = tail_ = node;
        } else {
            node->next = head_;
            head_->prev = node;
            head_ = node;
        }
    }

    // 辅助函数:移除链表尾部的节点
    void evictLast() {
        int key = tail_->key;
        cache_.erase(key);
        if (head_ == tail_) {
            delete tail_;
            head_ = tail_ = nullptr;
        } else {
            Node* newTail = tail_->prev;
            newTail->next = nullptr;
            delete tail_;
            tail_ = newTail;
        }
    }
};

9、除了LRU,还了解哪些缓存替换策略?

  1. FIFO(先进先出):这是最简单的替换策略,即最早进入缓存的项最早被替换。这种策略不考虑项的访问频率或重要性,而只关注项的到达顺序。FIFO通常在实现上比较容易,但可能不适用于需要更智能的替换策略的情况。
  2. LFU(最不经常使用):LFU策略根据项的访问次数来选择替换项。当缓存满时,它会选择访问次数最少的项进行替换。LFU适用于需要考虑项访问频率的场景。
  3. MRU(最近最常使用):MRU策略选择最近被访问的项来替换。它假设最近访问的项是最有可能被再次访问的。MRU适用于特定类型的工作负载,其中最近的访问对性能影响较大。
  4. Random(随机):随机策略根据随机选择项来替换。它不基于访问历史或其他信息来做决策,而是纯粹的随机选择。这种策略通常不是最优选择,但有时在某些场景下可以表现得很好。

10、自旋锁和互斥锁的区别?各自使用场景?怎么实现的?写伪代码。

自旋锁(Spin Lock):

  • 自旋锁是一种忙等待的同步机制。当线程尝试获取锁但锁已经被其他线程占用时,它会在一个循环中不断尝试获取锁,而不会被挂起。
  • 自旋锁通常在以下情况下使用:
    • 临界区的锁被占用的时间非常短暂,而线程被挂起和唤醒的开销较大。
    • 自旋锁适用于多核CPU,因为在线程自旋等待时,其他线程可以在不同的核上执行。
  • 自旋锁的实现通常使用原子操作,如Compare-And-Swap(CAS)指令。

给个伪代码:

while true {
    if (lock_is_free) {
        if (atomic_compare_and_swap(&lock, 0, 1)) {
            // 成功获取锁
            break;
        }
    }
    // 自旋等待
}
// 访问临界区
lock = 0; // 释放锁

互斥锁(Mutex):

  • 互斥锁是一种阻塞式的同步机制。当线程尝试获取锁但锁已经被其他线程占用时,它会被挂起,等待锁的释放。
  • 互斥锁通常在以下情况下使用:
    • 临界区的锁被占用的时间较长,而线程被挂起和唤醒的开销相对较小。
    • 互斥锁适用于单核或多核CPU。
  • 互斥锁的实现依赖于操作系统提供的原语,通常使用系统调用来挂起和唤醒线程。

再来个伪代码:

mutex_lock(&lock); // 尝试获取锁,如果锁已被占用,线程被挂起
// 访问临界区
mutex_unlock(&lock); // 释放锁

自旋锁适用于短暂的临界区,而互斥锁适用于较长的临界区。自旋锁可能会在多核CPU上表现良好,但会浪费CPU资源,而互斥锁会挂起等待的线程,但在某些情况下性能更好。

  • 32
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值