堆和栈:函数调用是如何影响到内存布局的?
在使用JavaScript的过程中,经常会遇到栈溢出的错误。
function foo() {
foo() // 是否存在堆栈溢出错误?
}
foo()
V8就会报告栈溢出的错误,为了解决栈溢出的问题,我们可以在foo函数内部使用setTimeout来触发foo函数的调用,改造之后的程序就可以正确执行。
function foo() {
setTimeout(foo, 0) // 是否存在堆栈溢出错误?
}
如果使用Promise来代替setTimeout,在Promise的then方法中调用foo函数,改造的代码如下:
function foo() {
return Promise.resolve().then(foo)
}
foo()
在浏览器中执行这段代码,并没有报告栈溢出的错误,但是你会发现,执行这段代码会让整个页面卡住了。
三段代码的底层执行逻辑是完全不同的:
第一段代码是在同一个任务中重复调用嵌套的foo函数;
第二段代码是使用setTimeout让foo函数在不同的任务中执行;
第三段代码是在同一个任务中执行foo函数,但是却不嵌套执行。
V8在执行这三种不同代码时,他们的内存布局是不同的,而不同的内存布局又会影响到代码的执行逻辑。接下来了解下JavaScript执行时的内存布局。
为什么使用栈结构来管理函数调用?
大多数高级语言使用栈这种结构管理函数调用,这是与函数的特性有关,通常有两个主要的特性:
- 函数可以被调用,一个函数可以调用另一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,执行控制权有返还给父函数。
- 函数具有作用域机制,函数可以定义函数内的变量和外部函数隔离。函数内部执行的变量称为临时变量。
int getZ()
{
return 4;
}
int add(int x, int y)
{
int z = getZ();
return x + y + z;
}
int main()
{
int x = 5;
int y = 6;
int ret = add(x, y);
}
观察上面这段代码:
- 当main函数调用add函数时,需要将代码执行控制权交给add函数;
- 然后add函数又调用了getZ函数,于是又将代码控制权转交给getZ函数;
- 接下来getZ函数执行完成,需要将控制权返回给add函数;
- 同样当add函数执行结束之后,需要将控制权返还给main函数;
然后main函数继续向下执行。
由上可以得出,函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束(先出)。
各个函数的生命周期如下所示:
因为函数是有作用域机制的,函数执行时,会分配函数内部的变量、上下文数据,函数执行完成后,内部资源数据会被释放掉。所以站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数(后进),而函数资源的释放则总是先于调用函数(先出)。
后进先出(LIFO)
关于栈,你可以结合这么一个贴切的例子来理解,一条单车道的单行线,一端被堵住了,而另一端入口处没有任何提示信息,堵住之后就只能后进去的车子先出来(后进先出),这时这个堵住的单行线就可以被看作是一个栈容器,车子开进单行线的操作叫做入栈,车子倒出去的操作叫做出栈。
在车流量较大的场景中,就会发生反复地入栈、栈满、出栈、空栈和再次入栈,一直循环。你可以参看下图:栈如何管理函数调用?
首先我们来分析最简单的场景:当执行一个函数的时候,栈怎么变化?
当一个函数被执行时,函数的参数、函数内部定义变量都会依次压入到栈中,我们结合实际的代码来分析下这个过程,你可以参考下图:
由上图的示例,可以发现,函数执行的过程中,其内部的临时变量会按照执行顺序被压入到栈中。
更复杂的场景:int add(num1,num2){ int x = num1; int y = num2; int ret = x + y; return ret; } int main() { int x = 5; int y = 6; x = 100; int z = add(x,y); return z; }
在x+y改造成了一个add函数,当执行到int z =add(x,y)时,当前栈的状态如下所示:
执行过程如下图:
执行到add函数,会先把add函数内的临时变量压栈。当add函数执行完成之后,需要将执行代码的控制权转交给main函数,这意味着需要将栈的状态恢复到main函数上次执行时的状态,这个过程称为恢复现场。
只要在寄存器中保存一个永远指向当前栈顶的指针,栈顶指针的作用就是告诉你应该往哪个位置添加新元素。只要更新了添加了新元素,就需要把新元素的地址更新到esp寄存器。
CPU是怎么知道要移动到这个地址呢?
CPU的解决方法是增加了另外一个ebp寄存器,用来保存当前函数的起始位置,我们把一个函数的起始位置也称为栈帧指针,ebp寄存器中保存的就是当前函数的栈帧指针,如下图所示:
当函数调用结束之后,就需要恢复main函数的执行现场了,首先取出ebp中的指针,写入esp中,然后从栈中取出之前保留的main的栈帧地址,将其写入ebp中,到了这里ebp和esp就都恢复了,可以继续执行main函数了。
栈帧:每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。
既然有了栈,为什么还要堆?
栈的优势:
- 栈的结构和非常适合函数调用过程。
在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。
缺点:
栈是连续的,所以要想在内存中分配一块连续的大空间是非常难的,因此栈空间是有限的。
堆:用来保存一些大数据。
和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,你可以在任何时候分配和释放它,为了更好地理解堆,我们看下面这段代码是怎么执行的:struct Point { int x; int y; }; int main() { int x = 5; int y = 6; int *z = new int; *z = 20; Point p; p.x = 100; p.y = 200; Point *pp = new Point(); pp->y = 400; pp->x = 500; delete z; delete pp; return 0; }
观察上面这段代码,你可以看到代码中有new int、new Point这种语句,当执行这些语句时,表示要在堆中分配一块数据,然后返回指针,通常返回的指针会被保存到栈中,下面我们来看看当main函数快执行结束时,堆和栈的状态,具体内容你可以参看下图:
观察上图,我们可以发现,当使用new时,我们会在堆中分配一块空间,在堆中分配空间之后,会返回分配后的地址,我们会把该地址保存在栈中,如上图中p和pp都是地址,它们保存在栈中,指向了在堆中分配的空间。
c语言需要我们手动调用free释放数据
c++通过使用delete来操作
不过JavaScript,Java使用了自动垃圾回收策略,可以实现垃圾自动回收,但是事情总有两面性,垃圾自动回收也会给我们带来一些性能问题。
此文章为5月Day17学习笔记,内容来源于极客时间《图解 Google V8》,日拱一卒,每天进步一点点💪💪
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。