js运行机制、js内存、v8回收机制

js运行机制

进程与线程

进程是cpu资源分配的最小单位,进程可以包含多个线程。 浏览器就是多进程的,每打开的一个浏览器窗口就是一个进程。

线程是cpu调度的最小单位,同一进程下的各个线程之间共享程序的内存空间。

可以把进程看做一个仓库,线程是可以运输的货车,每个仓库有属于自己的多辆货车为仓库服务(运货),每个仓库可以同时由多辆车同时拉货,但是每辆车同一时间只能干一件事,就是运输本次的货物。

渲染进程

浏览器包括4个进程:

  • 主进程(Browser进程),浏览器只有一个主进程,负责资源下载,界面展示等主要基础功能
  • GPU进程,负责3D图示绘制
  • 第三方插件进程,负责第三方插件处理
  • 渲染进程(Renderer进程),负责js执行,页面渲染等功能,渲染进程主要包括GUI渲染线程、Js引擎线程、事件循环线程、定时器线程、http异步线程。
GUI渲染线程

先看看浏览器得到一个网站资源后干了哪些事:

  • 首先浏览器会解析html代码(实际上html代码本质是字符串)转化为浏览器认识的节点,生成DOM树,也就是DOM Tree
  • 然后解析css,生成CSSOM(CSS规则树)
  • 把DOM Tree 和CSSOM结合,生成Rendering Tree(渲染树)

GUI就是来干这个事情的,如果修改了一些元素的颜色或者背景色,页面就会重绘(Repaint),如果修改元素的尺寸,页面就会回流(Reflow),当页面需要Repaing和Reflow时GUI多会执行,进行页面绘制。

这里提示一点:Reflow比Repaint的成本更高

JS引擎线程

js引擎线程就是js内核,负责解析与执行js代码,也称为主线程。浏览器同时只能有一个JS引擎线程在运行JS程序,所以js是单线程运行的。

需要注意的是,js引擎线程和GUI渲染线程同时只能有一个工作,js引擎线程会阻塞GUI渲染线程,在浏览器渲染的时候遇到script标签,就会停止GUI的渲染,然后js引擎线程开始工作,执行里面的js代码,等js执行完毕,js引擎线程停止工作,GUI继续渲染下面的内容。所以如果js执行时间太长就会造成页面卡顿的情况

事件循环线程

事件循环线程用来管理控制事件循环,并且管理着一个事件队列(task queue),当js执行碰到事件绑定和一些异步操作时,会把对应的事件添加到对应的线程中(比如定时器操作,便把定时器事件添加到定时器线程),等异步事件有了结果,便把他们的回调操作添加到事件队列,等待js引擎线程空闲时来处理。

定时器线程

由于js是单线程运行,所以不能抽出时间来计时,只能另开辟一个线程来处理定时器任务,等计时完成,把定时器要执行的操作添加到事件任务队列,等待js引擎线程来处理。这个线程就是定时器线程。

异步请求线程

当执行到一个http异步请求时,便把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),把回调函数添加到事件队列,等待js引擎线程来执行。

Event Loop

上面介绍了渲染进程中的5个主要的线程,下面就从Event Loop的角度来聊一聊他们之间是怎样合作的。

  • 当javascript代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。 但是我们这里说的执行栈和上面这个栈的意义却有些不同。

  • 我们知道,当每次调用一个方法的时候,js会生成一个与这个方法对应的执行环境(context),又叫执行上下文这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的this对象。 而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈
    在这里插入图片描述

  • 当一个脚本第一次执行的时候,js引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么js会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码 执行完毕并返回结果后,js会退出这个执行环境并把这个执行环境销毁,相应的栈内存中的内存也会销毁,回到上一个方法的执行环境。这个过程反复进行,直到执行栈中的代码全部执行完毕。

  • 一个方法执行会向执行栈中加入这个方法的执行环境,在这个执行环境中还可以调用其他方法,甚至是自己,其结果不过是在执行栈中再添加一个执行环境。这个过程可以是无限进行下去的,除非发生了栈溢出,即超过了所能使用内存的最大值。

以上的过程说的都是同步代码的执行。那么当一个异步代码(如发送ajax请求数据)执行后会如何呢?js的特点是非阻塞,实现这一点的关键在于下面要说的这项机制——事件队列(Task Queue)。

  • js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起(比如:定时操作在定时器线程上;http请求则在异步请求线程上处理),继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码…,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为事件循环(Event Loop)
    在这里插入图片描述
    图中的stack表示我们所说的执行栈,web apis则是代表异步事件,callback queue即事件队列。
console.log(1)
setTimeout(()=>{
	console.log(4)
},2000)
setTimeout(()=>{
	console.log(3)
},1000)
console.log(2)
// 1 2   1秒后打印3   再等1秒打印4
  • 以上的事件循环过程是一个宏观的表述,实际上因为异步任务之间并不相同,因此他们的执行优先级也有区别。不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

宏任务(macrotask)::

script(整体代码)、setTimeout、setInterval、UI 渲染、 I/O、postMessage、 MessageChannel、setImmediate(Node.js 环境)

微任务(microtask):

Promise、 MutaionObserver、process.nextTick(Node.js环境)、MutationObserver(html5新特性)

  • 前面我们介绍过,在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件会被放在对应的宏任务队列或者微任务队列中去。并且在执行栈为空的时候,主线程会先查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈…如此反复,进入循环。
console.log(1)
setTimeout(()=>{
	console.log(5)
	new Promise(function(resolve,reject){
	    console.log(6)
	    resolve()
	}).then(function(){
	    console.log(7);
	})
})
new Promise(function(resolve,reject){
    console.log(2)
    resolve()
}).then(function(){
    console.log(4);
})
setTimeout(()=>{
	console.log(8)
})
console.log(3)
//浏览器的结果 1 2 3 4 5 6 7 8 
//node中的结果 1 2 3 4 5 6 8 7

js内存

  • 全局执行上下文:只有一个,浏览器中的全局对象就是 window 对象,this 指向这个全局对象。

  • 函数执行上下文:存在无数个,只有在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文。

  • Eval 函数执行上下文: 指的是运行在 eval 函数中的代码,很少用而且不建议使用。

栈数据结构(stack):存放基本类型与引用类型的地址。

可以通过类比乒乓球盒子来分析,处于盒子中最顶层的乒乓球,它一定是最后被放进去,但可以最先被使用。而我们想要使用底层的乒乓球,就必须将上面的乒乓球取出来,让底层的乒乓球处于盒子顶层。这就是栈空间先进后出,后进先出的特点,栈底永远都是全局上下文,而栈顶就是当前正在执行的上下文。

堆数据结构(heap):引用类型

堆数据结构是一种树状结构。它的存取数据的方式,则与书架与书非常相似。书虽然也整齐的存放在书架上,但是我们只要知道书的名字,就可以很方便的取出我们想要的书,好比在JSON格式的数据中,我们存储的key-value是可以无序的,因为顺序的不同并不影响我们的使用,我们只需要关心书的名字。

队列数据结构

队列是一种先进先出(FIFO)的数据结构。正如排队过安检一样,排在队伍前面的人一定是最先过检的人

栈与堆存放数据区别

栈内存:Undefined、不是new出来的布尔、数字和字符串,它们都是直接按值存储在栈中的,每种类型的数据占用的内存空间的大小是确定的,并由系统自动分配和自动释放。这样带来的好处就是,内存可以及时得到回收,相对于堆来说,更加容易管理内存空间。

堆内存:引用类型的数据,如对象、数组、函数、Null等(typeof null 为 object),它们是通过拷贝和new出来的,这样数据的地址指针是存储于栈中的,当我们想要访问引用类型的值的时候,需要先从栈中获得对象的地址指针,然后,在通过地址指针找到堆中的所需要的数据。

在这里插入图片描述


var a = {n: 1};
var b = a;
a.x = a = {n: 2};

a.x 	// 这时 a.x 的值是undefined
b.x 	// 这时 b.x 的值是{n: 2}

在这里插入图片描述

回收机制

JavaScript拥有自动的垃圾回收机制,关于垃圾回收机制,有一个重要的行为,那就是,当一个值,在内存中失去引用时,js通过特定的算法来找到哪些对象是不再继续使用的(引用计数:现代浏览器不再使用,
标记清除:常用),使用a = null其实仅仅只是做了一个释放引用的操作,让 a 原本对应的值失去引用,脱离执行环境,这个值会在下一次垃圾收集器执行操作时被找到并释放。

[堆内存]
var o ={};当前对象对应的堆内存被变量o占用着呢,堆内存是无法销毁的。
o = null;null空对象指针,此时上一次的堆内存就没有被占用了。浏览器会在空闲时间把没有被占用的堆内存自动释放(销毁/回收)

[栈内存]
函数执行形成栈内存(执行上下文),函数执行完,生命周期结束,那么该函数的执行上下文就会失去引用,其占用的内存空间很快就会被垃圾回收器释放。可是闭包的存在,会阻止这一过程。(执行上下文(A),以及在该执行上下文中创建的函数(B)。当B执行时,如果访问了A中变量对象中的值,那么闭包就会产生。)

var fn = null;
function foo() {
	 var a = 2;
	 function innnerFoo() {
	   console.log(a);
	 }
	 fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
	fn(); // 此处的保留的innerFoo的引用
}
foo();
bar(); // 2

全局作用域在加载页面的时候执行,在关掉页面的时候销毁;
使用 Node 提供的 process.memoryUsage 方法可以查看内存使用。

console.log(process.memoryUsage());
// 输出
// { 
//   rss: 19656704,		// resident set size,所有内存占用,包括指令区和堆栈
//   heapTotal: 6537216,   // "堆"占用的内存,包括用到的和没用到的
//   heapUsed: 3842960,	// 用到的堆的部分
//   external: 8272 		// V8 引擎内部的 C++ 对象占用的内存
// }

V8回收机制

  • v8限制用户只能使用部分内存(64位约为1.4GB,32位约为0.7GB)

  • 原因:v8执行垃圾回收时会阻断js运行,以1.5GB的垃圾回收堆内存为例,v8做一次小的垃圾回收需要50ms以上,一次垃圾回收甚至要1秒以上。

  • 在自动垃圾回收的演变过程中,没有固定一种回收算法能胜任所有场景,V8采取分代式(新生代和老生代),可以对不同的代进行不能的处理,以提高效率。

堆空间:在这里插入图片描述

新生代 :主要采用Scavenge算法
  1. 过程:先在新生代区,将堆内存对半分,一半处于使用(即From区),另一半处于闲置(即To区),平时在From区进行操作。到了要回收的时候,检测From区的对象,存活的对象复制到To区,然后From区被释放了,之后对To区和From区调换名字,继续重复之前操作
  2. 缺点:只能使用一半空间,耗空间
  3. 优点:只需要复制少部分存活的对象,因为生命周期短的对象中存活的比较少,节省时间,典型的牺牲空间换时间

注意:当一个对象被复制多次依然存活,则被晋升到老生代,进行管理
(晋升条件:经历过Scavenge算法或To区使用超出25%)

老生代算法: 标记清除算法 搭配 标记整理算法
  1. 标记清除:标记清除算法,先将存活的对象进行标记,清除时只清除没有标记的(老生代中都是生命周期长的对象,刚好死亡的对象少),不过此时会出现内存不连续的情况。但是,如果此时需要放一个大对象,则放不下,从而导致再次引起回收。然而这次回收是不必要的。在这里插入图片描述
  2. 标记整理:先将死亡的对象进行标记,然后将存活得对象往一段移动,移动完成后,清理掉边界外的内存。在这里插入图片描述

V8主要还是使用标记清除,只有在新生代发生晋升,导致老生代分配不出足够空间时,采用标记整理

以上只是个人理解,不保证正确无疑

发布了8 篇原创文章 · 获赞 8 · 访问量 2336
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章