虎牙技术面 C++,问得真细节。。

662次阅读  |  发布于1年以前

1、虚函数底层?

这个是面向面试的,更详细的可以去看我总结的另一篇:C++虚函数详解

C++ 中虚函数的实现涉及到虚函数表(vtable)和虚函数指针(vptr),这是实现多态性的关键。虚函数表和虚函数指针允许程序在运行时确定要调用的函数,而不是在编译时确定。

  1. 虚函数表(vtable):
    1. 对于每个拥有虚函数的类,编译器会生成一个虚函数表,通常在类的内部,作为类的一部分。这个虚函数表是一个指向函数指针的数组,其中包含了该类中每个虚函数的地址。
    2. 虚函数表的第一个函数指针通常指向类的析构函数。接下来是按照虚函数在类中声明的顺序排列的函数指针。子类将继承父类的虚函数表,并可以在其末尾添加自己的虚函数指针。
  2. 虚函数指针(vptr):
    1. 对于每个类的对象,编译器会生成一个虚函数指针(通常称为vptr),该指针指向对象所属类的虚函数表。这个vptr通常位于对象的内存布局的开头。
    2. 当调用虚函数时,实际上是通过对象的vptr找到正确的虚函数表,然后在虚函数表中查找要调用的函数的地址,最后执行该函数。
  3. 多态的工作机制:
    1. 当通过基类指针或引用调用虚函数时,程序首先访问对象的vptr,然后从虚函数表中找到要调用的函数。
    2. 这个机制允许在运行时根据对象的实际类型而不是指针或引用的静态类型来调用正确的函数,实现了多态性。
  4. 注意事项:
    1. 构造函数不能是虚函数,因为在对象的构造过程中,虚函数表可能尚未完全设置。而析构函数可以为虚函数。
    2. C++标准并未规定虚函数表和虚函数指针的实现方式,因此不同编译器可能有不同的底层实现。但在大多数情况下,它们遵循上述概念。

2、vector 动态扩容底层?

在 C++ 中,std::vector 是一个动态数组,它可以自动扩容以容纳更多元素。其动态扩容的底层机制涉及到内存分配和复制元素。详细步骤:

  1. 初始内存分配:

当创建一个空的 std::vector 或者向一个已有的 std::vector 添加元素时,std::vector 会首先分配一块初始的内存空间。这个内存块的大小通常是小于或等于系统的内存页大小。这是一个较小的固定大小的内存块,称为容量(capacity),通常远远大于 std::vector 中当前元素的数量。它能容纳更多元素,以减少频繁的内存分配操作。2. 元素添加:

当你向 std::vector 添加元素时,如果元素数量(大小,size)等于容量(capacity),则需要触发扩容操作。3. 扩容操作:

  1. std::vector 的扩容操作会分配一块新的内存区域,通常是当前容量的两倍。
  2. 然后,它将现有的元素从旧内存复制到新的内存中,以保留现有的数据。
  3. 接着,释放旧内存区域。
  4. 最后,将新的元素插入到新内存的末尾。

这个过程确保了容器在元素添加时具有线性复杂度,即 O(1) 的均摊时间复杂度,因为扩容操作不会频繁发生,而且每次的扩容是成倍增加的。4. 重新分配内存:

需要注意的是,每次扩容后,std::vector 的容量会超过实际元素的数量。这种重新分配内存和复制数据的操作可能导致性能开销。因此,在对 std::vector 进行大量元素添加操作时,可以通过 reserve() 函数来预先分配足够的内存,以避免不必要的扩容和复制操作。

3、两个 vector 一个放普通数据类型一个放指针,扩容有什么区别?

  1. 存储普通数据类型的 std::vector
    1. std::vector 存储普通数据类型(如整数、浮点数、自定义结构体等)时,容器会在扩容时分配新的内存,并将现有数据复制到新内存中。
    2. 这意味着在扩容时,每个元素都会进行拷贝构造(如果是自定义数据类型,需要调用其拷贝构造函数),因为新内存中的元素是一个完全独立的拷贝。
  2. 存储指针的 std::vector
    1. std::vector 存储指针时,容器会在扩容时仅复制指针,而不会复制指针所指向的对象。这是因为指针本身的大小是固定的,通常为 4 字节或 8 字节(根据系统架构),因此拷贝指针的开销很小。
    2. 但需要注意的是,原始对象(指针所指向的对象)并没有被复制,它们仍然位于原来的内存位置。这意味着,当你修改其中一个 std::vector 中的指针指向的对象时,另一个 std::vector 中的相应指针也会反映这些修改。
  3. 请特别小心,在释放内存时,不要重复释放同一个内存块。通常情况下,只需要释放一次。

4、进程通信,共享内存如何实现进程安全?

  1. 互斥锁(Mutex):使用互斥锁来保护共享内存中的临界区。在访问共享数据之前,进程首先要获取锁,确保只有一个进程可以进入临界区。这可以防止多个进程同时修改共享内存,从而保持数据一致性。
  2. 信号量(Semaphore):信号量是一种更高级的同步机制,它可以用于控制多个进程对共享内存的访问。通过信号量,你可以指定共享内存的访问权限,确保一次只有一个进程可以对其进行读或写操作。
  3. 读写锁(Read-Write Lock):如果你的应用程序涉及到大量读操作和较少的写操作,可以使用读写锁。读写锁允许多个进程同时进行读操作,但只允许一个进程进行写操作。这有助于提高性能,减少写操作的争用。
  4. 原子操作:在某些情况下,可以使用原子操作来确保进程安全。原子操作是不可分割的操作,不需要额外的同步机制。例如,C++11引入的<span style="letter-spacing: 0.578px;text-decoration: none;font-size: 16px;">std::atomic类型可以用于实现原子操作,确保多个进程在更新共享内存时不会互相干扰。
  5. 事务性内存(Transactional Memory):一些现代处理器支持事务性内存,允许你将一系列内存操作包装成事务,要么全部成功,要么全部失败。这可以确保在多进程环境中的共享内存访问是原子的。
  6. 数据结构的设计:设计共享内存数据结构时,要考虑进程安全。使用适当的数据结构和算法可以减少竞争条件的发生。例如,避免使用不必要的全局变量,采用有序数据结构等。
  7. 错误处理:在共享内存操作中,需要仔细处理错误情况,例如无法获取锁或信号量的情况。在出现错误时,需要确保释放资源以避免资源泄漏。

5、malloc 和 free 如何知道释放内存具体大小?

它们并不存储关于分配内存块大小的信息。这是因为这两个函数设计时的出发点是提供一个轻量级的、固定的内存管理机制,以降低运行时的开销。这也就意味着,一旦你使用 malloc 分配了内存,你需要自行追踪该内存块的大小。有几种常见的方法来做到这一点:

  1. 固定大小分配:在分配内存之前,你可以明确知道所需的内存块大小,然后使用这个大小来分配内存。在释放内存时,你可以使用相同的大小信息。
size_t block_size = 100; // 假设内存块大小是100字节
void* ptr = malloc(block_size);
// ...
free(ptr);
  1. 分配包含大小信息的内存块:在实际分配的内存块前存储大小信息。这是一种常见的做法,通常称为"头信息"。这样,你可以在释放内存时,根据头信息来确定内存块的大小。

size_t block_size = 100;
void* ptr = malloc(block_size + sizeof(size_t)); // 额外存储大小信息
*((size_t*)ptr) = block_size; // 存储大小信息
// ...
size_t size = *((size_t*)ptr); // 从头信息中获取大小
free(ptr);
  1. 自定义内存分配器:你可以实现自己的内存分配器,该分配器跟踪分配的内存块的大小。这对于需要灵活内存管理的情况非常有用。

需要注意的是,使用mallocfree时,确保精确地跟踪内存块的大小至关重要,以避免内存泄漏和未定义的行为。

6、线程池?

线程池主要用于管理和重用线程以执行多个任务,从而提高多线程应用程序的效率和性能。线程池通常由线程队列和一组工作线程组成。

它的设计和使用的好处?

  1. 降低线程创建和销毁的开销:线程的创建和销毁是相对昂贵的操作。在多线程应用程序中,频繁地创建和销毁线程会导致系统开销过大。线程池通过维护一组可重用的线程,避免了这种开销,提高了程序的性能。
  2. 控制并发度:线程池允许开发者限制同时执行的线程数量。这对于控制系统资源的使用非常重要,因为过多的并发线程可能导致系统性能下降,甚至崩溃。线程池可帮助确保资源受到合理的利用。
  3. 任务队列管理:线程池通常包括一个任务队列,用于存储待执行的任务。这些任务可以按照加入的顺序进行执行,也可以设置优先级,从而实现任务的管理和排序。
  4. 减少线程冲突:在多线程环境中,多个线程可能会竞争资源或锁,导致死锁或性能下降。线程池可以通过限制并发线程的数量来减少这种竞争。
  5. 提高响应性:线程池可以更快地响应新任务的到来。当一个任务到达时,线程池中通常已经有工作线程准备好接受它,而不必等待新线程的创建。
  6. 资源管理:线程池允许限制和管理线程的数量,以避免资源过度占用。

线程池通常应用于需要执行大量独立任务的场景,如服务器应用、网络通信、图像处理等。使用线程池可以提高程序的并发性、性能和可维护性。不过,线程池的设计和配置需要根据具体应用的需求和系统特性来调整,包括线程数量、任务队列的大小、任务优先级等。

7、基类析构函数为什么是虚函数?

在C++中,基类(父类)的析构函数通常被声明为虚函数,这是为了实现多态和正确的资源释放。

  1. 多态行为:多态是面向对象编程的一个重要特性,允许通过基类指针或引用来调用派生类的函数。如果基类的析构函数不是虚函数,那么在使用多态时可能会导致问题。考虑以下情况:
Base* basePtr = new Derived;
delete basePtr; // 如果析构函数不是虚函数,只会调用Base的析构函数

如果Base类的析构函数不是虚函数,那么上述代码将调用Base类的析构函数,而Derived类中的资源可能不会被正确释放,导致资源泄漏。

  1. 当类中使用了动态分配的资源(如堆上的内存、文件句柄等),并在析构函数中负责释放这些资源时,需要确保在多态的情况下正确释放资源。只有将析构函数声明为虚函数,才能在基类指针或引用指向派生类对象时,调用派生类的析构函数,确保释放了派生类中的资源。
  2. 在C++中,一个派生类可能继承自多个基类。如果其中某个基类的析构函数不是虚函数,可能导致无法正确地释放所有基类的资源。通过使用虚函数,可以确保正确地调用所有相关基类和派生类的析构函数。

8、堆区和栈区的区别?

  1. 分配方式:
    1. 栈区(Stack): 栈区内存由编译器自动分配和释放。栈上的变量生命周期是有限的,通常在函数调用时创建,函数返回时销毁。这种自动分配和释放的机制使得栈区非常高效。
    2. 堆区(Heap): 堆区内存由程序员手动分配和释放。程序员可以在堆上分配内存,需要手动释放,否则可能导致内存泄漏。
  2. 生命周期:
    1. 栈区(Stack): 变量的生命周期与其作用域有关。一旦超出作用域,变量就会自动销毁。
    2. 堆区(Heap): 在堆上分配的内存不会自动释放,需要程序员负责管理其生命周期。如果没有正确释放堆内存,可能导致内存泄漏。
  3. 速度:
    1. 栈区(Stack): 由于栈上的分配和释放是自动的,所以栈操作通常比堆快。栈是有限的,分配的内存有限。
    2. 堆区(Heap): 堆上的分配和释放通常较慢,因为它涉及到更复杂的内存管理。堆的大小通常受限于操作系统的虚拟内存大小。
  4. 碎片:
    1. 栈区(Stack): 栈上的内存通常分配和释放非常整齐,不容易出现内存碎片问题。
    2. 堆区(Heap): 堆内存容易出现内存碎片,需要使用内存管理工具来处理。
  5. 使用:
    1. 栈区(Stack): 适用于存储局部变量、函数调用、快速分配和释放等临时数据。
    2. 堆区(Heap): 适用于存储动态分配的数据,如动态数组、对象等,具有较长的生命周期。
  6. 多线程访问:
    1. 栈区(Stack): 每个线程通常有自己的栈,因此多线程环境下不会互相干扰。
    2. 堆区(Heap): 多线程环境下需要额外的同步机制,以免多个线程同时分配或释放堆内存导致问题。

9、宏定义放在哪里?

  1. 头文件:通常,宏定义会放在头文件中,以便在多个源文件中共享。这些头文件通常包含在源文件中,以便在编译时展开宏定义。头文件通常使用 .h.hpp 为扩展名。

示例:

// mymacros.h
#ifndef MYMACROS_H
#define MYMACROS_H

#define MAX(a, b) ((a) > (b) ? (a) : (b)

#endif
在源文件中包含头文件:
// main.cpp
#include "mymacros.h"
#include <iostream>

int main() {
    int x = 5, y = 7;
    int result = MAX(x, y);
    std::cout << "Max: " << result << std::endl;
    return 0;
}
  1. 源文件:您也可以在源文件中定义宏,通常用于只在一个源文件中使用的情况。这些宏定义在源文件的顶部。

// main.cpp
#include <iostream>

#define PI 3.14159

int main() {
    double radius = 5.0;
    double area = PI * radius * radius;
    std::cout << "Area: " << area << std::endl;
    return 0;
}
  1. 命令行参数:您可以在编译源文件时,通过命令行参数 <span style="letter-spacing: 0.578px;text-decoration: none;font-size: 16px;">-D 来定义宏。这种宏定义仅适用于特定的编译。
g++ -DDEBUG=1 main.cpp -o myprogram

宏定义通常用于在源代码中创建简单的替代标识符,以便在编译时进行文本替换。然而,宏的滥用可能导致代码的可读性下降,因此需要慎重使用。

10、qt 信号与槽的连接方式?

在Qt中,信号与槽是一种强大的事件处理机制,用于实现对象之间的通信。以下是Qt中信号与槽的连接方式的详细说明:1. 手动连接:这是最基本的连接方式,通常用于在运行时建立连接。你可以使用QObjectconnect()方法手动连接信号和槽。这种方式需要使用SIGNAL()SLOT()宏来指定信号和槽。例如:

QObject::connect(sender, SIGNAL(senderSignal()), receiver, SLOT(receiverSlot()));
  1. 自动连接:Qt 5引入了新的连接语法,不再需要使用 SIGNAL()SLOT() 宏,而是使用槽函数的指针。这种方式更加类型安全,编译器可以检测连接的有效性。示例:
QObject::connect(sender, &SenderClass::senderSignal, receiver, &ReceiverClass::receiverSlot);
  1. Lambda表达式连接:你可以使用Lambda表达式连接信号与槽,这样可以在连接的同时执行一些自定义操作。示例:
QObject::connect(sender, &SenderClass::senderSignal, [=]() {
    // 处理信号触发的操作
});
  1. 自动连接方式:在Qt的UI设计中,你可以使用Qt Designer或Qt Creator生成的代码,这些代码会自动连接信号和槽,无需手动编写连接代码。

11、windows 消息循环?

用于接收和处理来自操作系统的消息,例如鼠标事件、键盘事件、窗口事件等。消息循环允许应用程序响应用户输入和操作系统的通知,以便进行适当的处理。详细解释:

  1. 消息队列:Windows操作系统使用消息队列来存储各种类型的消息,包括用户输入事件和系统通知。每个窗口和线程都有一个相关联的消息队列。
  2. 消息循环:消息循环是一个循环结构,它不断地从消息队列中获取消息并将其分派给适当的处理程序或窗口过程。消息循环通常在应用程序的主线程中运行,不断地检查消息队列以查看是否有新消息。
  3. 消息处理:消息循环根据消息的类型和目标窗口将消息传递给相应的消息处理程序。消息处理程序是回调函数或窗口过程,它们被定义为接收和处理特定类型的消息。
  4. 消息处理流程:消息循环的典型流程如下:
    1. 从消息队列中获取下一个消息。
    2. 根据消息的类型和目标窗口查找相应的消息处理程序。
    3. 将消息传递给消息处理程序。
    4. 消息处理程序执行相应的操作,可能会产生新的消息。
  5. 消息类型:Windows消息可以包括各种类型,如窗口事件消息(WM_CREATE、WM_DESTROY)、用户输入消息(WM_MOUSEMOVE、WM_KEYDOWN)、定时器消息(WM_TIMER)等。
  6. 非阻塞和阻塞:消息循环可以以阻塞和非阻塞的方式运行。在阻塞模式下,消息循环将等待消息到达消息队列并处理它们。在非阻塞模式下,消息循环将轮询消息队列,如果没有消息,它会立即返回。
  7. 消息分派:消息循环通过消息分派机制将消息传递给合适的窗口或控件。窗口和控件必须具有唯一的标识符,以便正确地将消息分派到目标。
  8. 响应用户输入:消息循环使应用程序能够响应用户的鼠标点击、键盘输入等操作。应用程序可以处理这些事件并采取适当的措施,例如更新界面或执行操作。

12、智能指针三种底层实现和应用场景?

智能指针是一种用于管理动态分配内存的智能工具,可以自动进行内存管理,避免了常见的内存泄漏问题。

  1. std::shared_ptr
    1. 底层实现:std::shared_ptr使用引用计数的技术来管理内存。多个shared_ptr对象可以共享同一个动态分配的对象,它们都维护一个引用计数,当引用计数变为零时,对象的内存将被自动释放。
    2. 应用场景:适用于多个所有权共享相同资源的情况。例如,在多个对象需要访问和管理相同的资源(如图形资源、文件句柄等)时,shared_ptr是一个合适的选择。
  2. std::unique_ptr
    1. 底层实现:std::unique_ptr采用独占拥有权的模式,每个unique_ptr对象独立拥有所管理的资源。当一个unique_ptr对象离开作用域或被销毁时,它的资源会被释放。
    2. 应用场景:适用于单一所有权和独占资源的情况。当你确切知道只有一个对象需要管理资源,或者需要实现移动语义时,unique_ptr是首选。
  3. std::weak_ptr
    1. 底层实现:std::weak_ptr是为了解决std::shared_ptr的循环引用问题而引入的。它允许访问由shared_ptr管理的资源,但不会增加引用计数。如果所有的shared_ptr都离开作用域,资源将被释放。
    2. 应用场景:适用于处理潜在的循环引用情况,其中两个或多个对象相互引用,可能导致内存泄漏。weak_ptr可以用于打破这种循环引用。

应用场景:- 使用std::shared_ptr来管理一个共享的配置对象,多个模块都需要访问该配置。

13、预防内存泄漏方式?

  1. 智能指针:使用C++中的智能指针(std::shared_ptrstd::unique_ptr等)可以大大减少内存泄漏的可能性。这些指针会在对象不再需要时自动释放内存。
  2. 合理的内存分配和释放:在分配内存后,务必在不再需要该内存时及时释放。使用new后应使用delete,使用new[]后应使用delete[],并避免悬挂指针。
  3. RAII(资源获取即初始化):利用C++的栈对象生命周期特性,确保在对象生命周期结束时释放资源。例如,使用自定义类管理资源的生命周期。
  4. 避免不必要的动态内存分配:如果可能的话,使用自动存储期的变量(栈内存)而不是动态内存。只有在确实需要动态内存时才分配。
  5. 内存检测工具:使用内存检测工具(如Valgrind、AddressSanitizer等)来帮助识别内存泄漏和其他内存错误。
  6. 编写清晰的代码:遵循C++最佳实践,使用合适的数据结构和算法,减少不必要的动态内存分配。
  7. 检查错误的返回值:在C++中,检查<span style="letter-spacing: 0.578px;text-decoration: none;font-size: 16px;">new<span style="letter-spacing: 0.578px;text-decoration: none;font-size: 16px;">malloc等分配内存的返回值是否为<span style="letter-spacing: 0.578px;text-decoration: none;font-size: 16px;">nullptr,以处理分配失败的情况。
  8. 断言:使用断言来检查代码的假设是否成立,帮助在开发和测试阶段尽早发现内存问题。
  9. 资源管理类:封装资源管理类,确保在离开作用域时释放资源。例如,使用RAII模式封装文件句柄或数据库连接。
  10. 异常安全性:保持代码的异常安全性,以便在发生异常时资源能够被正确释放。
  11. 定期审查和测试:定期审查代码并进行内存泄漏测试,特别是在代码中有复杂的资源管理时。

14、调试工具用什么?

前边的常用,可以再详细学一学,后边那几个不常用,作为了解。

  1. GDB(GNU Debugger):GDB是一个强大的开源命令行调试器,适用于C++以及其他编程语言。它可以用于跟踪程序的执行、检查内存和寄存器状态,并进行断点调试。
  2. Valgrind:Valgrind是一个内存调试工具,用于检测内存泄漏、越界访问等问题。它包括Memcheck、Cachegrind、Callgrind等工具,可以用于性能分析和内存错误检测。
  3. lldb:LLDB是另一个命令行调试器,它是用于调试C/C++的LLVM项目的一部分。它提供了类似GDB的功能,但具有更现代的设计。
  4. Visual Studio Debugger:如果你在使用Microsoft Visual Studio作为C++开发环境,内置的Visual Studio Debugger非常强大。它支持源代码级别的调试,具有用户友好的图形界面。
  5. Code::Blocks:Code::Blocks是一个开源的集成开发环境(IDE),内置有GDB调试器支持。它适用于C++开发,提供图形界面,使调试更加便捷。
  6. Eclipse CDT:Eclipse C/C++ Development Tools(CDT)是适用于C++的Eclipse插件,包含调试功能。它适用于开发者已经熟悉Eclipse的情况。
  7. CLion:CLion是JetBrains开发的C/C++集成开发环境,具有内置的调试器,提供了直观的图形界面。
  8. Xcode:如果你在macOS平台上进行C++开发,Xcode是一个常用的开发工具,具有强大的集成调试功能。
  9. Cppcheck:Cppcheck是一个静态代码分析工具,用于检测C/C++代码中的潜在问题,如内存泄漏、不安全的函数调用等。

15、互斥锁和条件变量的使用?

它们都是多线程编程中用于同步和线程间通信的重要工具。它们通常一起使用以实现线程之间的协调。互斥锁(Mutex):

  1. 互斥锁是一种用于保护共享资源免受并发线程干扰的同步机制。
  2. 在C++中,你可以使用std::mutex类来创建互斥锁。
  3. 互斥锁的基本操作包括lock()(获取锁)、unlock()(释放锁)和try_lock()(尝试获取锁,不会阻塞)。
  4. 互斥锁的典型使用场景是用它来锁住对共享数据的访问,以确保一次只有一个线程能够修改共享数据。
#include <iostream>
#include <mutex>

std::mutex mtx;

void someFunction() {
    std::lock_guard<std::mutex> lock(mtx);  // 获取锁
    // 访问共享资源
    // ...
}  // lock析构时会释放锁

条件变量(Condition Variable):

  1. 条件变量用于在线程之间发送信号以通知某些特定事件的发生。
  2. 在C++中,你可以使用std::condition_variable类来创建条件变量。
  3. 通常,条件变量与互斥锁一起使用。等待线程会等待某个条件为真时才能继续执行,而通知线程则会在某个条件变为真时通知等待线程。

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void waitForCondition() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });  // 等待条件变为真
    // 条件满足,执行后续操作
}

void notifyCondition() {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::lock_guard<std::mutex> lock(mtx);
    ready = true;
    cv.notify_one();  // 通知等待的线程条件已满足
}

int main() {
    std::thread t1(waitForCondition);
    std::thread t2(notifyCondition);

    t1.join();
    t2.join();

    return 0;
}

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8