C++内存分配详解三:内存分配模型

侯捷C++内存分配课程总结三:内存分配模型

文章内容参照于侯捷 C++内存分配系列教程

文章目录

  • 侯捷C++内存分配课程总结三:内存分配模型
  • 回顾:重载new行为的目的
  • 一、内存分配模型:内存池
  • 二、具体实现
    • 1.C++prime中的实现
    • 2.Effective C++中的实现
    • 3.实现内存分配重载在多个类中的复用
  • 三、存在问题的分析
  • 总结


回顾:重载new行为的目的

在之前的文章中,我门了解到malloc在分配内存的时候会额外分配出一些不能为用户所使用的内存块。当我们多次分配内存时,这一个开销会变得越来越大。而我们重载new行为的最重要的目的就是减少调用malloc分配内存所产生的额外开销。


一、内存分配模型:内存池

我们在开始讨论这种内存分配模型之前需要先明确一个问题:我们无法改变malloc的动作(除非我们去修改源码)。这也意味着我们无法改变每次调用malloc都会产生的额外开销,所以,我们去进行优化的方法就只能时减少对malloc的调用次数

实现方法:

  1. 在第一次分配内存时,调用malloc一次性分配一大块内存,称为内存池
  2. 当需要分配内存时,首先检查内存池是否有足够的容量,如果有就直接从内存池中为其分配内存,若不存在则调用malloc为内存池再次补充一大块内存
  3. 当已经被分配出的内存释放时,将它还于内存池

如此,我们可以很简单的减少了malloc的调用次数,尽管每一次调用malloc都要产生额外开销,但相比直接调用malloc会优化了很多。这里所说的只是大体的实现,下面我们去看更多的细节。


二、具体实现

1.C++prime中的实现

代码来自于C++prime

	//对于类的定义
	class Screen {
	public:
		Screen(int x) :i(x) {};
		int get() { return i; }
	
		void * operator new(size_t);//重载new的行为
		void operator delete(void *, size_t);//重载delete的行为
	private:
		Screen *next;//仅用于内存池的中,指向下一块可用的内存
		static Screen * freeStore;//内存池头节点的指针
		static const int screenChunk;//每次分配的数量
	private:
		int i;
	};
	Screen *Screen::freeStore = 0;
	const int Screen::screenChunk = 24;

我们首先分析这个类的定义:

  1. 构造函数、数据成员i和方法get,这三个成员我们不必去关心
  2. 重载了operator new 和 operator delete, 很明显,我们要改变new的行为就只能通过重载这两个函数
  3. 一根指向本体的指针:这根指针的作用是,当当前成员还在内存池中时,用于连接下一块内存
  4. 一根指向内存池头节点的指针,指向所维护的内存池的第一个可用内存
  5. 一个代表每次分配的数量的int,这也就是我之前说的分配一大块内存的那个一大块的数量
	//重载的operator new()
	void *Screen::operator new(size_t size)
	{
		Screen *p;
		if (!freeStore) {
			//当链表为空时,就申请一大片内存
			size_t chunk = screenChunk * size;//计算需要分配的大小
			//分配内存并将指针转型
			freeStore = p =reinterpret_cast<Screen*>(new char[chunk]);
			//将这一大片内存分割,当作链表串联起来
			for (; p != &freeStore[screenChunk - 1]; ++p)
				p->next = p + 1;
			p->next = 0;//将整个内存池链表的最后设为0
		}
		p = freeStore;//取出内存池中最开头的一块内存
		freeStore = freeStore->next;//内存池的头指针后移
		return p;//将取出的内存交付
	}

接着,我们来分析对于内存分配的动作

  1. 当freeStore为空时,也就证明当前内存池为空,那么就分配一大块内存给内存池
  2. 分配之后,将得到的一大块内存用之前定义的next这根指针串联在一起,这样就完成了逻辑上的分割,虽然物理上他们可能是相连的,但是只要我们分配的时候使用next指针,就能保证一块一块的分配出去
  3. 当freeStore不为空时(本身就不为空或是已经分配了内存),就取出内存池的第一块内存给p,并且将内存池头指针后移,然后将p指向的内存交付。

至此,我们已经获得了一根如下的链表:
在这里插入图片描述
这也就是我们得到的内存池,头节点由freeStore指向,尾节点的next为0;

	//重载的operator delete()
	void Screen::operator delete(void *p, size_t)
	{
		//将当前内存块的头指针指向可用内存链表的开头
		(static_cast<Screen*>(p))->next = freeStore;
		//将可用内存链表的头指针调整为当前内存块
		freeStore = static_cast<Screen*>(p);
	}

当已经分配出去的内存使用结束被delete时,我们将他再次连接到内存池的开头,这样就完成了归还动作。

问题分析:
基于C++primer的这种分配方式,我们可以发现它的两个缺陷:

  1. 每个内存块都需要维护一根next指针,而这跟指针只有当它在内存池中时才有效,离开内存池后也是一块额外开销
  2. 对于每一种类,都需要重写一次上边的内容
  3. 回收的内存仅仅被归还于内存池,而没有被还于OS。

2.Effective C++中的实现

代码来自于Effective C++

	//类的定义
	class Airplane {
	private:
		struct AirplaneRep
		{//定义一个数据成员的结构体
			unsigned long miles;
			char type;
		};
	private:
		union {//定义一个共用体,用来作为当前类的成员
			AirplaneRep rep;//当内存被分配时,将当前内存解释为AirplaneRep,作为数据成员使用
			Airplane *next;//当处于内存池中时,将当前内存解释为指针,指向下一个内存块
		};
	public:
		unsigned long getMiles() { return rep.miles; }
		char getType() { return rep.type; }
		void set(unsigned long m, char t) { rep.miles = m; rep.type = t; }
	
	public:
		static void *operator new(size_t size);//重载的operator new
		static void operator delete(void *deadObject, size_t size);//重载的operator delete
	private:
		static const int BLOCK_SIZE;//一次性分配的内存
		static Airplane * headOfFreeList;//内存池头节点指针
	};
	Airplane * Airplane::headOfFreeList;
	const int Airplane::BLOCK_SIZE = 512;

我们仍然先来看对类的定义,这次我们只去看不同的地方:类的成员
在上一种定义中,使用了一根指针和一个数据成员,这就意味着需要分配一个数据成员+一根指针的内存空间,但当使用时,指针的空间并没有被使用。在当前定义中,使用一个共用体,只分配一个数据成员的空间,当他在内存池中时,将该空间视为指针;当他被分配后,将该空间视为数据成员。

当我们将内存分配出去之后,使用者不知道也不必知道这块内存曾经被解释为指针,他会从头开始复写内存块中的内存,这样就为每一块内存空间节省下来一个指针的空间。

	//重载的operator new()
	void *Airplane::operator new(size_t size)
	{
		//若所需要分配的大小有误时,就转交给::operator new()
		if (size != sizeof(Airplane))
			return ::operator new(size);
	
		Airplane *p = headOfFreeList;
		if (p)//如果内存池不为空,就将头指针后移一个空间
			headOfFreeList = p->next;
		else//若内存池已空
		{
			//申请一大片空间
			Airplane *newBlock =
				static_cast<Airplane*>(::operator new((BLOCK_SIZE) * sizeof(Airplane)));
			//将他分割成小块,连接成链表
			//但跳过#0,会将他作为本次分配的结果交付
			for (int i = 1; i < BLOCK_SIZE - 1; ++i)
				newBlock[i].next = &newBlock[i + 1];
			newBlock[BLOCK_SIZE - 1].next = 0;//链表结尾的指针置为0
			p = newBlock;
			headOfFreeList = &newBlock[1];//设置头指针
		}
		return p;
	}

这里的operator new()所做的事情和上一种相同,这里就不做过多的解释了。只是有一点不同需要我们去考虑:为什么需要分配的内存会不等于当前类的大小呢?
答案很简单,当发生了继承时。

	//重载的operator delete()
	void Airplane::operator delete(void *deadObject, size_t size)
	{
		if (deadObject == 0) return;//若释放空指针则直接返回
		if (size != sizeof(Airplane)) {//若大小不相同则交给::operator delete()
			::operator delete(deadObject);
			return;
		}
		//将需要释放的内存连接到内存池的头部
		Airplane *carcass = static_cast<Airplane*>(deadObject);
		carcass->next = headOfFreeList;
		headOfFreeList = carcass;
	}

这里和上一种几乎一样,我就不多提了,看注释应该可以明白,也是将释放的内存连接到内存池链表的最前边。

问题分析:
这种实现方式虽然解决了对指针空间的利用,但是在释放的时候仍然没有将空间归还给OS,也没有解决对于每一种类都去重写一遍代码的问题,下边这种实现就实现了在多个类之间的复用


3.实现内存分配重载在多个类中的复用

代码来自于侯捷C++内存分配系列教程讲义

	//类的定义
	class myAllocator
	{
	public:
		void * allocate(size_t);//分配内存
		void deallocate(void *, size_t);//回收内存
	private:
		struct obj
		{
			struct obj* next;
		};
		obj *freeSorte = nullptr;//指向内存池的可用位置的第一个位置
		const int CHUNK = 5;//每次分配的大小
	};
	void * myAllocator::allocate(size_t size)
	{//分配内存
		obj *p;
		if ( !freeSorte )//如果当前链表为空就创建一个链表
		{
			size_t chunk = CHUNK * size;
			freeSorte = p = (obj*)malloc(chunk);//分配一大块内存
			//将内存分割成链表
			for (int i = 0; i < CHUNK - 1; ++i)
			{
				p->next = (obj*)((char*)p+size);
				p = p->next;
			}
			p->next = nullptr;//末尾置为空指针
		}
		p = freeSorte;
		freeSorte = freeSorte->next;
		return p;
	}
	void myAllocator::deallocate(void *p, size_t size)
	{//回收内存
		//将释放的位置插入当前所使用的内存链表的最前端
		((obj*)p)->next = freeSorte;
		freeSorte = ((obj*)p);
	}

	//使用方法
	class foo
	{
	public:
		foo(int i){ x = i, y = i; };
		int x;
		int y;
		static myAllocator myAlloca;//内存分配器
	
		static void *operator new(size_t size)//重载new的操作
		{return myAlloca.allocate(size);}
		static void operator delete(void* loc, size_t size)//重载delete操作
		{return myAlloca.deallocate(loc, size);}
	};
	myAllocator foo::myAlloca;//定义foo类的全局内存池

这种定义和前面两种的思想基本上相同,我就不再去展开分析了


三、存在问题的分析

上面三种方式到最后也没有解决的一个问题是:回收的内存没有还给OS,而是单纯连接在内存池中。

下面我们来分析一下这个问题是否可以解决

首先,在归还内存块的时候,并不会按照顺序归还,所以在内存链表中,内存块的连接是混乱的,我们无法有效的找到一个方法去判断相连的内存是否已经全部被归还

同时,由于分配出的内存块已经排除了cookie,而编辑器调用free去归还内存的动作也依赖于cookie,所以也没有办法在归还时直接调用free

此外,由于链表的多次连接与分配,我们已经不能找到最开始分配内存时所使用的指针,所以,即使我们知道了全部的内存空间都已经归还至了内存池,也无法找到最开始的那根指针而对其使用free


总结

  1. 重载new的行为的主要原因是减少malloc分配的额外空间的浪费
  2. 内存池的内存分配模型是当前较为常用的模型,VC本身也在使用这种方式

你可能感兴趣的:(C++内存分配,c++,内存管理)