C/C++知识点整理-持续更新中

1 如果类的数据成员不包含指针, 是否可以使用memcmp逐字节比较2个对象?

不可以, 原因如下:

  1. 虚函数表指针:如果对象是多态的,即它有虚函数,它可能包含一个指向虚函数表的隐藏指针(通常称为 vptr)。由于虚函数表的地址可能因为对象的创建方式不同而不同(比如通过不同的派生类创建基类的对象),这会导致 memcmp 认为对象不相等,即使它们在逻辑上是相同的。

  2. 填充字节和对齐:为了满足特定的对齐要求,编译器可能在对象的内存布局中插入填充字节。这些填充字节的值是未定义的,并且可能在不同的对象或相同对象的不同实例之间有所不同,导致 memcmp 返回不匹配的结果。

  3. 浮点成员:对于包含浮点数成员的对象,浮点数的二进制表示可能会有多种,即使它们代表的是相同的值。例如,NaN(非数)有多种不同的二进制表示。

2 用 memset(this,0,sizeof(*this)) 来将一个类对象的所有数据成员初始化为0(不考虑指针的正确性), 是否正确?

不可以, 原因如下:

  1. 虚函数表:如果类中有虚函数,那么对象内存布局中会有一个指向虚函数表的指针(vptr)。使用 memset 清零会破坏这个指针,使得后续对虚函数的调用成为未定义行为。

  2. 非标准布局:对于非标准布局的类型,成员可能不是连续存储的,memset 可能会覆盖成员之间的间隙,这些间隙可能由编译器用于内部目的或对齐。

3 实现一个引用计数的智能指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <iostream>

template <typename T> class RefPtr {
public:
// 构造函数
explicit RefPtr(T *ptr = nullptr) : ptr_(ptr), count_(new size_t(1)) {
if (ptr == nullptr) {
// 如果传入的是空指针,则将引用计数设置为0
*count_ = 0;
}
}

// 复制构造函数
RefPtr(const RefPtr &other) : ptr_(other.ptr_), count_(other.count_) {
if (ptr_) {
// 如果指针非空,则增加引用计数
++(*count_);
}
}

// 赋值运算符
RefPtr &operator=(const RefPtr &other) {
if (this != &other) { // 防止自赋值
// 减少当前对象的引用计数,如果变为0,则删除
release();

// 复制数据
ptr_ = other.ptr_;
count_ = other.count_;
if (ptr_) {
// 如果指针非空,则增加引用计数
++(*count_);
}
}
return *this;
}

// 析构函数
~RefPtr() { release(); }

// 重载解引用操作符
T &operator*() const { return *ptr_; }

// 重载箭头操作符
T *operator->() const { return ptr_; }

// 获取原始指针
T *get() const { return ptr_; }

private:
T *ptr_; // 指向被引用计数管理的对象
size_t *count_; // 引用计数

void release() {
if (ptr_ && --(*count_) == 0) {
delete ptr_;
delete count_;
ptr_ = nullptr;
count_ = nullptr;
}
}
};

// 测试代码
class Test {
public:
Test() { std::cout << "Test created\n"; }
~Test() { std::cout << "Test destroyed\n"; }
void greet() { std::cout << "Hello, world!\n"; }
};

int main() {
RefPtr<Test> ptr1(new Test()); // 创建一个Test对象
{
RefPtr<Test> ptr2 = ptr1; // 使用复制构造函数
ptr2->greet();
} // ptr2超出作用域,但Test对象不会被删除,因为ptr1还在引用它
ptr1->greet();
} // ptr1超出作用域,Test对象将被删除

4 C++有哪些避免内存泄漏的手段?

在 C++ 中防止内存泄露可以从编写代码的风格和习惯入手,同时还可以使用各种工具来帮助检测和分析内存泄露。以下是一些方法和工具:

编写代码时的手段:

  1. 智能指针:避免裸指针, 使用 C++11 引入的智能指针(如 std::unique_ptr, std::shared_ptrstd::weak_ptr)可以帮助自动管理内存。这些智能指针通过自动调用析构函数来释放内存,从而减少内存泄露的风险。

  2. 对象资源管理(RAII):遵循资源获取即初始化 (RAII) 原则,资源在对象构造时获得,在析构时释放。这样可以确保在对象生命周期结束时资源总是被正确地释放。

工具分析:

  1. 静态分析工具:使用静态分析工具,如 Clang Static Analyzer, Coverity, Cppcheck 等,可以在编译时检测潜在的内存泄露问题。

  2. 动态分析工具:动态分析工具在运行时分析程序的行为。这些工具包括:

    • Valgrind:一个广泛使用的 Linux 工具,能够检测内存泄露、缓冲区溢出等问题。
    • AddressSanitizer:一个快速的内存错误检测器,可以检测出各种内存访问错误,集成在 LLVM/Clang 和 GCC 编译器中。
    • Dr. Memory:是一个适用于 Windows 和 Linux 的内存监视工具,功能类似于 Valgrind。

5 ValgrindGdb的原理是什么?

Valgrind 的实现原理

  1. 代码拦截和重编译:Valgrind 通过接管程序的执行来运行一种虚拟 CPU。当程序运行时,Valgrind 不直接执行原始的二进制代码,而是将其翻译成 Valgrind 自己的中间代码(VEX IR),这个过程称为动态重编译或即时(JIT)重编译。

  2. 中间表示(IR):程序的机器代码被翻译成 Valgrind 自己的中间表示形式,这种中间表示与具体的机器代码和处理器架构无关。

  3. 仪器化:在将程序代码翻译成中间表示之后,Valgrind 插入额外的检查代码来仪器化原始代码,以便在运行时分析程序的行为。例如,Memcheck 会添加用于检测非法内存访问和内存泄漏的检查。

  4. 运行时监控:Valgrind 的工具(如 Memcheck)会监控程序的所有内存访问行为,包括对栈(stack)、堆(heap)和静态/全局数据的读写。通过这种监控,工具能够检测出内存错误。

  5. 性能损失:由于 Valgrind 对程序进行了重编译和仪器化,这导致了很大的性能开销,使得程序运行速度比正常慢很多倍。

GDB 的实现原理

GDB 是一个源代码级调试器,用于定位和解决程序中的错误。GDB 的实现原理包括以下几个方面:

  1. 符号表解析:GDB 利用程序编译时产生的调试信息(如 DWARF 或 stabs),这些信息包含了原始源代码中变量、函数、行号等与编译后的机器指令之间的映射。

  2. 进程控制和监视:GDB 使用操作系统提供的进程控制接口(例如 Linux 上的 ptrace 系统调用)来监视和控制目标程序的执行。通过这些接口,GDB 可以在特定的执行点停止程序,单步执行,检查和修改内存或寄存器的内容等。

  3. 断点和观察点:GDB 实现断点功能通常是通过修改目标程序的内存,将要断点的位置替换为触发异常或中断的指令,从而在程序执行到该位置时暂停执行。

  4. 堆栈检查:当程序暂停执行时,GDB 能够检查并显示当前的调用堆栈,包括每个堆栈帧中的参数、局部变量等。

  5. 信号处理:GDB 可以捕获和处理目标程序接收到的信号,允许调试器在异常情况下控制程序的行为。

  6. 远程调试:GDB 支持远程调试协议,允许开发者通过网络在一台机器上运行 GDB 服务器,而在另一台机器上运行 GDB 客户端来调试程序。

GDB是如何暂停程序执行的?

在类 Unix 系统(如 Linux)上,GDB 通常使用 ptrace 系统调用来控制和监视另一个进程。以下是 GDB 暂停程序执行的一般步骤:

  1. 断点设置:GDB 设置断点时,会在目标位置替换一条指令(比如 x86 架构上的 INT 3 指令,它对应的字节码为 0xCC),这是一条会触发中断的机器指令。

  2. 信号接收:当程序执行到这个位置时,处理器执行 INT 3 指令,导致操作系统生成一个 SIGTRAP 信号发送给程序。GDB 通过 ptrace 被告知这一信号。

  3. 程序暂停:程序收到 SIGTRAP 信号后,默认行为是暂停执行(如果没有调试器,它可能会终止)。GDB 接管这个信号,转而进入调试器的控制下。

  4. 用户交互:此时,GDB 可以显示当前的断点信息、堆栈、寄存器状态等,并等待用户输入进一步的调试命令,如单步执行、继续执行等。

  5. 控制恢复:用户完成调试命令后,GDB 会通过 ptrace 控制程序继续执行,直至下一个断点或程序结束。

在整个过程中,GDB 没有锁定任何内存页。它是通过中断和信号处理机制来暂停程序执行的。锁定内存页通常是指防止内存页被交换出物理内存(即交换到磁盘上),而这与 GDB 的暂停机制不同。

GDB是如何获取调试进程的内存布局的?

不同进程的页表和地址空间是不同的。操作系统为每个进程维护一个独立的虚拟地址空间,并通过页表将这些虚拟地址映射到物理内存地址。这是现代操作系统用于内存隔离和保护的基本机制。

当使用 GDB 调试一个进程时,GDB 实际上是在调试器进程的上下文中运行,而它需要访问和控制另一个进程的内存和状态。这是通过 ptrace 系统调用实现的。ptrace 允许一个进程(调试器)来观察和控制另一个进程(被调试的目标进程),并可以读取和写入目标进程的内存,读取其寄存器状态,以及改变执行流等。

当 GDB 使用 ptrace 来获取目标进程的堆栈内存信息时,它会进行如下步骤:

  1. 附加到进程:GDB 通过 ptrace(PTRACE_ATTACH, ...) 调用附加到目标进程。目标进程会被暂停,直到 GDB 发出进一步的指令。

  2. 读取内存:当目标进程被 GDB 控制之后,GDB 可以使用 ptracePTRACE_PEEKTEXTPTRACE_PEEKDATA 或类似的请求来读取进程的内存空间。操作系统会处理地址转换,允许 GDB 从调试器进程的上下文中以安全的方式访问目标进程的虚拟内存。

  3. 获取堆栈信息:GDB 可以根据当前寄存器的状态(特别是堆栈指针寄存器)来获取堆栈的内容。GDB 知道如何解析目标进程的内存布局,它可以找到堆栈的位置,并读取堆栈上的内容。

  4. 恢复执行:调试操作完成后,GDB 可以使用 ptrace(PTRACE_DETACH, ...) 来解除对目标进程的控制,允许它继续执行。

ptrace 提供的功能非常强大,它是许多调试器和系统监视工具的底层基础。通过操作系统提供的这些机制,调试器能够安全地访问和控制其他进程的地址空间,而不需要直接操作页表。这保证了系统的稳定性和安全性。

6 如何自己实现一个malloc和free, 来检查内存泄漏?

自定义 malloc (称为 my_malloc)

  1. 记录分配信息:为每次分配创建一个记录,包含分配的大小、分配的地址、调用 my_malloc 的文件和行号(通常通过宏传递)。
  2. 管理记录:在一个红黑树哈希表(按照地址排序)(如果不考虑效率, 可以使用链表来简化)中维护这些记录,以便可以追踪所有分配。
  3. 分配内存:实际分配所请求的内存,可能需要额外的空间来存储管理信息(如大小和可能的魔数/哨兵值,用于检测缓冲区溢出)。
  4. 返回内存:返回指向分配内存的指针(注意偏移,如果在内存块前面存储了管理信息)。

自定义 free (称为 my_free)

  1. 查找记录:在释放内存之前,查找对应的分配记录。
  2. 记录移除:从追踪数据结构中移除相应的分配记录。
  3. 释放内存:释放相关的内存块。
  4. 错误检查:如果释放了未知的或已经释放的内存,记录错误信息。

检测内存泄漏

  1. 程序结束时:在程序结束时,遍历的内存分配记录数据结构。
  2. 报告泄漏:对于任何仍然存在的记录,报告内存泄漏,包括分配的大小和分配时的位置(文件和行号)。
  3. 清理记录:清理剩余的内存分配记录,以防程序正常退出时的内存泄漏。

进一步需求:

实际中刚申请的内存没有释放是常见的, 但很久以前申请的内存还没有释放的危害是很大的, 在之前自定义的malloc中, 如何设法追踪很久以前分配的内存?

  1. 建立数据结构

    • 一个大根堆(优先队列),按照时间戳排序。
    • 一个红黑树或哈希表,用于存储当前所有的内存分配记录。
  2. 自定义 mallocmy_malloc

    • 每次分配内存时,创建一个包含时间戳、分配的大小、分配的地址等信息的记录节点。
    • 将记录节点插入到红黑树或哈希表中,以便快速查找和删除操作。
    • 创建一个包含时间戳和指向红黑树或哈希表中相应记录节点的指针的堆节点。
    • 将堆节点插入到大根堆中。如果大根堆达到了固定容量,移除堆顶元素
    • 返回分配的内存地址给调用者。
  3. 自定义 freemy_free

    • 释放内存时,使用传入的指针在红黑树或哈希表中查找对应的记录节点。
    • 在找到记录节点后,删除红黑树或哈希表中的记录。
    • 同时在大根堆中查找对应的堆节点并删除(从堆中查找数据可能比较复杂, 这里可以在红黑树的节点中再添加一个指向堆数据的指针来加速)。
  4. 检查内存泄漏

    • 可以通过检查大根堆的内容来确定最旧的内存分配记录。
    • 如果大根堆的堆顶元素(即最旧的内存分配记录)超出了设定的阈值,那么这可能表明存在内存泄漏。
    • 可以设定定期检查或者根据特定事件(如内存分配请求)来触发检查。
  5. 内存分配记录的清理

    • 确保在程序退出时清理所有内存分配记录,包括红黑树或哈希表和大根堆中的记录,以避免退出时造成的内存泄漏。

7 虚函数表会不会占用sizeof的内存大小输出?

会, 例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

using namespace std;

class A {
int a;
A(int _a) : a(_a) {}
const int &get_a() { return a; }
virtual void print() { cout << "a=" << a << endl; }
};

int main() { cout << sizeof(A) << endl; }

64位机器上输出是16, 是因为:

  • int类型的成员变量a占用4字节。
  • 虚函数表指针(vptr)占用8字节。
  • 为了内存对齐,编译器添加了额外的4字节的填充。

这样,类A的总大小就是16字节。实际的内存布局和对齐方式可能因编译器的实现细节(如内存对齐策略)而异,但上述解释是大多数情况下的通常原因。

8 下面这个类进行sizeof运算的大小是多少(64位平台)?

1
2
3
4
5
class Example {
char a;
int b;
double c;
};

答:

int类型的对齐要求是4字节,double类型的对齐要求是8字节。这意味着在char a后面,会添加3个填充字节,以确保int b从4字节对齐的地址开始。结构体Example的总大小可能是:

1
2
3
4
5
6
char a;    // 1字节
padding; // 3字节
int b; // 4字节
double c; // 8字节
-------------------
总计: 16字节

进阶: 如果有虚函数呢?

1
2
3
4
5
6
7
class Example {
char a;
int b;
double c;

virtual void print(){};
};

当类中有虚函数时,C++实现会为该类的每个对象添加一个指向虚函数表(vtable)的指针。这个指针的大小在64位架构上是8字节。

因此分析如下:

1
2
3
4
5
6
7
vtable pointer; // 8字节 (64位架构上的指针大小)
char a; // 1字节
padding; // 3字节 (对齐int b)
int b; // 4字节
double c; // 8字节
-------------------
总计: 24字节

9 什么是活锁?

活锁(Livelock)是并发计算中的一种现象,它与死锁(Deadlock)类似,因为它涉及到多个进程或线程无法向前推进执行。然而,与死锁的静止状态不同,活锁中的进程或线程处于不断的状态改变中,但这些改变并不帮助系统推进到下一个状态。

在活锁的情况下,线程或进程不断重复相同的交互,没有任何进展,通常是因为它们试图避免冲突或解决问题的机制反而导致了无尽的循环。这种情况下的行为看起来像是在做有用的工作,但实际上并没有任何实际进展。

活锁的例子

想象两个人在狭窄的走廊中相遇,每个人都试图让对方先过,于是他们不断地在左右之间切换方向,但最终都没有人能够通过。他们在不断地“活动”,但都没有实现目标——让对方通过。

活锁与死锁的区别

  • 死锁:在死锁中,涉及的进程或线程完全停止工作,等待一个永远不会发生的条件满足,例如,等待一个永远不会被释放的锁。
  • 活锁:在活锁中,进程或线程不停地尝试执行操作,但由于互相之间的干扰,这些操作总是失败,因此也无法取得任何实际进展。

活锁的解决办法

解决活锁通常涉及到改变进程或线程的决策算法,以确保它们不会无限期地重复相同的动作。一些常见的解决策略包括:

  • 引入随机性:当冲突发生时,可以通过引入随机的延迟或随机的决策来打破对称性,使得进程或线程不会每次都做出相同的选择。
  • 退避算法:在检测到冲突时,进程或线程可以使用退避算法,例如指数退避,其中它们在重试操作之前等待一个逐渐增加的时间间隔。
  • 优先级调整:给进程或线程分配优先级,并允许高优先级的实体在冲突中“获胜”,有时可以解决活锁问题。

活锁可能不如死锁那么常见,但它在设计复杂的并发系统时必须得到适当的考虑和处理。

10 C++如何让一个类只能被创建在堆上?

将其构造函数私有化或受保护化,并提供一个公共的静态方法来创建对象