一文说清垃圾回收机制、闭包与上下文

上下文与作用域

执行上下文

作用

上下文是指代码在执行时所处的环境,包括变量、函数、对象和执行期间的状态。

决定变量或函数可以访问哪些数据,以及他们的行为、变量的生命周期,用于确定什么时候释放变量的内存。

种类

全局上下文:最外层的上下文,代表了整个脚本的执行环境,JavaScript 代码在执行时,会首先创建一个全局执行上下文,它记录了全局变量、全局函数以及其他全局声明的信息。

函数上下文:每次调用函数时,都会创建一个新的函数执行上下文。函数执行上下文包括函数的参数、局部变量、this 值等信息。

eval 函数创建的临时上下文:eval 函数会将字符串参数作为一段 js 代码执行,在执行到 eval 函数时,他会临时创建一个执行上下文塞到上下文执行栈中。

上下文的销毁时机

上下文会在其对应的代码执行完毕后会被销毁,包括挂载在它上面的所有变量和函数。而全局上下文在应用程序退出前才会被销毁。

变量对象

作用与定义

每个上下文都有一个关联的变量对象。在该上下文定义的变量和函数都会挂载在这个对象上。虽然无法通过代码访问变量对象,但是在代码中若是访问标识符就会使用到他。

直接挂载在变量对象的属性都是存放在栈中的。而实际上在代码中的那些定义变量或是函数的语句,包括之后修改变量本身的值,其实都是在对变量对象的属性进行操作。

全局上下文的变量对象的创建过程

全局上下文的变量对象在整个程序加载时就会被创建,并且在整个程序执行期间都存在。

可以通过 globalThis 标识符来访问当前环境的全局对象。

eval 函数的临时上下文的变量对象的创建过程

在使用 eval 函数执行代码时,eval 函数的执行上下文被创建时,他的变量对象也会被创建

活动对象

作用与定义

一个特殊的变量对象,该对象包含了参数列表和argument对象等属性

若上下文是函数,则活动对象作为变量对象。而活动对象最初只有一个属性 arguments 。

创建的流程【请先了解作用域链再看】

首先函数在定义的时候,会进行活动对象的预装载:

  1. 创建活动对象
  2. 预先确定函数内部的变量声明和函数声明;
  3. 将这些预先确定的变量声明和函数声明作为属性添加到活动对象中;
  4. 初始化属性的值,变量声明的初始值为 undefined 。函数属性的初始化的值为函数对象的引用。但若是变量与函数重名则是先将属性初始化为函数对象的引用。之后具体执行代码时,在变量定义的语句后,若是变量被赋了值,那么该定义语句后该标识符【变量的名称】的值为被赋的值,若是没被赋值仅仅是定义了下,那么无论在变量定义语句前还是后,标识符的值都为函数。在变量定义语句前,标识符的值则为函数。【标识符冲突只可能出现在 var 上,const 和 let 不可能】

例如:

lua 复制代码
function a() {  // 定义时进行完活动对象预装载后,活动对象上 test 属性为 function test() {console.log("函数")} 这个函数
	console.log(test);
	var test = 1;
	function test() {console.log("函数")}
	console.log(test);
}
//具体执行函数时,则会先输出一个函数在输出一个数值1。
a();

但若是我将上面的 test 不进行赋值,那么在函数定义的时候,活动变量初始的属性值不会变,但是在执行的时候,test变量在定义后也会输出这个函数。

lua 复制代码
function a() {  // 定义时进行完活动对象预装载后,活动对象上 test 属性为 function test() {console.log("函数")} 这个函数
	console.log(test);
	var test = 1;
	function test() {console.log("函数")}
	console.log(test);
}
//具体执行函数时,会输出2个函数
a();
  1. 获取当前环境外部的作用域链,并将当前预装载的活动对象以及作用域链加入到 [[Scope]] 中。【作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。】

之后在函数具体执行时才会进行真正完整的活动对象的生成:

在函数执行后,会为这个函数调用创建一个执行上下文【相同函数不同的调用,每一次调用都会创建一个新的,与之前完全不同的执行上下文】,并通过函数内部的 [[Scope]] 复制其中存储的作用域链,将该作用域链作为上下文对应的作用域链。然后用 arguments 和其他命名参数来初始化与这个函数的执行上下文绑定的活动对象。并将它推入作用域链的最前端。

上下文执行栈

作用

js 在执行代码时,会通过维护上下文执行栈来维护代码的执行环境以及顺序。上下文执行栈的栈顶永远是正在执行的代码,一旦要执行下一个上下文对应的代码时,会将代码对应的上下文压栈。而在执行完这段代码时,则会将该上下文从上下文执行栈中弹出。

而上下文在执行完毕弹栈后,上下文对应的空间就会被释放掉,同时,上下文对应的变量对象的属性对应的所有栈空间也会被释放。

作用域

作用以及与上下文的不同点

决定变量或函数可以访问哪些数据,以及他们的行为、变量的生命周期,用于确定什么时候释放变量的内存。

与上下文很相似,但是上下文重点侧重于提供在执行代码的环境和状态信息。而作用域更加强调变量和函数的可访问情况。

类别

  • 块级作用域:指的是从该标识符的定义到定义语句所属的代码块结束这一段区域中,该定义对应的内容可用。比如: const、let 定义的变量,只有从定义的语句到包含这个定义语句的块结束这段区域中才可使用这个变量,出了这个块或者处于该块中但在定义语句前都是不可使用该变量的。
  • 函数作用域:指的是在函数内部,有着该作用域的标识符在定义时,无论是定义语句到该函数块的开头,以及定义语句到块的结束都可以访问该定义对应的内容。
  • 全局作用域:是指在整个 JavaScript 程序中都可以访问的变量和函数的作用范围。比如定义在全局代码中的 var 变量以及 function 函数。

作用域覆盖

当两个作用域的作用范围重叠,则访问标识符会优先使用内部作用域的内容。具体原理详见下面的标识符解析

作用域链

作用

用于搜索变量和函数,决定各级上下文中的代码在访问变量和函数时搜索的顺序。

代码执行时对一个标识符进行解析时,是沿着作用域逐级搜索标识符名称来完成的,先搜索当前上下文的变量对象内部,之后逐渐向外沿着作用域链搜索,直到找到对应的标识符。当搜索到全局变量对象之后还没找到,说明该作用域链上不存在该标识符,就会报错。而标识符在作用域越靠后,搜索的代价自然也就越高,开销就越大。所以尽量使用局部变量,少用全局变量。

特性
  • 正在执行的代码的上下文对应的变量对象 始终位于作用域链的最前端。
  • 作用域链最后端永远是全局对象。
实质

作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。ps:下图中的块 a 的上下文和活动对象指的是一个函数的上下文和活动对象

如何创建

非函数的上下文对应的代码执行的时候,会创建变量对象的一个作用域链。

函数上下文对应的作用域链创建过程:

创建一个函数时,会为它创建作用域链,预装载活动对象,并将其保存在函数内部的 [[Scope]] 中。

在调用一个函数时,会为这个函数调用创建一个执行上下文,并通过复制函数内部的 [[Scope]] 创建对应的作用域链。

然后用 arguments 和其他命名参数来初始化与这个函数的执行上下文绑定的活动对象。并将它推入作用域链的最前端。

标识符解析

代码执行时对一个标识符进行解析时,是沿着作用域逐级搜索标识符名称来完成的,先搜索当前上下文的变量对象身上的属性,之后逐渐向外沿着作用域链搜索,直到找到对应的标识符。当搜索到全局变量对象之后还没找到,说明该作用域链上不存在该标识符,就会报错。而标识符在作用域越靠后,搜索的代价自然也就越高,开销就越大。所以尽量使用局部变量,少用全局变量。

如何增强作用域链

通过某种方式使得在当前作用域链的前端临时添加一个上下文,该上下文会在代码执行后被删除。

目前有:

  • try-catch:创建一个新的变量对象,该变量对象要包含要抛出的错误对象的声明。
  • with 语句:在作用域前端添加指定的对象。
  • await : 创建一个闭包。

领域

领域在 ECMAScript 规范中是指一个包含一套完整内置对象的集合,比如全局对象、内置函数(如Array或Object构造函数)以及相应的原型链。

每个领域还包括它自己的微任务队列和与执行上下文相关的其他状态信息。在 Web 浏览器中,一个领域一般对应于一个全局环境,如一个窗口或一个 Web Worker。

领域为执行上下文提供了执行环境,每个领域都有其自己的全局上下文,以及与之相关的所有其他执行上下文。

内存泄漏

一些变量、对象或是函数明明之后不会使用了,但是却仍然占着内存,导致程序内存资源消耗增多,这种就叫做内存泄漏。

如果内存资源消耗过大,则会让系统更卡甚至直接崩溃,所以我们期望:不再使用的内存资源要及时释放。

垃圾回收机制 Garbage Collection【GC】

作用

js 引擎会自动帮助我们去清理用不到的标识符对应的那部分内存空间。

机制运行流程

原始类型的数据存储在栈中【原始类型包括数值numberBigInt、字符串string、布尔值boolean、符号Symbolundefinednull】,引用类型的数据实体存储在堆中,但是会在栈中存储数据实体的引用。

栈中数据内存分配、内存清除与代码运行机制简述

运行机制

当上下文对应的代码执行完成后则会将这个上下文从栈中弹出。而运行到一个新的代码块或是函数的调用时,会将对应的上下文入栈。

上下文执行栈,身为一个栈主要是利用栈指针来指示当前栈的顶部位置。而 ESP 扩展栈指针寄存器就是用来存储栈指针的数据的。当上下文入栈,ESP会向下移动,上下文弹栈,ESP 则是会向上移动。释放被弹出栈帧的空间。【栈帧就是栈中的一个元素】不过要注意 ESP 下移之后,原本被弹出的栈帧中的数据仍然存在,只是不使用了,他会在 ESP 再次指向这个空间也就是新的上下文入栈到此位置之后,才会用新的上下文的数据覆盖掉原来的数据。

内存分配机制

当 ESP 指向一个上下文时,上下文对应的代码块开始执行,而碰到标识符声明语句时【包括函数、类的定义】,就会给这些标识符进行栈中内存的分配。

数据清除【垃圾回收】机制

其实清除活动就发生在 ESP 指针上移的时刻,上下文被弹出,而此上下文中所有的栈中数据对应的内存都将会被释放掉。

那么为什么栈中数据内存被释放掉了,但是还会有基本类型的数据会产生闭包现象呢

在JavaScript中,js引擎会通过逃逸检测来查看哪些变量是处于闭包中,会将这部分基础类型存储在堆中。

堆中数据内存清除机制简述

在定义一个引用类型的对象时,栈中会存储这个对象的引用【对应空间的指针指向】,而对象真正内容是在堆中存储的。

堆内存组成部分

一个 V8 进程的内存通常由以下部分组成

  • 新生代内存区(new space):用于存储生命周期较短的对象。新生代内存区进一步分为 From 空间和 To 空间,用于存放不同阶段的对象。
  • 老生代内存区(old space):用于存储生命周期较长的对象。这些对象经历了多次垃圾回收后仍然存活。
  • 大对象区(large object space):用于存储较大的对象的空间,有效管理内存,避免了由于大对象的创建和销毁导致内存碎片化和性能下降。大对象可能具有更长的生命周期和更大的内存占用,因此将它们单独存放在大对象区可以提高内存分配的效率。一些引擎可能会优化大对象的内存分配和回收,采用延迟回收等策略来提高性能和效率。开发者可以通过避免不必要的大对象的创建,及时释放不再使用的大对象的引用,以及合理使用内存,来帮助垃圾回收器更好地管理大对象的内存。这样可以减少内存占用,避免内存泄漏和性能问题。
  • 代码区(code space):用于存放可执行代码的内存空间。代码区在程序加载和执行时被分配和使用,它是只读的,不可修改。在程序运行过程中,代码区的内容会被加载到处理器中执行。主要作用是存储程序的指令和定义,供计算机执行。它在程序加载时被分配一次,程序执行时不会改变其大小。代码区中的代码通常是静态的,不包含动态分配的对象或变量。因此,代码区的访问速度较快,且不需要进行垃圾回收。代码区的大小取决于程序的规模和复杂性。优化代码的大小和结构可以减少代码区的占用,提高程序的性能和内存利用率。
  • map 区(map space):用于存储 JavaScript 对象的元数据,比如对象的属性信息、类型信息等。在 V8 引擎中,对象的实例数据存储在堆(Heap)中,而对象的元数据存储在 Map 区中。这种分离的设计有助于提高内存的使用效率。Map 区相对于 Heap 区来说,是一个较小的内存区域。当创建一个新的对象时,会先在 Map 区中为该对象分配元数据,然后在 Heap 区中为实例数据分配内存空间。元数据中包含对象的属性信息,例如对象的结构、属性数量、各属性的类型等。这样,V8 引擎可以更高效地处理对象的访问和属性操作。
代际假说------垃圾回收策略基础理论
  • 大部分对象在内存中存活时间很短,即:对象一经内存分配 很快就会无法访问
  • 可以持续访问的对象存活时间较长
分代收集策略------堆的垃圾回收策略
新生代回收机制------ Scavenge 算法

新生代内存区使用副垃圾回收器来负责该区域垃圾回收的。

该算法将新生代孔家划分为两个相同空间的区域:

  • 对象区域 Form
  • 空闲区域 To

新的对象首先分配到 from 空间,当此空间快被写满时会进行一次垃圾回收操作:

现将 from space 中存活的对象赋到 to space ,然后对为存活的空间回收。

复制完成后将 form 和 to 的身份调换。

这样就是算法的一轮操作。在代码运行过程中会不断重复上述操作,直至代码结束。

而判断对象是否存活,Scavenge 算法使用了追踪式垃圾回收的方式。从根对象开始,它会遍历对象引用关系。所有可访问的对象会被标记,之后将被标记的对象 copy 到 to space 。

老生代回收机制------增量标记法

当新生代中的对象经历了两轮回收后仍然存在,那么该对象就会被迁移到老生代的空间。

它不会像新生代区那样使用 Scavenge 算法,因为复制大对象所花费的时间长,执行效率并不高。所以老生代区的垃圾回收工作是主垃圾回收器使用增量标记算法来完成的。

增量标记算法是标记清除【Mark-Sweep】算法的最终优化版,我们先来了解标记清除算法:

核心思想:无法到达的对象就回收。

该算法会使用标记思想,即当一个对象被标记2次后原来的标记会被抵消,那么此时对象的状态处于未标记状态。

主垃圾回收器先将所有可以访问的【能到达的】对象标记,之后再将内存中所有的对象全部标记一次,这样原来能到达的对象的标记就被抵消了。剩下被标记的就是那些不能到达的也就是要被回收的对象了。之后将这些被标记的对象进行回收。

由于这样回收完成之后,可是用的空间变得零碎化,所以主垃圾回收器会将此时被使用的空间整理在一起,使得一整块连续的空间被使用,剩余未被使用的空间就变的大而连续。

此时标记-清除算法优化成了标记-清除-整理算法。

但是由于 js 引擎为单线程,老生代区的对象相对大,虽然采用"标记-清除"算法会比 Scavenge 更快,但仍会有卡顿问题。为此 V8 将整个标记对象的过程划分为:初始化标记、增量标记、回收与整理3步,增量标记是每次事件循环机制执行完后分批次处理,其余两部则是分别在一次事件循环机制后完整执行该步

引用计数算法【已废弃】

大致思路为,每被引用一次,对应内存的引用次数就会被加一,当该内存引用次数为 0 时就说明该内存该被释放。垃圾回收处理器会每隔一段时间进行检测并回收。

但是由于循环引用和自引用,导致有些内存的引用次数始终不为 0 ,所以该算法被废弃。

循环引用例如: A 对象的 a 属性指向了 B 对象, B 对象的 b 属性指向了 A 对象。所以此时 A 对象的引用数为 2 【自己本身和 B 的 b 属性的引用】。当上下文被销毁时,A 对象仅仅损失了 A 标识符本身的引用,但是 B 对象的 b 属性的引用仍然被计算为引用数,所以 A 对象的引用数永远不可能为 0 。就造成了内存泄漏。

自引用也类似: A 对象的 a 属性引用了 A 本身,上下文被销毁,A对象损失了 A 标识符的引用, 但是 a 属性的引用仍在。

闭包

现象

闭包内可以使用闭包外部的标识符。且通过标识符访问的内存空间也是外部环境对应的内存空间。

种类

闭包分为两种:函数闭包和 js 生成的闭包。

js 生成的闭包主要是指:在使用 await 时,js 会自动生成一个闭包来保留当前函数的执行上下文的状态,以便在异步操作完成后恢复执行。它并不是函数闭包,但是效果和函数闭包一致。

函数闭包则是指: 函数内部又定义了一个函数,那么内部函数就可以访问到外部函数的变量、函数等各种标识符。

原理

在闭包对应的代码在定义时,比如函数的定义。会预装载活动对象,在此过程中会将外部的作用域链也copy一份在 [[Scope]] 特性中。

而在闭包内部进行标识符访问的时候,会进行标识符解析,而标识符解析则是逐层访问作用域链上的属性直到找到对应的标识符。而外部的数据可以在作用域链上找到,所以闭包才可以访问到外部的数据。

不过基础类型的数据在闭包时,会存储在堆中,js会依靠逃逸分析来判定哪些变量应该存放在堆中。

注意事项

  • JavaScript 中的栈数据不会使用标记清除法进行回收的,这点一定不要搞混了。栈数据的回收是由 JavaScript 引擎是随着上下文对应的代码块执行完毕而去自动处理的。【此处注意不要把上下文对应的代码块单纯的理解为函数,因为有一些特殊情况下,闭包产生并不是依赖函数的。】
  • 当在 async 函数中使用 await 关键字时,JavaScript 引擎会创建一个闭包来保留当前函数执行上下文的状态,以便于在异步操作完成后恢复执行。所以其实闭包不完全是以函数为维度的。
相关推荐
雯0609~2 分钟前
js:循环查询数组对象中的某一项的值是否为空
开发语言·前端·javascript
bingbingyihao7 分钟前
个人博客系统
前端·javascript·vue.js
尘寰ya8 分钟前
前端面试-HTML5与CSS3
前端·面试·css3·html5
最新信息10 分钟前
PHP与HTML配合搭建网站指南
前端
前端开发张小七24 分钟前
每日一练:3统计数组中相等且可以被整除的数对
前端·python
天天扭码38 分钟前
一杯咖啡的时间吃透一道算法题——2.两数相加(使用链表)
前端·javascript·算法
Hello.Reader43 分钟前
在 Web 中调试 Rust-Generated WebAssembly
前端·rust·wasm
NetX行者1 小时前
详解正则表达式中的?:、?= 、 ?! 、?<=、?<!
开发语言·前端·javascript·正则表达式
流云一号1 小时前
Python实现贪吃蛇三
开发语言·前端·python
liangshanbo12151 小时前
深入讲解 CSS 选择器权重及实战
前端·css