Sunny-lucking/blog

从栈、堆、预解析来解释闭包原理(读浏览器核心原理)

Sunny-lucking opened this issue · 0 comments

1.下面三段代码会执行结果什么不同

function foo() {
  foo() // 是否存在堆栈溢出错误?
}
foo()
function foo() {
setTimeout(foo, 0) // 是否存在堆栈溢出错误?
}
function foo() {
return Promise.resolve().then(foo)
}
foo()

A:

  • 第一段:V8就会报告 栈溢出的错误
  • 第二段:正确执⾏
  • 第三段:没有栈溢出的错误,却会造成⻚⾯的卡死

2.为什么第一段会栈溢出

由于foo函数内部嵌套调⽤它⾃⼰,所以在调⽤foo函数的时候,它的栈会⼀直向上增⻓,但是由于栈空间在内存中是连续的,所以通常我们都会限制调⽤栈的⼤⼩,如果当函数嵌套层数过深时,过多的执⾏上下⽂堆积在栈中便会导致栈溢出,最终如下图所⽰:

3.为什么第二段会正常

setTimeout的本质是将同步函数调⽤改成异步函数调⽤,这⾥的异步调⽤是将foo封装成事件,并将其添加进 「消息队列」中,然后主线程再按照⼀定规则循环地从消息队列中读取下⼀个任务。

⾸先,主线程会从消息队列中取出需要执⾏的宏任务,假设当前取出的任务就是要执⾏的这段代码,这时候主线程便会进⼊代码的执⾏状态。这时关于主线程、消息队列、调⽤栈的关系如下图所⽰

接下来V8就要执⾏foo函数了,同样执⾏foo函数时,会创建foo函数的执⾏上下⽂,并将其压⼊栈中,最终
效果如下图所⽰:

当V8执⾏执⾏foo函数中的setTimeout时,setTimeout会将foo函数封装成⼀个新的宏任务,并将其添加到消息队列中,在V8执⾏setTimeout函数时的状态图如下所⽰:

等foo函数执⾏结束,V8就会结束当前的宏任务,调⽤栈也会被清空,调⽤栈被清空后状态如下图所⽰

当⼀个宏任务执⾏结束之后,忙碌的主线程依然不会闲下来,它会⼀直重复这个取宏任务、执⾏宏任务的过程。刚才通过setTimeout封装的回调宏任务,也会在某⼀时刻被主线取出并执⾏,这个执⾏过程,就是foo函数的调⽤过程。具体⽰意图如下所⽰:

因为foo函数并不是在当前的⽗函数内部被执⾏的,⽽是封装成了宏任务,并丢进了消息队列中,然后等待
主线程从消息队列中取出该任务,再执⾏该回调函数foo,这样就解决了栈溢出的问题。

4.为什么第三段会卡住页面

理解微任务的执⾏时机,你只需要记住以下两点:

  • ⾸先,如果当前的任务中产⽣了⼀个微任务,通过Promise.resolve()或者Promise.reject()都会触发微任务,触发的微任务不会在当前的函数中被执⾏,所以执⾏微任务时,不会导致栈的⽆限扩张;
  • 其次,和异步调⽤不同,微任务依然会在当前任务执⾏结束之前被执⾏,这也就意味着在当前微任务执⾏结束之前,消息队列中的其他任务是不可能被执⾏的

因此在函数内部触发的微任务,⼀定⽐在函数内部触发的宏任务要优先执⾏。

当执⾏foo函数时,由于foo函数中调⽤了Promise.resolve(),这会触发⼀个微任务,那么此时,V8会将该微任务添加进微任务队列中,退出当前foo函数的执⾏。

然后,V8在准备退出当前的宏任务之前,会检查微任务队列,发现微任务队列中有⼀个微任务,于是先执⾏微任务。由于这个微任务就是调⽤foo函数本⾝,所以在执⾏微任务的过程中,需要继续调⽤foo函数,在执⾏foo函数的过程中,⼜会触发了同样的微任务。

那么这个循环就会⼀直持续下去,当前的宏任务⽆法退出,也就意味着消息队列中其他的宏任务是⽆法被执⾏的,⽐如通过⿏标、键盘所产⽣的事件。这些事件会⼀直保存在消息队列中,⻚⾯⽆法响应这些事件,具体的体现就是⻚⾯的卡死。

不过,由于V8每次执⾏微任务时,都会退出当前foo函数的调⽤栈,所以这段代码是不会造成栈溢出的。

5.为什么使⽤栈结构来管理函数调⽤?

我们都知道,v8执行JavaScript时存在预编译和执行可执行代码两个部分。

我们知道,⼤部分⾼级语⾔都不约⽽同地采⽤栈这种结构来管理函数调⽤,为什么呢?这与函数的特性有关。通常函数有两个主要的特性:

  1. 第⼀个特点是函数 「可以被调⽤」,你可以在⼀个函数中调⽤另外⼀个函数,当函数调⽤发⽣时,执⾏代码的控制权将从⽗函数转移到⼦函数,⼦函数执⾏结束之后,⼜会将代码执⾏控制权返还给⽗函数;
  2. 第⼆个特点是函数 「具有作⽤域机制」,所谓作⽤域机制,是指函数在执⾏的时候可以将定义在函数内部的变量和外部环境隔离,在函数内部定义的变量我们也称为 「临时变量」,临时变量只能在该函数中被访问,外部函数通常⽆权访问,当函数执⾏结束之后,存放在内存中的临时变量也随之被销毁。

6.栈如何管理函数调⽤?

(这个问题即使不理解也不影响下面的,可以跳过,因为我觉得后面闭包的问题,真的有意思,只不过理解这个,能加深堆闭包的理解)

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

观察上⾯这段代码,当执⾏到int z = add(x,y)时,当前栈的

状态如下所⽰:

接下来,就要调⽤add函数了,理想状态下,执⾏add函数的过程是下⾯这样的:

当执⾏到add函数时,会先把参数num1和num2压栈,接着我们再把变量x、y、ret的值依次压栈,不过执⾏这⾥,会遇到⼀个问题,那就是当add函数执⾏完成之后,需要将执⾏代码的控制权转交给main函数,这意味着需要将栈的状态恢复到main函数上次执⾏时的状态,我们把这个过程叫 「恢复现场」。那么应该怎么恢复main函数的执⾏现场呢?

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

当add函数执⾏结束时,只需要将栈顶指针向下移动就可以了

这里又有一个问题,那就是add函数执行完毕后,esp指针怎么知道移到下面的哪里呢?

这时又来了一个栈帧指针ebp。ebp指向当前执行函数的初始位置。

在main函数调⽤add函数的时候,main函数的栈顶指针就变成了add函数的栈帧指针,所以需要将main函数的栈顶指针保存到ebp中,当add函数执⾏结束之后,我需要销毁add函数的栈帧,并恢复main函数的栈帧,那么只需要取出main函数的栈顶指针写到esp中即可(main函数的栈顶指针是保存在ebp中的),这就相当于将栈顶指针移动到main函数的区域。

这里调用栈里只有两个函数,因此只需要一个ebp指向main函数顶部,一个esp指向栈顶。那要是两个以上呢? 我刚开始以为是要一个函数就对应一个ebp,然后这个epb指向该函数的顶部。发现不是我所想的这样,v8的处理非常巧妙。他是直接把调用栈中的下一个函数的顶部保存在上一个函数中的顶部。看图

这样,当add函数执行完毕的时候,就把此时的ebp的值赋值给esp,而将main函数顶部内存的值赋值给epb,这样,epb就指向下一个函数的顶部了。

(这个即使不理解也不影响下面的,可以继续看)

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

使⽤栈有⾮常多的优势:

  1. 栈的结构和⾮常适合函数调⽤过程。
  2. 在栈上分配资源和销毁资源的速度⾮常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。

虽然操作速度⾮常快,但是栈也是有缺点的,其中最⼤的缺点也是它的优点所造成的,那就是栈是连续的,所以要想在内存中分配⼀块连续的⼤空间是⾮常难的,因此栈空间是有限的。

因为栈空间是有限的,这就导致我们在编写程序的时候,经常⼀不⼩⼼就会导致栈溢出,⽐如函数循环嵌套层次太多,或者在栈上分配的数据过⼤,都会导致栈溢出,基于栈不⽅便存放⼤的数据,因此我们使⽤了另外⼀种数据结构⽤来保存⼀些⼤数据,这就是 「

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

8.什么是惰性解析

在编译JavaScript代码的过程中,V8并不会⼀次性将所有的JavaScript解析为中间代码,这主要是基于以下
两点:

  1. ⾸先,如果⼀次解析和编译所有的JavaScript代码,过多的代码会增加编译时间,这会严重影响到⾸次执⾏JavaScript代码的速度,让⽤⼾感觉到卡顿。因为有时候⼀个⻚⾯的JavaScript代码都有10多兆,如果要将所有的代码⼀次性解析编译完成,那么会⼤⼤增加⽤⼾的等待时间;
  2. 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果⼀次性解析和编译所有JavaScript代码,那么这些中间代码和机器代码将会⼀直占⽤内存,特别是在⼿机普及的年代,内存是⾮常宝贵的资源。

基于以上的原因,所有主流的JavaScript虚拟机都实现了 惰性解析。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其⽣成AST和字节码,⽽仅仅⽣成顶层代码的AST和字节码。

就像是我们经常解释变量提升时候的预编译阶段。

9.惰性解析的过程是怎样的呢

我们可以结合下⾯这个例⼦来分析下:

function foo(a,b) {
  var d = 100
  var f = 10
  return d + f + a + b;
}
var a = 1
var c = 4
foo(1, 5)

当把这段代码交给V8处理时,V8会⾄上⽽下解析这段代码,在解析过程中⾸先会遇到foo函数,由于这只是⼀个函数声明语句,V8在这个阶段只需要将该函数转换为函数对象,如下图所⽰:

注意,这⾥只是将该函数声明转换为函数对象,但是并有没有解析和编译函数内部的代码,所以也不会为foo函数的内部代码⽣成抽象语法树。

然后继续往下解析,由于后续的代码都是顶层代码,所以V8会为它们⽣成抽象语法树,最终⽣成的结果如下所⽰:

代码解析完成之后,V8便会按照顺序⾃上⽽下执⾏代码,⾸先会先执⾏“a=1”和“c=4”这两个赋值表达式,接下来执⾏foo函数的调⽤,过程是从foo函数对象中取出函数代码,然后和编译顶层代码⼀样,V8会先编译foo函数的代码,编译时同样需要先将其编译为抽象语法树和字节码,然后再解释执⾏。

好了,上⾯就是惰性解析的⼀个⼤致过程,看上去是不是很简单,不过在V8实现惰性解析的过程中,需要⽀持JavaScript中的闭包特性,这会使得V8的解析过程变得异常复杂。为什么闭包会让V8解析代码的过程变得复杂呢?要解答这个问题,我们先来拆解闭包的特性,然后再来分析为什么闭包影响到了V8的解析流程。

10.闭包有哪三个特性

  1. 可以在JavaScript函数内部定义新的函数;
  2. 内部函数中访问⽗函数中定义的变量;
  3. 因为JavaScript中的函数是⼀等公⺠,所以函数可以作为另外⼀个函数的返回值。

11.那闭包给惰性解析带来什么问题呢

function foo() {
  var d = 20
  return function inner(a, b) {
    const c = a + b + d
    return c
  }
}
const f = foo()

观察上⾯上⾯这段代码,我们在foo函数中定义了inner函数,并返回inner函数,同时在inner函数中访问了
foo函数中的变量d。

我们可以分析下上⾯这段代码的执⾏过程:

  1. 当调⽤foo函数时,foo函数会将它的内部函数inner返回给全局变量f;
  2. 然后foo函数执⾏结束,执⾏上下⽂被V8销毁了;
  3. 虽然foo函数的执⾏上下⽂被销毁了,但是依然存活的inner函数引⽤了foo函数作⽤域中的变量d。

按照通⽤的做法,d已经被v8销毁了,但是由于存活的函数inner依然引⽤了foo函数中的变量d,这样就会带来两个问题:

  1. 当foo执⾏结束时,变量d该不该被销毁?如果不应该被销毁,那么应该采⽤什么策略?
  2. 如果采⽤了惰性解析,那么当执⾏到foo函数时,V8只会解析foo函数,并不会解析内部的inner函数,那么这时候V8就不知道inner函数中是否引⽤了foo函数的变量d。

12.怎么处理闭包带来的问题

在执⾏foo函数的阶段,虽然采取了惰性解析,不会解析和执⾏foo函数中的inner函数,但是V8还是需要判断inner函数是否引⽤了foo函数中的变量,负责处理这个任务的模块叫着预解析器。V8引⼊预解析器,⽐如当解析顶层代码的时候,遇到了⼀个函数,那么预解析器并不会直接跳过该函数,⽽是对该函数做⼀次快速的预解析,其主要⽬的有两个。

第⼀,是判断当前函数是不是存在⼀些语法上的错误,如下⾯这段代码:

function foo(a, b) {
  {/} //语法错误
}
var a = 1
var c = 4
foo(1, 5)

在预解析过程中,预解析器发现了语法错误,那么就会向V8抛出语法错误第⼆,除了检查语法错误之外,预解析器另外的⼀个重要的功能就是检查函数内部是否引⽤了外部变量,如果引⽤了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执⾏到该函数的时候,直接使⽤堆中的引⽤,这样就解决了闭包所带来的问题。

function a(){
  let str = '123';
  function b(){
    console.log(str);
  }
  
}

13. 当调⽤foo函数时,foo函数内部的变量a会分别分配到栈上?还是堆上?

function foo() {
var a = 0
return function inner() {
  return a++
  }
}

变量a同时在栈和堆上,当解析foo函数的时候,预解析有发现内部函数引⽤外部变量a,这时候就会把a复制到堆上,当⽗函数执⾏到a的赋值语句时,会同时修改?栈和堆上的变量a的值,⽗函数销毁的时候也只会销毁栈上的变量a,堆上的变量a保留。最后当内部函数执⾏完后,堆上的变量a就没有再被引⽤,就会被垃圾回收掉。

实际上每个函数都有一个[[scope]]属性,当执行到预解析inner判断有用到外部的d时,就会给[[scope]]属性添加一个对象Closure(foo),如图所示

14.真的要把内部函数return 出去才算闭包吗?

我请教过很多人,大部人说要符合上面所说的三个特性,即要return出去,就算闭包,但是这个从上面闭包的原理看来,预解析阶段,并不关♥ 内部函数有没有被return出去。只是判断内部函数有没有引用内部变量。因此,我深不以为然,于是就实践下。不出所料,看下面代码,并没有return出去,但还是生成了闭包