侯捷老师 C++内存管理-第二讲 学习笔记

发布于:2023-01-04 ⋅ 阅读:(253) ⋅ 点赞:(0)

第二讲 std::allocator

标准库的std::allocator

一、回顾VC6 malloc()

image-20220823142716448

  • 根据内存管理的要求,尽量减少malloc的调用(通常人们对malloc()有误解,觉得malloc()很慢),内存池的设计解决了。

  • 但还有一件事,就是VC6下的malloc()分配的内存,除了我们需要的内存之外,还有一些必要的额外开销(overhead),例如:cookie用来记录内存块的大小,

    一般关注的是overhead的耗用率,如果分配的内存很大,则耗用率就低,如果分配的内存小,则耗用率就高。

  • 并且通常来说,一个对象的内存不会很大,但是有多种并且量多(即海量的小区块),此时,cookie这个必要的开销就显得有点浪费。

所以问题就来了,如何更好的设计,来避免cookie占用的内存

类比第一讲中,per_class allocator的版本更迭设计思想,利用 embedded pointer

二、编译器中分配器allocator的实现

注意:以下凡是allocator表示标准库中的,其余的(alloc,_pool_alloc)表示为扩展的或编制外的

1.VC6 allocator (Visual C++)

image-20220823145723862

  • 我们来看VC6 中的allocator设计

    首先看allocate()函数,调用了_Allocate函数,接着后者又调用了operator new(),我们知道operator new()是调用了malloc()函数。

    接下来看deallocate()函数,直接调用了operator delete()函数,而 operator delete()函数是调用了free()函数

所以如PPT所说,VC6的allocator没有任何特殊的设计

  • 再来看,右侧的模板容器,发现所有的模板容器的第二个参数默认为allocator,即都是使用allocator来分配内存的,并且是以传入进来的对象为单位,例如,右下角是分配512个int

2.BC5 allocator (Borland C++)

image-20220823150911840

如图,BC5 的allocator实现与 VC6 一样,没有任何特殊的设计,只是间接的调用malloc()和free()

并且也是以传入进来的对象为单位,例如,右下角是分配512个int

3.G2.9 allocator (GNU C++)

image-20220823151218412

如图,G2.9 的allocator实现与 之前一样,没有任何特殊的设计,只是间接的调用malloc()和free()

并且注意:右侧红字,标准的allocator是不让使用的,既然不让使用,那么G2.9容器的allocator是如何实现的呢

观察下面图片,G2.9 的容器的分配器其实是std::alloc,而它是编制外的,即不是标准的;

image-20220823151503403

还记得第一讲中提到的 alloc::allocator()与__gnu_cxx::__pool_alloc< T >().allocate()么

image-20220823152115124

image-20220823154022717

对比 GUN 2.9 与 GNU 4.9,发现其实是换汤不换药,有些只是换了个名字

还记得我们的问题么,如何更好的设计,来避免cookie占用的内存

G4.9的__pool_alloc与 G2.9的alloc 就解决了这个问题,后续详细讨论

4.G4.9 allocator

image-20220823155207297

观察发现:GNU 4.9 的 allocator 继承自 _allocator_base,而后者为一个define,即是一个 new_allocator,而new_allocator 的allocator与deallocator分别调用的是 operator new 与 operator delete

即GNU 4.9的allocator也是没有任何特殊设计

image-20220823160612687

好,我们来看一下,G4.9 标准外的pool_alloc的使用(前面说到,它解决了我们之前的问题)

  • 我们直接看右上部分的标准外的测试代码:

​ 第一行 ,cout显示大小,按理说应该是0,但是由于一些原因,显示为1

​ 第二行,编译通过证明没有问题

​ 第三行,将该分配器传入cookie_test,并使用它来分配内存,查看打印效果

注意:打印的第一行是连续的空间,可以说明一些问题

由于传入分配器的类型为double,占用8个字节,如果没有cookie,则间隔应该为8,而第一行确实如 此,且是连续的内存空间,便证明它解决了cookie的内存占用问题。

而打印的第二行,看起来间隔很大,因为它有可能不是连续的内存空间,不能说明问题

  • 然后对比看右下部分的标准的allocator测试代码:

    直接看第三行,分配器传入的同样是double,占用8个字节,输出结果是连续的且间隔为16字节,说明,它没有解决cookie的内存占用问题

三、G2.9 std::alloc

前面提到,它的实现与G4.9 pool_alloc是一样的,只是一些换了变量名字

并且解决了cookie的内存占用问题,所以我们来分析G2.9的分配器实现

1.分配器行为

image-20220823163102989

嚯,看起来很复杂,别急,我们一点点来理解,此处尿素过多,请仔细理解

还记得我们第一讲实现的allocator么,为class写了专属的分配器,并且该分配器中只使用一条链表来管理内存。

std::alloc是使用16条链表来分别管理不同大小的内存请求。即free_list[16]

图中 #0~#15,这16条链表分别管理8,16,24,32,……,128字节的内存,

如果分配对象的大小不为8的整数倍,则会上调为8的倍数,然后进行管理

如果分配对象的内存大小大于此范围,则调用malloc()为其分配,而alloc无法进行管理

假设现在,有一个vector,里面存放的是10个32字节的对象,那么对于alloc则会交给 #3 这个管理32字节的链表去管理,与之前一样,它也是来挖一大块内存(调用allocate()函数)去切割(还记得第一讲的版本3的static allocator,标准库是挖了20块这样的内存),而实际上是挖了 20 * 2块内存(后面详细说,此处先理解为20块),每一块都是32字节,它们之间使用链表连接。

然后现在,我们又有一个容器,里面存放的是64字节对象,那么对于alloc则会交给 #7 这个管理64字节的链表去管理,与之前不同的是,前面我们说了,第一次分配的是20 * 2块内存,而alloc会将它分为两大块(这就是为什么是说 * 2,而不是直接说40),前半块用来存储,后半块用做战备池 ,而它发现后半块没有被使用,所以原本是32 * 20 字节的内存被重新划分为64 * 10来存储。

然后现在,又有一个容器,里面存放的是96字节对象,对于alloc则会交给 #11 这个管理96字节的链表去管理,并且会在之前分配好的战备池中去寻找是否有空闲的,如果有则使用,如果没有则再次挖去一大块(20 * 2块)96字节的内存。

而如果,我们需要存放的是256字节对象,大于alloc所负责的区域,则调用malloc()

在回收的时候,则会将内存回收到对应的链表中

还有一些细节,后面会提到

image-20220823180015197

接下来谈一谈 embedded pointer,来看看官方是怎样定义的

Embedded pointers are pointers that are embedded in data structures such as arrays, structures, and unions.

它的工作原理,我的理解是,借用对象的低字节去存储指针(网上查到的都是前4/8个字节去存储指针,个人感觉应该用低字节更准确一些,拙见)

注意:如果要使用嵌入式指针,需要对象的内存大于等于指针的内存大小

2.std::alloc过程理解

image-20220824212818242

  • 申请32 bytes

image-20220823190243017

内存分配过程:

其实,在整个系统中,总是先把分配到的内存放到战备池pool中,然后再挖适当的内存给链表管理,

申请32bytes,由于一开始战备池pool为空,所以申请并放入32 * 20 * 2 + RoundUp(0>>4) = 1280bytes ,从中切出一块给用户,19块给#3管理,

此时,累计申请量:1280bytes

​ 战备池pool剩余:1280 - 32 * 20 = 640bytes 备用。

  • 指针start_free与指针end_free之间的内存为战备池pool

  • RoundUp() 是个函数,返回一个追加量,会将累计申请量右移4位然后上调到8的倍数,一开始是0

  • 申请64bytes

image-20220823192142877

接上一部分,再次申请64bytes,由于战备池pool有剩余,将其切割分为640/64 = 10块,第一块分给客户,剩余9块由#7管理

此时,累计申请量:1280bytes

​ 战备池pool剩余:640 - 64 * 10 = 0 bytes

注意:它有一个设计就是从战备池pool切出的内存块数永远在1-20块之间

  • 申请96bytes

image-20220823193132235

接上一部分,再次申请96bytes,此时战备池pool为空,故再次向战备池pool中分配96 * 20 * 2 + RoundUp(1280 >> 4) = 3920bytes内存,从中切出一块给用户,19块给#11管理

此时,累计申请量:5200bytes

​ 战备池pool:3920 - 96 * 20 = 2000bytes

  • 申请88bytes

image-20220823202533298

接上一部分,再次申请88bytes,此时战备池pool有剩余,取战备池划分为20块,从中切出一块给用户,19块给#10管理。

此时,累计申请量:5200bytes

​ 战备池pool:2000 - 88 * 20 = 240bytes

  • 多次申请88bytes

image-20220823205705754

接上一部分,连续3次申请88bytes,直接由链表#10取出给客户

此时,累计申请量:5200bytes

​ 战备池pool:240bytes

  • 申请8bytes

image-20220823205910907

接上一部分,申请8bytes,由于战备池pool有剩余,故取战备池pool划分20块,从中切出一块给用户,19块给#0管理。

此时,累计申请量:5200bytes

​ 战备池pool:240 - 8 * 20 = 80bytes

  • 申请104bytes

image-20220823210303313

接上一部分,申请104bytes,链表#12没有内存,战备池pool又不足供1块,于是先将战备池pool的内存划分给对应的链表#9管理,这就是碎片管理,然后再次向战备池pool中分配内存104 * 20 *2 + RoundUp(5200 >> 4) = 4488bytes

此时,累计申请量:9688bytes

​ 战备池pool:4488 - 104 * 20 = 2408bytes

  • 申请112bytes

image-20220823212251869

接上一部分,申请112bytes,由于战备池pool有剩余,于是从中取20块,从中切出一块给用户,19块给#13管理。

此时,累计申请量:9688bytes

​ 战备池pool:2408 - 112 * 20 = 168bytes

  • 申请48bytes

image-20220823212738276

接上一部分,再次申请48bytes,由于战备池pool有剩余,于是从中取3块,从中切出一块给用户,2块给#5管理。

此时,累计申请量:9688bytes

​ 战备池pool:168 - 48 * 3 = 24bytes

  • 申请72bytes

image-20220823212951555

目的,观察当内存用到山穷水尽时,会发生什么(修改内存总量为10000bytes,已经分配了9688bytes)

接上一部分,再次申请72bytes,链表#8没有内存,战备池pool又不足供1块,于是先将战备池pool的内存划分给对应的链表#2管理(碎片管理),然后再次向战备池pool中分配内存72 * 20 *2 + RoundUp(9688 >> 4) = 3488bytes,此时系统内存不足,无法分配(但问题是,已经分配的内存中存在很多没有使用的),于是alloc从已经分配的内存中取最靠近72bytes的内存,此处为80bytes(#9管理),填回战备池pool,在从中切出1块72bytes给用户。

此时,累计申请量:9688bytes

​ 战备池pool:80 - 72 = 8bytes

  • 再次申请72bytes

image-20220823215029561

目的,观察当内存用到山穷水尽时,会发生什么(修改内存总量为10000bytes,已经分配了9688bytes)

接上一部分,再次申请72bytes,链表#8没有内存,战备池pool又不足供1块,于是先将战备池pool的内存划分给对应的链表#0管理(碎片管理),然后再次向战备池pool中分配内存72 * 20 *2 + RoundUp(9688 >> 4) = 3488bytes,此时系统内存不足,无法分配(但问题是,已经分配的内存中存在很多没有使用的),于是alloc从已经分配的内存中取最靠近72bytes的内存,此处为88bytes(#10管理),填回战备池pool,在从中切出1块72bytes给用户。

此时,累计申请量:9688bytes

​ 战备池pool:88 - 72 = 16bytes

  • 申请120bytes

image-20220823215624963

目的,观察当内存用到山穷水尽时,会发生什么(修改内存总量为10000bytes,已经分配了9688bytes)

接上一部分,再次申请120bytes,链表#14没有内存,战备池pool又不足供1块,于是先将战备池pool的内存划分给对应的链表#1管理(碎片管理),然后再次向战备池pool中分配内存120 * 20 *2 + RoundUp(9688 >> 4) = 5408bytes,此时系统内存不足,无法分配,于是alloc从已经分配的内存中取最靠近120bytes的内存,发现没有,真正的是山穷水尽,但是还是有问题已经分配的内存中存在很多没有使用的

此时,累计申请量:9688bytes

​ 战备池pool:0

image-20220823220137004

延续之前的问题:已经分配的内存中存在很多没有使用的

方法1:将小块内存合并

方法2:战备池pool还剩余312,将内存申请不断折半,直到能够满足为止。(这个方法,对于不同的理解角度有不同的看法,后续总结整理提到)

但是,GNU没有做任何动作

好,现在内存分配的整个过程我们理解了,接下来看看源代码是如何实现的。

四、std::alloc源码剖析

首先,G2.9 的std::alloc 分为两级

第一级,是用作模拟new handler 的(G4.9 就抛弃了这一设计),了解即可

第二级,是我们前面的过程理解(重点)

1.std::alloc 第一级分配器(了解)

image-20220824212858363

image-20220824212912128

image-20220824212926350

2.std::alloc 第二级分配器(重点)

image-20220824212940193

分析常量, __ALIGN 小内存块的上调边界

​ __MAX_BYTES 小内存块的上限

​ __NFREELISTS alloc的free_list 个数

观察类名,__default_alloc_template不是alloc,其实alloc是个typedef,这个后面会看到

函数RoundUp(),作用是上调到__ALIGN的倍数,记得我们前面使用它来参与计算分配内存大小

然后有一个union(也可以是struct)实现了embedded pointer

链表数组free_list ,用来管理不同字节内存的链表数组

函数FREELIST_INDEX(),对传入的申请的内存分配给对应的链表管理,例如 8字节 #0 管理

函数chunk_alloc(),分配一大块内存

函数refill(),填充free_list数组中链表元素,例如,当申请8字节内存,并且#0链表为空的时候,调用该方法

start_free指针,end_free指针,分别指向战备池的头尾,两者之间的内存为战备池pool

heap_size,累计分配量,记得前面是用来参与计算下一次内存分配

image-20220824212958353

来看deallocate(),其中有两个问题:

1.没有调用free(),也就是没有将释放的内存归还给操作系统,而是由链表管理。(这个第四讲会解决)

2.没有判断传进来的指针p是否由alloc分配,因为alloc分配的内存为8的倍数,而如果是调用malloc()分配的内存传入,则会造成灾难。

下面来看refill()实现,

image-20220824213019998

由前面所说,当申请分配内存并且管理该内存的链表为空时,调用该方法。

我们现在来分析内部的实现,

​ 首先定义了一个int,nobjs表示预设分配的内存块数;

​ 然后调用chunk_alloc(),挖取指定的内存;

​ 后面,开始判断,如果分配的内存块为1,直接发送给用户;

​ 否则,直接将第一块发送给用户,然后从第2块开始,将内存块用链表串联起来,再返回给对应指针管理。

下面来看chunk_alloc()的实现,

image-20220824213046801

分析,

首先计算需要内存的总字节,接着通过指针相减计算战备池pool中的内存大小,

然后比较大小,按情况分配内存

如果,大于需要的内存,很好,直接交给它

否则如果小于,并且大于单块内存,则计算最多能够分配多少块内存,然后交给它

再否则,也就是小于单块内存,也就是需要碎片管理,再次计算需要的总内存字节,判断并由对应指针管理

image-20220824213102201

此时还没结束,还记得前面的过程理解么,碎片管理后,又重新分配内存,而之前再次计算的总内存字节,就是用来分配的字节,如果成功,则加入战备池pool中,再调用自己重复上述操作。如果不成功,说明内存已经山穷水尽,被用完了,如前面所说一样,还有许多已经被分配的内存但还没有被使用,于是从已经分配的内存中查找能够满足所需内存的最近位置,拿出一块放入战备池pool,然后再次递归调用。如果没有查找到,则真的没有内存可用,调用第一级new handler去处理。

至此,内存分配的过程就结束了。就如

image-20220824213146413

如前面所说,alloc就是一个typedef

五、总结整理

按侯捷老师所说,加上这个整理后,任通二脉全部打通

image-20220824213201651

G2.9 下,

首先,声明了一个list,里面存放Foo对象,(注G2.9下,容器默认使用alloc分配器),并且分配成功,然后copy一份并push_back到链表中,前面在stack中申请的Foo临时对象就被销毁,链表中的内存没有cookie

下面继续,在heap中new 了一个对象,该对象内存带有cookie,将它copy并push到list中,然后delete,此时heap中没有了该对象,并且list中的copy对象也没有带cookie

这样就解决了我们之前心心念念的cookie内存占用问题。

下面我们来看一看,实现代码中的一些细节,

image-20220824213321511

先来看 1,观察条件表达式的写法,与我们的习惯正好相反,为的是避免 当使用 “==” 时,而误用 “=” 造成的bug,并且难以发现,这是一种好的习惯,值得学习

再来看 2,# 136 可能还可以看懂,但是 #210,可能就蒙了,其结果正如右边所示,有多种理解方式,但只有一个正确,可读性低,不推荐。

再来看 3,注意观察行号,发现在定义和第一次使用之间穿插了许多代码,不好,其中effective c++有个原则是Postpone variable definitions as long as possible(尽可能延后变量定义的出现时间),毕竟如果他们之间如果有异常抛出,则会浪费无用的时间和空间,并且降低了可维护性,不好。

再来看 4,真是“懵逼树上懵逼果,懵逼树下你和我”,它其实就是定义了一个函数指针,然后另一个函数是以该函数指针为参数和返回值,它是在第一级分配器中实现的,模仿了new_handler的实现,这样的写法没有任何好处,不好。

再来看三行注释,可以根据行号查找到在源代码中的位置,发现它是在内存山穷水尽的时候出现的,我们之前说过,当时还有很多已经分配的内存并没有使用,并给出了2个解决方法,现在看方法2,在来理解这段注释,它说,

“尝试做一些我们能够做的事情,它不会造成伤害;”

“但是我们不会去尝试更小的内存请求,因为那会在多线程机器中造成灾难;”

这与我们的方法正好相反,这是一个理解问题,

对于内存自己来说,你要,我就尽可能的给你,感觉这样设计很不错;

但对于其他程序来说,内存全部都被你用光了,那我们怎么办呢,所以说是一个大灾难。

还有一个就是deallocator()没有调用free()去将内存归还给操作系统,(第四讲,会去解决这个问题)

六、测试

注意一个地方,前面我们说到,G4.9与G2.9alloc的实现的几乎一样的,但是还是有不一样的地方,就是operator new() 和malloc()(可以返回前面去观察),我们知道,operator new 是可以被重载的,而malloc是不可以被重载的,所以在G4.9中,我们可以重载operator new来观察一些我们想要的。

因此此处是在G4.9下重载operator new测试的,

image-20220824213413256

countNew 为内存的累计分配总量

timesNew 为累计分配次数

image-20220824213430373

还有一个需要注意的是,G4.9下容器的分配器默认使用的是标准库的allocator,没有解决cookie问题

另一个 非标准的__gnu_cxx::__pool_alloc才解决了这个问题,不确定的可以回到前面再去看一眼G4.9容器的设计。

右边的是测试使用默认分配器,

左边是测试显示使用__gun_cxx::__pool_alloc分配器,

观察注解,对比timesNew,发现左边分配的次数远远小于右边,左边浪费了122 * 8个cookie内存,而右边浪费了1000000 * 8 个cookie内存,差距巨大,达到了我们的效果

我们再来计算countNew,右边是16000000,左边大于右边,

先来看右边内存累加量的计算,int占4个字节,cookie占用4 + 4 = 8个字节,然后填充pad 4个字节,总共16字节,添加1000000个,因此是16000000。

而左边没有cookie,但是还记得我们之前说过,它每次分配的内存会越来越多,是为了方便进行管理,这样的设计是好的

七、G2.9std::alloc 移植至C

image-20220824213631535
最后:
欢迎指正不足或错误的地方。如果文章对你有所帮助,欢迎点赞支持。欢迎转载。


网站公告

今日签到

点亮在社区的每一天
去签到