您的位置:首页 > 文旅 > 美景 > 公司取名网免费版_网站建设报价新鸿儒_什么是搜索引擎销售_推广链接点击器

公司取名网免费版_网站建设报价新鸿儒_什么是搜索引擎销售_推广链接点击器

2025/3/20 18:19:15 来源:https://blog.csdn.net/2201_75584283/article/details/143749597  浏览:    关键词:公司取名网免费版_网站建设报价新鸿儒_什么是搜索引擎销售_推广链接点击器
公司取名网免费版_网站建设报价新鸿儒_什么是搜索引擎销售_推广链接点击器

个人主页: 熬夜学编程的小林

💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】【Linux网络编程】【项目详解】

目录

1、项目介绍

1.1、这个项目做的是什么

1.2、这个项目的要求的知识储备

2、什么是内存池

2.1、池化技术

2.2、内存池

2.3、内存池主要解决的问题

2.4、malloc

2.5、设计定长的内存池

2.5.1、 VirtualAlloc

2.5.1、 brk

2.5.1、 mmap 

2.5.2、成员变量

2.5.3、管理释放的对象

2.5.4、释放对象

2.5.5、申请对象

2.5.6、性能测试


1、项目介绍

1.1、这个项目做的是什么

        当前项目是实现一个高并发的内存池,他的原型是google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)
        我们这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcamlloc的精华,这种方式有点类似我们之前学习STL容器的方式。但是相比STL容器部分,tcmalloc的代码量和复杂度上升了很多,大家要有心理准备。当前另一方面,难度的上升,我们的收获和成长也是在这个过程中同步上升。
        另一方面tcmalloc是全球大厂google开源的,可以认为当时顶尖的C++高手写出来的,他的知名度也是非常高的,不少公司都在用它,Go语言直接用它做了自己内存分配器。所以很多程序员是熟悉这个项目的,那么有好处,也有坏处。好处就是把这个项目理解扎实了,会很受面试官的认可。坏处就是面试官可能也比较熟悉项目,对项目会问得比较深,比较细。如果你对项目掌握得不扎实,那么就容易碰钉子。

tcmalloc源代码https://gitee.com/mirrors/tcmalloc

1.2、这个项目的要求的知识储备

这个项目会用到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等等方面的知识。

2、什么是内存池

2.1、池化技术

所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态

2.2、内存池

内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放

2.3、内存池主要解决的问题

内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。那么什么是内存碎片呢?

在内存分配和释放过程中,由于分配的内存块大小和位置的不同,导致内存空间中出现了许多不连续的小块空闲内存,而这些小块内存无法被有效利用来满足新的内存请求。内存碎片分为内部碎片和外部碎片两种。

上面我们讲的外碎片问题

内部碎片(Internal Fragmentation)

  • 外部碎片是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。

外部碎片(External Fragmentation)

  • 内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。内碎片问题,我们后面项目就会看到,那会再进行更准确的理解。 

2.4、malloc

C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,而malloc就是一个内存池malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”

  • malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。 

下面有几篇关于这块的文章,大概可以去简单看看了解一下,关于ptmalloc,学完我们的项目以后,有兴趣uu们可以去看看他的实现细节。

一文了解,Linux内存管理,malloc、free 实现原理

malloc()背后的实现原理——内存池

malloc的底层实现(ptmalloc)

2.5、设计定长的内存池

作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场景下都可以用但是什么场景下都可以用就意味着什么场景下都不会有很高的性能,下面我们就先来设计一个定长内存池做个开胃菜,当然这个定长内存池在我们后面的高并发内存池中也是有价值的,所以学习它的目的有两层,先熟悉一下简单内存池是如何控制的,第二他会作为我们后面内存池的一个基础组件。 

固定大小的内存申请释放需求特点:

  • 1、性能达到极致
  • 2、不考虑内存碎片问题等

如何实现定长?

在实现定长内存池时要做到 " 定长 " 有很多种方法,比如我们可以使用非类型模板参数,使得在该内存池中申请到的对象的大小都是N

template<size_t N>
class ObjectPool
{};

此外,定长内存池也叫做对象池,在创建对象池时,对象池可以根据传入的对象类型的大小来实现" 定长 ",因此我们可以通过使用模板参数来实现 " 定长 ",比如创建定长内存池时传入的对象类型是int,那么该内存池就只支持4字节大小内存的申请和释放。

template<class T>
class ObjectPool
{};

如何直接向堆申请页为单位的大块内存:

既然是内存池,那么我们首先得向系统申请一块内存空间,然后对其进行管理。要想直接向堆申请内存空间,在Windows下,可以调用VirtualAlloc函数在Linux下,可以调用brk或mmap函数。

2.5.1、 VirtualAlloc

VirtualAlloc

在进程的虚拟地址空间中分配或保留内存!

#include <windows.h>LPVOID VirtualAlloc
(LPVOID IpAddress,      // 要分配的内存区域的地址SIZE_T dwSize,         // 分配的大小DWORD  flAllocationType,// 分配的类型DWORD  flProtect       // 该内存的初始保护属性
);

参数:

  1. lpAddress指定要分配的内存区域的起始地址。如果此参数为nullptr,则系统会自动决定分配内存区域的位置,并且按64KB向上取整。

  2. dwSize指定要分配或保留的区域的大小,以字节为单位。系统会根据这个大小一直分配到下页的边界。

  3. flAllocationType指定分配类型,可以是指定或合并以下标志:

    • MEM_COMMIT为指定地址空间提交物理内存
    • MEM_RESERVE保留指定地址空间,不分配物理内存。这样可以阻止其他内存分配函数(如malloc和LocalAlloc等)再使用已保留的内存范围,直到它被释放。
    • MEM_TOP_DOWN:在尽可能高的地址分配内存。
    • MEM_LARGE_PAGES:分配内存时使用大页面支持。大小和对齐必须是一个大页面的最低倍数。
  4. flProtect指定被分配区域的访问保护方式。可能的值包括:

    • PAGE_READWRITE区域可以执行代码,应用程序可以读写该区域
    • PAGE_READONLY:区域为只读。如果应用程序试图访问区域中的页,将会被拒绝访问。
    • PAGE_NOACCESS:任何访问该区域的操作将被拒绝。
    • PAGE_GUARD:区域第一次被访问时进入一个STATUS_GUARD_PAGE异常,这个标志要和其他保护标志合并使用。
    • PAGE_NOCACHE:RAM中的页映射到该区域时将不会被微处理器缓存(cached)。

返回值:

  • 如果函数调用成功,则返回分配的首地址;
  • 如果调用失败,则返回nullptr。可以通过GetLastError函数来获取错误信息。

2.5.1、 brk

brk

改变进程的数据段的结束地址,允许进程请求或释放由操作系统为其数据段分配的额外内存空间。 

#include <unistd.h>int brk(void *addr);

参数:

  • addr:这是一个指向void的指针,指定了新的数据段结束地址。如果这个地址高于当前的数据段结束地址,系统尝试为进程分配更多的内存,从而扩展数据段。如果这个地址低于当前的结束地址,系统可能会释放不再需要的内存,从而收缩数据段。

返回值:

  • 成功时,brk() 返回0
  • 失败时,返回-1,并设置errno以指示错误类型。

使用场景:

  • brk() 主要用于手动管理内存的低级系统编程中。在大多数现代应用程序中,推荐使用更高级的内存分配函数,如malloc()calloc()realloc(),这些函数提供了更灵活的内存管理功能,并且通常更易于使用和维护。

2.5.1、 mmap 

mmap 

mmap() 是一种内存映射文件的机制,它允许文件的内容被映射到进程的地址空间中,从而可以像访问普通内存一样访问文件的内容。 

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

参数:

  1. addr映射区域的起始地址。如果设置为 nullptr,则由系统自动选择一个合适的、页对齐的地址作为映射区域的起始地址。

  2. length需要映射的字节数,它决定了映射区域的大小。这个值必须是页大小的整数倍,否则系统可能会将其向上取整至页大小。

  3. prot映射区域的保护方式,它指定了映射区域的内存保护属性。可以是以下几种方式的组合:

    • PROT_READ:映射区域可读。
    • PROT_WRITE:映射区域可写。
    • PROT_EXEC:映射区域可执行。
    • PROT_NONE:映射区域不可访问。
  4. flags影响映射区域的各种特性。在调用 mmap() 时,必须定 MAP_SHARED 或 MAP_PRIVATE 之一。其他可选的标志位包括:

    • MAP_SHARED:对映射区域的写入数据会复制回文件内,而且允许其他映射该文件的进程共享。
    • MAP_PRIVATE:对映射区域的写入操作会产生一个映射文件的复制,即私人的“写入时复制”(copy on write)。对此区域作的任何修改都不会写回原来的文件内容。
    • MAP_FIXED:如果参数 addr 所指的地址无法成功建立映射时,则放弃映射,不对地址做修正。通常不鼓励使用此标志位。
    • MAP_ANONYMOUS:建立匿名映射。此时会忽略参数 fd,不涉及文件,而且映射区域无法和其他进程共享。
    • MAP_DENYWRITE:只允许对映射区域的写入操作,其他对文件直接写入的操作将会被拒绝。
    • MAP_LOCKED:将映射区域锁定住,这表示该区域不会被置换(swap)。
  5. fd要映射到内存中的文件描述符。如果使用匿名内存映射时(即 flags 中设置了 MAP_ANONYMOUS),fd 设为 -1

  6. offset文件映射的偏移量,它表示从文件的哪个字节开始映射。通常设置为 0,代表从文件最前方开始对应。offset 必须是分页大小的整数倍。

返回值:

  • 成功时,mmap() 返回指向映射区域首地址的指针
  • 失败时,返回 MAP_FAILED(其值通常为 (void*)-1,并设置 errno 以指示错误类型。

在堆上按页申请空间(Windows环境)

// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else// linux下brk mmap等
#endifif (ptr == nullptr)throw std::bad_alloc();return ptr;
}

此处是在windows环境下运行,因此我们也可以给头文件添加条件编译!

#ifdef _WIN32
#include <windows.h>
#else
// 
#endif

2.5.2、成员变量

1、对于向堆申请到的大块内存,我们可以用一个指针来对其进行管理,但仅用一个指针肯定是不够的,我们还需要用一个变量来记录这块内存的长度。

2、由于此后我们需要将这块内存进行切分,为了方便切分操作,指向这块内存的指针最好是字符指针,因为指针的类型决定了指针向前或向后走一步有多大距离,对于字符指针来说,当我们需要向后移动n个字节时,直接对字符指针进行加n操作即可。

3、为了方便判断内存空间是否足够,使用一个变量记录剩余内存大小

 4、释放回来的定长内存块也需要被管理,我们可以将这些释放回来的定长内存块链接成一个链表,这里我们将管理释放回来的内存块的链表叫做自由链表,为了能找到这个自由链表,我们还需要一个指向自由链表的指针

因此,定长内存池当中包含三个成员变量

  • _memory:指向大块内存的指针
  • _remainBytes:大块内存切分过程中剩余字节数
  • _freeList:内存还回来过程中链接的自由链表的头指针
template<class T>
class ObjectPool
{
private:char* _memory = nullptr; // 指向一块大内存的指针size_t _remainBytes = 0;// 大块内存切分过程中剩余的内存void* _freeList = nullptr; // 内存还回来过程中链接的自由链表的头指针
};

2.5.3、管理释放的对象

定长内存池如何管理释放的空间?

  • 对于还回来的定长内存块,我们可以用自由链表将其链接起来,但我们并不需要为其专门定义链式结构,我们可以让内存块的前4个字节(32位平台)或8个字节(64位平台)作为指针,存储后面内存块的起始地址即可
  • 因此在向自由链表插入被释放的内存块时先让该内存块的前4个字节或8个字节存储自由链表中第一个内存块的地址,然后再让_freeList指向该内存块即可,也就是一个简单的链表头插操作。

 

此处有一个问题:如何让一个指针在32位平台下解引用后能向后访问4个字节,在64位平台下解引用后能向后访问8个字节? 

此处可以使用一个很妙的方法,使用二级指针

  • 当我们需要访问一个内存块的前4/8个字节时,我们就可以先将该内存块的地址先强转为二级指针,由于二级指针存储的是一级指针的地址,二级指针解引用能向后访问一个指针的大小,因此在32位平台下访问的就是4个字节,在64位平台下访问的就是8个字节,此时我们访问到了该内存块的前4/8个字节。
// 获取地址
static void*& Next(void* obj)
{return *(void**)obj;
}

2.5.4、释放对象

在释放对象时,我们应该显示调用该对象的析构函数清理该对象(释放自定义类型内部结构),因为该对象可能还管理着其他某些资源,如果不对其进行清理那么这些资源将无法被释放,就会导致内存泄漏

void Delete(T* obj)
{// 显示调用析构,为了自定义类型,释放内部结构obj->~T();// 将内存头插到链表上,obj的前一个指针变量的大小存放下一个数据的地址*(void**)obj = _freeList;// obj 前面一个指针大小存放链表头结点地址// 更新头结点_freeList = obj;
}

2.5.5、申请对象

1、当我们申请对象时,内存池应该优先把还回来的内存块对象再次重复利用,因此如果自由链表当中有内存块的话,就直接从自由链表头删一个内存块进行返回即可

2、如果自由链表当中没有内存块,那么我们就在大块内存中切出定长的内存块进行返回,当内存块切出后及时更新_memory指针的指向,以及_remainBytes的值即可。 

 

注意:

  • 由于当内存块释放时我们需要将内存块链接到自由链表当中,因此我们必须保证切出来的对象至少能够存储得下一个地址,所以当对象的大小小于当前所在平台指针的大小时,需要按指针的大小进行内存块的切分。

  • 当大块内存已经不足以切分出一个对象时,我们就应该调用我们封装的SystemAlloc函数,再次向堆申请一块内存空间,此时也要注意及时更新_memory指针的指向,以及_remainBytes的值

 

  • 与释放对象时需要显示调用该对象的析构函数一样,当内存块切分出来后,我们也应该使用定位new,显示调用该对象的构造函数对其进行初始化。 
T* New()
{T* obj = nullptr;// 0.优先使用还回来的内存if (_freeList){// next存放指向下一个对象的地址void* next = *(void**)_freeList;obj = (T*)_freeList;// 迭代到下一个_freeList = next;}else{// 1.空间不够申请一大块内存if (_remainBytes < sizeof(T)){_remainBytes = 128 * 1024;// 使用C语言开辟空间接口//_memory = (char*)malloc(_remainBytes);// 使用系统调用_memory = (char*)SystemAlloc(_remainBytes >> 13);if (_memory == nullptr){throw std::bad_alloc();// 申请空间失败直接抛异常}}obj = (T*)_memory;// 至少得跳跃一个指针大小的长度,以便指针能够解引用size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);_memory += objSize;		// 更新地址位置_remainBytes -= objSize;// 更新剩余大小}// 定位new,显示调用T的构造函数初始化,用于自定义类型new(obj)T;return obj;
}

2.5.6、性能测试

下面我们将实现的定长内存池和malloc/free进行性能对比,测试代码如下:

struct TreeNode
{int _val;TreeNode* _left;TreeNode* _right;TreeNode():_val(0), _left(nullptr), _right(nullptr){}
};void TestObjectPool()
{// 申请释放的轮次const size_t Rounds = 5;// 每轮申请释放多少次const size_t N = 1000000;std::vector<TreeNode*> v1;v1.reserve(N);size_t begin1 = clock();for (size_t j = 0; j < Rounds; ++j){for (int i = 0; i < N; ++i){v1.push_back(new TreeNode);}for (int i = 0; i < N; ++i){delete v1[i];}v1.clear();}size_t end1 = clock();std::vector<TreeNode*> v2;v2.reserve(N);ObjectPool<TreeNode> TNPool;size_t begin2 = clock();for (size_t j = 0; j < Rounds; ++j){for (int i = 0; i < N; ++i){v2.push_back(TNPool.New());}for (int i = 0; i < N; ++i){TNPool.Delete(v2[i]);}v2.clear();}size_t end2 = clock();cout << "new cost time:" << end1 - begin1 << endl;cout << "object pool cost time:" << end2 - begin2 << endl;
}

在代码中,我们先用new申请若干个TreeNode对象,然后再用delete将这些对象再释放,通过clock函数得到整个过程消耗的时间。(new和delete底层就是封装的malloc和free)

从上面的测试结果可以看到,定长内存池消耗的时间比malloc/free消耗的时间要短。这就是因为malloc是一个通用的内存池,而定长内存池是专门针对申请定长对象而设计的,因此在这种特殊场景下定长内存池的效率更高。 

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com