内存剖析:从用户态到内核态内存都做了什么?

描述

编者按:本文顺着c++关键字new向下,旨在分析介绍底层各层到底做了什么,为什么这么做。

1.c++用户层

1.1提供的接口

1.1.1new

l 调用operator new 从自由存储区分配一块足够大的内存(sizeof(结构))

l 调用相应的构造函数

l 构造完成后返回指向该对象的指针

1.1.2delete

l 调用相应的析构函数

l 调用operator delete将内存归还给自由存储区

1.1.3new数组

l 调用operator new[] 从自由存储区分配一块足够大的内存(sizeof(结构)+用区分对象数组指针和对象指针以及对象数组大小的额外数据),注意简单对象(即不需要构造函数的类型)将不会有额外数据的申请。

l 依次在内存中调用相应的构造函数

l 构造完成后返回指向该对象数组的起始地址,不包括前面的额外数据部分。

1.1.4delete数组

l 获取数组起始地址前面的额外数据,计算出数组长度

l 根据数据长度依次调用相应的析构函数

l调用operator delete将内存归还给自由存储区

1.2operator new 的三种形式

形式1.void* operator new (std::size_t size)throw (std::bad_alloc);

形式2.void* operator new (std::size_t size,const std::nothrow_t& nothrow_value) throw();

形式3.void* operator new (std::size_t size,void* ptr) throw();

形式1跟形式2的区别仅仅是是否抛出异常,当分配失败时,前者会抛出bad_alloc异常,后者返回NULL,不会抛出异常。它们都分配一个固定大小的连续内存。

形式3又被称为placement new,它多接收一个ptr参数,并且只是简单地返回该ptr。调用形式为 A* a=new(ptr)A()。在内存池中有广泛应用,ptr即来自自由存储区,可以是堆、栈或者预分配的内存块。

上述形式1和形式2都可以被重载,遵循作用域覆盖原则,即在里向外寻找operator new的重载时,只要找到operator new()函数就不再向外查找,如果参数符合则通过,如果参数不符合则报错,而不管全局是否还有相匹配的函数原型。

注意在形式1中,如果new分配异常,将抛出异常导致后续代码不能被正常执行。即如果在new操作后有解锁操作,该解锁操作将不会执行导致死锁。

1.3设定内存分配失败入口函数

C++

1.4自由存储区和堆的区别

从技术上来说,堆是C语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当运行程序调用malloc()时就会从中分配,稍后调用free可把内存交还。而自由存储是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认使用堆来实现自由存储,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来被实现,这时藉由new运算符分配的对象,说它在堆上也对,说它在自由存储区上也正确。但程序员也可以通过重载操作符,改用其他内存来实现自由存储,例如全局变量做的对象池,这时自由存储区就区别于堆了。

我们只需要记住:堆是操作系统维护的一块内存,而自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价。这种区分大概是不同语言背景造成的。

1.5默认内存初始值

在vs2008(32bit)的debug模式下,由堆分配的内存初始值为0xcdcd,中文“屯”;由栈分配的内存初始值为0xcccc,中文“烫”。

1.6重载::operator new的理由

l 定位检查代码中内存错误

l 优化内存分配性能

l 获得内存使用统计数据

1.7重载::operator new的两种方式

方式1:不改变签名,替换系统现有版本

void* operator new(size_t size);

void operator delete(void* p);

使用方不需要包含任何特殊的头文件,也就是说不需要看见这两个函数声明。“性能优化”通常用这种方式。

方式2:增加新参数        

// 其返回的指针必须能被普通的 ::operator delete(void*) 释放

void* operator new(size_t size, const char* file, int line);

Foo* p = new (__FILE, __LINE__) Foo;

也可以用宏替换 new 来节省打字。此种方式使用方需要看到这两个函数声明,也就是说要主动包含提供的头文件。“检测内存错误”和“统计内存使用情况”通常会用这种方式重载。

1.8重载::operator new的困境

1.8.1绝不能在library中重载::operator new

如果以上文提到的方式1来重载全局的::operator new,非常具有侵略性。使用该library的程序被迫使用了被重载的::operator new,并且一旦有另外的library也同样重载了::operator new,就将会导致链接问题。

那么如果采用上文提到的方式2来额外提供一个::operator new 版本呢,那就需要考虑重载后的::operator new 返回的指针能否被系统默认的::operator delete释放。如果不兼容系统则需要以方式1重载::operator new ,回到了上文提过的问题。如果兼容,那么在新版本的::operator new中能做的事比较有限,比如不能额外申请内存记录统计信息,除非定义一个包含统计信息的基类来作为所有申请对象的父类,但这样就相当于设定了开发规范,稍有不注意可能就会出错。

1.8.2使用重载带新参数的版本会有什么影响

如果使用方式1重载::operator new 使用起来似乎没有什么问题,但要考虑上节中提到的链接问题。

如果使用方式2来重载::operator new,分成以下两种场合。

对于以头文件形式提供的library,可以在所有的cpp实现文件起始部分包含重载::operator new 的头文件,但这具有侵略性。

对于以头文件加二进制库提供的library,实际上带新参数的版本并不会被这些库使用。

1.9单独为特定类重载成员函数operator new怎么样

与全局 ::operator new() 不同,per-class operator new() 和 operator delete () 的影响面要小得多,它只影响本 class 及其派生类。似乎重载 member operator new() 是可行的。但是我并不赞同这种做法。

如果一个类需要重载成员函数operator new(),说明它用到了特殊的内存分配策略,常见的情况是使用了内存池或对象池。宁愿把这一事实明显地摆出来,而不是改变 new的默认行为。

这可以归结为最小惊讶原则:如果我们在代码里读到 Node* p = new Node,通常我们会认为它在堆上分配了内存,如果 Node 类重载了成员函数operator new(),那么就需要事先仔细阅读 node.h 才能发现其实这行代码使用了私有的内存池。为什么不写得明确一点呢?如果写成Node*p = Node::createNode(),那么我们可能能猜到 Node::createNode() 肯定做了什么与 new不一样的事情,免得将来大吃一惊。

1.10代替重载::operator new的方案

从glibc的malloc入手,替换掉malloc。具体方式参考tcmalloc中的override方式,点此链接[1]。

主要使用了gcc提供的alias别名属性和weak属性,我们能实现替换掉系统默认的malloc原因在于系统提供的malloc系列函数都是被weak属性修饰的。

对于全局函数,如果没有显示修饰称weak属性,那么他属于强符号;对于全局变量,已初始化完毕的属于强符号,没有初始化完毕的则属于弱符号。

有如下3点规则:

l 链接时强弱符号都存在时以强符号为准;

l 链接时如果只有弱符号时以弱符号为准;

l 链接时如两个都是弱符号,则以内存占用大小较大的那个符号为准;

2.glibc层

2.1概述

实际上glibc采用了一种批发和零售的方式来管理内存。glibc每次通过系统调用的方式申请一大块内存(虚拟内存),当进程申请内存时,glibc就从自己获得的内存中取出一块给进程。

glibc对于heap内存申请大于128k的内存申请,glibc采用mmap的方式向内核申请内存,也就是此时的malloc是由mmap来实现的,这不能保证内存地址向上增长;小于128k的则采用brk,malloc调用系统调用brk来实现向内核批发虚拟内存,对于它来讲是正确的。128k的阀值,可以通过glibc的库函数进行设置。

审核编辑:汤梓红

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分