前端应该知道的浏览器中的内存知识
为了保证我们网页的稳定性,浏览器的内存知识对我们来说是十分必要的,我们不应该只考虑网页打开时的性能,也应该考虑网页长时间挂载下的稳定性。
本次梳理以Chrome为例。
chrome的内存限制
堆内存的限制是由 V8 来设置的。
存在限制
64位系统
物理内存 > 16G => 最大堆内存限制为4G
物理内存 <= 16G => 最大堆内存限制为2G
32位系统
最大堆内存限制为1G
堆内存是计算机系统中,当多个程序同时运行时,为了这些进程能够共享数据、交换信息而把它们的数据存放在一个连续的区域。它是一个连续的内存区域,在物理上并不存在。
何为内存
内存(Memory)是计算机的重要部件,也称内存储器和主存储器,它用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。它是外部存储器与CPU进行沟通的桥梁,计算机中所有程序的运行都在内存中进行,内存性能的强弱影响计算机整体发挥的水平。只要计算机开始运行,操作系统就会把需要运算的数据从内存调到CPU中进行运算,当运算完成,CPU将结果传送出来。
所以内存的运行决定计算机整体运行快慢。
为何限制
Chrome之所以限制了内存的大小,表面上的原因是V8最初是作为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景,而深层次的原因:则是由于V8的垃圾回收机制的限制。
由于V8需要保证JavaScript应用逻辑与垃圾回收器所看到的不一样,V8在执行垃圾回收时会阻塞 JavaScript应用逻辑,直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。
若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。这样浏览器将在1s内失去对用户的响应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。
chrome网页是如何占用内存的
chrome之所以很吃内存,是因为chrome使用了多进程机制,每一个chrome的标签页以及每一个扩展,都是独立的进程。在目前的chrome进程架构里,访问一个网站至少包含四个进程:一个浏览器进程、一个GPU进程、一个渲染进程和一个网络进程。除此之外还有包含多个插件进程组成chrome的进程架构。
1. V8
V8 是google 开发的开源高性能 javascript引擎,V8引擎用C++语言开发,被用在Google的chrome浏览器,android 浏览器js引擎默认也用V8。
V8最初是为了提高web浏览器中的JavaScript运行性能设计的。为了提升性能,V8将JavaScript代码翻译为更高效的机器语言,而不是使用解释程序。它通过实现一个JIT(Just-In-Time,即时) 编译器来将JavaScript代码编译为机器语言,就像很多现代JavaScript引擎如SpiderMonkey或Rhino(Mozilla)做的那样。V8和它们主要的区别是它不会生成字节码或其他中间代码。
1.1 V8如何执行JavaScript
V8执行js的主要流程如下:
- 准备执行JS需要的基础环境
- 解析源码生成ast和作用域
- 依据ast和作用域生成字节码
- 解释器解释执行字节码
- 监听热点代码
- 编译器优化热点代码为二进制的机器码
- 反优化二进制机器代码
1.1.1 准备执行JS需要的基础环境
这些基础环境包括:
- 堆空间和栈空间
- 全局执行上下文
- 全局作用域
- 内置函数
- 宿主环境提供的扩展函数和对象
- 事件循环系统
1. 堆空间
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,以及一些占用内存比较大的数据。
存在堆空间的:
- 函数
- 数组
- 在浏览器中还有 window 对象
- document 对象等
2. 栈空间
栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。
特点:
- 先进后出
- 空间连续
- 查找效率非常高
函数调用过程中,什么会存在栈里:
- 原生类型
- 引用到的对象的地址
- 函数的执行状态
- this 值等
3. 全局执行上下文
V8 初始化了基础的存储空间之后,接下来就需要初始化全局执行上下文和全局作用域。 当 V8 开始执行一段可执行代码时,会生成一个执行上下文来维护执行当前代码所需要的变量声明、this 指向等。
执行上下文中主要包含:
- 变量环境
- 词法环境:包含了使用 let、const 等变量的内容
- this 关键字
全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中。
4. 全局作用域
var x = 5
{
let y = 2
const z = 3
}
这段代码在执行时,会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中。
5. 内置函数
JavaScript的内置函数是浏览器内核自带的,不用任何函数库引入就可以直接使用的函数。JavaScript内置函数一共可分为五类:
- 常规函数
- 数组函数
- 日期函数
- 数学函数
- 字符串函数
6. 宿主环境提供的扩展函数和对象
什么是宿主环境?
宿主环境可以是浏览器中的渲染进程,可以是 Node.js 进程, 也可以是其他的定制开发的环境,而这些宿主则提供了很多 V8 执行 JavaScript 时所需的基础功能部件。
7. 事件循环系统(Event Loop)
V8 还需要有一个主线程,用来执行 JavaScript 和执行垃圾回收等工作。
V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的。
在执行完代码之后,为了让线程继续运行,通常的做法是在代码中添加一个循环语句,在循环语句中监听下个事件。
如果主线程正在执行一个任务,这时候又来了一个新任务,那么这种情况下就需要引入一个任务队列,这个任务队列是放在了事件触发线程,让新任务暂存到任务队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务。当执行完一个任务之后,我们的事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务。
事件循环系统主要用来处理任务的排队和任务的调度。
1.1.2 解析源码生成ast和作用域
V8接收到JavaScript源代码后,解析器(Parser)会对其进行词法分析和语法分析,结构化JavaScript字符串,生成AST(抽象语法树)。
解析代码需要时间,所以 JavaScript 引擎会尽可能避免完全解析源代码文件。另一方面,在一次用户访问中,页面中会有很多代码不会被执行到,比如,通过用户交互行为触发的动作。
正因为如此,所有主流浏览器都实现了惰性解析(Lazy Parsing)。解析器不必为每个函数生成 AST(Abstract Syntax tree,抽象语法树),而是可以决定“预解析”(Pre-parsing)或“完全解析”它所遇到的函数。
预解析会检查源代码的语法并抛出语法错误,但不会解析函数中变量的作用域或生成 AST。完全解析则将分析函数体并生成源代码对应的 AST 数据结构。相比正常解析,预解析的速度快了 2 倍。
生成 AST 主要经过两个阶段:分词和语义分析。AST 旨在通过一种结构化的树形数据结构来描述源代码的具体语法组成,常用于语法检查(静态代码分析)、代码混淆、代码优化等。
V8 的 AST 表示方式。
1.1.3 依据ast和作用域生成字节码
V8 引入 JIT(Just In Time,即时编译)技术,通过 Ignition 基线编译器快速生成字节码进行执行。
字节码是机器码的抽象,字节码可以直接被优化编译器 TurboFan 用于生成图(TurboFan 对代码的优化基于图),避免优化编译器在优化代码时需要对 JavaScript 源代码重新进行解析。
1.1.4 优化编译器 TurboFan
解释器执行字节码过程中,如果发现代码被重复执行,监控机器人会把这段代码标记为热点代码。热点代码会丢给优化编译器编译成二进制代码,然后优化。下次再执行时就执行这段优化后的二进制代码。
1.1.5 反优化
JS 语言是动态语言,非常之灵活,对象的结构和属性在运行时是可以发生改变的,设想一个问题,如果热代码在某次执行的时候,突然其中的某个属性被修改了,那么编译成机器码的热代码还能继续执行吗?
答案是肯定不能。这个时候就要使用到优化编译器的反优化了,他会将热代码退回到 AST 这一步,这个时候解释器会重新解释执行被修改的代码,如果代码再次被标记为热代码,那么会重复执行优化编译器的这个步骤。
1.2 内存管理
内存是计算机中重要的部件之一,它是与CPU进行沟通的桥梁。
计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。
高效的程序离不开内存的有效管理,内存管理的优势:
- 减少内存分配
- 回收开销
- 避免内存碎片
- 定位内存位置
- 方便内存整理
- 跟踪内存使用
1.2.1 V8 引擎的内存结构
因为 JavaScript 是单线程,所以 V8 在每个上下文都使用一个进程,如果你使用 Service Worker ,它也会为每个 Service Worker 生成一个新的进程。
Service Worker:一个服务器与浏览器之间的中间人角色,如果网站中注册了service worker那么它可以拦截当前网站所有的请求,进行判断(需要编写相应的判断程序),如果需要向服务器发起请求的就转给服务器,如果可以直接使用缓存的就直接返回缓存不再转给服务器,从而大大提高浏览体验。
一个正在运行的程序是由 V8 进程分配的内存来表示的,这被称为 Resident Set(常驻集)。这些内存会进一步划分成不同的部分。
一个 V8 进程的内存通常由以下几个块构成:
- **新生代内存区(new space)
**大多数的对象都会被分配在这里,这个区域很小但是垃圾回收比较频繁; - 老生代内存区(old space)
属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针; - **大对象区(large object space)
**这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象区; - 代码区(code space)
代码对象,会被分配在这里。唯一拥有执行权限的内存; - map 区(map space)
存放 Cell 和 Map,每个区域都是存放相同大小的元素,结构简单。
如下图:
Heap Memory(堆内存)
这是 V8 引擎存储对象(Object)和动态数据(Dynamic Data)的地方。这也是程序对于内存区域中最大的一块地方,同时**垃圾回收( GC )**也发生在这里。并不是整个 Heap (堆)内存都进行垃圾回收,只有新空间(New Space)和旧空间(Old Space)由垃圾回收管理。
整个堆内存被划分为以下几个部分:
新空间:是新对象存活的地方,这些对象的生命周期都很短。这个空间很小,由两个 Semi-Space 组成,类似与 JVM 中的 S0 和 S1。
我们将会在后面的内容看到它。新空间的大小是由两个 V8 中的标志位来控制: min_semi_space_size(Initial) 和 max_semi_space_size(Max) 。旧空间:在新空间中存活了两个 minor GC 周期的对象,会被迁移到这里。
这个空间由 Major GC(Mark-Sweep & Mark-Compact) 管理。我们也会在后面内容中看到它。旧空间的大小也是由两个 V8 中的标志位来控制:nitial_old_space_size(Initial) 和 max_old_space_size(Max) 。
旧空间被分成两个部分:旧指针空间:这些存活下来的的对象都包含了指向其他对象的指针。
旧数据空间:这些对象只包含数据,没有指向其他对象的指针。在新空间中存活两个 minor GC 周期后,String,已经装箱的数字,未装箱的双精度数组会被迁移到这里。
大型对象空间(Large object space):大于其他空间大小限制的对象存放在这里。每个对象都有自己的内存区域,这里的对象不会被垃圾回收器移动。
代码空间(Code-space):这是即时编译器(JIT)存储已经编译的代码块的地方。这是唯一可执行内存的空间(尽管代码可能被分配到大型对象空间(Large object space),那也是可以执行的)。
单元空间(Cell Space),属性单元空间(Property Cell Space)和映射空间(Map Space):这些空间分别存放 Cell,PropertyCell 和 Map。这些空间包含的对象大小相同,并且对对象类型有些限制,可以简化回收工作。
每个空间(除了大型对象空间)都由一组 Page 组成。一个 page 是由操作系统分配的一个连续内存块,大小为 1MB。
Stack(栈)
每个 V8 进程都有一个栈(Stack),这里保存静态数据的地方,比如:方法/函数框架,原型对象的值(Primitive value)和指针。栈(Stack)内存的大小由 V8 的标志位来设置:stack_size。
- 全局作用域被保存在 Stack 中的 Global frame 中。
- 每个函数调用都做为 frame 块添加到 Stack 中。
- 所有的局部变量,包括参数和返回值都保持在 Stack 的函数 frame 上。
- 所有的原型类型的数据,比如 int 和 String,都直接保持在 Stack 上。(是的,JavaScript 中 String 是原型数据)
- 所有的对象类型,比如 Employee 和 Function 都保存在 Heap 中,并且通过 Stack 上的指针来引用。(函数在 JavaScript 中也是对象。)
- 从当前函数中调用的函数被压入了 Stack 的顶部。
- 当函数返回是,它的 frame 块将会从 Stack 中移除。
- 一旦主进程完成,Heap 上的对象就不再有任何来自 Stack 的指针,这些对象将成为孤儿。
- 除非显式的复制,否则其他对象中的所有对象引用都是通过指针完成的。
正如你所看到的,Stack 是自动管理的,而且是由操作系统而不是 V8 本身完成的。因此我们不必担心 Stack 的问题。另一方面,Heap 不是由操作系统自动管理的,由于 Heap 是程序内存块中最大的内存空间,并且保存动态数据,所以它的空间使用会指数级增长,从而导致我们的程序内存耗尽。
1.2.2 V8 内存的使用
我们通过一段代码来看JS程序被执行时是如何使用内存的。
class Employee {
constructor(name, salary, sales) {
this.name = name;
this.salary = salary;
this.sales = sales;
}
}
const BONUS_PERCENTAGE = 10;
function getBonusPercentage(salary) {
const percentage = (salary * BONUS_PERCENTAGE) / 100;
return percentage;
}
function findEmployeeBonus(salary, noOfSales) {
const bonusPercentage = getBonusPercentage(salary);
const bonus = bonusPercentage * noOfSales;
return bonus;
}
let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);
就像你看到的那样:
- 全局作用域被保存在 Stack 中的 Global frame 中。
- 每个函数调用都做为 frame 块添加到 Stack 中。
- 所有的局部变量,包括参数和返回值都保持在 Stack 的函数 frame 上。
- 所有的原型类型的数据,比如 int 和 String,都直接保持在 Stack 上。
- 所有的对象类型,比如 Employee 和 Function 都保存在 Heap 中,并且通过 Stack 上的指针来引用。
- 从当前函数中调用的函数被压入了 Stack 的顶部。
- 当函数返回是,它的 frame 块将会从 Stack 中移除。
- 一旦主进程完成,Heap 上的对象就不再有任何来自 Stack 的指针,这些对象将成为孤儿。
- 除非显式的复制,否则其他对象中的所有对象引用都是通过指针完成的
Stack 是自动管理的,而且是由操作系统而不是 V8 本身完成的。因此我们不必担心 Stack 的问题。另一方面,Heap 不是由操作系统自动管理的,由于 Heap 是程序内存块中最大的内存空间,并且保存动态数据,所以它的空间使用会指数级增长,从而导致我们的程序内存耗尽。
而且 Heap 中的内存也会随着时间的推移,变得支离破碎,从而拖慢程序。这时候就需要垃圾回收发挥作用了。
1.3 垃圾回收 Garbage collection
垃圾回收是指回收那些在应用程序中不再引用的对象,当一个对象无法从根节点访问这个对象就会做为垃圾回收的候选对象。这里的根对象可以为全局对象、局部变量,无法从根节点访问指的也就是不会在被任何其它活动对象所引用。
我们知道了 V8 是如何分配内存的,现在让我们来看看它是如何自动管理 Heap 内存的,这对程序的性能非常重要。
当程序试图在 Heap 中分配超过可用的内存时,就会遇到内存不足的错误,整个页面都会崩溃。
一个不正确的 Heap 内存管理也可能导致内存泄漏。
V8 引擎通过垃圾回收来管理 Heap 内存。简单来说,就是释放孤立(orphan)对象使用的内存。比如,一个对象并没有直接或者间接被 Stack 中的指针所引用,就会释放相应内存为新对象腾出空间。
V8 的垃圾回收器负责回收未使用的内存,以便 V8 进程重新使用。
1.3.1 如何判断非活跃对象
判断对象是否是活跃的一般有两种方法,引用计数法和可访问性分析法。
1. 引用计数法
V8中并没有使用这种方法,因为每当有引用对象的地方,就加1,去掉引用的地方就减1,这种方式无法解决A与B循环引用的情况,引用计数都无法为0,导致无法完成gc。
2. 可访问性分析法
V8中采用了这种方法,将一个称为GC Roots的对象(在浏览器环境中,GC Roots可以包括:全局的window对象,所有原生dom节点集合等等)作为所有初始存活的对象集合,从这个对象出发,进行遍历,遍历到的就认为是可访问的,为活动对象,需要保留;如果没有遍历到的对象,就是不可访问的,这些就是非活动对象,可能就会被垃圾回收。
在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种):
- 全局的 window 对象(位于每个 iframe 中)。
- 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成。
- 存放栈上变量。
1.3.2 代际假说
代际假说(The Generational Hypothesis)是垃圾回收领域中的一个重要术语,它有两个特点
大部分对象在内存中存活时间很短,比如函数内部声明变量,块级作用域中的变量等,这些代码块执行完分配的内存就会被清掉。
不死的对象会活得更久,比如全局的window、Dom、全局api等对象。
基于代际假说的理论,在V8引擎中,垃圾回收算法被分为两种,一个是Major GC,主要使用了Mark-Sweep & Mark-Compact算法,针对的是堆内存中的老生代进行垃圾回收;
另外一个是Minor GC,主要使用了Scavenger算法,针对于堆内存中的新生代进行垃圾回收。
注:
所谓老生代指的就是那些存活时间很久没有被清理的对象,而新生代指的是存活时间很短的对象。
1.3.3 Scavenger算法
是在新生代内存中使用的算法,速度更快,空间占用更多的算法。New space区域分为了两个半区,分别为from-space和to-space。不断经过下图中的过程,在两个空间的角色互换中,完成垃圾回收的过程。每次都会有对象复制的操作,为了控制这里产生的时间成本和执行效率,往往新生代的空间并不大。同时为了避免长时间之后,某些对象会一直积压在新生代区域,V8制定了晋升机制,满足任一条件就会被分配到老生代的内存区中。
经历一次Scavenger算法后,仍未被标记清除的对象。
进行复制的对象大于to space空间大小的25%。
1.3.4 Mark-Sweep & Mark-Compact算法
是老生代内存中的垃圾回收算法,标记-清除 & 标记-整理,老生代里面的对象一般占用空间大,而且存活时间长,如果也用Scavenger算法,复制会花费大量时间,而且还需要浪费一半的空间。
- 标记-清除过程:也就是可访问性分析法,从GC Root开始遍历,标记完成后,就直接进行垃圾数据的清理工作。
- 标记-整理过程:清除算法后会产生大量不连续的内存碎片,碎片过多会导致后面大对象无法分配到足够的空间,所以需要进行整理,第一步的标记是一样的,但标记完成活跃对象后,并不是进行清理,而是将所有存活的对象向一端移动,然后清理掉这端之外的内存。
1.3.5 优化策略
由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。这种行为叫做全停顿(Stop-The-World)。
STW(全停顿)会造成系统周期性的卡顿,对实时性高的和与时间相关的任务执行成功率会有非常大的影响,例如:js逻辑需要执行动画,刚好碰到gc的过程,会导致整个动画卡顿,用户体验极差。
为了降低这种STW导致的卡顿和性能不佳,V8引擎中目前的垃圾回收器名为Orinoco,经过多年的不断精细化打磨和优化,已经具备了多种优化手段,极大地提升了GC整个过程的性能及体验。
Orinoco 是 V8 GC 项目的代号,它利用并行,增量和并发的技术进行垃圾回收,来释放主线程。
1.3.6 并行回收
简单来讲,就是主线程执行一次完整的垃圾回收时间比较长,开启多个辅助线程(web-worker)来并行处理,整体的耗时会变少,所有线程执行GC的时间点是一致的,js代码也不会有影响,不同线程只需要一点同步的时间,在新生代里面执行的就是并行策略。
1.3.7 增量回收
并行策略说到底还是STW(全停顿)的机制,如果老生代里面存放一些大对象,处理这些依然很耗时,Orinoco又增加了增量回收的策略。将标记工作分解成小块,插在主线程不同的任务之间执行,类似于React fiber的分片机制,等待空闲时间分配。这里需要满足两个实现条件:
1. 随时可以暂停和启动,暂停要保存当前的结果,等下一次空闲时机来才能启动。
2. 暂停时间内,如果已经标记好的数据被js代码修改了,回收器要能正确地处理。
下面要讲到的就是Orinoco引入了三色标记法来解决随时启动或者暂停且不丢之前标记结果的问题。
1.3.8 三色标记法
三色标记法的规则如下:
1. 最开始所有对象都是白色状态
2. 从GC Root遍历所有可到达的对象,标记为灰色,放入待处理队列
3. 从待处理队列中取出灰色对象,将其引用的对象标记为灰色放入待处理队列,自身标记为黑色
4. 重复3中动作,直到灰色对象队列为空,此时白色对象就是垃圾,进行回收。
垃圾回收器可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。
下面将要解决由于js代码导致对象引用发生变化的情况,Orinoco借鉴了写屏障的处理办法。
1.3.9 写屏障(write-barrier)
一旦对象发生变化时,如何精确地更新标记的结果,我们可以分析下一般js执行过程中带来的对象的变化有哪些,其实主要有2种:
1. 标记过的黑色或者灰色的对象不再被其他对象所引用。
2. 引入新的对象,新的对象可能是白色的,面临随时被清除的危险,导致代码异常。
第一种问题不大,在下次执行gc的过程中会被再次标记为白色,最后会被清空掉;
第二种就使用到了写屏障策略,一旦有黑色对象引用到了白色对象,系统会强制将白色对象标记成为灰色对象,从而保证了下次gc执行时状态的正确,这种模式也称为强三色原则。
1.3.10 并发回收
虽说三色标记法和写屏障保证了增量回收的机制可以实现,但依然改变不了需要占用主线程的情况,一旦主线程繁忙,垃圾回收依然会影响性能,所以增加了并发回收的机制。
V8里面的并发机制相对复杂,简化来看,当主线程运行代码时,辅助线程并发进行标记,当标记完成后,主线程执行清理的过程时,辅助线程也并行执行。
1.4 D8
D8 是一个十分有用的调试工具,你能够把它看成是 debug for V8 的缩写。我们可以应用 d8 来查看 V8 在执行 JavaScript 过程中的各种两头数据,例如作用域、AST、字节码、优化的二进制代码、垃圾回收的状态,还能够应用 d8 提供的公有 API 查看一些外部信息。
该工具的下载教程和使用方式:blog.csdn.net/heyYouU/art…
2. memory cache
在我们使用强缓存+协商缓存的时候,我们会将一部分资源放在内存中缓存起来。
内存中的缓存,主要包含的是当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。
我们上面谈到了,V8对堆内存的大小做了限制,如果超过了限制会导致网络崩溃的现象,那么我们的memory cache的占用内存受不受V8的约束呢。
当然是受约束的,如果要缓存大量的资源,还得需要用到磁盘缓存。
参考: blog.csdn.net/qiwoo_weekl…
来源:juejin.cn/post/7221793823704514620