跳到主要内容

1 JavaScript 的内存机制

问题1:JavaScript 是如何存储数据的?

JavaScript 是动态语言,在声明变量之前不需要确认变量的数据类型。JavaScript 引擎在运行代码的时候自己会计算出变量的类型。

JavaScript 是弱类型语言,它支持隐式类型转换。可以使用同一个变量保存不同类型的数据。

image

JavaScript 有 7 种原始类型数据:

  • null、undefined、number、boolean、string、symbol、bigInt

JavaScript 有 1 种引用类型数据:

  • object

image

如上图所示,JavaScript 在执行过程中, 有三种类型内存空间:

  • 代码空间:存储可执行代码。
  • 栈空间:就是调用栈,存储执行上下文、原始类型数据。
  • 堆空间:存储闭包、引用类型数据

举例:

function foo(){
var a = "极客时间"
var b = a
var c = {name:"极客时间"}
var d = c
}
foo()

下图可以看到,对象类型是存放在堆空间的,在栈空间中只是保留了对象的引用地址。当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的,相当于多了一道转手流程。

image

为什么要把引用数据类型全部存在堆内存中?

因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。

所以通常情况下,栈空间的体积偏小,主要用来存放原始类型的小体积数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到空间更大的堆中。堆内存的缺点是 分配内存回收内存 都会有一定的开销。

问题2:闭包的存储机制是怎样的?

闭包存储在堆空间中。

通过一段代码距离:

function foo() {
var myName = "极客时间"
let test1 = 1
const test2 = 2
var innerBar = {
setName:function(newName){
myName = newName
},
getName:function(){
console.log(test1)
return myName
}
}
return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())

当执行这段代码的时候,你应该有过这样的分析:

  1. 由于变量 myNametest1test2 都是原始类型数据,所以在执行 foo 函数的时候,它们会被压入到调用栈中;
  2. 当 JavaScript 引擎执行到 foo 函数时:
    1. 首先进入编译阶段,创建一个空执行上下文,然后依次声明 myNametest1test2innerBar 变量。
    2. 之后进入运行阶段,当执行到对 innerBar 赋值的代码时,发现其内部函数 setNamegetName 引用了外部函数 foo 的变量 nyNametest1
      1. 内部函数引用外部变量,这就形成了一个 闭包。所以,此时会在堆内存中创建一个 closure(foo) 对象,存储 foo 的变量 nyNametest1
      2. 然后,在 foo 执行上下文中的环境变量中,删除变量 nyNametest1的信息,通过引用 closure(foo) 对象获取 myNametest1,如下图。
    3. 最后当 foo 函数执行完毕,返回 innerBar 后,foo 执行上下文销毁,test2 变量也一并被销毁。
      1. 但是在堆内存中的 closure(foo) 依然保存了myNametest
      2. 返回的 innerBar 对象中,内部属性 [[Scopes]] 保存了对 Closure(foo) 的引用。可以顺利访问到闭包的内容。

image

下图可以看到,getName 方法引用了 foo 闭包 CLosure(foo)。这个引用保存在内部属性 [[Scope]] 中,形成完整的作用域链。

image

总的来说,产生闭包的核心有两步:

  • 第一步是需要预扫描内部函数;

  • 第二步是把内部函数引用的外部变量保存到堆中。

问题3:垃圾数据是如何自动回收的?

  • C/C++ 使用手动回收策略,何时分配内存、何时销毁内存都是由代码手动控制的。

  • JavaScript、Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的。

因为数据是存储在栈和堆两种内存空间中的,所以接下来我们就来分别介绍 栈中的垃圾数据堆中的垃圾数据 是如何回收的。

栈中的垃圾数据:通过 ESP 指针的移动,直接抛弃。

堆中的垃圾数据:垃圾回收器 通过三个步骤:标记对象、回收内存、整理内存,完成回收工作,具体划分为:

  • 新生代的回收内存,副垃圾回收器通过 Scavenge 算法,利用 对象/空闲 两个区域来回 翻转 实现垃圾回收和整理。然后通过两次存活则 对象晋升的办法来解决容积问题。
  • 老生代的回收内存,负责体积大或存活时间久的对象,主垃圾回收器通过 标记 - 清除(Mark-Sweep)算法标记 - 整理(Mark-Compact)算法 来回收和整理内存。

最后,

因为垃圾回收器执行时间过长(尤其是老生代的工作时间长),产生了主线程工作 全停顿(Stop-The-World)现象等待垃圾回收的问题。

对此,解决方案是利用 增量标记(Incremental Marking)算法 ,把垃圾标记的任务拆分成多个小任务,在 JavaScript 代码执行中穿插完成,减小卡顿问题。

1 调用栈中的数据回收

当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。

通过一段代码理解:

function foo(){
var a = 1
var b = {name:"极客邦"}
function showName(){
var c = 2
var d = {name:"极客时间"}
}
showName()
}
foo()

当执行到第 6 行代码时,其调用栈和堆空间状态图如下所示:

在调用栈中,还有 一个记录当前执行状态的指针(称为 ESP),指向调用栈中 showName 函数的执行上下文,表示当前正在执行 showName 函数。

image

showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP 这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo 函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程。

image

从图中可以看出,当 showName 函数执行结束之后,ESP 向下指向 foo 函数的执行上下文。上面 showName 的执行上下文虽然保存在栈内存中,但是已经是无效内存了。当有新的执行上下文加入时,这块内容会就会被直接覆盖掉。

所以说,当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。

2 堆中的数据回收

要回收堆中的垃圾数据,就需要用到 JavaScript 中的 垃圾回收器 了。

2.1 代际假说

代际假说(The Generational Hypothesis)是一个垃圾回收的基础理论:

  • 大部分对象在内存中存在的时间很短。简单来说,就是很多对象一经分配内存,很快就变得不可访问(不再使用,没有指针指向这些对象);

  • 不死的对象,会活得更久。

2.2 分代收集

分代收集是 V8 引擎采用的垃圾回收策略。

在 V8 中会把堆分为 新生代老生代 两个区域。

  • 新生代:存放生存 时间短的对象,新生区通常只支持 1~8M 的容量。副垃圾回收器,主要负责新生代的垃圾回收。
  • 老生代:存放生存 时间久的对象。老生区支持非常大的容量。主垃圾回收器,主要负责老生代的垃圾回收。

2.3 垃圾回收器的工作流程

不论什么类型(主/副)的垃圾回收器,它们都有一套共同的执行流程。

  • 第一步:标记对象。标记空间中 活动对象(还在使用的对象) 和 非活动对象(可以进行垃圾回收的对象)。

  • 第二步:回收内存。回收非活动对象所占据的 内存,统一清理内存中所有被标记为可回收的对象。

  • 第三步:整理内存。频繁回收对象后,内存中就会存在大量不连续空间,称为内存碎片。需要移动内存碎片,空出连续的空间,

    • 当内存中出现了大量的内存碎片,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。

副垃圾回收器

副垃圾回收器主要负责新生区的垃圾回收。而通常情况下,大多数小的对象都会被分配到新生区,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。

新生代中用 Scavenge 算法 来处理。

所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域一半是空闲区域,如下图所示:

image

流程如下:

新加入的体积较小的对象都会存放到对象区域。当对象区域快被写满时,就需要执行一次垃圾清理操作。

  1. 标记对象。标记空间中活动对象和非活动的垃圾对象。

  2. 回收内存 + 整理内存。把存活的活动对象复制到空闲区域中,在复制的过程中,同时把这些对象进行有序排列。所以回收内存的同时也完成了碎片的整理。

  3. 角色反转。原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。

  4. 对象晋升。经过 两次 垃圾回收依然还存活的对象,会被移动到老生区中。

探讨:

  1. 体积小。复制操作需要时间成本,如果新生区空间设置得太大,会导致每次清理的时间就会过久。为了 执行效率,一般新生区的空间会被设置得比较小
  2. 新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略策略。

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收。

老生区中对象的来源:

  1. 一些大的对象会直接被分配到老生区。
  2. 从新生区晋升而来的对象。

因此老生区中的对象有两个特点:占用空间大、存活时间长。

新生代中用 标记 - 清除(Mark-Sweep)标记 - 整理(Mark-Compact)来处理。流程如下:

  1. 标记对象。挨个遍历调用栈中的全部变量,以每一个变量为根元素,遍历这组根元素。所有遍历过程中,能到达的元素称为 活动对象,没有到达的元素判断为 垃圾数据。如下图
  2. 回收内存:标记 - 清除(Mark-Sweep)。直接清理掉标记为垃圾数据的对象。如下图
  3. 整理内存:标记 - 整理(Mark-Compact)。并不是每次都执行整理工作。执行时,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。如下图
  1. 标记对象:

image

  1. 回收内存:标记 - 清除(Mark-Sweep)

image

  1. 内存整理:标记 - 整理(Mark-Compact)

image

2.4 全停顿(Stop-The-World)和 增量标记(Incremental Marking)

全停顿(Stop-The-World):

由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。

比如堆中的数据有 1.5GB,V8 实现一次完整的垃圾回收需要 1 秒以上的时间,这也是由于垃圾回收而引起 JavaScript 线程暂停执行的时间,若是这样的时间花销,那么应用的性能和响应能力都会直线下降。主垃圾回收器执行一次完整的垃圾回收流程如下图所示:

image

在这 200 毫秒内,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行的,这将会造成页面的卡顿现象。

增量标记(Incremental Marking):

这其中,对象标记需要便利整个调用栈和堆内存中的所有对象,非常耗时。

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为 增量标记(Incremental Marking)算法。如下图所示:

image

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务。这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

问题4:什么是内存泄露?

如果有大量的变量在业务上已没有实际意义,但却因被引用而无法正确被 GC 回收,最终导致占用内存,这个现象就叫内存泄露。

内存泄露的情况:

  1. 全局变量。全局变量会一直被引用,无法被 GC 回收。
  2. 闭包。闭包如果被引用,就无法被 GC 回收。
  3. 被遗忘的定时器。setInterval 会周期性的调用回掉函数,有时会忘记对 setInterval 进行删除。
  4. DOM 引用。考虑到性能或代码简洁方面,我们代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放。