首先,献祭一张图:并发模型与事件循环 - JavaScript | MDN
一、先来一个例子
不知道上图有多少人能够看懂,不过不要紧,今天我们就一次性讲清楚。
我尽量用最少的代码,来解释这些概念。
请大家看下下面的代码,来思考一下js的内存分布以及事件循环。
javascript
var globalVar = "全局对象";
function outerFunction() {
var outerVar = "外部函数变量";
var newUser = { name: "蒙多", age: 25 };
function innerFunction() {
var innerVar = "内部函数变量";
setTimeout(function () {
console.log("1s计时器结束");
}, 1000);
document.getElementById("myButton").addEventListener("click", function () {
console.log(newUser.name);
});
Promise.resolve().then(function () {
console.log("微任务结束");
});
}
innerFunction();
}
outerFunction();
var newUser = { name: "艾希", age: 25 };
console.log(newUser.name);
二、内存管理
重点提示:栈中操作的单元是"栈帧",每个栈帧对应独立的上下文。
内存布局
JavaScript 的内存布局主要包括堆和栈。栈内存用于存储函数调用帧和原始类型数据,而堆内存用于存储对象和复杂数据结构。
我们先绘制第一部分的代码内存布局:
在上图中,栈包含多个调用帧,堆中存储着多个对象。js运行时负责压栈操作。
内存布局主要涉及栈和堆两部分:
- 栈:用于存储函数调用帧(Call Stack),包括函数的局部变量和原始类型数据。即是运行时空间,又是存储空间。
- 堆:用于存储引用类型的数据,如对象和函数。
栈帧
每次调用一个函数时,会在栈上创建一个新的栈帧,包含该函数的局部变量、参数和返回地址。引用类型的数据存储在堆中,并通过栈中的引用进行访问。
每一帧都是一个作用域或者上下文(scope),可以在chrome浏览器上看到:
每一个栈帧中,作用于里面复杂类型都是引用类型,基础类型都是copy;
对于在同一个栈中的所有帧,都可以进行 "时光倒流" 。对于同一个栈,可以回退到栈底方向的任意一帧。(有消息队列的时候不行)。
对于纯函数来说,这是一个非常重要的调试技巧。
闭包的形成
栈帧中,从下至上是global->outerFuntion->innerFunction,当一个函数(栈帧)返回的对象是一个函数的时候,就形成了一个闭包。
修改最开始的代码:
ini
var globalVar = "全局对象";
function outerFunction() {
var outerVar = "外部函数变量";
function innerFunction() {
var innerVar = "内部函数变量";
console.log(outerVar);
}
return innerFunction;
}
outerFunction()();
当前程序中,当我们执行闭包的时候,会发现栈帧列表变成了 global->innerFunction。
如果按照没有闭包的话,全局作用于是无法直接访问到innerFunction的。
闭包的优劣势
闭包可以缩短调用的层级;
闭包可以访问已经被移除栈的outerFunction的内部对象,保留对象的引用关系;
闭包在当前程序中,并未引起内存泄漏,但是,在大型项目中,由于运行时间较长,结构复杂,很难把控闭包中引用关系的清除。
防漏
JavaScript引擎通过垃圾回收机制自动管理内存。垃圾回收器会定期检查堆中的对象,回收不再使用的对象所占用的内存。常见的垃圾回收算法包括:
标记-清除(Mark-and-Sweep):
- 垃圾回收器从根对象(如全局对象、当前执行上下文中的局部变量)开始,递归标记所有可达对象。
- 未被标记的对象被视为垃圾,内存被回收。
分代回收(Generational Collection):
- 堆内存分为新生代和老年代。
- 新生代存储生命周期短的对象,老年代存储生命周期长的对象。
- 垃圾回收器对新生代进行频繁回收,对老年代进行较少的回收。
防漏方案
根据上面的一通分析,我们可以关注与EventLoop以及闭包相关的内存泄漏问题。
对于闭包,需要谨慎使用,因为闭包会保留对其外部函数作用域的引用,这可能导致内存泄漏。具体来说,如果闭包中的引用没有被正确管理,或者引用了大量不必要的数据,就会导致内存泄漏。
对于setTimeout
、setInterval
或事件监听器,即使相关的对象已经不再需要,它们也无法被回收,从而导致内存泄漏。一定要记得移除。
三、事件循环与消息队列
JavaScript 是单线程的,这意味着它一次只能执行一个任务。事件循环负责管理任务的执行顺序,而消息队列则存储待处理的任务。
我们先绘制一下任务调用的图例:
在上图中,当调用栈执行主任务结束后,事件循环开始检查消息队列中的任务,并将任务推送到调用栈中执行。
对于事件循环来说,由于是"异步"的,所以会很难把控已经执行过的栈对堆内存的影响,对于 "时光倒流" 来说不可控,因此,无法倒流到主任务的栈帧上。
为了进一步研究,我们将调用时序进行简化,只保留事件循环、消息队列、调用栈
事件触发机制
事件可以由用户操作(如点击、输入)或其他线程(如计时器、网络请求)触发,但事件的处理始终在 JavaScript 单线程中进行。
谁控制消息队列与事件循环
JavaScript 的运行时环境(如浏览器或 Node.js)负责维护消息队列和执行事件循环。消息队列由运行时环境中的一个或多个线程维护,但执行事件循环的线程是单独的 JavaScript 线程。
宏任务微任务
上面的示例,我们忽略了这两者,因为他们都是消息队列的一种类型。废话不多说,直接上图:
用户事件
还记得有一个绑定用户点击的事件回调么?这个不属于js引擎核心部分,这里属于浏览器与js交流部分,与计时器、Promise一样,它也会触发EventLoop。这也解释了,为什么主程序已经执行结束了,还能再次调用堆栈进行运行。
用户事件也会放入消息队列中。如果说它属于什么类型的任务,应该算是"宏任务"(除了微任务,就是宏任务)。
事件循环:微任务
事件循环:宏任务
事件循环:用户输入
总结
你可能已经忘记最上面的一张图长什么样子了,我们来还原一下。
上面的例子我已经尽量做到精简并且保留主要核心概念。
理解 JavaScript 的内存管理、事件循环和消息队列是编写高效代码的关键。通过可视化和示例代码,我们可以更清晰地理解这些概念,并在实际开发中应用这些知识来优化应用程序的性能和响应速度。
在 JavaScript 中,内存管理涉及堆和栈的分配与释放。复杂数据结构如对象和数组存储在堆内存中,而原始数据类型和函数调用帧则存储在栈内存中。全局对象通常存储在堆内存中,确保在整个应用程序生命周期内都可访问。
事件循环与消息队列的关系决定了 JavaScript 如何处理异步操作。运行时环境(如浏览器或 Node.js)负责维护消息队列,并在适当的时机将回调函数推送到调用栈中执行。事件触发机制可以来自用户操作或其他线程,但事件处理始终在 JavaScript 的单线程中进行。