堆和栈:函数调用是如何影响到内存布局的?

在使用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执行时的内存布局。

为什么使用栈结构来管理函数调用?

大多数高级语言使用栈这种结构管理函数调用,这是与函数的特性有关,通常有两个主要的特性:

  1. 函数可以被调用,一个函数可以调用另一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,执行控制权有返还给父函数。
  2. 函数具有作用域机制,函数可以定义函数内的变量和外部函数隔离。函数内部执行的变量称为临时变量。
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);
}

观察上面这段代码:

  1. 当main函数调用add函数时,需要将代码执行控制权交给add函数;
  2. 然后add函数又调用了getZ函数,于是又将代码控制权转交给getZ函数;
  3. 接下来getZ函数执行完成,需要将控制权返回给add函数;
  4. 同样当add函数执行结束之后,需要将控制权返还给main函数;
  5. 然后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函数了。
栈帧:每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。

既然有了栈,为什么还要堆?

栈的优势:

  1. 栈的结构和非常适合函数调用过程。
  2. 在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。
    缺点:
    栈是连续的,所以要想在内存中分配一块连续的大空间是非常难的,因此栈空间是有限的。
    堆:用来保存一些大数据。
    和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,你可以在任何时候分配和释放它,为了更好地理解堆,我们看下面这段代码是怎么执行的:

    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》,日拱一卒,每天进步一点点💪💪

豪猪
4 声望4 粉丝

undefined