【C++】关于内存那些事

1,009 阅读15分钟

关于内存那些事(序章)

  • 作者: Wel2018
  • 文章状态: 更新中
  • 系列状态: 未完结
  • 阅读预计用时:20 分钟
  • 开源地址: github.com/Wel2018/lea…

本文概要

本文主要内容包括:内存分配和释放的方式对比、align-malloc、内存泄露检查、相关调试工具、智能指针、内存池、对象池等。

更新记录

  • 2022-08-12:创建配套代码仓库
  • 2022-08-08:发布

待更新:C++ GC 机制、多线程环境下如何确保内存一致性、缓存淘汰算法实现、malloc 原理分析、slab 机制管理内存映射、bitmap 的应用等。

思考

在阅读本文之前,先来回忆如下几个问题,看看能不能立即想到答案:

  • 同结构体的两个对象能否直接比较?
  • 结构体变量占用内存的计算?何时占用的内存最大?何时最小?
  • 使用 malloc 分配的内存,为什么使用 free 释放时只需要提供首地址,而不需要提供内存大小?
  • malloc(0) 到底分配了多少字节?malloc 最多可以分配多少字节?
  • 是否可以使用 malloc 创建 class 的实例?
  • 重复 free (或重复 delete )会导致什么问题?
  • memmovememcpy 的区别?

内存分配

当我们启动一个程序时,操作系统就会为该程序创建对应的运行实例 — 进程,创建步骤可以简单分为以下 4 步:

  1. 为该进程分配一个全局唯一的 进程 ID
  2. 向系统申请一个空白的进程控制块(PCB)
  3. 初始化 PCB 并分配进程必要的资源(如内存、文件表等)
  4. 将 PCB 插入进程就绪队列

由于操作系统的虚拟内存机制,进程的内存空间地址指的是逻辑地址,而内存空间大小一般为指针能寻址的最大范围,在 32 位系统上为 4GB,如此一来,运行中的每个进程在逻辑上都以为自己独享整个逻辑地址空间。

在 Linux 环境下,关于进程空间的一些参数限制有:

$ ulimit -a

-t: cpu time (seconds)              unlimited
-f: file size (blocks)              unlimited
-d: data seg size (kbytes)          unlimited
-s: stack size (kbytes)             8192 # 栈帧的最大长度
-c: core file size (blocks)         0
-m: resident set size (kbytes)      unlimited
-u: processes                       15676
-n: file descriptors                1024 # 文件表项个数上限
-l: locked-in-memory size (kbytes)  64
-v: address space (kbytes)          unlimited # 地址空间,由指针寻址能力决定
-x: file locks                      unlimited
-i: pending signals                 15676
-q: bytes in POSIX msg queues       819200
-e: max nice                        0
-r: max rt priority                 0
-N 15:                              unlimited

进程的内存空间从不同角度看去稍有区别,如下图所示:

image.png

其中栈区、全局区、代码区内存的分配和释放是由编译器负责的,而堆区和内存映射区则是由程序员负责,堆内存使用不当可能会导致内存碎片、堆溢出、内存泄露等问题,而内存映射使用不当会导致内存资源使用不充分、映射文件占用等问题。本文重点分析堆内存的分配、管理和释放。

动态内存

在 C 程序中,堆内存(动态内存)的分配和释放一般是通过如下几个函数实现的:

作用API含义初始化
分配void* malloc(int num * size)在堆区分配一块指定大小的内存空间随机值
分配void* calloc(int num, int size) 分配 num 个长度为 size 的连续空间0
扩容void* realloc(void *address, int newsize)重新分配内存,把内存扩展到 newsize-
释放void free(void* address)释放 address 所指向的内存块

说明:

  • malloc:分配内存成功后返回这段内存的指针,不会对这段内存进行初始化;
  • free:传入 null 时直接返回,如果传入错误地址、或是重复释放该地址,可能会出现越界、内存不一致问题;
  • calloc:分配完成后会对这段内存初始化为 0,一般用于创建动态数组。如果两个参数其中任何一个整数溢出则会报错,而 malloc 不会,但是分配的内存大小不符合预期。
  • realloc:分配 newsize 大小的内存,先尝试原地扩容,系统总是返回大于 newsize 的字节数用于下次原地扩容。该函数传入特别的参数时,有:
    • realloc(NULL, 100) == malloc(100)
    • realloc(p, 0) == free(p)

在 manpage 1 中,可以发现除了上述 4 个函数外,还多出来一个“新面孔”:

// Since glibc 2.29:
void *reallocarray(void *ptr, size_t nmemb, size_t size);
// 等价于
void* realloc(ptr, nmemb * size);

官方解释如下:

The reallocarray() function changes the size of (and possibly moves) the memory block pointed to by ptr to be large enough for an array of nmemb elements, each of which is size bytes. It is equivalent to the call realloc(ptr, nmemb * size); However, unlike that realloc() call, reallocarray() fails safely in the case where the multiplication would overflow. If such an overflow occurs, reallocarray() returns an error.

reallocarray 函数会对 ptr 指向的内存块进行扩容(扩容期间可能会涉及到内存块的移动)以确保有足够大的空间容纳下 nmemb 个大小为 size 的元素。 reallocarray 与 realloc 的区别在于,当出现乘法溢出时,前者返回一个错误码而不会触发异常,而后者则可能导致程序的非正常退出。

new 运算符

示例:

#define ALLOC(T)       		(T *)malloc(sizeof(T))
#define ALLOC_N(T, n)  		(T *)malloc(sizeof(T) * n)
#define FREE(addr)     		do { if (addr) free(addr); addr = 0; } while(0)
#define ALLOC_SIZE(addr) 	malloc_usable_size(addr)

int main()
{
    // malloc
    int *pArr = ALLOC_N(int, INIT_SIZE);
    pArr[0] = 100;
    pArr[1] = 200;
    pArr[2] = 300;
    
    // free
    FREE(pArr);

    // new
    int *p1 = new int;    // 分配一个整数 
    int *p2 = new int(3); // 分配一个整数,初始化为3
    int *p3 = new int[3]; // new[]: 分配数组,有3个元素
    
    // delete
    delete p1;
    delete p2;
    delete[] p3;
}

C 和 C++ 中的内存分配方式区别主要体现在分配方式、返回状态、重载、扩容等方面,对比如下:

特点newmalloc
分配 : 失败默认抛出异常返回 NULL
分配 : 内存不足异常捕获
分配 : 内存位置自由存储区
分配 : 内存大小编译器自动计算得到必须由用户显式指定
扩充realloc
安全性返回类型匹配的指针返回 void* 后强转
处理数组使用 new[]需要用户显示指定
重载

注:new、delete,malloc、free,new[]、delete[] 要配套使用!更多细节见 《C++ Primer》 第五版的 12.2 一节。

自由存储区具体看 operator new 的重载实现,默认为堆区 2

align_malloc

align_malloc 的官方解释如下:

Allocate size bytes of uninitialized storage whose alignment is specified by alignment. The size parameter must be an integral multiple of alignment 3.

首先根据指定值 alignment 对齐到某个位置,然后以该位置为起点分配 size 字节个未初始化的内存,其中 size 的大小必须是 alignment 的整数倍。

函数原型为:

// Defined in header <cstdlib> (since C++17)
void* aligned_alloc( std::size_t alignment, std::size_t size );

示例:

#include <cstdio>
#include <cstdlib>
 
int main()
{
    int* p = static_cast<int*>(std::aligned_alloc(1024, 1024));
    std::printf("%p\n", static_cast<void*>(p));
    std::free(p);
}

上述代码首先按照 1024 字节对齐,即开始分配内存的位置 p 左侧(低地址方向)空出来 1024 个字节,然后从 p 的位置向右侧(高地址方向)分配 1024 字节内存,由于 (400)16==(1024)10(400)_{16} == (1024)_{10},所以此处的 p 的值总是为 0x.....400 ,如 0x55bb83f1a4000x2222400 等。

空间分配器

空间分配器(allocator),顾名思义,数据元素不一定非要分配在堆内存中,可以通过重载 new 运算符将元素分配到二进制文件、内存映射等位置。空间分配器在 STL 容器中也有广泛的应用,例如在 vector 中,模板参数默认已经预置了一个空间分配器:

template<typename _Tp, typename _Alloc = std::allocator<_Tp>>
class vector : protected _Vector_base<_Tp, _Alloc>

一个简单的空间分配器的实现如下:

// 空间分配器

#include <new>     // for placement new
#include <cstddef> // for ptrdiff_t size_t
#include <cstdlib> // for exit()
#include <climits> // for UINT_MAX

template <class T>
inline T* _alloc(int size, T*) {
    // Takes a replacement handler as the argument, returns the previous handler.
    set_new_handler(0);
    T* t = (T*)(::operator new((size_t)(size * sizeof(T)));
    if(t == nullptr) {
        cerr << "alloc failed" << endl;
        exit(1);
    }
    return t;
}

template <class T>
inline void _dealloc(T* t) {
    if(t == nullptr) return;
    ::operator delete(t);
}

template <class T1, class T2>
inline void _construct(T1* t, const T2& val) {
    new(t) T2(val); // 在 t 这段内存使用 T2(val) 拷贝构造初始化
}

template <class T>
inline void _deconstruct(T* t) {
    t->~T();
}

// 定义一个空间分配器
template <class T>
class Jallocator {
public: 
    // ready
    typedef T value_type;
    typedef T* pointer;
    typedef const T* const_pointer;
    typedef T& ref;
    typedef const T& const_ref;
    typedef size_t size_type;
    typedef ptrdiff_t diff_type;

    // rebind
    template<class U> struct rebind {
        typedef Jallocator<U> other;
    };

    pointer alloc(size_type n, const void* hint=0) {
        return _alloc((diff_type)n, (pointer)0);
    }

    void dealloc(pointer p, size_type n) {
        _dealloc(p);
    }

    void construct(pointer p, const T& val) {
        _construct(p, val);
    }

    void deconstruct(pointer p) { _deconstruct(p); }
    pointer address(ref x) { return (pointer)(&x); }
    size_type max_size() const { return size_type(UINT_MAX / sizeof(T)); }
};

代码参考:《STL 源码分析》2.2 节。

使用示例:

int main()
{
    Jallocator<string> alloc;

    auto const head = alloc.allocate(1024); // 只分配内存,不调用构造函数
    // auto const p = new string[10]; // new 运算符在申请内存后自动调用默认构造函数
    auto p = head; // string*

    alloc.construct(p++, "xxxxx");
    alloc.construct(p++, 10, 'c');

    // 此时 p 落在下一个已申请内存但没有调用构造的位置
    cout << head[0] << endl;    // xxxxx ,也可以使用指针访问 *(head+1)
    cout << head[1] << endl;    // cccccccccc

    // 对于前两个已经初始化的位置调用析构,释放,只能对已经构造过的内存使用析构
    alloc.destroy(head + 0);
    alloc.destroy(head + 1);
    alloc.deallocate(head, 1024); // 将申请的这段内存归还给系统
}

内存管理

虽然操作系统在管理内存方面可能做了很多工作,相关知识也并不简单,但是程序员们找系统要内存就像接一杯水一样容易。Java 借助 JVM 运行时,自带的 GC 机制可以让 Java 程序员不用太担心内存泄露问题,而在Python 中,我就没有考虑过关于内存的任何事。相较于一些自带 GC 机制的高级语言,C++ 中的内存问题对我来说并不简单。

为什么需要内存管理? 试想一下,当我们的程序非常庞大,这里分配个 10 KB 存文件名,那里分配个 2MB 当缓冲区,动态数组剩余容量不够要扩容了,或是频繁地 new 再 delete …,实际场景肯定比这复杂的多,这时我们会想,如果能一次性向系统申请一大块内存统一管理,这样就能减少内存碎片了,还可以通过记录这些内存申请的位置、申请的字节数等信息,我们就可以随时统计程序当前的内存使用详情,甚至可以在程序中的某个位置(如抛出异常时)自动释放堆内存。总之,使用合适的内存管理方式,无疑可以减轻程序员使用堆内存的思想负担,同时增强了程序的可靠性。

那么,拿到内存后应该怎样管理它们呢? 最常用的方法 —— 内存池

内存池

内存池(MemPool)其实每个人都间接地使用到了,malloc 实现(全名 ptmalloc,源码见 glibc/malloc/malloc.c)实际上底层是通过如下系统调用实现的:

#include <unistd.h>
// 扩展 heap 的上界 brk
int brk ( const void *addr ); 
void* sbrk ( intptr_t incr ); 

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length); // 释放

malloc 向系统申请内存后,按照不同尺度的内存块(chunk)管理,而内存块由内存块链表(bins)管理,一个简单的示意图如下图所示:

image.png

除了标准库自带的实现外,facebook 也给出另一个实现版本 jemalloc 4,其在 firefox、服务器、FreeBSD、android 中大量使用,Redis 的内存分配也是基于该方式。jemalloc 主要优势体现在:使用线程内部锁,减少了加锁粒度,在多线程下加锁次数比 ptmalloc 少,从而提高了并发性能,此外还引入了页表的 slab 机制,从而减少了内存碎片。

Nginx 比较硬核,其基于 malloc 实现自己的内存池 ngx_pool 来管理内存,如下图所示(随手画的将就看吧):

image.png

有了内存池机制,就可以基于内存池实现 缓冲池,常见形式有:

  • 单缓冲 :本质上就是一个缓冲队列,读写者分别从队列的两端存入、消费数据
  • 双缓冲:读写分离,需要两个缓冲队列
  • 循环缓冲:一般使用循环队列实现 5

缓冲池应用也非常广泛,例如在 Reactor 类型的网络库(如 libevent)中,服务器和客户端连接之间一般都是通过缓冲区交换数据的,由于服务器会管理多个客户端连接,自然会有多个缓冲区,这些缓冲区都是交给缓冲池统一管理的。

注:内存池、mmap、slab 机制相关实现细节较多,后续有空我会另开一桌,单独写一篇实现原理及源码分析,此处不深入讨论。

对象池

提起 “池” 大家可能会立即联想到像数据库连接池、字符串常量池、对象池等等,它们都有着共同的思想:对共享元素重复利用,避免反复创建释放浪费 CPU 资源、或是创建大量重复资源,它们都是对设计模式中 享元模式 (Flyweight Pattern)的应用。

在一个项目中,往往会有各种各样的对象,我们可以这样使用:

  • new 重载:创建一个新对象
    • 首先去全局的对象池中申请一个空闲对象
    • 对象池没有空闲对象:向内存池申请内存,创建一个对象放入到对象池
  • delete 重载:不释放对象内存,而是将其再次放入到对象池中

对象池的一般实现思路:

  • 提供接口:获取、创建、退还对象、限制对象池容量、清空、删除某个对象等
  • 每个对象池都是单一类型的容器,它无法接受多种类型的对象(可以用【字典】管理对个对象池变相支持)

注:简单实现见配套代码仓库。

内存释放

为什么要内存释放? 系统的内存资源是有限的,如果程序员只借不还,或是忘记还,亦或是因为特殊情况(触发异常)没有正常归还,在操作系统的角度看来,都属于借钱不还的“老赖行为”,下次再申请内存时,系统也许会拒绝搭理你(开玩笑的)。

仅仅是内存要释放? 不,例如一个资源管理类,其构造函数中可能打开了一些二进制文件,创建网络连接,维护了一些状态变量等,因此除了要确保内存资源的正确释放外,还要确保能正确析构对象。

如何确保内存的正确释放? 利用 C++ 的 RAII 机制,在此基础上实现的智能指针,可以避免大部分关于内存等资源非正常释放的问题。此外,还有一些调试工具可以帮助我们快速定位潜在的内存泄露等 BUG。

RAII

RAII(Resource Acquisition Is Initialization,资源获取即初始化)是 C++ 中常用的资源管理方式,通过类来管理资源,在构造函数中申请所需资源,在析构时释放资源,当对象生命周期结束时,析构函数会被自动调用。

栈解旋(栈展开、unwinding、stack unwind)是在程序 throw 抛出异常时,首先在函数调用链上逐层向上寻找匹配的 catch 语句,如果找不到就直接 terminate,找到了就返回 throw 的位置,将调用局部作用域内所有对象的析构函数释放资源,再转到 catch 的位置进行 “善后处理”。过程为:

  • 从抛出异常的函数开始,沿着 函数调用栈 向上寻找对应的 catch
  • 找到
    • 回到异常抛出点
    • 逐帧清理函数调用栈上的 局部变量
    • 直到 catch 的位置,执行对应操作
  • 未找到:直接调用 std::terminate 退出程序

注:在栈解旋期间抛出的异常会触发 std::terminate

示例:

// 自定义异常
class MyExcept : public exception {
public:
    MyExcept() : exception() {}
    ~MyExcept() {}
    // 小心!必须标记为 const noexcept,
    // 直接写 const char* what() 报错:签名不一致
    const char* what() const noexcept override {
        return "MyExcept auto stack unwind";
    }
};

// 资源类
// RAII 确保引用它的函数在抛出异常时会自动调用其析构函数
class Resource {
    string res;
public:
    Resource(string name) : res(name) { 
        cout << "资源申请 " << res << endl; 
    }
    ~Resource() { 
        cout << "资源释放 " << res << endl; 
    }
};

// 抛出异常,从 f 开始顺着【函数调用栈】寻找匹配的 catch
// 没有就直接 terminate,有就【栈解旋】
void f() {
    Resource res1("res1"); // 抛出异常后自动释放
    throw MyExcept();  // or throw exception(); 
    Resource res2("res2"); // 这个资源不会被创建!
}

// 捕获第一个异常执行对应 catch 就会退出,不会再捕获其他异常了
// 没有正确捕获会调用系统的 terminate 直接中断程序
int main()
{
    try { 
        f(); 
    }
    // 可以捕获 MyExcept 对象
    catch (const exception& e) {
        cerr << e.what() << '\n';
    }
    catch(const string &e) {
        cerr << e << '\n';
    }
}

输出:

资源申请 res1
资源释放 res1
MyExcept auto stack unwind

RAII 是智能指针的实现基础,而栈解旋保证了 RAII 异常安全,关系如下图所示:

graph LR
su["stack unwinding"] --> RAII --> sp["smart pointer"]

智能指针

智能指针(Smart-Pointer )是一种 资源管理类,它会接管用户提供的原始指针,在析构时自动释放原始指针指向的内存。它可以解决如下三类问题:

  • 内存泄露问题:malloc 申请的堆内存不释放(或忘记释放)就会导致泄露,因此可以使用智能指针(推荐)或实现 GC 机制来处理。
  • 异常安全问题:即便在函数的最后调用 delete 或 free,但是在函数执行途中抛出异常也会导致泄露。而智能指针可以不用调用 delete,它会在引用为 0 时 自动释放,它是 异常安全 的,抛出异常时,当前栈帧内的所有对象的析构函数会被调用,其析构函数负责释放资源。
  • 线程安全问题:智能指针基于引用计数机制实现,对于计数值的维护通过互斥量(读写锁)保证线程安全。

标准库里有如下三种智能指针:

shared_ptrunique_ptrweak_ptr
含义引用计数型(共享)互斥所有权型(独占)弱引用
特点自动维护全局的引用计数所有权转移解决循环引用
常用方法reset、swap、get、use_count 等release、swap、get 等reset、use_count、expired、lock 等
工具函数make_sharedmake_unique(C++17)-
对应管理锁的智能指针shared_lockunique_lock-

智能指针通常使用 引用计数方式 进行管理,其拥有对象的 所有权,且不能简单地赋值。具体示例见 cppreference.com

补充:scoped_ptr

scoped_ptr 是 Boost 库提供的一种类似于 shared_ptr 但比之更为轻量级的智能指针,在 stackoverflow 6 上有大佬解释说:

shared_ptr is more heavyweight than scoped_ptr. It needs to allocate and free a reference count object as well as the managed object, and to handle thread-safe reference counting - on one platform I worked on, this was a significant overhead. My advice (in general) is to use the simplest object that meets your needs. If you need reference-counted sharing, use shared_ptr; if you just need automatic deletion once you've finished with a single reference, use scoped_ptr. ——(answered by Mike Seymour at 2009-11-20)

意思是说,shared_ptr 比 scoped_ptr 更重量级,主要体现在:前者需要维护一个全局的引用计数,由于可能会被多个线程中的 shared_ptr 引用同一个对象,因此 shared_ptr 需要确保线程安全,底层使用锁来保证引用计数在多个线程中同步,对于一些简单对象,使用 shared_ptr 的成本较高,而 scoped_ptr 不提供引用计数,它只能管理一个指针,在析构时释放资源,因此更适合一些简单场景。

注:智能指针的底层原理和简单实现、GC 机制原理这部分内容细节较多,后续会”另开一桌“。

虚析构函数

struct A {
    string data1 = "A.data1";
    string data2 = "A.data2";
    A() { puts("A()"); }
	// 如果父类的析构函数不是虚函数则只会释放父类申请的资源
	// 将父类的析构改为 virtual,在释放子类时会先调用子类的析构版本
    virtual ~A() { puts("~A()"); }
    virtual void vf1() { puts("A::vf1"); }
    void f1() { puts("A::f1"); }
};

struct B: public A {
    string data2 = "B.data2";
    B() { puts("B()"); }
    // 只需要将 A 的析构声明为 virtual,之后的都默认为 virtual
   ~B() { puts("~B()"); } 
    virtual void vf1() { puts("B::vf1"); }
    virtual void vf2() { puts("B::vf2"); }
    void f2() { puts("B::f2"); }
};

int main()
{
    A* p = new B();
    delete p;
}

// 输出:
// A() B() 
// ~B() ~A()

构造顺序是先构造父类,再构造子类,而析构的顺序则相反。如果类 A 的析构是非 virtual 的,那么在 delete 时只会调用 ~A 而不会调用 ~B 从而导致错误。

这两个类的结构如图所示:

image.png

图中的虚函数表(虚表、vtable)是由编译器在编译时生成,保存在 .rodata 中,其中存放了函数指针列表,只有声明为 virtual 的函数才会放入其中。

如果要使用动态绑定,即用父类指针引用子类对象实现多态时,父类析构函数必须是虚函数。 原因是:如果父类的析构函数不是虚函数,则只会涉及 静态绑定,指针类型为父类指针,因此在 delete 的时 只会调用父类析构函数,而不会调用子类的析构。当然,如果在子类中没有动态申请任何资源,那么看起来也没什么问题。

注:静态绑定 发生于编译期,就是传统的函数调用。而 动态绑定 (动态链接、动态联编)发生于运行期,根据所调用的对象类型来选择调用的函数,通过父类指针访问子类对象。

内存泄露检查

为了记录程序中申请的内存位置和字节大小,可以实现一个全局的内存申请记录中心:

// 内存申请记录中心(单例模式)
class Registry final : public noncopyable {
    using addr_t = std::string;
    using line_t = std::string;
    unordered_map <addr_t, line_t> registry; 
public:
    ...
    virtual void memcheck()
    {
        if(registry.empty()) {
            puts("all heap memory is free.");
            return;
        }
        printf("detect %ld memory leak points: \n", registry.size());
        for(auto& [addr, line]: registry) {
            printf("addr: %s, line: %s\n", addr.c_str(), line.c_str());
        }
    }

    static Registry& get_instance() {
        static Registry R;
        return R;
    }
};

然后重载运算符 new 和 delete:

#define debug_new(p, size) \
    printf("[file: %s - line %d] malloc %ld bytes at %p\n", file, line, size, p)

decltype(auto) R = Registry::get_instance();

void* operator new(std::size_t size, const char* file, int line) {
    string val = to_string(line);
    void* p = (void*) malloc(size);
    debug_new(p, size);
    R.add(get_address(p), val);
    return p;
}

void free_pointer(void* p, const char* file, int line) {
    auto addr = get_address(p);
    if(R.get(addr) == "not found") return;
    printf("free %s which allocated at %s\n", addr.c_str(), R.get(addr));
    R.remove(addr);
    free(p); // ::operator delete (p); // not free(p)
}

// 重载 new delete 运算符
void operator delete(void* ptr) noexcept { 
    free_pointer(ptr, nullptr, 0); 
}

#define new new(__FILE__, __LINE__)

测试如下示例:

int main()
{
    char   *m1 = new char; 
    int    *m2 = new int(100);  // leak
    double *m3 = new double;
    delete m1;
    delete m3;
    R.memcheck();
}

可以得到如下输出:

[file: memcheck.cpp - line 90] malloc 1 bytes at 0x5616c3205eb0
[file: memcheck.cpp - line 91] malloc 4 bytes at 0x5616c32063b0
[file: memcheck.cpp - line 92] malloc 8 bytes at 0x5616c3206430
free 0x5616c3205eb0 which allocated at 90
free 0x5616c3206430 which allocated at 92
detect 1 memory leak points: 
addr: 0x5616c32063b0, line: 91

上述实现的优点是:

  • 创建、释放内存不需要直接向 Registry 申请,代码是非侵入式的,方便测试环境下定位泄露
  • 使用简单,只需要在适当的位置调用 memcheck 方法就可以得到内存统计信息

但是该实现有如下不足:

  • 每次申请内存都要创建一条记录存入哈希字典中,维护 Registry 有空间成本
  • 在多线程环境下可能存在性能问题
  • 对于以 daemon 模式运行的后台服务进程,在运行期间无法检测出是否有泄露

改进思路:

  • 每个子模块维护自己的 Registry,而不是全局共用一个
  • 对字典的键值对进行数据压缩,引入常量池记录文件名和代码行
  • 在抛出异常时将当前位置所在的源文件和函数名也一并抛出,在异常处理中释放相关资源

注:该实现简单易懂、代码量少,仅仅出于学习目的,实际开发时用到的 memcheck 其实现思路有较大区别。

相关调试工具

1、辅助手段:打印日志输出调试信息、帮助我们快速定位

这种方式看起来好像不太聪明的样子,但是简单粗暴、容易使用,我小时候就喜欢这样打印日志:

// DEBUG 宏:编译时通过传递宏 -D DEBUG 启用调试输出

#ifdef _DEBUG
#define printd(fmt, args...) printf(fmt, ##args)
#else
#define printd(fmt, args...)
#endif

nginx 中实现了自己的日志系统,可以根据配置信息决定输出样式和输出位置,值得借鉴:

#define ngx_log_debug(level, log, args...)                                    \
    if ((log)->log_level & level)                                             \
        ngx_log_error_core(NGX_LOG_DEBUG, log, args) // 在该函数中根据配置决定输出方式

注:在 C++ 中一般实现自己的 Logger 类,后续单独写一篇:使用变参模板、递归、字符串格式化(形如 logger.info("% % %", "hello", 123, 3.14))实现一个简单的日志类。

2、gcc 自带工具:sanitize、asan

// 使用方法:
g++ -fsanitize=address -std=c++2a -g tmp.cpp -o ~/.out/tmp

// 可能的输出:
// =================================================================
// ==31572==ERROR: LeakSanitizer: detected memory leaks

// Direct leak of 32 byte(s) in 1 object(s) allocated from:
//     #0 0x7f8a4282b587 in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cc:104
//     #1 0x55e5dec3d997 in main xxxxx/tmp.cpp:7
//     #2 0x7f8a41f4b082 in __libc_start_main ../csu/libc-start.c:308

// SUMMARY: AddressSanitizer: 32 byte(s) leaked in 1 allocation(s).

使用 g++ -g tmp.cpp -l asan -o tmp.out 也可以得到类似的输出。

3、对于大型程序,应优先考虑使用一些较为成熟的调试分析工具:

Gperftools

gperftools is a collection of a high-performance multi-threaded malloc() implementation, plus some pretty nifty performance analysis tools 7.

它主要有如下组件:

valgrind

Valgrind is an instrumentation framework for building dynamic analysis tools. There are Valgrind tools that can automatically detect many memory management and threading bugs, and profile your programs in detail. You can also use Valgrind to build new tools 8.

Valgrind 是一个用于构建动态分析工具的工具框架。有了它就可以自动检测许多内存管理问题和线程错误,并详细分析您的程序。也可以基于 Valgrind 构建新的工具。

手册中介绍了如下工具:

工具说明
memcheck内存错误检测
callgrind生成缓存和分支预测分析器的调用图
cachegrind缓存和分支预测分析器
cacheline缓存定位命中率
helgrind线程错误检测器
massif堆栈分析器

上述工具的使用方法见 Valgrind 使用手册

总结

注:内容全部同步完成后再做总结。

鸣谢

说明

  • 【C++】系列相关博客正在更新中,感兴趣的朋友欢迎 star,您的支持是我继续更新下去的最大动力!
  • 由于本人水平、精力有限,文中可能存在疏漏之处,欢迎读者大佬们指正。
  • 对于高质量、格式规范的建议(示例:给出原文具体段落、修改内容、相关依据),确认无误后会合并到博客中,并将贡献者加入【鸣谢】名单中。
  • 可以转载但要注明出处。

Footnotes

  1. reallocarray(3) — Arch manual pages (archlinux.org) 以及 reallocarray - man pages section 3: Basic Library Functions (oracle.com)

  2. C++ 自由存储区是否等价于堆? - melonstreet - 博客园 (cnblogs.com)

  3. std::aligned_alloc - cppreference.com

  4. 官网 jemalloc.net/

  5. 622. 设计循环队列 - 力扣(LeetCode)

  6. c++ - shared_ptr vs scoped_ptr - Stack Overflow

  7. gperftools/gperftools: Main gperftools repository (github.com)

  8. Valgrind