作为一名程序员,对于堆、栈,不一定有多深入的了解,但是,基本的一些知识还是属于必备的。这里我把堆和栈中间加了一个顿号,因为我很不喜欢程序员把这两个词放在一起,因为这是两个不同的东西。
堆(heap)堆是一个内存空间,这个内存控件可以由程序员分配和释放,当然部分语言自带 GC( Garbage Collection 垃圾回收),部分堆内存可以由 GC 回收。这里千万要注意,这里说的堆和 数据结构里面说的堆不是同一个概念,大家千万不要混淆。
堆是程序在运行的时候请求操作系统分配给自己内存。由于从操作系统管理的内存分配,所以在分配和销毁时都要占用时间,因此用堆的效率相对栈来说略低。但是堆的优点在于,编译器不必知道要从堆里分配多少内存空间,也不必知道存储的数据要在堆里停留多长的时间,因此用堆保存数据时会得到更大的灵活性。因此,为达到这种灵活性,在堆里分配存储空间时会花掉相对更长的时间,这也是效率低于栈的原因。
栈(stack)栈是由编译器自动分配和释放的,存放函数的参数值,局部变量的值等。也请注意,这里说的栈 不是数据结构中的栈,大家千万不要混淆。这里请注意,栈是由由系统自动分配。
栈的优势是,存取速度比堆要快,仅次于寄存器,栈数据可以共享。但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。
实际应用我想,看完上面一段文字,大多数同学会觉得这和课本上讲的内容差不多。懂的自然就懂了,而不懂的,还是一脸懵。这些东西我知道了有什么实际意义?比如栈既然是系统分配和释放,我干预不了,我知道它干什么?好,那么我就以现在非常流行的编程语言 C#(C Sharp)来举例说明。
首先,先解释几点内容:
- 为什么选择 C# 语言进行说明
首先声明,存在即合理。现有的大部分的编程语言,都有其优势和劣势,要看其使用的方向、成本等等多方面因素。C# 目前开源了其 .NET Framework ,大家完全可以看其源码。今天既然讲到了堆、栈,算是相对底层一些的内容,大部分的语言其实这部分内容都是类似的。由于我经常用 C# 进行开发,因此讲起来相对比较熟练。
- 我不会 C# 下面内容是否可以看
其实下面虽然是 C# 讲解的,但是并不是在讲 C# 的堆、栈,而是通过下面的讲解,来让一些对这部分内容不是很清楚的同学可以了解到,学习堆、栈有什么用。我基本上不会贴太多的 C# 代码,所以大家可以放轻松看下去。
好了,废话不多说了,我们开始进入正题。
我们都知道,C# 的数据类型有两大类型 —— 值类型 和 引用类型。大致结构如下图:
值类型我们看到包含内置值类型、用户定义的值类型和枚举。枚举就不用说了,内置值类型指的是 int、float、bool、double 等等;用户定义的值类型指的就是 struct(这里需要说明一下的是,C# 里 struct 是值类型,而部分语言是引用类型,这里就不多做说明)。引用类型大概我们学 C 语言的时候学到的指针类型(C# 做了封装,所以我们看不到 int * 这类的代码,当然可以使用 unsafe 关键字,这里就不多赘述)、接口类型、用户自定义的类(class),数组等。
这里为什么要解释这两个大的类型?首先是因为值类型是放在栈上的(某些特殊情况其也可以在堆上的)。怎么讲?值类型变量声明后,不管是否已经赋值,编译器为其分配内存。然而引用类型当声明一个变量时,只在栈中分配一小片内存用于容纳一个地址,而此时并没有为其分配堆上的内存空间。当使用 new 创建一个类的实例时,这时候会分配堆上的空间,并把堆上空间的地址保存到栈上分配的小片空间中。
说通俗一点,堆上存放的是我们声明出来的实例对象,当我们需要访问这个实例对象的时候,我们找到它的方法是先找到变量对应在栈上的内存,然后通过栈上存放的数据,我们才能找到其真正的实例在堆的什么位置。
好,说了这么多,貌似还是对了解堆、栈有什么实际意义不是很清楚。我们继续往下看,先看一段代码。
我们发现,m 和 n 的值是分别独立的,而 a 和 b 修改其中一个会修改两个值。这是因为 m 和 n 都是分配在栈上的,而 a 和 b 虽然也是在栈上,但是其只是存储的堆上的一个地址索引,我们不管通过 a 还是 b 索引到的堆上的内存都是同一份。
我们再来看值类型和引用类型,在栈上的数据访问快,在堆上的数据访问相对慢,因此,当我们开发过程中,通过需求,比如底层的一些不变的数据,完全可以有 struct 来实现,因为其不会为 null,符合值类型的要求,而且我们经常访问,会更快一些。
我们看上图发现,值类型和引用类型都是继承自 Object ,也就是说:
这两个都是合法的语句。但是这里面隐藏了一个非常常见的一个现象,那就是装箱和拆箱。什么是装箱什么是拆箱?
装箱实际上是将值类型转换为引用类型,而拆箱是将引用类型转换为值类型。再说的透彻点,实际上装箱拆箱就是栈内存和堆内存的来回拷贝和赋值,这不仅仅浪费性能,而且还会造成没必要的 GC。因此,能避免 “装箱”、“拆箱” 操作的就尽量避免。
除了上面说的这些数据类型,还有一个写代码必不可少的,那就是函数。函数调用的时候是怎么调用的呢?实际上函数调用的参数是通过栈空间来传递的,在调用过程中会占用线程的栈资源。
当我们使用递归算法的时候,每次虽然调用的是同一个函数,但是会在栈中占用一个空间,只有走到最后的结束点后函数才能依次退出,而未到达最后的结束点之前,占用的栈空间一直没有释放。因此如果递归调用次数过多,就可能导致占用的栈资源超过线程的最大值,从而导致栈溢出,这也是为什么我们一定要尽量少用递归的原因(支持尾递归的编程语言尾递归是没这个问题的,这里不多赘述,有兴趣的同学可以自己查看一下这部分内容)。
总结上面说了好多,好像也没说什么堆、栈的应用,但我们回过头来再看,了解了堆、栈的知识和特点(优缺点上面其实都提到了,这里就不总结了),我们会更加了解我们写的代码都 “干了什么”,因此才有可能写出更高效、更可靠的代码。对于一名程序员来说,不管未来(至少近些年还是这样的)编程语言发展的多么容易上手,一定不要忘了学习计算机的基础知识,哪怕是先学会了编程再返回来补习这些知识。祝大家工作、生活愉快!
相关 Explore 专题推荐:队列 & 栈,