内存分配方式:
c++中,内存分成五个区:
堆:new分配执行的内存块,一个new一个delete。
栈:执行函数,局部变量的存储单元在栈上创建,执行结束存储单元自动释放。
自由存储区:由malloc分配的内存块,和堆相似,用free结束自己的生命。
全局/静态存储区:全局变量和静态变量被分配到这一块内存中。
常量存储区:存放常量,不允许修改。
linux中虚拟内存管理:
关键概念:
1.每个进程都有独立的虚拟地址空间 ,进程访问的虚拟地址不是真的物理地址。
2.虚拟地址可以通过每个进程上的页表(也就是在每个进程的内核的虚拟空间)与物理地址进行映射,获得真正的物理地址。
3。如果虚拟地址对应的物理地址不在物理内存中,就会发生缺页中断,真正分配一个物理地址,同时还会更新进程的页表;如果物理内存耗尽了,就会根据内存替换算法,淘汰部分页面至物理磁盘中。
四种典型的页替换算法
1.LRU:算法根据数据的历史访问记录来进行淘汰数据,核心思想是:如果数据最近被访问过,那么将来被访问的几率也会更高。
LRU的实现:用一个双向链表保存缓存的数据,将新数据插入头部,当缓存命中时,命中的数据移动到链表的头部,链表满时,删除链表尾部的数据。
LRU的实现为什么用哈希辅助双向链表?
答:1.用队列:只能做到先进先出,如果重复使用中间的数据时,无法把数据移动到顶端。
2.用单链表:可以实现新的放在头部,不用的在尾部删除,但是删除的时候,因为单链表只有头指针,需要遍历到尾部。当用到重复使用过的数据时,还需要重新遍历整合链表,确定有没有用过,再遍历的相应的位置来剔除节点,重新放在头部。
3.单链表匹配哈希表呢?:hashmap可以在单位1的时间内判断value的值是否存在,key直接存储节点对象,直接定位删除对应的节点,并将此节点的前驱节点指向此节点的后继节点。而且单链表不能通过一个节点直接获得前驱节点,双链表可以直接定位到前驱节点,剔除尾节点也非常方便。
Redis的LRU实现:Redis随机取出若干个key,按照访问时间排序后,淘汰掉最不经常使用的。
2.OPT:最佳替换算法,替换下次访问据当前时间最长的页,但是OPT算法需要知道操作系统将来的事件,显示无法实现,所以他是作为衡量其他算法的标准。
3.FIFO:将页面看作一个缓冲区,按照循环方式替换,替换驻留在内存时间最长的页。(一部分程序或者数据在整个程序的生命周期中使用频率很高,会导致频繁反复的换入换出)如果内存有空闲位置,直接替换,否则想象先有一个指针,最开始指向第一个位置,没发生替换时,指针位置不变,每次需要替换时,替换指针所指的位置,然后将指针后移一位,指针如果位于最后,移动到内存第一个位置。
4.Clock:时钟替换算法,给每个页关联一个使用位,当该页第一次装入内存或者被重新访问时,将使用位置置为1,每次需要替换时,查找使用位被置为0的第一个帧进行替换,扫描过程中,如果碰到使用位为1的帧,使用位置为0,再继续扫描。如果所有帧的使用位都为0,则替换第一个帧。
Linux虚拟地址空间分布:
Linux使用虚拟地址空间,增加了进程的寻址空间,由低到高地址分别是:
1.只读段:空间只能读,不能写;包括:代码段,rodata段(C常量字符串和#define定义的常量)
2.数据段:保存全局变量和静态变量的空间。
3.堆:动态内存,malloc和new大部分来源于此,堆顶位置可以通过函数brk和sbrk进行动态调整。
4.文件映射区域:动态库、共享内存等映射物理空间的内存,一般是mmap函数所分配的虚拟地址空间。
5.栈:用于维护函数调用的上下文空间,一般是8M,通过ulimt -s查看。
6.内核虚拟空间:用户代码不可见的内存区域,由内核管理(页表存放在内核虚拟空间)。
32位有4G的地址空间和64位有4G的地址空间:
32: 0x08048000~0xbfffffff 是用户空间,0xc0000000~0xffffffff 是内核空间,包括内核代码和数据、与进程相关的数据结构。%esp 执行栈顶,往低地址方向变化;brk/sbrk 函数控制堆顶_edata往高0地址方向变化。
64:虚拟地址空间划分相较于32位发生了改变。
1.地址空间大小不是2^32也不是2^64,是2^48。因为2^64太大,过大空间导致资源浪费,48位表示虚拟地址空间,40位表示物理地址。(可通过#cat /proc/cpuinfo 来查看)
2.0x0000000000000000~0x00007fffffffffff 表示用户空间, 0xFFFF800000000000~ 0xFFFFFFFFFFFFFFFF 表示内核空间,共提供 256TB(2^48) 的寻址空间。
这两个区间的特点是,第 47 位与 48~63 位相同,若这些位为 0 表示用户空间,否则表示内核空间。
3.用户空间由低地址到高地址仍然是只读段、数据段、堆、文件映射区域、栈。
new和malloc的区别:
1.new是操作符,malloc是函数。
2.new需要调用构造函数,释放时需要调用析构函数;malloc不需要。
3.malloc需要指定调用内存的大小,返回的指针需要强转;new不需要给定内存大小,返回指针void*类型,不需要强转。
4.new可以重载,malloc不行。
5.new分配内存更直接安全。
6.new发生错误抛出异常,malloc返回null。
底层实现:
malloc底层实现:
从操作系统角度看:进程分配内存有两种方式,分别由两个系统调用完成brk和mmap(不考虑共享内存)。
1.brk是将数据段(.data)的最高地址指针_edata向高地址推。
2.mmap是在进程的虚拟地址空间中(堆栈中间,文件映射区域)找一块空闲的虚拟内存。
(都是分配虚拟内存,没有分配物理内存)
在标准c库中:malloc/free函数分配内存,由底层brk,mmap,munmap系统调用实现。
1.当malloc分配小于128k内存时,使用brk分配内存,将_edata往高地址推(只分配虚拟空间,不对应物理内存(因此没有初始化),第一次读/写数据时,引起内核缺页中断,内核才分配对应的物理内存,然后虚拟地址空间建立映射关系),如果malloc分配了这块内容,但从不访问,那么它对应的物理页不会被分配。
2.当malloc分配大于128k的内存时,使用mmap分配内存,在堆和栈之间找一块空闲内存分配(对应独立内存,而且初始化为0)。
3.原因:brk分配的内存需要等高地址内存释放以后才能释放,但是mmap可以单独释放。
4.当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。
5.malloc采用内存池的管理方式,来减少内存碎片,先申请大块内存作为堆区,然后将堆区分为多个内存块,当用户申请内存时,直接从堆区分配一块合适的空闲块,采用隐式链表将所有空闲块中的每一个记录一个未分配的、连续的内存地址。
new底层实现:
1.创建一个新的对象。
2.将构造函数的作用域赋值给这个新的对象。
3.执行构造函数中的代码(为对象添加新属性)。
4.返回新对象。
缺页中断:
查看进程发生缺页中断的次数:用ps -o majflt,minflt -C program
命令查看
majflt代表major fault,大错误;minflt代表minor fault,小错误。
两个数值表示一个进程自启动以来发生缺页中断的次数,都是累加值。(在对高性能要求的程序做压力测试时,可以多关注这两个值)
如果一个进程使用了mmap将很大的数据文件映射到进程的虚拟地址空间,我们重点关注majflt的值,相较于minflt,majflt对性能的损害是致命的,随机读取一次磁盘耗时数量级在几个毫秒,而minflt数量很大时才会对性能产生影响。
发生缺页中断后的操作:
发生缺页中断时,会陷入内核态:
1.查找访问的虚拟地址是否合法
2.查找/分配一个物理页
3.填充物理页内容(读取磁盘、直接置0、无操作)
4.建立映射关系(虚拟地址->物理地址)
(如果需要读取磁盘,那么就是majflt)
被测模块在内核态(CPU)高的原因:
- 每次请求来都
malloc
一块 2 M的内存,默认情况下,malloc
调用mmap
分配内存,请求结束的时候,调用munmap
释放内存。 - 假设每个请求需要 6 个物理页,那么每个请求就会产生 6 个缺页中断,在 2000 的压力下,每秒就产生了 10000 多次缺页中断,这些缺页中断不需要读取磁盘解决,所以叫做
minflt
; - 缺页中断在内核态执行,因此进程的内核态
cpu
消耗很大。 - 缺页中断分散在整个请求的处理过程中,所以表现为分配语句耗时( 10 us)相对于整条请求的处理时间( 1000 us)比重很小。
解决办法:
- 将动态内存改为静态分配,或者启动的时候,用
malloc
为每个线程分配,然后保存在threaddata
里面。但是,由于这个模块的特殊性,静态分配,或者启动时候分配都不可行。另外,Linux
下默认栈的大小限制是 10 M,如果在栈上分配几M的内存,有风险。 - 禁止
malloc
调用mmap
分配内存,禁止内存紧缩。 - 在进程启动时候,加入以下两行代码:
mallopt(M_MMAP_MAX, 0); // 禁止malloc调用mmap分配内存
mallopt(M_TRIM_THRESHOLD, -1); // 禁止内存紧缩
效果:加入这两行代码以后,用ps
命令观察,压力稳定以后,majlt
和minflt
都为 0 。进程的系统态cpu
从 20 降到 10 。
既然堆内内存brk
和sbrk
不能直接释放,为什么不全部使用 mmap
来分配,munmap
直接释放呢?
既然堆内碎片不能直接释放,导致疑似"内存泄露"问题,为什么malloc
不全部使用mmap
来实现呢(mmap
分配的内存可以会通过munmap
进行free
,实现真正释放)?而是仅仅对于大于 128 k 的大块内存才使用mmap
?
- 其实,进程向
OS
申请和释放地址空间的接口sbrk/mmap/munmap
都是系统调用,频繁调用系统调用都比较消耗系统资源的。 - 并且,
mmap
申请的内存被munmap
后,重新申请会产生更多的缺页中断。例如使用mmap
分配 1 M 空间,第一次调用产生了大量缺页中断 ( 1 M/ 4 K 次 ) ,当munmap
后再次分配 1 M 空间,会再次产生大量缺页中断缺页中断是内核行为,会导致内核态CPU
消耗较大。 - 另外,如果使用
mmap
分配小内存,会导致地址空间的分片更多,内核的管理负担更大。 - 同时堆是一个连续空间,并且堆内碎片由于没有归还
OS
,如果可重用碎片,再次访问该内存很可能不需产生任何系统调用和缺页中断,这将大大降低CPU
的消耗。 - 因此,
glibc
的malloc
实现中,充分考虑了sbrk
和mmap
行为上的差异及优缺点,默认分配大块内存 ( 128 k) 才使用mmap
获得地址空间,也可通过mallopt(M_MMAP_THRESHOLD, <SIZE>)
来修改这个临界值。