【C++】如何用C++创建对象,理解作用域、堆栈、内存分配

九、如何用C++创建对象,理解作用域、堆栈、内存分配

C++是对内存管控最强的一门编程语言。

当我们写完一个类,如果这个类不是完全静态的(我们现在不讨论这种情况),那接下来就该实例化这个类来创建对象了。此时就面临实例化的对象存放在内存哪里的问题。
即使一个没有类成员的类,就是啥也没有的类,你实例化它创建的对象也是至少要占用1个字节的内存的。正常的情况下,一个类中是有很多类成员的,所以我们需要内存存放类实例的这一堆变量的值。

一般情况,应用程序会将内存分为堆和栈两个主要区域(当然还有其他区域,比如源代码区,都是二进制的机器代码,相关内容可参考 【C++】深度理解C++数据类型:常量、变量、数组、字符串、指针、函数_c++ 字符串常量-CSDN博客 文中开头部分。本部分主要讨论如何用C++在堆和栈上创建对象。

1、在栈上创建类实例的代码书写

2、在堆上创建类实例的代码书写

一是,由于在栈上创建类实例的情况没有列举完,还有构造函数重载情况,所以上图又罗列了一个列子。

二是,在堆上创建类实例其实就是对关键字new和delete的使用,所以建议先看 【C++】C++中的关键字:const、mutable、auto、new、explicit、this....-CSDN博客 中的关键字new、delete,然后再理解本篇。

三是,其实在堆上创建类实例也不止是用new、delete方法,还可以用智能指针、共享指针在堆上创建实例,但是这又会牵扯到很多智能指针的东西,所以相关内容我放到了 【C++】作用域指针、智能指针、共享指针、弱指针-CSDN博客 这篇博文中,感兴趣的可以看链接。

3、栈对象和堆对象的共同点
从上两张图可见,不管在栈上创建类实例还是在堆上创建类实例,都自动默认的调用了构造函数。即使没有构造函数的类,其实底层也会调用元类的构造函数,只是我们无感而已。而关于构造函数的重载、隐式转换、参数列表等内容参考我前面写的文章 【C++】lambda表达式、类成员初始化列表、三元运算符、运算符及其重载....-CSDN博客 ,里面有对应的小标题,这里点到,不展开详讲了。

从代码运行结果来看,上述创建对象的方式都非常简单顺利。但是栈对象和堆对象的区别在于二者的差异,下面我们开始聊差异。

4、栈对象和堆对象的区别
对象创建在堆上还是栈上,是有不同的功能差异的。这里我先把结论摆出来,后面再逐个细化分析。

第一个差异是:对象的自动生存期的差异。
栈对象有一个自动的生存期,这个生存期是由它声明的地方的作用域决定的。只要变量超出其作用域,变量所在的内存就被释放了。也就是当作用域结束时,栈会弹出作用域里的东西,栈上的任何东西都会被释放。而堆是不同的,一旦你在堆中创建一个对象,这个对象就会一直在那里,直到你做出释放delete它的决定。

第二个差异是:如果你的对象太大,或者对象太多,你最好也是把你的对象创建在堆上。因为栈上的空间有限,一般是1兆或2兆,这得看你的平台和编译器。而堆空间则是一块很大的内存区域,而且还可以动态增加

第三个差异是:如果你在堆上创建对象,那你一定得记得用完要delete,手动释放被分配的内存。如果你一直没有delete,那一直要等到应用程序结束退出,系统才会清除你new的对象,这样很容易造成内存泄露。当然如果你使用的是智能指针,那情况就另论,详情见智能指针章节。

第四个差异是:从性能角度看,在堆上创建对象要比栈上花费更长的时间。在栈上创建对象要自动化、简单化很多。本部分的理解可以参考文章后面的内存分配一起理解。

5、作用域可以是函数,也可以是if语句、for循环、while循环、也可以是个{}花括号等等。下面以函数和花括号为例,给大家展示一下作用域:

针对花括号的例子,我们再打断点看看内存的变化:

所以,如果你想让某个对象在其作用域之外还存在,你就不能把变量创建在栈上,你就得创建在堆上。上面例子就得改成下面的代码:

我们new的对象都是存储在堆上,此时这个对象的生存期就是永久的,直到你delete创建这个对象时的指针,这个对象才从堆内存中消失。如果你一直不delete,那就得等到应用程序结束,操纵系统才清除这个对象,这很容易造成内存泄露。也所以一定记得:有new就得有delete

当然智能指针也可以在堆上创建对象,其实是因为智能指针底层就是对new和delete再次包装的产物,所以本质上也是在堆上new的对象,然后delete的,只不过是把new和delete自动化了。当然智能指针还是有自己的一套底层逻辑,详情参考智能指针章节。

6、从物理层面看栈内存和堆内存
当一个应用程序启动后,操纵系统要做的就是,将整个程序加载到内存,并分配一大堆物理ram,以便我们的实际应用程序可以运行。所以当我们的程序开始的时候,它被分成了一堆不同的内存区域进行存放,除了栈和堆之外,还有很多东西,但本篇我们只讨论栈和堆。

栈和堆是ram中实际存在的两个区域。栈通常是一个预定义大小的内存区域,通常约为2兆字节左右。堆也是一个预定义了默认值的区域,但是堆可以生长,并随着应用程序的进行而改变。重要的是,要知道这两个内存区域的实际位置(物理位置)在我们的ram中是完全一样的。很多人倾向于认为栈可能是存储在CPU缓存中或类似的地方,其实它是活跃在缓存中,因为cpu要不断访问它嘛,但是不是所有的栈内存都会存储在缓存中。你只要记住,栈和堆这两个内存区域的实际位置都在内存中即可。不过也当然了,呵呵。

在我们的程序中,内存是用来实际存储数据的,因为我们需要一个地方来存储运行程序所需的数据,不管是局部变量还是从文件中读取的东西,我们都需要处理这些数据,所以我们需要一个地方存储这些数据,而栈和堆就是我们可以存储数据的地方。虽然栈和堆的工作原理非常非常不同,但是本质上它们做的事情都是一样的:存储数据

7、从栈、堆的工作原理看二者在内存分配上的差异
栈和堆工作原理的差异才是二者根本的区别。而栈和堆的本质都是存储数据,所以二者根本的差异就是:二者在分配内存上存在极大的差异。
比如,当我要存一个int数据,意思就是我要找一块连续4个字节的内存块来存储这个数据,因为int整数在大多数平台上都是4个字节。此时我的要求就叫做内存分配。当我们要求内存分配时,栈内存和堆内存帮我们找到这4个连续内存块的方式是极为不同的。

(1)下面我先展示一下程序员是如何在C++程序中要求分配内存的,我用整型、数组、类实例3种不同类型的数据为例来说明:

上图就是在代码层面,栈对象和堆对象的内存分配方式。其实还有使用智能指针在堆上创建对象,但是你要知道其实智能指针的底层也是调用new和delete。所以本质上和上图的new对象是一样的。此外还要强调的是,在栈上创建对象我们是不需要考虑删除的,因为一旦对象所在的作用域结束了,对象就湮灭了。但是如果你要是在堆上new的对象,那你必须得手动delete,否则这个对象就会一直存在,堆内存就一直不能释放。

(2)下面我在debug模式下,从第10行打个断点,打开内存视图窗口,看看程序运行过程中,栈内存和堆内存是如何工作的:
内存视图窗口中的cc cc,在debug模式下,意思是还没有初始化的字节。

上图是代码运行到第16行结束时,栈内存的变化情况。从上图可以看到:
对象a被存储在54-57号内存中,存的数值是十六进制的42。对象名a本身是不用存储的,编译器直接将a和地址54映射了。这样就是我们在栈内存存储了一个整型对象a,值是66。
数组对象s被存储在了78-8b这连续20个字节中。
类实例v被存储在了a8-b3这连续的12个字节中,存的值是十六进制的a(10)、b(11)、c(12)。

但是最重要的是:对象a、s、v是按顺序依次排列的、而且距离都很近。虽然a和s之间、s和v之间都有一些cc cc字节,因为这是我们在调试模式下运行的,它实际上只是在对象之间添加了一些安全守卫(safety guards),以确保这些对象不会出现溢出。
依次排列又很近其实是因为,当我们在栈中分配变量内存空间时,其实是栈顶部的栈指针移动的字节。当我们需要分配4个字节的栈内存,栈指针就向下移动4个字节。当我们需要分配20个字节存储数组时,栈指针就继续向下连续移动20个字节即可。最后的类实例需要12个字节,栈指针继续移动12个字节即可。就是这样内存是不断叠加存储的,这也叫栈的生长。有的系统的栈实现是从高地址到低地址这样倒着来的,就是反向生长;我这里是从低到高实现的。低地址存储第一个int对象,中间是数组对象,最高处是类实例对象。但是无论如何,栈的工作原理只是把数据堆在一起。这也是为什么我们经常说栈分配是非常快速的。因为它就是一条cpu指令,我们所作的就是移动栈指针,然后返回栈指针的地址,就这样就可以完成栈内存分配了。

当我们完成数据的栈内存分配后,对象想加就加、想减就减、anyway, 都在栈内完成。也所以,栈和作用域是息息相关。一旦一个作用域结束时,所有在栈内分配的、栈内修改的的数据就统统被弹出,也就是被释放,也就是消失了,湮灭了。因为栈移动到它原来的位置(在我们进入到这个作用域之前的位置)。

也所以栈内存是没有任何开销的。因为栈释放内存和分配内存一样,是不需要将指针反向移动然后返回栈指针地址的,因为作用域结束后,它弹出栈中的东西,就是栈指针一步步回到作用域开始位置的过程。一条cpu的删除指令就可以释放所有的东西。

(3)下面我们继续看看堆内存的变化情况:

上图是代码从第17运行到25行结束时,堆内存的变化情况。 从上图可以看出:
指针pa指向的内存是B0-B4这连续的4个字节,存储的是十六进制58; 指针ps指向的内存是70-83这连续的20个字节,存储的是12345,5个数字;指针pv指向的内存地址是A0-A11这12个连续的字节,存储的是十进制10、11、12三个数字。

堆内存的分配是我们调用new关键字实现的,而new的底层其实是调用了C中的malloc函数。malloc是memory allocate的缩写。malloc通常是调用更底层的操纵系统或平台的特定函数,才能在堆上帮我们分配内存。流程应该是:当你启动你的应用时,你会得到一定数量的物理ram分配给你,而且你的程序会维护一个叫做空闲列表(free list)的东西,这个空闲列表是跟踪哪些内存块是空闲的、以及它们的地址。当你需要动态内存的时候,也就是使用动态堆内存时,就得要malloc请求浏览空闲列表,然后找到一块大小符合你要求的空闲内存块,然后返回你一个指针,并且还得记录比如分配的大小、当前被分配的状态等一堆记录,以示这块内存不能再被使用了。也所以malloc其实是一个很大的函数,因为它需要做很多记录,不仅仅是找到一块内存那么简单。如果情况更加复杂,比如如果你想要更多的内存,多到超过了空闲列表,也就是超过了操纵系统给你的初始分配,这时你的应用程序就需要询问你的操纵系统,申请更多的内存。这种情况就更复杂了,也代表着潜在的成本是巨大的。这里说这么多,其实就是想说,在堆上分配内存是一堆的事情,而在栈上分配内存就像一条cpu指令。这就是二者内存分配上的重要区别。

也所以,从上面堆内存分配例子来看,我们完全看不出三个对象的内存之间有什么规律。

也所以有观点认为,就是因为栈上存储的变量之间挨得很近,所以可以把它们放到cpu缓存线上(Cache Line 可理解为CPU Cache中的最小缓存单位)。这也导致在栈中分配中,可能极少出现cache miss(cpu要访问的数据在cache中,称为hit,不在的称为miss). 也所以我们把像函数、局部变量等活跃的东西都放到栈上。 反观对于堆变量,可能就会出现一些cache miss,如果我们要处理数百万的cache miss就是一个大问题了。

也所以,其实栈内存分配和堆内存分配最大的差别是,分配快慢的问题。栈内存分配就是一条cpu指令,而堆内存分配则更麻烦,我们从汇编角度再来理解一下栈和堆的内存分配快慢差别:

上面截图是在debug模式下的汇编码,在release模式下,代码会简洁一点,但也不会减少太多。
汇编码显示我们在栈上创建一个int对象,仅仅就是一条mov指令;创建一个数组,实质也就是为数组的各个元素分配足够的空间;创建类实例对象时要复杂一点,要调用call类构造函数的同时也是分配了内存空间。然而在堆上创建int对象,调用了操作符new,new又调用了malloc,malloc要去空闲列表查找是否有足够的内存,然后得到内存的首地址,然后还得记录已经被拿走的内存的数量、状态,使用完毕还得delete释放这块内存,释放时还得和空闲列表打交道标记内存。所以堆内存上创建和删除对象的指令也非常多。

8、小结
上面说了这么多,其实就是想说,你应该尽量在栈上分配内存。而在堆上分配内存的唯一原因应该是你没法在栈上分配了,才去堆上分配。比如你需要一个生命周期比函数作用域更长的对象、或者你需要加载一个像50M这么大的数据等等,这种就不适合在栈上分配,你就不得不在堆上分配。只要你能在栈上分配就应该总是在栈上分配。因为它就仅仅是一条cpu指令,有非常真实的性能实惠。

也所以,性能的不同是因为分配的不同。所以从理论上讲,如果你预先分配了,比如一个4g大小的内存块,在你运行你的程序之前,在堆上分配,然后你要从预先分配的4g内存块中进行堆分配,那它们的开销就几乎是一样的了。此时你唯一可能要处理的就是cpu cache miss问题(缓存不命中),但这个问题相对于new查找、标记、释放这些流程来说,通常可以忽略不计。

但是接着说,如果你正在编写一个包含100万个元素的集合,然后每个元素都会cache miss,此时才是真正的性能差异,那就是另一个问题了:cpu缓存优化的问题了。

总之,这就是内存分配、以及如何最小化分配(的开销)的相关知识(内存管理技术),包含了大部分的栈和堆对比问题,再深就是如何在操纵系统层面上工作,水平有限就不继续了。而至于cpu缓存优化则是另外一个话题了,本篇不做讨论。

猜你喜欢

转载自blog.csdn.net/friday1203/article/details/141927885