面试凉经,代码最近写的太少了,被面试官屠杀。
痛定思痛,对C++新经典中的内存话题进行复现。
new A
与 new A()
的差别
(1)如果是一个空类,这两行代码没什么区别。当然现实中也没有程序员会写一个空类。
(2)类A
中如果有public
修饰的成员变量,new A()
这种初始化对象的方式会把和成员变量有关的内存设置为0
。
(3)如果类A
中有用public
修饰的构造函数,new A()
这种初始化对象的方式也不会把和成员变量有关的内存设置为0
。想必是成员变量的初始化工作要转交给类A
的构造函数做了(不再是系统内部做),而构造函数的函数体为空(什么也没做),所以,m_i
的值是一个随机的值。
(4)感觉不同。new A()
感觉像调用了一个无参的构造函数。而new A
不像函数调用,但实际上它也是调用类A
的默认构造函数的。
(5)简单类型的差别主要还是在初值上。
new A()
做了什么事
new
可以称为关键字,速览定义可以发现 operator new
字样如下:
设置断点,打开反汇编窗口,这样就可以看代码对应的汇编语言代码。
new
关键字主要做了两件事:
①调用 operator new
。
②调用类 A
的构造函数。
调试中可以逐语句跳转进operator new
,发现operator new
调用了malloc
。
new
关键字的调用关系如下:
delete
做了什么事
分配内存,必然有释放内存。调试,反汇编
delete
关键字的调用关系如下
new
与 malloc
的区别
(1)new
是关键字/操作符,而 malloc
是函数。
(2)new
一个对象的时候,不但分配内存,而且还会调用类的构造函数(如果类没有构造函数,系统也没有给类生成构造函数,那就没法调用构造函数了)。
(3)在某些情况下,new A()
可以把对象的某些成员变量设置为 0
,这是 new
的能力之一,malloc
没这个能力。
(4)new
最终是通过调用 malloc
来分配内存的。
delete
与 free
的区别
delete
不但释放内存,而且在释放内存之前会调用类的析构函数(当然必须要类的析构函数存在)。
malloc
怎样分配内存
这很复杂(它内部有各种链表,链来链去,又要记录分配,当释放内存的时候如果相邻的位置有空闲的内存块,又要把空闲内存块与释放的内存块进行合并等),不同的操作系统有不同的做法。
malloc
可能还需要调用与操作系统有关的更底层的函数来实现内存的分配和管理,malloc
是跨平台的、通用的函数,但是malloc
再往深入探究,代码就不通用了。
operator new
、operator delete
极少会在实际项目中用到.
malloc
、free
也只会在C
风格的代码中才会用到。
写 C++
程序,多数情况下还是提倡使用 new
和 delete
。
new
内存分配细节
断点调试,在内存窗口中观看指针变量 ppoint
所指向的内存中的内容。
ppoint
所指向的内存起始地址是 0x000001DF0C412510
,目前分配的是10
字节内存,每字节内存中的内容都是00
。
观察ppoint
所指向的内存附近。
释放ppoint
内存,影响的范围很广,虽然分配内存的时候分配出去的是10
字节,但释放内存的时候影响的不止是10
字节的内存单元,而是一大片。
内存的分配与释放时临近内存的合并
(a)分配了5
块内存(一共new
了5
次),当然每次new
的内存大小可以不同。
(b)率先释放了第3
块内存。
(c)再过一会儿,把第2
块内存也释放了。这时free
函数还要负责把临近的空闲块也合并到一起。
内存释放细节
free
一个内存块并不是一件很简单的事。free
内部有很多的处理,包括合并数据块、登记空闲块的大小、设置空闲块首位的一些标记以方便下次分配等一系列工作。
分配内存的时候,指明了分配10
字节,但释放内存的时候,我们并没有告诉编译器要释放多少字节。
显然编译器肯定在哪里记录了这块内存分配出去的是10
字节,在释放内存的时候编译器才能正好把这10
字节的内存释放掉。
那么,编译器是在哪里记录呢?可以用观察法通过观察来猜测一下。
在ppoint
所指向的首地址之前的16
字节的位置有一个0a
,转换成十进制数字就是10
,这里的10
估计就是编译器用来记录所分配出去的内存字节数的,这样在释放内存的时候就知道所需要释放内存的大小。
将代码改成分配55
字节,通过设置断点调试,在所分配的55
字节内存首地址前面的16
字节位置是也记录着所分配的内存大小这个数字。
分配内存这件事,假设需要分配10
字节,但这绝不意味着只是简单分配出去10
字节,而是在这10
字节周围的内存中记录了很多其他内容,如记录分配出去的字节数等。
分配内存最终还是通过malloc
函数进行的。
分配10
字节内存,malloc
函数可能会分配出如图所示的内存。
编译器要有效地管理内存的分配和回收,肯定在分配一块内存之外额外要多分配出许多空间保存更多的信息。
编译器最终是把它分出去的这一大块内存中间某个位置的指针返回给ppoint
,作为程序员能够使用的内存的起始地址。
一次申请1000
字节,多浪费40
字节,也还比较好接受。但若是一次只申请1
字节,结果系统一下多分配出40
多字节,浪费的实在太多。
重载类中的operator new
和operator delete
操作符
站在编译器的角度,可以把 new A()
和delete pa
翻译成C++
代码。
new A()
如下
delete pa
如下
可以自己写一个类 A
的 operator new
和 operator delete
成员函数来取代系统的 operator new
和 operator delete
函数,自己写的这两个成员函数负责分配内存和释放内存,同时,还可以往自己写的这两个成员函数中插入一些额外代码。
因为new
和delete
本身称为关键字或者操作符,所以类A
中的operator new
和operator delete
叫作重载operator new
和operator delete
操作符,但这里将重载后的 operator new
和 operator delete
称为成员函数也没问题。
设置断点调试,确定可以调用类A
的operator new
和operator delete
成员函数,观察调用operator new
时传递进去的形参size
的值,发现是1
(因为类至少是1
字节大小)。
向类A
中增加public
修饰的构造函数和析构函数:
现在既然在类A
中实现了operator new
和operater delete
,那么在new
和delete
一个类A
对象的时候,就会调用程序员自己实现的类A
中的operator new
和operator delete
。
如果程序员突然不想用自己写的operator new
和operator delete
成员函数了,怎样做到呢?
不需要把类A
中的operator new
和operator delete
注释掉,只需要在使用new
和delete
关键字时在其之前增加::
即可。
::
叫作作用域运算符,在new
和delete
关键字之前增加::
的写法,表示调用全局的new
和delete
关键字。
重载类中的operator new[]
和operator delete[]
操作符
这种写法并不调用类A
中的operator new
和operator delete
。
为数组分配内存,需要重载operator new[]
和operator delete[]
。
在类A
定义的内部增加两个public
修饰的成员函数声明,在类A
的外面增加这两个成员函数的实现。
operator new[]
和operator delete[]
只会被调用1
次,但是类A
的构造函数和析构函数会被分别调用3
次
不要误以为3
个元素大小的数组new
的时候就会分配3
次,delete
执行3
次。
断点调试,形参size
的值是11
。
为什么会是11
呢?
因为这里创建的是3
个对象的数组,每个对象占1
字节,3
个对象正好占用3
字节。另外8
字节是做什么用的呢?
ppoint
返回的内存地址是0x0000022eb1113b00
。
pa
返回的内存地址是0x0000022eb1113b08
。
也就是说真正拿到手的指针是0x0000022eb1113b08
,而0x0000022eb1113b00
实际上是编译器malloc
分配内存时得到的首地址,这里多了8
字节。多出这8
字节是其实是记录数组大小的,数组大小为3
,所以,这8
字节里面记录的内容就是3
。
释放数组内存的时候必然会用到这个数字3
,通过这个数字才知道new
和delete
时数组的大小是多少,从而知道调用多少次类A
的构造函数和析构函数。
new
一个对象数组时真正分配出去的内存概貌:
内存池的概念和实现原理
从malloc
内存分配原理,可以体会到使用malloc
这种分配方式来分配内存会产生比较大的内存浪费,尤其是频繁分配小块内存时,浪费更加明显。
所以内存池就应运而生,内存池的代码实现千差万别,但是核心的实现思想比较统一。
内存池要解决的主要问题是:
减少malloc
调用次数,这意味着减少对内存的浪费。
减少对malloc
的调用次数后,能不能提高程序的运行速度呢?(比如避免malloc
的系统调用导致的性能问题)。
内存池的实现原理是什么?
就是用malloc
申请一大块内存,分配内存的时候,就从这一大块内存中一点点分配给程序员,当一大块内存差不多用完的时候,再申请一大块内存,然后再一点一点地分配给程序员使用。这种做法有效地减少了malloc
的调用次数,从而减少了对内存的浪费。但因为是申请一大块内存,然后一小块一小块分配给程序员用,那么这里面就涉及怎样分成一小块一小块以及怎样回收的问题。
通过类的operator new
、operator delete
操作符的重载来实现一个针对某个类(类A
)的内存池。
第一次调用operator new
成员函数分配内存的时候,if
条件是成立的,因此会执行该条件内的for
循环语句。整个if
条件中的代码执行完毕后看起来是一个链表,提前分配了5
块内存(每块正好是一个类A
对象的大小),然后每一块的next
(指针成员变量)都指向下一块的首地址,这样就非常方便从当前的块找到下一块。
内存池初次创建时的情形:
跳出if
语句并执行if
后面的几行代码的含义是:m_FreePosi
总是指向下一个能分配的空闲块的开始地址,而后把tmplink
返回去。
从内存池中返回一块能用的内存,内存池中的空闲位置指针往下走指向下一个空闲块:
每次new一个该类(类A
)对象,m_FreePosi
都会往下走指向下一块空闲待分配内存块的首地址。假设程序员new
了5
次对象,把内存池中事先准备好的5
块内存都消耗光了,m_FreePosi
就会指向nullptr
了。此时,程序员第6
次new
对象的话,那么程序中if
条件就又成立了,这时程序又会分配出5
块内存,并且将新分配的5
块内存中的第1
块拿出来返回,m_FreePosi
指向第2
块新分配的内存块。
内存池用尽时就要重新new
一大块内存并链入整个内存池中:
深色代表已经分配出去的内存块,浅色代表没有分配出去的内存块。
看内存的回收。
内存池当前已经分配出去了9
块内存,剩余1
块空闲内存:
把图中左上5
块内存中中间的一块(第3
块)内存释放掉,看一看operator delete
函数里做了什么事。
operator delete
并不是把内存真正归还给系统,因为把内存真正归还给系统是需要调用free
函数的,operator delete
做的事情是把要释放的内存块链回到空闲的内存块链表中来。
(1)由m_FreePosi
串起来的这个链(链表)代表的是空闲内存块的链,m_FreePosi
指向的是整个链的第一个空闲块的位置,当需要分配内存时,就把这第一个空闲块分配出去,m_FreePosi
就指向第二个空闲块。
(2)当回收内存块的时候,m_FreePosi
就会立即指向这块回收回来的内存块的首地址,然后让回收回来的这块内存的next
指针指向原来m_FreePosi
所指向的那个空闲块。所以,m_FreePosi
始终是空闲块这个链的第一个空闲块(链表头)。
(3)对于已经分配出去的内存块的next
指针指向什么已经没有实际意义了。已经分配出去的内存块,程序要对它们负责,程序要保证及时地delete
它们促使类A
的operator delete
成员函数被及时执行,从而把不用的内存块归还到内存池中。
内存池回收第3
块内存块后的情形,由m_FreePosi
串起整个空闲内存块链:
创建类A
对象时所支持的内存池功能就写好了。进行测试:
如果增加内存池一次分配的内存块数,就能进一步减少malloc
的调用次数。
感觉能提升一定的速度。
如果不用内存池,而用原生的malloc
进行内存分配,看一看效率如何:
根据运行结果,感觉整个还是要慢一些。
现在把m_sTrunkCount
调整回5
,把MYMEMPOOL
宏定义行改为1
。
在main主函数中修改一下代码,分配内存的次数由原来的500
万次修改为15
次,方便观察内存分配数据。同时,在for
循环中打印一下所分配的内存地址。
每5
个分配的内存地址都是挨着的(间隔8
字节),这说明内存池机制在发挥作用(因为内存池是一次分配5
块内存,显然这5
块内存地址是挨在一起的)。
如果关闭内存池,会发现每次malloc
的地址是不一定挨着的。
当然,这个内存池代码不完善,例如分配内存的时候是用new
分配的,释放内存的时候并没有真正地用delete
来释放,而是把这块要释放的内存通过一个空闲链连起来而已。
这种内存池技术的实现要是想通过delete
来真正释放内存(把内存归还给操作系统),并不容易做到,索性把回收回来的内存攥在手里,需要的时候再分配出去,不需要的时候一直攥在手里(这不属于内存泄漏),只要内存有分配,有回收,这个内存池耗费的内存空间总归还是有限的。
当整个程序运行即将结束退出的时候,建议把分配出去的内存真正释放掉,这是一个比较好的习惯。这个内存池所占用的内存如何写代码来真正地释放掉,这个问题待填坑。
嵌入式指针(embedded pointer)
嵌入式指针其实也是一个指针,常用于内存池的代码实现中。
在刚刚的实现代码中,为了让空闲的内存块能够正确地分配出去,在类A中引入了一个成员变量next
,这是一个指针,每new
一个类A
对象,都会有这么一个8
字节的next
指针出现,这个多出来的8
字节属于内存空间的浪费。
嵌入式指针能够把这8
字节省下来。
嵌入式指针的工作原理就是:
借用类A
对象所占的内存空间的前8
字节(代替next指针),这8
字节专门用来链住这些空闲的内存块。当一个空闲的内存块分配出去之后,这前8
字节的内容就不需要了(对于已经分配出去的内存块的next指针指向什么已经没有实际意义),即便这8
字节的内容被具体的对象内的数据所覆盖,也无所谓。
嵌入式指针要成功地使用需要一个前提条件:
那就是这个类A
的sizeof
(new
一个该类对象时所占用的内存字节数)必须要不少于8
字节。
类A
的sizeof
值正好是8字节,而这8
字节恰好是next
成员变量所占的8字节。如果拿掉类A
中的next
成员,就导致sizeof(A)
不够8
字节,没法使用嵌入式指针技术了。向类A中增加两个public
修饰的long long
成员变量,则sizeof(A)
变成16
,这个大小足够演示嵌入式指针技术了。
利用嵌入式指针实现的内存池初次创建时的情形:
嵌入式指针的实现:
写一个类,名字叫作TestEP
,为了保证该类的sizeof
值不小于8
,这里给该类两个long long
类型的成员变量。
类里多了一个结构的定义。其实,跟在类外定义这个结构没什么区别,只不过如果把这个结构定义在类TestEP
外面的话,外界要用这个obj
结构名时直接写成obj
,如果定义在类TestEP
里面,外界要用obj
类名时就需要写成TestEP::obj
,所以这个嵌入式指针是嵌到类里面的一个类(结构)。
为什么obj
这个结构要嵌入到类TestEP
里面,显然是因为只想在类TestEP
里面使用这个obj
结构,而不希望在TestEP
类外面也能用obj
这个名字,所以放到里面来(而且一般都用private
修饰)。
struct obj* next
是一个指针变量,名字叫next
。这个指针变量指向一个obj
结构。
这就是一个链表,自己是一个obj
结构对象,把自己这个对象的next
指针指向另外一个obj
结构对象,最终就是把多个自己这种类型的对象通过next
指针串起来:
写几行测试代码看一看嵌入式指针是怎样使用的:
设置断点调试,ptmp->next=nullptr
;对应着把mytest
对象内存地址的前8
字节清0
。所以说这里的ptmp->next
占用的是对象mytest
的前8字节。
借用对象的前8
字节保存嵌入式指针指向的内容。测试代码是让这个嵌入式指针指向空了,可以让它指向下一个内存池中内存块的地址(这里也涉及一个类对象在内存中的布局问题)。
嵌入式指针改进内存池
对内存池进行改进,应用嵌入式指针来作为块与块之间的链。
同时上面的内存池是只针对一个类(类A
)而写的,如果应用到别的类如类B
中,还得在类B
中写一堆代码,很不方便。
为了把内存池技术更好地应用到其他类中,这里单独为内存池技术的使用写一个类。
有了这个专用的内存池类或者说是内存分配类,怎样使用?
改造类A
中的代码。代码中定义了一个静态成员变量myalloc
,然后改造了一下类A
中的operator new
和operator delete
成员函数。
每5
个分配的内存地址都是挨着的(间隔16
字节),这说明内存池机制在发挥作用(因为内存池是一次分配5
块内存,显然这5
块内存地址是挨在一起的,因为这5
块内存实际上是一次分配出来的一大块内存)。
除了可以对类中的operator new
、operator delete
以及operator new[]
、operator delete[]
重载,也可以重载全局的operator new
、operator delete
以及operator new[]
、operator delete[]
。
在重载这些全局函数的时候,一定要放在全局空间里,不要放在自定义的命名空间里,否则编译器会报语法错。
虽然可以重载全局的operator new
、operator delete
、operator new[]
、operator delete[]
,但很少有人这样做,因为这种重载影响面太广。一般都是重载某个类中的operator new
、operator delete
,这样影响面比较小(只限制在某个类内),也更实用。
当然,如果类 A
中也重载了operator new
、operator delete
、operator new[]
、operator delete[]
,那么类中的重载会覆盖掉全局的重载。
推荐一下
0voice