JavaScript逆向爬虫教程-------基础篇之深入JavaScript运行原理以及内存管理
目录
- 一、JavaScript运行原理
-
- 1.1 前端需要掌握的三大技术
- 1.2 为什么要学习JavaScript
- 1.3 浏览器的工作原理
- 1.4 浏览器的内核
- 1.5 浏览器渲染过程
- 1.6 认识JavaScript引擎
- 1.7 V8引擎以及JavaScript的执行过程
- 1.8 V8引擎执行过程
- 二、JavaScript的执行过程
-
- 2.1 初始化全局对象
- 2.2 执行上下文栈(调用栈)
- 2.3 调用栈调用GEC的过程
- 2.4 函数执行上下文
- 2.5 变量环境和记录
- 2.6 全局代码执行过程(函数嵌套)
- 2.7 小结
- 三、JavaScript的内存管理
-
- 3.1 什么是内存管理?
- 3.2 JavaScript的内存分配
- 3.3 JavaScript的垃圾回收机制
- 3.4 两种常见的GC算法
-
- 3.4.1 引用计数
- 3.4.2 标记清除
一、JavaScript运行原理
1.1 前端需要掌握的三大技术
前端开发最主要需要掌握的是三个知识点:HTML、CSS、JavaScript。
- HTML:简单易学,掌握常用的标签即可;
- CSS:CSS属性规则较多,多做练习和项目;
- JavaScript:上手容易,但是精通很难。学会它需要几分钟,掌握它需要很多年。
1.2 为什么要学习JavaScript
- 从开发的角度:
- JavaScript 是前端万丈高楼的根基。 前端行业在近几年快速发展,并且开发模式、框架越来越丰富。但是不管你学习的是 Vue、React、Angular,包括 jQuery,以及一些新出的框架。它们本身都是基于 JavaScript 的,使用它们的过程中你都必须好好掌握 JavaScript。所以 JavaScript 是我们前端万丈高楼的根基,无论是前端发展的万丈高楼,还是我们筑建自己的万丈高楼。
- JavaScript 在工作中至关重要。 在工作中无论你使用什么样的技术,比如 Vue、React、Angular、uniapp、taro、ReactNative。也无论你做什么平台的应用程序,比如 PC web、移动端web、小程序、公众号、移动端App,它们都离不开 JavaScript,并且深入掌握 JavaScript 不仅可以提高我们的开发效率,也可以帮助我们快速解决在开发中遇到的各种问题。所以往往在面试时(特别是高级岗位),往往会考察更多面试者的 JavaScript 功底。
- 前端的未来依然是 JavaScript。 在可预见的前端的未来中,我们依然是离不开 JavaScript 的。目前前端快速发展,无论是框架还是构建工具,都像雨后春笋一样,琳琅满目。而且框架也会进行不断的更新,比如 vue3、react18、vite2、TypeScript4.x等。前端开发者面对这些不断变化的内容,往往内心会有很多的焦虑,但是其实只要深入掌握了 JavaScript,这些框架或者工具都是离不开 JavaScript 的。
- 从逆向的角度:
- 网页上的加密,多数使用的都是 JavaScript
- Android 和 IOS 上使用的 hook 框架 frida,使用 JavaScript 来写代码
- Android 和 IOS 上的一些 app,其实是嵌入一个网页,其加密全部或者一部分使用的是 JavaScript
- 小程序本质上也是一个网页,因此加密也是使用 JavaScript
1.3 浏览器的工作原理
在浏览器中输入查找内容,浏览器是怎样将页面加载出来的?以及 JavaScript 代码在浏览器中是如何被执行的?大致流程如下图所示:
解析:
- 首先,用户在浏览器搜索栏中输入服务器地址,对浏览器输入的地址进行DNS解析,将域名解析成对应的IP地址;然后向这个IP地址发送http请求,服务器收到发送的http请求,处理并响应;最终浏览器得到服务器响应的内容;
- 服务器返回对应的静态资源(一般为index.html);
- 然后,浏览器拿到 index.html 后对其进行解析;
- 当解析时遇到css或js文件,就向服务器请求并下载对应的css文件和js文件;
- 最后,浏览器对页面进行渲染,并执行相应的js代码;
参考文章:https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work
1.4 浏览器的内核
浏览器从服务器下载的文件最终要进行解析,那么内部是谁在帮助解析呢?这里就涉及到浏览器内核。浏览器内核是浏览器的核心组件,负责解释和渲染网页内容。它是一个软件模块,实现了网页布局、解析 HTML、执行 JavaScript、渲染 CSS 等功能。浏览器内核通常由两部分组成:渲染引擎和 JavaScript 引擎。不同的浏览器由不同的内核构成,以下是几个常见的浏览器内核:
- Gecko:早期被 Netscape 和 Mozilla Firefox 浏览器使用过;
- Trident:由微软开发的,IE浏览器一直在使用,但 Edge 浏览器内核已经转向了 Blink;
- Webkit:苹果基于 KHTML 开发,并且是开源的,用于 Safari、Google 、Chrome浏览器早期也在使用;
- Blink:Google 基于 Webkit 开发的,是 Webkit 的一个分支,目前应用于 Google Chrome、Edge、Opera 等等;
参考文章:https://baike.baidu.com/item/%E6%B5%8F%E8%A7%88%E5%99%A8%E5%86%85%E6%A0%B8/10602413
https://zhuanlan.zhihu.com/p/99777087
1.5 浏览器渲染过程
浏览器从服务器下载完文件后,就需要对其进行解析和渲染,流程如下:
解析:
- HTML Parser 将 HTML 解析转换成 DOM 树;
- CSS Parser 将样式表解析转换成 CSS 规则树;
- 转换完成的 DOM 树和 CSS 规则树 Attachment(附加) 在一起,并生成一个 Render Tree(渲染树);
- 需要注意的是,在生成 Render Tree 并不会立即进行绘制,中间还会有一个 Layout(布局) 操作,也就是布局引擎;为什么需要布局引擎再对 Render Tree 进行操作?因为不同时候浏览器所处的状态是不一样的(比如浏览器宽度),Layout 的作用就是确定元素具体的展示位置和展示效果;
- 有了最终的 Render Tree,浏览器就进行 Painting(绘制),最后进行 Display 展示;
可以发现上图中还有一个紫色的 DOM 三角,实际上这里是 js 对 DOM 的相关操作;在 HTML 解析时,如果遇到 JavaScript 标签,就会停止解析 HTML,而去加载和执行 JavaScript 代码;那么,JavaScript 代码由谁来执行呢?下面该 JavaScript 引擎出场了。
1.6 认识JavaScript引擎
为什么需要 JavaScript 引擎呢? 高级的编程语言都是需要转成最终的机器指令来执行的,事实上我们编写的 JavaScript 无论你交给浏览器或者 Node 执行,最后都是需要被 CPU 执行的,但是 CPU 只认识自己的指令集,实际上是机器语言,才能被 CPU 所执行,所以我们需要 JavaScript 引擎帮助我们将 JavaScript 代码翻译成 CPU 指令来执行。
比较常见的 JavaScript 引擎有哪些呢? https://blog.51cto.com/u_12970/6565469
https://www.jianshu.com/p/4e0205726fb5
浏览器内核和 JavaScript 引擎的关系? 浏览器内核和 JavaScript 引擎是浏览器中两个不同但密切相关的组件,它们共同负责解析和执行网页中的 JavaScript 代码,但其功能和职责略有不同。浏览器内核: 浏览器内核是浏览器的核心组件之一,负责解析 HTML、CSS 和 JavaScript 等网页内容,并将其呈现给用户。浏览器内核通常包括渲染引擎(用于解析和渲染 HTML、CSS) 和 JavaScript 引擎。不同的浏览器使用不同的内核,例如:
- WebKit:用于 Safari 浏览器和一些其他浏览器(如早期版本的 Chrome 和 Opera)。
- Blink:是基于 WebKit 的一个分支,目前用于 Chrome 浏览器和大多数基于 Chromium 的浏览器。
- Gecko:用于 Firefox 浏览器。
- Trident:用于早期版本的 Internet Explorer 浏览器。
- EdgeHTML:用于 Edge 浏览器的早期版本。
JavaScript 引擎: JavaScript 引擎是解析和执行 JavaScript 代码的组件。它负责将 JavaScript 代码转换为计算机可执行的指令,并在运行时处理变量、函数、对象等。常见的 JavaScript 引擎包括 V8、SpiderMonkey、JavaScriptCore 等。在浏览器内核中,JavaScript 引擎负责处理网页中的 JavaScript 代码,以便在用户的浏览器中执行。虽然浏览器内核通常包括 JavaScript 引擎,但它们并不是同一个概念。浏览器内核还包括其他组件,如渲染引擎、网络模块等,而 JavaScript 引擎专门负责处理 JavaScript 代码的执行。在大多数现代浏览器中,JavaScript 引擎是浏览器内核中的一个重要组成部分,负责执行网页中的 JavaScript 代码,从而实现交互性和动态性。 以 WebKit 为例,参考文章:https://blog.csdn.net/qq_44918090/article/details/131640533 在小程序中编写的 JavaScript 代码就是被 JSCore 执行的:
1.7 V8引擎以及JavaScript的执行过程
下面一起深入了解一下强大的 V8 引擎。先了解一下官方对 V8 引擎的定义:
- V8 引擎使用 C++ 编写的 Google 开源高性能 JavaScript 和 WebAssembly 引擎,它用于 Chrome 和 Node.js 等,可以独立运行,也可以嵌入到任何 C++ 的应用程序中。
- 所以说 V8 并不单单只是服务于 JavaScript 的,还可以用于 WebAssembly(一种用于基于堆栈的虚拟机的二进制指令格式),并且可以运行在多个平台。
- 下图简单的展示了 V8 的底层架构:
V8 的底层架构主要有三个核心模块(Parse、Ignition 和 TurboFan),接下来对上面架构图进行详细说明。
① Parse 模块: 将 JavaScript 代码转换成 AST(抽象语法树)。该过程主要对 JavaScript 源代码进行词法分析和语法分析;词法分析:对代码中的每一个词或符号进行解析,最终会生成很多 tokens)一个数组,里面包含很多对象);比如,对 const name = 'amo'
这一行代码进行词法分析:
// 首先对const进行解析,因为const为一个关键字,所以类型会被记为一个关键词,值为const
tokens: [
{ type: 'keyword', value: 'const' }
]
// 接着对name进行解析,因为name为一个标识符,所以类型会被记为一个标识符,值为name
tokens: [
{ type: 'keyword', value: 'const' },
{ type: 'identifier', value: 'name' }
]
// 以此类推...
语法分析:在词法分析的基础上,拿到 tokens 中的一个个对象,根据它们不同的类型再进一步分析具体语法,最终生成 AST;以上即为简单的 JS 词法分析和语法分析过程介绍,如果想详细查看我们的 JavaScript 代码在通过 Parse 转换后的 AST,可以使用 AST Explorer 工具:AST 在前端应用场景特别多,比如将 TypeScript 代码转成 JavaScript 代码、ES6转ES5、还有像 Vue 中的 template 等,都是先将其转换成对应的 AST,然后再生成目标代码;参考官方文档:https://v8.dev/blog/scanner
② Ignition 模块: 一个解释器,可以将 AST 转换成 ByteCode(字节码)。字节码(Byte-code):是一种包含执行程序,由一序列 op 代码/数据对组成的二进制文件,是一种中间码。将 JS 代码转成 AST 是便于引擎对其进行操作,前面说到 JS 代码最终是转成机器码给 CPU 执行的,为什么还要先转换成字节码呢?因为 JS 运行所处的环境是不一定的,可能是 Windows 或 Linux 或 iOS,不同的操作系统其 CPU 所能识别的机器指令也是不一样的。字节码是一种中间码,本身就有跨平台的特性,然后 V8 引擎再根据当前所处的环境将字节码编译成对应的机器指令给当前环境的 CPU 执行。参考官方文档:https://v8.dev/blog/ignition-interpreter
③ TurboFan 模块: 一个编译器,可以将字节码编译为 CPU 认识的机器码。在了解 TurboFan 模块之前可以先考虑一个问题,如果每执行一次代码,就要先将 AST 转成字节码然后再解析成机器指令,是不是有点损耗性能呢?强大的 V8 早就考虑到了,所以出现了 TurboFan 这么一个库;TurboFan 可以获取到 Ignition 收集的一些信息,如果一个函数在代码中被多次调用,那么就会被标记为热点函数,然后经过 TurboFan 转换成优化的机器码,再次执行该函数的时候就直接执行该机器码,提高代码的执行性能;图中还存在一个 Deoptimization 过程,其实就是机器码被还原成 ByteCode,比如,在后续执行代码的过程中传入热点函数的参数类型发生了变化(如果给 sum 函数传入 number 类型的参数,那么就是做加法;如果给 sum 函数传入 String 类型的参数,那么就是做字符串拼接),可能之前优化的机器码就不能满足需求了,就会逆向转成字节码,字节码再编译成正确的机器码进行执行;从这里就可以发现,如果在编写代码时给函数传递固定类型的参数,是可以从一定程度上优化我们代码执行效率的,所以 TypeScript 编译出来的 JavaScript 代码的性能是比较好的;参考官方文档:https://v8.dev/blog/turbofan-jit
1.8 V8引擎执行过程
V8 引擎的官方在 Parse 过程提供了以下这幅图,最后就来详细了解一下 Parse 具体的执行过程。
解析:
-
Blink 内核将 JS 源码交给 V8 引擎;
-
Stream 获取到 JS 源码进行编码转换;
-
Scanner 进行词法分析,将代码转换成 tokens;
-
经过语法分析后,tokens 会被转换成 AST,中间会经过 Parser 和 PreParser 过程:
- Parser:直接解析,将 tokens 转成 AST 树;
- PreParser:预解析(为什么需要预解析?)。因为并不是所有的 JavaScript 代码,在一开始时就会执行的,如果一股脑对所有 JavaScript 代码进行解析,必然会影响性能,所以 V8 就实现了 Lazy Parsing(延迟解析) 方案,对不必要的函数代码进行预解析,也就是先解析急需要执行的代码内容,对函数的全量解析会放到函数被调用时进行。
-
生成 AST 后,会被 Ignition 转换成字节码,然后转成机器码,最后就是代码的执行过程了;
二、JavaScript的执行过程
编写一段 JavaScript 代码,它是如何执行的呢?简单来说,JavaScript 引擎在执行 JavaScript 代码的过程中需要先解析再执行。那么在解析阶段 JavaScript 引擎又会进行哪些操作,接下来就一起来了解一下 JavaScript 在执行过程中的详细过程,包括 执行上下文、GO、AO、VO 和 VE 等概念的理解。PS:我理解的是在创建函数 AO 的时候其实是不会对函数中定义的变量进行赋值的,与全局对象一样,最开始会收集函数中定义的变量且默认值都为 undefined,只有真正在执行函数体中的代码时才会进行赋值操作,下文画图的时候为了方便直接在创建 AO 对象时就将值写了上去,大家注意。
2.1 初始化全局对象
首先,JavaScript 引擎会在执行代码之前,也就是解析代码时,会在我们的堆内存创建一个全局对象:Global Object(简称GO),观察以下代码,在全局中定义了几个变量:
var name = 'amo'
var message = 'I have a dream'
var num = 18
JavaScript 引擎内部在解析以上代码时,会创建一个全局对象(伪代码如下):
-
所有的作用域(scope)都可以访问该全局对象;
-
对象里面会包含一些全局的方法和类,像 Math、Date、String、Array、setTimeout 等等;
-
其中有一个 window 属性是指向该全局对象自身的;
-
该对象中会收集我们上面全局定义的变量,并设置成 undefined;
-
全局对象是非常重要的,我们平时之所以能够使用这些全局方法和类,都是在这个全局对象中获取的;
var GlobalObject = { Math: '类', Date: '类', String: '类', setTimeout: '函数', setInterval: '函数', window: GlobalObject, ... name: undefined, message: undefined, num: undefined }
2.2 执行上下文栈(调用栈)
了解了什么是全局对象后,下面就来聊聊代码具体执行的地方。JavaScript 引擎为了执行代码,引擎内部会有一个 执行上下文栈(Execution Context Stack,简称 ECS), 它是用来执行代码的调用栈。
① ECS如何执行?先执行谁呢?
无疑是先执行我们的全局代码块;在执行前全局代码会构建一个全局执行上下文(Global Execution Context,简称 GEC);一开始 GEC 就会被放入到 ECS 中执行;
② 那么全局执行上下文(GEC)包含那些内容呢?
第一部分:执行代码前。在转成抽象语法树之前,会将全局定义的变量、函数等加入到 Global Object 中,也就是上面初始化全局对象的过程;但是并不会真正赋值(表现为 undefined),所以这个过程也称之为变量的作用域提升(hoisting);第二部分:代码执行。对变量进行赋值,或者执行其它函数等;下面就通过一幅图,来看看 GEC 被放入 ECS 后的表现形式:
2.3 调用栈调用GEC的过程
接下来,将全局代码复杂化一点,再来看看调用栈调用全局执行上下文(GEC)的过程。示例代码:
var name = 'curry'
console.log(message)
var message = 'I am a coder'
function foo() {
var name = 'foo'
console.log(name)
}
var num1 = 30
var num2 = 20
var result = num1 + num2
foo()
调用栈调用过程:
- 初始化全局对象。这里需要注意的是函数存放的是地址,会指向函数对象,与普通变量有所不同;从上往下解析 JS 代码,当解析到 foo 函数时,因为 foo 不是普通变量,并不会赋为 undefined,JS 引擎会在堆内存中开辟一块空间存放 foo 函数,在全局对象中引用其地址;这个开辟的函数存储空间最主要存放了该函数的父级作用域和函数的执行体代码块;
- 构建一个全局执行上下文(GEC),代码执行前将 VO 的内存地址指向 GlobalObject(GO)。
- 将全局执行上下文(GEC)放入执行上下文栈(ECS)中。
- 从上往下开始执行全局代码,依次对 GO 对象中的全局变量进行赋值。
- 当执行
var name = 'curry'
时,就从 VO(对应的就是GO)中找到 name 属性赋值为 curry; - 接下来执行 console.log(message),就从 VO 中找到 message,注意此时的 message 还为 undefined,因为 message 真正赋值在下一行代码,所以就直接打印 undefined(也就是我们经常说的变量作用域提升);
- 后面就依次进行赋值,执行到
var result = num1 + num2
,也是从 VO 中找到 num1 和 num2 两个属性的值进行相加,然后赋值给 result,result 最终就为 50; - 最后执行到 foo(),也就是需要去执行 foo 函数了,这里的操作是比较特殊的,涉及到函数执行上下文,下面来详细了解;
- 当执行
2.4 函数执行上下文
在执行全局代码遇到函数如何执行呢?在执行的过程中遇到函数,就会根据函数体创建一个 函数执行上下文(Functional Execution Context,简称 FEC), 并且加入到执行上下文栈(ECS)中。函数执行上下文(FEC)包含三部分内容:
- AO:在解析函数时,会创建一个 Activation Objec(AO);
- 作用域链:由函数 VO 和父级 VO 组成,查找是一层层往外层查找;
- this 指向:this 绑定的值,在函数执行时确定;
其实全局执行上下文(GEC)也有自己的作用域链和 this 指向,只是它对应的作用域链就是自己本身,而 this 指向为 Window。继续来看上面的代码执行,当执行到 foo() 时:先找到 foo 函数的存储地址,然后解析 foo 函数,生成函数的 AO;根据 AO 生成函数执行上下文(FEC),并将其放入执行上下文栈(ECS)中;开始执行 foo 函数内代码,依次找到 AO 中的属性并赋值,当执行 console.log(name)
时,就会去 foo 的 VO(对应的就是 foo 函数的 AO)中找到 name 属性值并打印;
2.5 变量环境和记录
上文中提到了很多次 VO,那么 VO 到底是什么呢?下面从 ECMA 新旧版本规范中来谈谈 VO。在早期 ECMA 的版本规范中:每一个执行上下文会被关联到一个变量环境(Variable Object,简称 VO),在源代码中的变量和函数声明会被作为属性添加到 VO 中。对应函数来说,参数也会被添加到 VO 中。也就是上面所创建的 GO 或者 AO 都会被关联到变量环境(VO)上,可以通过 VO 查找到需要的属性;规定了 VO 为 Object 类型,上文所提到的 GO 和 AO 都是 Object 类型;在最新 ECMA 的版本规范中:每一个执行上下文会关联到一个变量环境(Variable Environment,简称 VE),在执行代码中变量和函数的声明会作为环境记录(Environment Record)添加到变量环境中。对于函数来说,参数也会被作为环境记录添加到变量环境中。也就是相比于早期的版本规范,对于变量环境,已经去除了 VO 这个概念,提出了一个新的概念 VE;没有规定 VE 必须为 Object,不同的 JS 引擎可以使用不同的类型,作为一条环境记录添加进去即可;虽然新版本规范将变量环境改成了 VE,但是 JavaScript 的执行过程还是不变的,只是关联的变量环境不同,将 VE 看成 VO 即可;
2.6 全局代码执行过程(函数嵌套)
了解了上面相关的概念和调用流程之后,就来看一下存在函数嵌套调用的代码是如何执行的,以及执行过程中的一些细节,以下面代码为例:
var message = 'global'
function foo(m) {
var message = 'foo'
console.log(m)
function bar() {
console.log(message)
}
bar()
}
foo(30)
① 初始化全局对象(GO),执行全局代码前创建 GEC,并将 GO 关联到 VO,然后将 GEC 加入 ECS 中:foo 函数存储空间中指定的父级作用域为全局对象;
② 开始执行全局代码,从上往下依次给全局属性赋值(给 message 属性赋值为 global):
③ 执行到 foo 函数调用,准备执行 foo 函数前,创建 foo 函数的 AO:bar 函数存储空间中指定父级作用域为 foo 函数的 AO;
④ 创建 foo 函数的 FEC,并加入到 ECS 中,然后开始执行 foo 函数体内的代码:根据 foo 函数调用的传参,给形参 m 赋值为 30,接着给 message 属性赋值为 foo;所以,m 打印结果为 30;
⑤ 执行到 bar 函数调用,准备执行 bar 函数前,创建 bar 函数的 AO:bar 函数中没有定义属性和声明函数,以空对象表示;
⑥ 创建 bar 函数的 FEC,并加入到 ECS 中,然后开始执行 bar 函数体内的代码:执行 console.log(message)
,会先去 bar 函数自己的 VO 中找 message,没有找到就往上层作用域的 VO 中找;这里 bar 函数的父级作用域为 foo 函数,所以找到 foo 函数 VO 中的 message 为 foo,打印结果为 foo;
⑦ 全局中所有代码执行完成,bar 函数执行上下文出栈,bar 函数 AO 对象失去了引用,进行销毁。接着 foo 函数执行上下文出栈,foo 函数 AO 对象失去了引用,进行销毁,同样,foo 函数 AO 对象销毁后,bar 函数的存储空间也失去引用,进行销毁。
2.7 小结
① 函数在执行前就已经确定了其父级作用域,与函数在哪执行没有关系,以函数声明的位置为主;
② 执行代码查找变量属性时,会沿着作用域链一层层往上查找(沿着 VO 往上找),如果一直找到全局对象中还没有该变量属性,就会报错未定义;
③ 上文中提到了很多概念名词,下面来总结一下:
ECS 执行上下文栈(Execution Context Stack),也可称为调用栈,以栈的形式调用创建的执行上下文
GEC 全局执行上下文(Global Execution Context),在执行全局代码前创建
FEC 函数执行上下文(Functional Execution Context),在执行函数前创建
VO Variable Object,早期ECMA规范中的变量环境,对应Object
VE Variable Environment,最新ECMA规范中的变量环境,对应环境记录
GO 全局对象(Global Object),解析全局代码时创建,GEC中关联的VO就是GO
AO 函数对象(Activation Object),解析函数体代码时创建,FEC中关联的VO就是AO
三、JavaScript的内存管理
3.1 什么是内存管理?
在了解 JavaScript 的内存管理之前,可以先大致熟悉一下什么是内存管理,不管什么样的编程语言,在其代码执行的过程中都是需要为其分配内存的。不管什么样的编程语言,以及它用什么方式来管理内存,其内存的管理都具备以下的生命周期:
- 申请内存:分配其需要的内存。
- 使用内存:使用分配的内存。
- 释放内存:使用完毕后,对其进行释放。
但是不同的编程语言对内存的申请和释放会有不同的实现,主要分为手动和自动管理内存:
- 手动管理内存:像 C、C++ 等一些接近底层的编程语言,都是需要手动来申请和释放内存(malloc 函数用于申请内存、free 函数用于释放内存)。
- 自动管理内存:像 Java、JavaScript、Python 等一些高级编程语言,都是自动帮助我们管理内存的。
3.2 JavaScript的内存分配
通过上面对内存管理的简单介绍可以知道,JavaScript 是自动管理内存的,所以在我们编写 JavaScript 代码定义变量时就会为其分配内存。根据 JavaScript 不同的数据类型,会对其分配到不同的内存空间中,数据类型主要分为基本数据类型和复杂数据类型:对于基本数据类型的内存分配会在执行时,直接在栈空间中进行分配。基本数据类型(也称值类型):string、number、boolean、undefined、null、symbol;对于复杂数据类型的内存分配会在堆内存中开辟一块空间,变量引用其内存地址。复杂数据类型(也称引用类型):object、function、array;以下代码在内存结构中的表现形式如下:
var name = 'amo'
const age = 18
const info = {
name: 'jerry',
age: 30
}
图示:
3.3 JavaScript的垃圾回收机制
在管理内存的生命周期中是包括内存的释放,因为我们的内存大小是有限的,所以当代码执行完毕,不再需要内存的时候,那么就需要对其进行内存释放,以便腾出更多的内存空间给其它的应用程序使用。而在手动管理内存的编程语言中,需要自己通过一些方式来释放不再需要的内存,这样就需要编写专门用于管理内存的代码,不仅影响编写代码的效率,管理不当也有可能产生内存泄露。所以大部分现代的编程语言都是有自己的垃圾回收机制的,那么什么是垃圾回收机制?垃圾回收(Garbage Collection,简称 GC), 就是对于那些不再使用的数据,都可以称之为垃圾,需要通过回收来释放内存空间;在 JavaScript 的运行环境 JS 引擎中就存在垃圾回收的功能模块,这个功能模块就称为垃圾回收器;那么这里就可以提出一个疑问,GC 是如何找到不再使用的数据,并对其进行内存回收呢?这里就用到了 GC 算法,下面介绍两种常见的 GC 算法;
3.4 两种常见的GC算法
3.4.1 引用计数
什么是引用计数?当一个对象有一个引用指向它时,那么这个对象的引用就加1,并且将其引用次数保存起来,而当一个对象的引用为 0
时,那么这个对象就可以被销毁了(回收)。示例代码:
let person1 = {name: 'amo'} // person1的引用次数为3
let person2 = {
name: 'jerry',
friend: person1
}
let person3 = {
name: 'bob',
friend: person1
} //person2和person3的引用次数为1
内存表现:
如果接着执行 person3 = null,那么 person3 的引用指向次数就会减1,变为0,从而销毁。而 person3 销毁后 person1 也会失去 person3 的指向,引用指向次数也会减1,变为2。缺点: 但是引用计数这个 GC 算法,存在一个很大的弊端,就是当出现循环引用时,就无法进行正确的回收,导致内存泄露,如下示例代码:
//amo的好朋友是jerry,巧合的是jerry的好朋友是amo,这样就出现了对象的循环引用
let person1 = {
name: 'amo',
friend: person2
}
let person2 = {
name: 'jerry',
friend: person1
}
内存表现:
即使执行 person1 = null;person2 = null
,person1 和 person2 对象的引用次数依然为1;所以引用计数就无法很好的处理这种情况了;
3.4.2 标记清除
什么是标记清除?这个算法设置了一个根对象(root object),GC 会定期从这个根对象开始往下查找有引用到的对象,而对于那些没有引用到的对象,也就是没有查找到的对象,就认为是需要进行回收的对象。标记清除的一大优势就是可以很好的解决循环引用的问题,如下图:
标记清除算法首先会从 root object 往下开始查找引用到的对象;而对于 object6 和 object7 进行了循环引用了的对象,是查找不到的,就会被视为回收对象,从而被 GC 回收;目前的 JavaScript 引擎的 GC 核心采用的比较多的算法就是标记清除,类似于 V8 引擎不单单只是用了标记清除,同时也结合了一些其它的算法来应对更多的情况。
说明
文章转载自@Amo Xiang,2024最新版JavaScript逆向爬虫教程-------基础篇之深入JavaScript运行原理以及内存管理