V8引擎浅析-内存管理

303 阅读13分钟

1、内存

内存作为计算机的最重要部分之一,它是与CPU进行沟通的桥梁,程序运行时CPU需要调用的指令和数据只能通过内存获取。计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。内存一般是半导体存储单元,包括了ROM + RAM( + Cache(高速缓存),其中最重要的就是RAM部分。

  • ROM 只读存储器,以非破坏性读出方式工作,只能读出无法写入信息。信息一旦写入后就固定下来,即使切断电源,信息也不会丢失,所以又称为固定存储器;常用于存储各种固定程序和数据
  • RAM 随机存储器也叫主存、运行内存,是与CPU直接交换数据的内部存储器。它可以随时读写(刷新时除外),而且速度很快,通常作为操作系统或其他正在运行中的程序的临时数据存储介质。它与ROM的最大区别是数据的易失性,即一旦断电所存储的数据将随之丢失。RAM在计算机和数字系统中用来暂时存储程序、数据和中间结果。内存条(SIMM)就是将RAM集成块集中在一起的一小块电路板
  • Cache 高速缓冲存储器,其原始意义是指存取速度比一般随机存取记忆体(RAM)来得快的一种RAM,一般而言它不像系统主记忆体那样使用DRAM技术,而使用昂贵但较快速的SRAM技术,也有快取记忆体的名称。

内存的生命周期一般包括:分配内存大小 -> 使用内存(读 or 写)-> 不需要时进行释放。

运行js代码时,内存空间使用包括了堆内存和栈内存。

2、栈

小而连续,数组结构,由系统自动分配相对固定大小的内存空间,并由系统自动释放,遵循LIFO后进先出的规则,主要职责是javascript中存储局部变量及管理函数调用。

基础数据类型的变量都是直接存储在栈中,复杂类型数据会将对象的引用(实际存储的指针地址)存储在栈中,数据本身存储在堆中。

每个函数调用时,解释器都会现在栈中创建一个调用栈(call stack)来存储函数的调用流程顺序。然后把该函数添加进调用栈,解释器会为被添加进的函数再创建一个栈帧(为一个函数调用单独分配的那部分栈空间)并立即执行。如果正在执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈并执行。直到这个函数执行结束,对应的栈帧也会被立即销毁。栈帧中一般会存放信息包括:

  • 函数的返回地址和参数
  • 临时变量:函数局部变量+编译器自动生成的其他临时变量
  • 函数调用的上下文

3、堆

栈空间是连续的,在栈上分配资源和销毁资源的速度非常快,分配空间和销毁空间只需要移动下指针就可以了。但是如果想在内存中分配一块连续的大空间是非常难的,栈空间是有上限的,一旦函数循环嵌套次数过多,或者分配的数据过大,就会造成栈溢出问题,所以我们需要另外一种数据结构来存储大数据。堆内存的存储不同于栈,虽然他们都是内存中的一片空间,但是堆内存存储变量时没有什么规律可言。它只会用一块足够大的空间来存储变量

4、内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时就必须供给内存。对于持续运行的服务进程必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。不再用到的内存,没有及时释放,就叫做内存泄漏

  • 闭包使用不当
  • 意外生成的全局变量
  • 未清零的定时器
  • 未分离的DOM

如何判断是否有内存泄漏?

5、垃圾回收

垃圾回收(GC = Garbage Collection),是指在内存空间进行垃圾回收的过程。如果不做GC,容易造成内存空间大小超过上限而导致程序的崩溃,对比C/C++等语言中,开发者需要手动处理内存的分配和释放,人工控制优势是在于可以细粒度控制,不足在于人工会导致失误率的提高,分配或释放太晚或太早会造成引用错误和内存泄漏,同时也增加了开发者的心智负担。一些语言例如js、java等,会选择在语言运行时中内置垃圾回收机制,虽然失去细颗粒度的控制,但得到了更高的开发效率,也解耦了对底层api的依赖,提高了内存的安全性。

Javascript的标准ECMAScript并没有对GC做相关的要求,GC完全依赖底层引擎的能力。

堆内存中存储着动态数据,随着代码的运行,这些数据随时都可能会发生变化,而且这部分数据可能会相互引用,引擎需要不断地遍历找到这些数据相互之间的关系,从而发现哪些数据是非活动对象并对其进行gc操作,所以gc的算法及策略的好坏,直接影响着整个引擎执行代码的性能,这部分是非常关键的。

5.1、如何判断非活跃对象

判断对象是否是活跃的一般有两种方法,引用计数法和可访问性分析法。引用计数法:v8中并没有使用这种方法,因为每当有引用对象的地方,就加1,去掉引用的地方就减1,这种方式无法解决A与B循环引用的情况,引用计数都无法为0,导致无法完成gc可访问性分析法:将一个称为GC Roots的对象(在浏览器环境中,GC Roots可以包括:全局的window对象、所有原生dom节点集合等等)作为所有初始存活的对象集合,从这个对象出发,进行遍历,遍历到的就认为是可访问的,为活动对象,需要保留;如果没有遍历到的对象,就是不可访问的,这些就是非活动对象,可能就会被垃圾回收

6、栈内存回收

调用栈中有一个记录当前执行状态的指针(称为 ESP),随着函数的执行,函数执行上下文被压入调用栈中,执行上下文中的数据会按照前面说的JS数据存储机制被分配到堆栈中,ESP会指向最后压栈的执行上下文,如左图所示的fn2函数。当fn2函数调用完毕,JS 会把ESP指针下移至fn1函数,这个指针下移的操作就是销毁fn1函数执行上下文的过程。最后fn1函数执行上下文所占用的区域会变成无效区域,下一个函数执行上下文压入调用栈的时候会直接覆盖其内存空间。简而言之,只要函数调用结束,该栈内存就会自动被回收,不需要我们操心。如果出现闭包的情况,闭包的数据就会组成一个对象保存在堆空间里。

7、堆内存回收

7.1、堆内存的结构

相对栈内存结构来说,堆内存内部结构比较复杂,V8引擎内存分配和垃圾回收机制复杂的设计也重点体现在堆内存管理上,下图中是V8引擎内存结构总览,我们来重点剖析下堆内存的结构。

主要分为以下几个区域:

  • New space(新生代)

新生代主要是由两个半空间(semi space)组成,一个是from space,一个是to space,空间的大小由--min_semi_space_size(初始值) 和 --max_semi_space_size(最大值) 两个标志来控制,在64位和32位操作系统中最大值分别为64MB和32MB,新生代空间主要是用于新对象的存储,后面配合垃圾回收再深入讲下gc的过程。

  • Old space (老生代)

这部分存储的是经过多次gc后仍在新生代中存在的对象,空间的大小由--initial_old_space_size(初始值) 和--max_old_space_size(最大值) 两个标志来控制。

  • 这个区域包括了两个部分:

    • Old pointer space: 存放存活下来包含指向其他对象指针的对象

    • Old data space:存放仅保存数据的对象,不含指向其他对象指针的对象,字符串等数据

  • Large object space (大对象区)这是大于其他空间大小限制的对象存储的地方,避免大对象的频繁拷贝导致性能变差。大对象是不会被垃圾回收的。

  • Code space(代码区): 即时(JIT)编译器存储编译代码块的地方。唯一可执行代码的空间

  • Cell space (单元区)

  • Property cell space(属性单元区)

  • Map space(map 区域):用来存放对象的map信息,每个对象的隐藏类,为了快速定位,单独开辟了一个区域来用来存放这部分信息

  • Stack(栈内存)

7.2、代际假说

内存垃圾回收领域中有个重要术语:代际假说,其有以下两个特点:

1.大部分对象在内存中存活时间很短,比如函数内部声明变量,块级作用域中的变量等,这些代码块执行完分配的内存就会被清掉

2.不死的对象会活的更久,比如全局的window、Dom、全局api等对象。

基于代际假说的理论,在V8引擎中,垃圾回收算法被分为两种,一个是Major GC(主垃圾回收器),主要使用了Mark-Sweep & Mark-Compact算法,针对的是堆内存中的老生代进行垃圾回收;另外一个是Minor GC(副垃圾回收器),主要使用了Scavenger算法,针对于堆内存中的新生代进行垃圾回收。

其中垃圾回收一般都有相同的执行流程:

1.标记空间中活动对象和非活动对象

2.回收非活动对象所占据的内存

3.内存整理,这步是可选的,因为有的垃圾回收器工作过程会产生内存碎片,这时就需要内存整理防止不够连续空间分配给大数据

7.3、副垃圾回收器

采用Scavenger算法,是在新生代内存中使用的算法,速度更快,空间占用更多的算法。New space区域分为了两个半区,分别为from-space(对象区域)和to-space(空闲区域)。新加入的对象都会存放到对象区域,当对象区域快被写满时,会对对象区域进行垃圾标记,把存活对象复制并有序排列至空闲区域,完成后让这两个区域角色互转,由此便能无限循环进行垃圾回收,V8制定了晋升机制,满足任一条件就会被分配到老生代的内存区中。

1.经历一次Scavenger算法后,仍未被标记清除的对象

2.进行复制的对象大于to space空间大小的25%

7.4、主垃圾回收器

是老生代内存中的垃圾回收算法,标记-清除 & 标记-整理,老生代里面的对象一般占用空间大,而且存活时间长,如果也用Scavenger算法,复制会花费大量时间,而且还需要浪费一半的空间。

标记-清除过程:与之前讲过的可访问性分析一致,从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。标记完成后,就直接进行垃圾数据的清理工作。

标记-整理过程:清除算法后会产生大量不连续的内存碎片,碎片过多会导致后面大对象无法分配到足够的空间,所以需要进行整理,第一步的标记是一样的,但标记完成活跃对象后,并不是进行清理,而是将所有存活的对象向一端移动,然后清理掉这端之外的内存。

7.5、优化策略

由于垃圾回收是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。这种行为叫做全停顿(Stop-The-World)

STW会造成系统周期性的卡顿,对实时性高的和与时间相关的任务执行成功率会有非常大的影响。例如:js逻辑需要执行动画,刚好碰到gc的过程,会导致整个动画卡顿,用户体验极差。

为了降低这种STW导致的卡顿和性能不佳,V8引擎中目前的垃圾回收器名为Orinoco,经过多年的不断精细化打磨和优化,已经具备了多种优化手段,极大地提升了GC整个过程的性能及体验。

7.5.1、并行回收

简单来讲,就是主线程执行一次完整的垃圾回收时间比较长,开启多个辅助线程来并行处理,整体的耗时会变少,所有线程执行gc的时间点是一致的,js代码也不会有影响,不同线程只需要一点同步的时间,在新生代里面执行的就是并行策略。

7.5.2、并发回收

当主线程运行代码时,辅助线程并发进行标记,当标记完成后,主线程执行清理的过程时,辅助线程也并行执行。

7.5.3、增量回收

并行策略说到底还是STW的机制,如果老生代里面存放一些大对象,处理这些依然很耗时,Orinoco又增加了增量回收的策略。将标记工作分解成小块,插在主线程不同的任务之间执行,类似于React fiber的分片机制,等待空闲时间分配。这里需要满足两个实现条件:

  • 随时可以暂停和启动,暂停要保存当前的结果,等下一次空闲时机来才能启动

  • 暂停时间内,如果已经标记好的数据被js代码修改了,回收器要能正确地处理