本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力。
我们知道JavaScript为我们提供了非常不错的事件循环机制,接下来我们看看事件循环到底是怎么工作的。
事件循环
官网说,JavaScript有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。
仔细来看看事件循环的责任,也就是他负责了什么:
- 执行代码
- 收集和处理事件
- 执行队列中的子任务
我们能够接收到这样的三个信息,基于这三个信息,我们来思考如下问题:
- 如何执行代码?以什么样的流程操作的?
- 如何收集事件?如何处理事件?是否存在事件的优先级,比如说宏任务/微任务先执行哪个?
- 如何执行队列中的子任务?
根据下图和图下的代码进行分析:
function foo(b) {
let a = 10;
return a + b + 11;
}
function bar(x) {
let y = 3;
return foo(x * y);
}
console.log(bar(7)); // 返回 42
栈 Stack
在JavaScript中,函数的调用行成了一个由若干帧组成的栈。每一帧是60Hz,大概是16.6ms。
上面代码为例,当调用bar时,第一帧被创建并压入栈中,这一帧包含了bar的参数和局部变量。
当bar调用foo的时候,第二个帧被创建并压入栈中,并放在第一个帧之上,第二帧中包含foo的参数和局部变量。
当foo执行完毕然后返回时,第二个帧就被弹出栈了。此时剩下bar函数调用帧。
当bar也执行完毕然后返回时,第一个栈也被弹出了,栈就被清空了。
堆 Heap
堆是用来表示一大块(通常是非结构化的)内存区域的计算机术语。
对象会被分配在堆中。比如:用一个变量存储对象,这个对象会在堆中有一个唯一标识,栈中的变量会保存这个唯一标识来确定你引用的是哪个对象。多个变量标识如果一样,证明引用了同一个对象。
队列 Queue
一段JavaScript代码运行时包含了一个待处理消息的消息队列。每一个消息都包含着用来处理这个消息的回调函数。
在事件循环期间的某个时刻,运行时会从最先进入队列中的消息开始处理。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。这个函数调用还会创建一个新的栈。
函数的处理会一直进行到栈空了为止;然后事件循环会继续处理任务队列中下一个消息。
执行完成
每一个消息被完整的执行完之后,其他消息才会被执行。
这样做会为程序的分析提供了一些优秀的特性,比如下面这个:
- 当一个函数被执行时不会被抢占,只有在他运行完毕之后才会运行其他代码,才会修改这个函数操作的数据。
当然这个模型也是存在缺点的:
- 当一个消息需要很久的时间才能处理完毕,Web应用程序会无法处理与用户的交互,比如滚动/点击。
一定养成好的习惯,缩短单个消息处理的时间。
添加消息
在浏览器里,每当有一个事件发生并且有一个事件监听绑定在改事件上时,一个消息就会被添加到事件队列中。如果没有事件监听器,这个事件将会丢失。
比如带有点击事件的元素被点击时,就会像其他事件一样产生一个消息。
setTimeout 函数是特殊的,他接受两个参数:待加入队列的消息 | 一个时间值(默认是0)。
这个时间表示消息实际被加入到队列的最小延时时间。意思就是:如果队列中没有其他消息,并且栈为空,在这段延时的时间过去之后,setTimeout 里的消息就会被立马处理。但是,如果有其他消息,setTimeout 必须等其他消息处理完才能在这段延时时间过去之后处理消息。
这就是常说的宏任务与微任务。setTimeout 作为宏任务,始终是等前面的微任务执行完之后,延迟执行。其他的宏任务还有JS文件,也就是前面的JS文件会作为优先后面的JS文件的宏任务。
永不阻塞
JavaScript事件循环模型与其它语言不同,有一个非常有趣的特性:永不阻塞。例如:在用户XHR请求返回时,我们任然可以进对输入框的输入。
会有一些例外,如alert或者同步XHR。
垃圾回收
垃圾回收在计算机编程中用于描述查找和删除那些不再被其他对象引用的对象的处理过程。
当某个程序占用一部分内存空间,并且不再被这个程序访问时,这个程序就会借助垃圾回收算法向操作系统归还这部分操作空间。
记不记得事件循环中的Heap,堆就是管理所有内存空间的地方。当有一个对象没有被使用到,也就是说谁也没有引用到这个对象。那就会被回收,会被垃圾回收算法进行处理。
如何处理的?我们接下来看看。
原理
垃圾回收有两个基本原理:
- 考虑某个对象在未来的程序运行中,将不会被访问。
- 回收这些对象所占用的存储器。
思考下面这个问题:
- 如何知道这个对象在未来会不被使用到?
跟踪收集器
跟踪收集器是通过算法运行的,他主要做的是定期遍历它所管理的内存空间。
它会从若干个根存储对象中开始查找与之相关的存储对象,然后标记其余的没有关联的存储对象,最后回收这些没有关联的存储对象占用的内存空间。
跟踪收集器中的大概运行流程我们明白了,接下来看看他都有哪些算法吧。
标记 - 清除
先暂停整个程序的全部运行线程,让回收线程以单线程进行扫描标记,并进行直接清除回收,然后回收完成后,恢复运行线程。
这样做会产生大量的空闲空间碎片,和使得大容量对象不容易获得连续的内存空间,从而造成浪费。
我们来举例子:
JS运行是单线程的,在运行之前会被进行垃圾回收,先暂停JS的运行,回收线程进行扫描,标记没有用到的对象,标记完成之后直接清除这个对象,回收存储器。最后恢复JS运行。
由于我们回收的空间可能不是连续的,就会导致产生空闲空间碎片,如果这个被回收的存储器左右皆是大容量对象进行存储,这样大容量对象就不会有连续的存储空间,从而造成浪费。
为什么不连续会造成浪费?如果一个变量应用这个大容量对象,本来应该是找到下一个存储器就可以,由于不连续,得找下下一个,这就是浪费。
标记 - 压缩
和标记 - 清除相似,不同的是,回收期间同时会将保留的存储对象搬运到连续的内存空间。
这样会对空闲的空间进行整合,避免内存碎片化。
复制
需要程序将所拥有的空间分成两个部分。程序运行所需要的存储对象会被先存储在一个分区(分区0)。同样暂停整个运行程序的全部运行线程,进行标记之后,回首期间将保留的存储对象搬运汇集到另一个分区(分区1),完成回收,程序在本次回收之后将接下来产生的存储对象存储到“分区1”。在下一次回收时,将两个分区角色对调。
增量回收器
需要程序将所拥有的内存空间分成若干个分区。程序运行时所需要的存储对象会分布在这些分区中,每次只对一个分区进行回收操作。
这样避免了暂停所有正在运行的线程来进行回收,允许部分线程在不影响回收行为下保持运行,降低了回收时间,增加了响应速度。
分代
由于“复制”算法对于存活时间长,大容量的存储对象移动时消耗时间长,和存在存储对象的存活时间的差异。需要程序将所有的内存空间分成若干分区,并标记为年轻代空间和老年代空间。
运行时所需存储的对象会先被存储到年轻代分区。
年轻代分区会较为频繁的进行垃圾回收行为,每次回收完成,幸存的存储对象内的寿命计数器会+1。当年轻代分区存储对象的寿命计数器达到了一定阀值或存储对象占用的空间超出了一定阀值时,会被移动到老年代分区。
老年代分区有较少运行的垃圾回收行为。
一般情况下还有永久代空间,用于程序整个运行的生命周期的对象存储,例如运行代码、数据常量等。该空间不会运行垃圾回收的操作。
通过这样分代处理,存活在局限域、小容量、寿命短的存储对象会被快速回收;存活在全局域、大容量、寿命长的存储对象就较少的被回收行为所处理。
总结
到此本篇文章就讲完了事件循环和垃圾回收的工作机制,来简单回顾一下。
- 事件回收:
- 栈(Stack):队列中消息被压入
- 堆(Heap):栈中引用对象变量。存储对象的内存。
- 队列(Queue):消息的执行顺序。
- 垃圾回收:针对堆中,存储对象如何存储,如何回收,通过相关算法实现。
- 标记 - 清除:碎片多、不连续。
- 标记 - 压缩:减少了碎片、使得空间连续,减少浪费。但是时间相对较长,需要整理空间。
- 复制:移动会有时间损耗。存储对象存活时间有差异。
- 增量回收器:避免暂停了所有线程。降低了回收时间,增加了响应速度。
- 分代:年轻代分区、老年代分区。更好的优化了复制算法。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!