C++ STL 的空间配置器与底层内存分配器

https://blog.csdn.net/vjhghjghj/article/details/88647336

https://blog.csdn.net/songchuwang1868/article/details/89951543

空间配置器

allocator 是 STL 的六大组件(容器、适配器、算法、函数对象、迭代器、空间配置器)之一

函数对象比较少见到,例如 std::bind

其作用就是为各个容器管理内存(内存开辟与回收),有时不一定是内存,也可以是硬盘,这里主要考虑内存

STL 中,allocator 将 newdelete 一个对象的两个阶段分开,分别用四个函数实现:

  • alloc::allocate()
  • ::construct()
  • ::destroy()
  • alloc::deallocate()

空间配置器放在 memory 头文件中,它引用了 bits 下的几个重要的头文件:

  • stl_construct.h:定义了全局函数 ::construct()::destroy()
  • stl_alloc.h(SGI 风格,现已被废弃):定义了一二级配置器
  • new_alloctor.h、alloctor.h、alloc_traits.h:定义了空间配置器和它的类型萃取
  • stl_uninitialized.h:定义了一些全局函数,用来填充或拷贝大块的内存数据,例如 un_initialized_copy() 函数

构造和析构

在 bits/stl_construct.h 头文件中,源码简略表示如下:

template <typename T>
constexpr void destroy_at(T* location) {
    if constexpr (is_array_v<T>) {
	    for (auto& x : *location) {
            // 如果是个 array,那么要对所有位置执行 destroy
            std::destroy_at(std::addressof(x));
        }
	} else {
        location->~T();
    }
}

template <typename T, typename... Args>
constexpr auto construct_at(T* location, Args&&... args)
    noexcept(noexcept(::new(static_cast<void*>(nullptr)) T(std::declval<Args>()...)))
    -> decltype(::new(static_cast<void*>(nullptr)) T(std::declval<Args>()...)) {
    // 在 location 位置传入 args 构造一个对象
    return ::new(static_cast<void*>(location)) T(std::forward<Args>(args)...);
}

template <typename ForwardIterator>
constexpr void Destroy(ForwardIterator first, ForwardIterator last);

template <typename T>
constexpr void Destroy(T* pointer) {
    // 单个指针直接调用 destroy_at
    std::destroy_at(pointer);
}

template <bool>
struct Destroy_aux {
    template <typename ForwardIterator>
    static constexpr void destroy(ForwardIterator first, ForwardIterator last) {
        for (; first != last; ++first) {
            std::Destroy(std::addressof(*first));
        }
    }
};

template <>
struct Destroy_aux<true> {
    template <typename ForwardIterator>
    static void destroy(ForwardIterator, ForwardIterator) {
    }
};

template <typename ForwardIterator>
constexpr void Destroy(ForwardIterator first, ForwardIterator last) {
    using Value_type = typename std::iterator_traits<ForwardIterator>::value_typ;
    // 必须可以被析构
    static_assert(std::is_destructible_v<Value_type>, "value type is destructible");
    if (std::is_constant_evaluated()) {
        // 如果当前是 constexpr 语境,那么直接使用 constexpr 版本的函数,在编译期展开
        return Destroy_aux<false>::destroy(first, last);
    }
    // 如果非 constexpr 语境,因为调用函数会产生开销,只需要在非平凡析构时执行析构函数
    // __has_trivial_destructor 在用户有自定义的析构函数时返回 false
    std::Destroy_aux<__has_trivial_destructor(Value_type)>::destroy(first, last);
}

template <bool>
struct Destroy_n_aux {
    template <typename ForwardIterator, typename Size>
    static constexpr ForwardIterator destroy_n(ForwardIterator first, Size count) {
        for (; count > 0; (void)++first, --count) {
            std::Destroy(std::addressof(*first));
        }
        return first;
    }
};

template <>
struct Destroy_n_aux<true> {
    template <typename ForwardIterator, typename Size>
    static ForwardIterator destroy_n(ForwardIterator first, Size count) {
        // 将 first 迭代器直接移动到 count 处
        std::advance(first, count);
        return first;
    }
};


template <typename ForwardIterator, typename Size>
constexpr ForwardIterator Destroy_n(ForwardIterator first, Size count) {
    using Value_type = typename std::iterator_traits<ForwardIterator>::value_type;
    static_assert(std::is_destructible_v<Value_type>, "value type is destructible");
    if (std::is_constant_evaluated()) {
        return Destroy_n_aux<false>::destroy_n(first, count);
    }
    return std::Destroy_n_aux<__has_trivial_destructor(Value_type)>::destroy_n(first, count);
}

template <typename ForwardIterator>
constexpr void destroy(ForwardIterator first, ForwardIterator last) {
    std::Destroy(first, last);
}

template <typename ForwardIterator, typename Size>
constexpr ForwardIterator destroy_n(ForwardIterator first, Size count) {
    return std::Destroy_n(first, count);
}

SGI 风格的内存的释放与分配

在 SGI 风格 STL 中(现在已经不再使用),为了解决小型区块所可能造成的内存破碎问题,设计了双层级配置器:

  • 当申请的内存大小大于 128 字节时,就启动第一级分配器通过 malloc 直接向系统的堆空间分配
  • 小于等于 128 字节时,就启动第二级分配器,从一个预先分配好的内存池中取一块内存交付给用户,这个内存池由 16 个不同大小(8 的倍数,8 到 128 字节)的空闲列表组成,分配器会根据申请内存的大小(将这个大小向上取整成 8 的倍数)从对应的空闲块列表取表头块给用户

优点:

  • 小对象的快速分配
  • 减少内存碎片的产生

现代 STL 中的内存分配与释放

  • alloctor<T>::allocate(size_t) 直接调用 ::operator new(size_t)
  • alloctor<T>::deallocate() 直接调用 ::operator delete()

简化的代码如下:

template<typename T>
class new_allocator {
    ...
    
    [[no_discard]] T allocate(std::size_t n, const void* = static_cast<const void*>(nullptr)) {
	    static_assert(sizeof(T) != 0, "cannot allocate incomplete types");
        
        // 检查一下数量
	    if (__builtin_expect(n > std::size_t(__PTRDIFF_MAX__) / sizeof(T), false)) {
	        if (n > (std::size_t(-1) / sizeof(T))) {
                std::throw_bad_array_new_length();
            }
	        std::throw_bad_alloc();
	    }
	    return static_cast<T*>(::operator new(n * sizeof(T)));
    }
    
    void deallocate(T* p, std::size_t n) {
        ::operator delete(p, n * sizeof(T));
    }
    
    ...
};

template<typename T>
class allocator : public new_allocator<T> {
    ...
    
    constexpr T* allocate(std::size_t n) {
        if (std::is_constant_evaluated()) {
            // 看一下 n * sizeof(T) 是否会溢出
    	    if (__builtin_mul_overflow(n, sizeof(T), &n)) {
                std::throw_bad_array_new_length();
            }
    	    return static_cast<T*>(::operator new(n));
    	}
        return new_allocator<T>::allocate(n);
    }
    
    constexpr void deallocate(T* p, std::size_t n) {
    	if (std::is_constant_evaluated()) {
    	    ::operator delete(p);
    	    return;
    	}
    	new_allocator<T>::deallocate(p, n);
    }
    
    ...
};

然后使用了对空间配置器的类型萃取来简化使用

底层内存分配器

delete 通常不会直接将内存立即交还给操作系统,而是交还给 C++ 运行时的内存分配器(如 malloc/free 实现),由它决定何时释放给操作系统

malloc/free 的实现与操作系统有关,会调用操作系统层次的底层分配器

常见的有:

  • glibc 的 ptmalloc 策略:
    • malloc() 会从堆区或通过 mmap 分配内存
    • free() 会将小块内存(通常 < 128KB)放入 fastbinsunsorted bins。只有当内存块足够且位于独立的 mmap 区域时才可能真正调用 munmap() 把内存归还操作系统
  • Windows 的 HeapAlloc/HeapFree
  • jemalloc、tcmalloc

下面直接看比较典型的 glibc 的 ptmalloc 策略

brk()sbrk() 函数(系统调用)

放在 unistd.h 头文件中

这两者的作用是扩展 heap 的上界 brk,其中:

  • int brk(const void*) 会将它的参数设置为新的 brk 上界地址(堆空间从低地址到高地址),成功返回 1,失败返回 0
  • void* sbrk(intptr_t) 的参数为申请内存的大小,它会扩展 brk 的上界地址,返回新的 brk 的地址

mmap()munmap() 函数(系统调用)

放在 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);

mmap() 有两种用法:

  • 第一种用法是映射此盘文件到内存中
  • 第二种用法是匿名映射,不映射磁盘文件,而向映射区申请一块内存。

malloc 使用的是 mmap 的第二种用法(匿名映射)

munmap() 函数用于释放内存

malloc() 实现原理

brksbrkmmap 都属于系统调用,若每次申请内存,都调用这三个,那么:

  • 每次都会产生系统调用,影响性能
  • 会产生碎片,如果高地址的内存没有被释放,低地址的内存就不能被回收

所以 malloc 采用的是内存池的管理方式(ptmalloc)

它使用边界标识法(Boundary Tag Method)将连续的内存划分为连续的若干个块(chunk),从而对内存的分配与回收进行管理

内存块的描述结构

块的定义如下:

struct malloc_chunk {  
  INTERNAL_SIZE_T      prev_size;    // 如果前一个块空闲,那么这个值记录的是前一个块的大小,否则无意义
  INTERNAL_SIZE_T      size;         // 当前块的大小,在这个变量的某几位记录了 overhead 头信息
  
  // 下面两个变量只有当当前块空闲时才有用
  struct malloc_chunk* fd;           // 空闲块链表 next
  struct malloc_chunk* bk;           // 空闲块链表 prev
  
  // 下面两个变量只有当当前的块存在于 large bins 时才有用
  struct malloc_chunk* fd_nextsize;  // 指向下一个比当前空闲块大的第一个空闲块
  struct malloc_chunk* bk_nextsize;  // 指向前一个比当前空闲块大的第一个空闲块
};

其中,overhead 头信息包含在 size 变量中,可以使用位运算获取:

#define PREV_INUSE     0x1  // 前一个块是否正在使用
#define IS_MMAPPED     0x2  // 是否由 mmap 分配
#define NON_MAIN_ARENA 0x4  // 是否非主线程使用的 arena

真正的大小其实是 chunk->size & ~0x7,这就要求块的大小一定是 8 的倍数

关于为什么需要在 large bins 时设置两个 nextsize 变量:large bins 中的空闲块是按照大小排序的,但同一个大小的块可能有多个,设置这两个变量可以加快空闲空间的查找

注意:四个指针变量仅仅当块为空时才有用,否则会交给用户使用,作为空闲空间的一部分

内存块的结构

chunk 的结构可以分为使用中的 chunk 和空闲的 chunk,使用中的 chunk 和空闲的 chunk 数据结构基本相同,但是会有一些设计上的小技巧,巧妙的节省了内存

使用中的 chunk 如下:

img

其中:

  • A = 0 为主分配区分配;A = 1 为非主分配区分配
  • M = 1 为 mmap 映射区域分配;M = 0 为 heap 区域分配(由 brk 分配)
  • P = 0 表示前一个 chunk 空闲;P = 1 表示前一个 chunk 正在使用

空闲的 chunk 如下:

img

其中:

  • 当 chunk 空闲时,其 M 状态是不存在的,只有 A 和 P 状态。因为 M 表示是由 brk 还是 mmap 分配的内存,而 mmap 分配的内存 free 时直接 ummap,不会放到空闲链表中。换言之空闲链表中的都是 brk 分配的,所以不用额外记录

ptmalloc 的空间复用比较离谱,在 chunk 的使用中 prev_size 并没有意义,所以它可能被“借”给前一个 chunk。

所以,计算出:

  • in_use_size = (request_size + 8 - 4)(需要存储 prev_sizesize,然后向下一个 chunk 借了一个 prev_size),然后对齐到 8 字节。这表示使用中的 chunk 需要的内存空间
  • chunk_size = max(in_use_size, 16)(空闲的 chunk 由于要存至少两个指针,后面两个可能不需要,所以共 16 字节),这表示一个 chunk 需要的内存空间

空闲链表

当用户使用 free 函数释放掉的内存,ptmalloc 并不会马上交还给操作系统,而是被 ptmalloc 本身的空闲链表 bins 管理起来了

malloc 将相似大小的 chunk 用双向链表链接起来,这样一个链表被称为一个 bin。ptmalloc 共维护了 128 个 bin,每个 bin 都用双向链表维护了一些大小相近的 chunk

基于 chunk 的大小,有下列几种类型的 bin:

  • Fast bins
  • Unsorted bin
  • Small bins
  • Large bins

Fast bins

除非特定情况,fast bins 上两个毗连的空闲 chunk 并不会被合并成一个空闲 chunk

fast bins 是一种高速缓冲,它大约有 10 个定长队列,每个定长队列记录了相同大小的 chunk,如下:

img

当用户释放一块不大于 max_fast(默认值 64B)的 chunk 的时候,会默认会被放到 fast bins 上

当需要给用户分配的 chunk 小于或等于 max_fast 时,malloc 首先会到 fast bins 上寻找是否有合适的chunk

Unsorted bin

unsorted bin 也是一种缓冲区,用户释放的内存大于 max_fast 或者 fast bins 合并后的 chunk 都会首先进入 unsorted bin 上

这里的 chunk 大小无限制,也没有排序,这样的方式给予 malloc 第二次机会以重新使用最近 free 掉的 chunk

用户 malloc 时,如果在 fast bins 中没有找到合适的 chunk,则 malloc 会先在 unsorted bin 中查找合适的空闲 chunk

Small bins 和 Large bins

小于 512 字节的 chunk 被称为 small chunk,大于等于 512 字节的被称为 large chunk

在前两种特殊的缓冲区 bin 之后就是普通的 bin,普通的 bin 分为 Small bins 和 Large bins

在这两种 bin 中,两个毗连的空闲 chunk 会被合并成一个空闲 chunk

  • 前 64 个为 Small bins(每个 bin 以 8 字节为单位递增)
  • 在 Small bins 后面就是 Large bins,共有 63 个

三种特殊的 chunk

有三种特殊的 chunk:

  • top chunk:分配区的顶部空闲内存,每次 malloc() 不能从 bin 找到合适块时,会尝试从 top chunk 分割内存
  • mmaped chunk:mmap 分配的内存
  • last remainder chunk:一种优化方法,当从 unsorted bin 或 large bin 找到一个比需要的 malloc 大的块时,需要拆分(split),多余部分叫 “remainder”,并作为 last remainder 暂存。下一次 malloc 时会优先尝试使用这个块,避免插回 bin 再取出来