文章

V8引擎(十一)

V8

V8 引擎学习(十一)

V8引擎(十一)

前瞻

在前一章我们介绍了 V8 的基础环境,当中提到了堆空间和栈空间。栈空间是一块连续的栈结构空间,被调用的函数会依次放入栈空间中,但由于 V8 对栈空间大小进行了限制,于是又有了堆空间。堆空间是个树形结构,可以存储很多数据,因此被常用来存放对象类型的数据。在 JavaScript 中,函数也是对象,因此函数可以被放入堆空间中。那么既然被调用的函数既可以放入栈空间中,又可以放入堆空间中,不管栈空间还是堆空间都会消耗一定的内存,因此是不是可以提出”函数调用是如何影响到内存的”这个问题呢?

函数调用是如何影响内存的?

在使用 JavaScript 中,经常会遇到栈溢出的错误,如下面的代码:

function foo() {
  foo();
}
foo();

很显然,V8 就会报告栈溢出的错误,为了解决栈溢出的问题,可以在 foo 函数内部使用 setTimeout 来触发 foo 函数的调用。

function foo() {
  setTimeout(foo, 0);
}
foo();

这样改造后,就可以正确地执行了。

如果使用 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 = 5;
  int ret = add(x, y);
}

虽然上方代码中包含了多层函数嵌套调用,但过程其实很简单,以下是函数调用示意图:

image-20230303225123732

观察流程图,是否可以看出:函数调用者的生命周期总是长于被调用者,并且被调用者的生命周期总是先于调用者的生命周期结束。我们知道,被调用的函数会被放入到调用栈中,那么”函数调用者的生命周期总是长于被调用者”这句话是不是就是(后进)的意思?“被调用者的生命周期总是先于调用者的生命周期结束”这句话是不是就是(先出)的意思?因为是栈结构嘛,栈的特点不就是先进后出、后进先出吗?

注:可能上面先进后出有点抽象,但把视角放到”被调用者”就好理解了。是被调用者后进先出,这样就符合生命周期的说法了。

因为函数是有作用域机制的,作用域机制通常表现在函数执行时,会在内存中分配函数内部的变量、上下文等数据,在函数执行完成之后,这些存储的内部数据就会被销毁掉。所以站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数,而函数资源的释放则总是先于调用函数。

不论从生命周期的角度,还是函数的资源分配的角度,都能发现它们符合”后进先出”的策略,而栈结构正好满足这种后进先出的需求,所以选择栈来管理函数调用关系是一种很自然的选择。

栈如何管理函数调用?

当执行一个函数的时候,栈怎么变化?

当一个函数被执行时,函数的参数、函数内部定义的变量都会依次压入到栈中。

函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中。

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;
}

image-20230303234724481

当 add 函数执行完成之后,需要将执行代码的控制权转交给 main 函数,意味着需要将栈的状态恢复到 main 函数上次执行时的状态,这个过程叫做恢复现场。如何恢复到 main 函数的执行现场呢?

只要在寄存器中保存一个永远指向当前栈顶的指针,栈顶指针的作用就是告诉你应该往哪个位置添加新元素,这个指针通常存放在 esp 寄存器中。如果想往栈中添加一个元素,那么需要先根据 esp 寄存器找到当前栈顶的位置,然后在栈顶上方添加新元素,新元素添加后,还需要将新元素的地址更新到 esp 寄存器中。

image-20230304001719671

将 esp 的指针向下移动到之前 main 函数执行时的地方就可以了,不过 CPU 是如何知道要移动到这个地址的呢?

解决办法是增加了另外一个 ebp 寄存器,用来保存当前函数的起始位置,一个函数的起始位置也成为栈帧指针,ebp 寄存器中保存的就是当前函数的栈帧指针。

image-20230304001953448

在 main 函数调用 add 函数时,main 函数的栈顶指针就变了 add 函数的栈帧指针了,所以需要将 main 函数的栈顶指针保存到 ebp 中,当 add 函数执行结束后,需要销毁 add 函数的栈帧,并恢复 main 函数的栈帧。因为 main 函数也有它自己的栈帧指针,所以在执行 main 函数之前,还需恢复它的栈帧指针,那么如何恢复呢?

通常是在 main 函数中调用 add 函数时,CPU 会将当前 main 函数的栈帧指针保存在栈中,当函数调用结束之后,就需要恢复 main 函数的执行现场了,首先取 ebp 中的指针,并写入 esp 中,然后从栈中取出之前保留的 main 函数的栈帧地址,将其写入 ebp 中,到这里 ebp 和 esp 都恢复了,就可以继续执行 main 函数了。

image-20230304003406412

什么是栈帧?

每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。

在 JavaScript 中,函数的执行过程也是类似的,如果调用一个新函数,那么 V8 会为该函数创建栈帧,等函数执行结束之后,销毁该栈帧,并且 V8 限制了栈结构的大小,因此如果重复嵌套执行一个函数,那么就会导致栈溢出。

至此,再回头看看开头的三段代码:

  • 第一段代码由于循环嵌套调用了 foo,所以当函数运行时,就会导致 foo 函数会不断地调用 foo 函数自身,这样会导致栈无限增,进而导致栈溢出的错误。
  • 第二段代码是在函数内部使用了 setTimeout 来启动 foo 函数,这段代码之所以不会导致栈溢出,是因为 setTimeout 会使得 foo 函数在消息队列后面的任务中执行,并不会影响到当前的栈结构,也就不会导致栈溢出。
  • 第三段代码是 Promise,Promise 涉及到了微任务,这种方式会导致主线程卡死,但不会造成栈溢出。微任务与宏任务,会在后面章节继续研究。

有了栈,为什么还会有堆?

前面提到,栈管理函数调用,具有很多优势:

  • 栈结构的特点”后进先出”非常适合函数调用过程
  • 在栈上分配资源和销毁资源的速度非常快,因为栈空间是连续的

栈最大的缺点也是它的优点所造成的,那就是连续的空间要想在内存中分配一块连续的大空间是非常难的,因此导致了栈空间是有限的,正是基于栈不方便存放大的数据,因此有了另外一种数据结构用来保存一些大数据,这就是堆。

和栈空间不同,存放在堆空间的数据是不要求连续存放的,从堆上分配内存块没有固定的模式,可以在任何时候分配和释放它。

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 时,表示要在堆中分配一块空间,然后返回分配后的地址,通常返回的地址会被保存到栈中。当然,堆中的数据不再需要时,需要对其进行销毁。C、C++需要手动管理内存,没有手动销毁堆中的数据,会造成内存泄漏,Java、JavaScript 使用了自动垃圾回收的策略。

总结

  • 每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁。因此会使用栈结构来管理函数的调用过程,也把管理函数调用过程的栈结构称为调用栈。
  • 函数可以被调用。
  • 函数具有作用域机制。
  • 函数调用者的生命周期总是长于被调用者,并且被调用者的生命周期总是先于调用者的生命周期结束。
  • 站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数,而函数资源的释放则总是先于调用函数。
  • 当一个函数被执行时,函数的参数、函数内部定义的变量都会依次压入到栈中。
  • 什么是栈帧?每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。
  • 如果调用一个新函数,那么 V8 会为该函数创建栈帧,等函数执行结束之后,销毁该栈帧,并且 V8 限制了栈结构的大小,因此如果重复嵌套执行一个函数,那么就会导致栈溢出。