C++浓缩学习笔记(2)-C++内存

 文章主要引用:

阿秀的学习笔记 (interviewguide.cn)

牛客网 - 找工作神器|笔试题库|面试经验|实习招聘内推,求职就业一站解决_牛客网 (nowcoder.com)

一、虚存

程序有哪些section,分别的作用?程序启动的过程?怎么判断数据分配在栈上还是堆上?

一个程序有哪些section:

如上图,从低地址到高地址,一个程序由代码段、数据段、BSS段、堆、共享区、栈等组成。

  1. 数据段:存放程序中已初始化的全局变量和静态变量的一块内存区域。

  2. 代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。

  3. BSS 段:存放程序中未初始化的全局变量和静态变量的一块内存区域。

  4. 可执行程序在运行时又会多出两个区域:堆区和栈区。

    堆区:动态申请内存用。堆从低地址向高地址增长。

    栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。

  5. 最后还有一个共享区(文件映射取),位于堆和栈之间。(OS:文件映射区,包括动态库和共享内存

  6. 代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。

内存模型,堆栈,常量区。

参考回答

内存模型(内存布局):

如上图,从低地址到高地址,一个程序由代码段、数据段、BSS段、堆、共享区、栈等组成。

  1. 数据段:存放程序中已初始化的全局变量和静态变量的一块内存区域。

  2. 代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。

  3. BSS 段:存放程序中未初始化的全局变量和静态变量的一块内存区域。

  4. 可执行程序在运行时又会多出两个区域:堆区和栈区。

    堆区:动态申请内存用。堆从低地址向高地址增长。

    栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。

  5. 最后还有一个共享区,位于堆和栈之间。

堆 heap :由new分配的内存块,其释放由程序员控制(一个new对应一个delete)

栈 stack :是那些编译器在需要时分配,在不需要时自动清除的存储区。存放局部变量、函数参数。

常量存储区 :存放常量,不允许修改。

程序启动的过程:

  1. 操作系统首先创建相应的进程并分配私有的进程空间,然后操作系统的加载器负责把可执行文件的数据段代码段映射到进程的虚拟内存空间中。

  2. 加载器读入可执行程序的导入符号表,根据这些符号表可以查找出该可执行程序的所有依赖的动态链接库

  3. 加载器针对该程序的每一个动态链接库调用LoadLibrary (1)查找对应的动态库文件,加载器为该动态链接库确定一个合适的基地址。 (2)加载器读取该动态链接库的导入符号表和导出符号表,比较应用程序要求的导入符号是否匹配该库的导出符号。 (3)针对该库的导入符号表,查找对应的依赖的动态链接库,如有跳转,则跳到3 (4)调用该动态链接库的初始化函数

  4. 初始化应用程序的全局变量,对于全局对象自动调用构造函数。

  5. 进入应用程序入口点函数开始执行。

怎么判断数据分配在栈上还是堆上:首先局部变量分配在栈上;而通过malloc和new申请的空间是在堆上。

初始化为0的全局变量在bss还是data

参考回答

BSS段通常是指用来存放程序中未初始化的或者初始化为0的全局变量和静态变量的一块内存区域。特点是可读写的,在程序执行之前BSS段会自动清0。

简述一下堆和栈的区别

参考回答

区别:

  1. 堆栈空间分配不同。栈由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等;堆一般由程序员分配释放。

  2. 堆栈缓存方式不同。栈使用的是一级缓存, 它们通常都是被调用时处于存储空间中,调用完毕立即释放;堆则是存放在二级缓存中,速度要慢些。

  3. 堆栈数据结构不同。堆类似数组结构;栈类似栈结构,先进后出。

  4. 产生碎片不同:对堆来说,频繁使用new/delete或者malloc/free会造成内存空间的不连续,产生大量碎片,是程序效率降低;对栈来说,不存在碎片问题,因为栈具有先进后出的特性;

  5. 申请大小限制不同:栈顶和栈底是预设好的,大小固定;堆是不连续的内存区域,其大小可以灵活调整;

malloc和局部变量分配在堆还是栈?

参考回答

​ malloc是在堆上分配内存,需要程序员自己回收内存;局部变量是在栈中分配内存,超过作用域就自动回收。

实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存

malloc 申请内存的时候,会有两种方式向操作系统申请堆内存

  • 方式一:通过 brk() 系统调用从堆分配内存,小于 128 KB,free释放后并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用堆内将产生越来越多不可用的碎片

  • 方式二:通过 mmap() 系统调用在文件映射区域分配内存,大于 128 KB,free释放后会把内存归还给操作系统,内存得到真正的释放。每次都要执行系统调用。

动态内存分配new/delete和malloc/free区别,为什么用delete[] ?

new与delete:
//动态分配了存放int型的内存空间,并将初值2存入,然后将首地址赋给指针point。
int* point = new int(2); 
int* point = new int();   //用0对该对象初始化;
int* point = new int;     //没有初始化
delete point;
​
int* p = new int[10]();   //动态创建一维数组,用0初始化;
delete[] p;               //删除动态数组时,delete后面要加上[];
  1. 本质区别:new/delete 在C++中是运算符不是函数,需要编译器支持。malloc/free是库函数,需要头文件支持;

  2. 返回类型:new操作符内存分配成功时,返回的是提供的对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。且new/delete可以重载,malloc则不可。

  3. malloc只能分配内存,new可以分配内存也可以构造对象;free只能释放内存,delete能够释放内存以及析构函数。对于非内部数据类型而言,Malloc/free无法满足动态对象的要求,因为对象在创建的同时要执行构造函数,对象在消亡的时候要执行析构函数。由于malloc/free是库函数,不在编译器的控制权限内,不能够自动实现构造函数和析构函数。

  4. 内存块太小导致malloc和new返回空指针,对于malloc来说,需要判断其是否返回空指针,如果是则马上用return语句终止该函数或者exit终止该程序;对于new来说,默认抛出异常,所以可以使用try...catch...代码块的方式;

delete 释放new分配的单个对象指针指向的内存;delete[] 释放new分配的对象数组指针指向的内存。

有析构函数的需要delete[]。

delete p;//仅释放了p指针指向的全部内存空间,但是只调用了p[0]对象的析构函数,剩下的从p[1]到p[9]这9个用户自行分配的对应内存空间将不能释放,从而造成内存泄漏;

delete []p;//调用使用类对象的析构函数释放自己分配内存空间,并且释放了p指针指向的全部内存空间(逐一释放数组中每个对象的内存)

最恰当的方式就是如果用了new,就用delete;如果用了new [],就用delete []

delete和free被调用后,内存不会立即回收,指针也不会指向空,delete或free仅仅是告诉操作系统,这一块内存被释放了,可以用作其他用途。但是由于没有重新对这块内存进行写操作,所以内存中的变量数值并没有发生变化,出现野指针的情况。因此,释放完内存后,应该讲该指针指向NULL。

补充:动态分配的内存单元,没有名称,只能通过地址访问。

静态内存分配和动态内存分配有什么区别?

  1. 静态内存分配是在编译时期完成的,不占用CPU资源;动态内存分配是在运行时期完成的,分配和释放需要占用CPU资源;

  2. 静态内存分配是在上分配的;动态内存分配是在上分配的;

  3. 动态内存分配需要指针或引用类型的支持,静态内存分配不需要;

  4. 静态内存分配是按计划分配的,在编译前确定内存块的大小;动态内存分配是按需分配的;

  5. 静态内存分配是把内存的控制权交给了编译器;动态内存分配是把内存的控制权给了程序员

  6. 静态内存分配的运行效率比动态内存分配高,动态内存分配不当可能造成内存泄漏。

如何让类对象只在栈或堆上分配空间?

在C++中,类的对象建立分为两种,

  1. 静态建立,如A a;

  2. 动态建立,如A* ptr = new A;

1、静态建立类对象:是由编译器为对象在栈空间中分配内存,是通过直接移动栈顶指针,挪出适当的空间,然后在这片内存空间上调用构造函数形成一个栈对象。使用这种方法,直接调用类的构造函数。

2、动态建立类对象:是使用new运算符将对象建立在堆空间中。这个过程分为两步,第一步是执行operator new()函数,在堆空间中搜索合适的内存并进行分配;第二步是调用构造函数构造对象,初始化这片内存空间。这种方法,间接调用类的构造函数。

如何限制类对象只能在堆或者栈上建立呢?

1、只能在堆上分配类对象,就是不能静态建立类对象,即不能直接调用类的构造函数。

如果将构造函数设为私有。在构造函数私有之后,无法在类外部调用构造函数来构造类对象,只能使用new运算符来建立对象。然而,前面已经说过,new运算符的执行过程分为两步,C++提供new运算符的重载,其实是只允许重载operator new()函数,而operator new()函数只用于分配内存,无法提供构造功能。因此,这种方法不可以。

当对象建立在栈上面时,是由编译器分配内存空间的,调用构造函数来构造栈对象。当对象使用完后,编译器会调用析构函数来释放栈对象所占的空间。编译器管理了对象的整个生命周期。如果编译器无法调用类的析构函数,情况会是怎样的呢?比如,类的析构函数是私有的,编译器无法调用析构函数来释放内存。所以,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性,其实不光是析构函数,只要是非静态的函数,编译器都会进行检查。如果类的析构函数是私有的,则编译器不会在栈空间上为类对象分配内存。因此,将析构函数设为私有,类对象就无法建立在栈上了。

例如:使用A a;来建立对象,编译报错,提示析构函数无法访问。这样就只能使用new操作符来建立对象,构造函数是公有的,可以直接调用。类中必须提供一个destory函数,来进行内存空间的释放。类对象使用完成后,必须调用destory函数。

缺点:

  1. 无法解决继承问题。如果A作为其它类的基类,则析构函数通常要设为virtual,然后在子类重写,以实现多态。因此析构函数不能设为private。还好C++提供了第三种访问控制,protected。将析构函数设为protected可以有效解决这个问题,类外无法访问protected成员,子类则可以访问。

  2. 类的使用很不方便,使用new建立对象,却使用destory函数释放对象,而不是使用delete。使用delete会报错,因为delete对象的指针,会调用对象的析构函数,而析构函数类外不可访问,这种使用方式比较怪异。

2.只能在栈上分配类对象

只有使用new运算符,对象才会建立在堆上,因此,只要禁用new运算符就可以实现类对象只能建立在栈上。虽然你不能影响new operator的能力(因为那是C++语言内建的),但是你可以利用一个事实:new operator 总是先调用 operator new,而后者我们是可以自行声明重写的。因此,将operator new()设为私有即可禁止对象被new在堆上。

二、内存管理

C++中的内存分区有哪些?

在C++中,内存主要分为以下五个区域:

图片

  • 栈区(Stack):由编译器自动分配释放,存放函数的参数值,局部变量等。其操作方式类似于数据结构中的栈。

  • 堆区(Heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意,与数据结构中的堆是两回事,分配方式倒是类似于链表。

  • 全局区(静态区)(Static):全局变量和静态变量被分配到同一块内存中。在C++中,全局区还包含了常量区,字符串常量和其他常量也是存储在此。

  • 常量区:是全局区的一部分,存放常量,不允许修改。

  • 代码区(Text):存放函数体的二进制代码。

内存分配方式

在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。

,就是那些由new分配的内存块,一般一个new就要对应一个delete。通常以页(page)为单位进行分配。

自由存储区,就是那些由malloc等分配的内存块,和堆是十分相似的,不过是用free来结束自己的生命。如果说堆是操作系统维护的一块内存,那么自由存储区就是C++中通过new和delete动态分配和释放对象的抽象概念。需要注意的是,自由存储区和堆比较像,但不等价;自由存储区的内存分配单位是字节(byte)。

全局/静态存储区,全局变量和静态变量被分配到同一块内存中,变量若没有初始化,则会被自动初始化,例如int型变量自动初始为0;

常量存储区,这是一块比较特殊的存储区,里面存放的是常量,不允许修改。

常见的内存错误及其对策

(1)内存分配未成功,却使用了它。

(2)内存分配虽然成功,但是尚未初始化就引用它。

(3)内存分配成功并且已经初始化,但操作越过了内存的边界。

(4)忘记了释放内存,造成内存泄露。

(5)释放了内存却继续使用它。

对策:

(1)定义指针时,先初始化为NULL。

(2)用malloc或new申请内存之后,应该用assert立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

(3)不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

(4)避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作

(5)动态内存的申请与释放必须配对,防止内存泄漏

(6)用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”

(7)使用智能指针。

内存泄露及解决办法

什么是内存泄露?

简单地说就是申请了一块内存空间,使用完毕后没有释放掉。(1)new和malloc申请资源使用后,没有用delete和free释放;(2)子类继承父类时,父类析构函数不是虚函数。(3)指针指向改变,未释放动态内存分配

怎么检测?

第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。

第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。

第三:使用智能指针。

第四:使用内存泄漏工具进行检测。VLD(Visual LeakDetector)内存泄露库。

三、原子顺序与内存对齐

请简述一下atomoic内存顺序。

原子操作:就是多线程程序中“最小的且不可并行化的”操作。

这就涉及到临界区的访问和互斥锁的管理,为了抽象问题引入,atomic类型,以达到对开发者掩盖互斥锁、临界区的目的。

C++11 对常见的原子操作进行了抽象,定义出统一的接口,并根据编译选项/环境产生平台相关的实现。新标准将原子操作定义为atomic模板类的成员函数,囊括了绝大多数典型的操作——读、写、比较、交换等。

强顺序的数据模型:处理器按照顺序执行代码,不能更改。

弱顺序的数据模型:指令顺序可以乱序执行。可以进一步挖掘指令中的并行性,提高指令执行的性能。

你一会想顺序执行,一会又想“乱序”执行,更有甚者,还想对“乱”的程度分等级……如何提供这种灵活性呢

在C++11标准中,设计者给出的解决方式是让程序员为原子操作指定所谓的内存顺序:memory_order。

有六个内存顺序选项可应用于对原子类型的操作:

  1. memory_order_relaxed:不对执行顺序做任何保证,想怎么乱就怎么乱吧在原子类型上的操作以自由序列执行,没有任何同步关系,仅对此操作要求原子性。

  2. memory_order_consume:本线程中,所有后续的有关本数据的操作,必须在本条原子操作完成之后执行。(本线程中,我只关心我自己,当我用memory_order_consume时,后面所有对我的读写操作都不能被提前执行……)memory_order_consume只会对其标识的对象保证该对象存储先行于那些需要加载该对象的操作。

  3. memory_order_acquire:本线程中,所有后续的读操作,必须在本条原子操作完成后执行。使用memory_order_acquire的原子操作,当前线程的读写操作都不能重排到此操作之前。

  4. memory_order_release:本线程中,所有之前的写操作完成后,才能执行本原子操作。使用memory_order_release的原子操作,当前线程的读写操作都不能重排到此操作之后。

  5. memory_order_acq_rel:同时包含以上两种。memory_order_acq_rel在此内存顺序的读-改-写操作既是获得加载又是释放操作。没有操作能够从此操作之后被重排到此操作之前,也没有操作能够从此操作之前被重排到此操作之后。

  6. memory_order_seq_cst:全部存取都按照顺序执行。memory_order_seq_cst比std::memory_order_acq_rel更为严格。memory_order_seq_cst不仅是一个"获取释放"内存顺序,它还会对所有拥有此标签的内存操作建立一个单独全序。

除非你为特定的操作指定一个顺序选项,否则内存顺序选项对于所有原子类型默认都是memory_order_seq_cst。

简述C++中内存对齐的使用场景

参考回答

内存对齐应用于三种数据类型中:struct/class/union

struct/class/union内存对齐原则有四个:

  1. 数据成员对齐规则:结构(struct)或联合(union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小的整数倍开始。

  2. 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部"最宽基本类型成员"的整数倍地址开始存储。(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储)。

  3. 收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的"最宽基本类型成员"的整数倍。不足的要补齐。(基本类型不包括struct/class/uinon)。

  4. sizeof(union),以结构里面size最大元素为union的size,因为在某一时刻,union只有一个成员真正存储于该地址。

答案解析

  1. 什么是内存对齐?

    那么什么是字节对齐?在C语言中,结构体是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体、联合体等)的数据单元。在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。

    为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。

    比如在32位cpu下,假设一个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

  2. 为什么要字节对齐?

    需要字节对齐的根本原因在于CPU访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的一个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。

    而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。

    各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。

  3. 字节对齐实例

    union example {    int a[5];    char b;    double c;   };   int result = sizeof(example);   /* 如果以最长20字节为准,内部double占8字节,这段内存的地址0x00000020并不是double的整数倍,只有当最小为0x00000024时可以满足整除double(8Byte)同时又可以容纳int a[5]的大小,所以正确的结果应该是result=24 */  struct example {    int a[5];    char b;    double c;   }test_struct; int result = sizeof(test_struct);   /* 如果我们不考虑字节对齐,那么内存地址0x0021不是double(8Byte)的整数倍,所以需要字节对齐,那么此时满足是double(8Byte)的整数倍的最小整数是0x0024,说明此时char b对齐int扩充了三个字节。所以最后的结果是result=32 */  struct example {    char b;    double c;    int a;   }test_struct;   int result = sizeof(test_struct);   /* 字节对齐除了内存起始地址要是数据类型的整数倍以外,还要满足一个条件,那就是占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍,所以20不是double(8Byte)的整数倍,我们还要扩充四个字节,最后的结果是result=24 */

介绍一下内存对齐

内存对齐就是就是将数据存放在内存的某个位置,使得CPU可以更快地访问到这个数据,以空间换时间的方式来提高 cpu 访问数据的性能。

在C++中,内存对齐主要涉及到两个概念:对齐边界和填充字节。

  • 对齐边界:一般情况下,编译器会自动地将数据存放在它的自然边界上。例如,int类型的数据,它的大小为4字节,编译器会将其存放在4的倍数的地址上。这就是所谓的对齐边界。

  • 填充字节:为了满足对齐边界的要求,编译器有时候需要在数据之间填充一些字节。这些字节没有实际的意义,只是为了满足内存对齐的要求(空间换时间)。

为什么要字节对齐?

  • 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

  • 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

猜你喜欢

转载自blog.csdn.net/shisniend/article/details/131908945