Javascript的垃圾回收机制总结

 

1 写在前面

本文主要围绕JS引擎相关知识,来深入了解底层运行逻辑,这对于日常开发维护高性能Javascript代码以及排查代码性能问题有着很好的帮助。关于JS引擎底层的垃圾回收机制,后面才能理解内存泄漏的问题以及手动预防和优化,实现对JS内存管理以及内存溢出的处理。

  • 那么我们需要考虑几个问题:
  • 什么是垃圾回收机制(GC)?
  • 垃圾是怎样产生的?
  • 为什么要进行垃圾回收?
  • Javascript的内存是如何管理的?
  • Chrome浏览器又是如何进行垃圾回收的?

2 内存管理

在Javascript编程中,内存管理大概分成三个步骤,也是内存的生命周期:

  • 分配你所需系统内存的空间
  • 使用分配到的内存进行读写操作
  • 不需要使用内存时,将空间进行释放和归还

内存的生命周期

与其它手动管理内存的语言不一样的是,在Javascript中,当我们创建变量时,系统会给对象进行自动分配对应的内存空间以及闲置资源回收,也就是不需要我们手动进行分配。但是,正是因为垃圾回收机制导致开发者有着错误的感觉,就是他们不用关心内存管理。

 
const name = "yichuan";//给字符串分配栈内存 
const age = 18;//给数值分配栈内存 
 
//给对象以及包含的值分配堆内存 
const user = { 
  name: "onechuan", 
  age: 19 
} 
//给数组以及包含的值分配堆内存 
const arr = ["yichuan","onechuan",18]; 
//给函数对象分配堆内存 
function sum(x,y){ 
  return x + y; 
} 

 

 
我们先介绍一下基础数据类型和引用数据类型的分配机制,即:
  • 简单数据类型内存保存在固定的栈空间中,可直接通过值进行访问
  • 引用数据类型的值大小不固定,其引用地址保存在栈空间、引用所指向的值保存在堆空间中,需要通过引用进行访问

栈内存中的基本数据类型,可以直接通过操作系统进行处理,而堆内存中的引用数据类型的值大小不确定,因此需要JS的引擎通过垃圾回收机制进行处理。

3 内存回收机制(GC)

Javascript的V8引擎被限制了内存的使用,因此根据不同操作系统的内存大小会不一样。

V8引擎最初设计是作为浏览器的引擎,并未考虑占据过多的内存空间,随着web技术工程化的发展,占据了越来越多的内存空间。又由于被v8的会回收机制所限制,这样就引起了js执行的线程被挂起,会影响当前执行的页面应用性能。

垃圾回收算法:就是垃圾收集器按照固定的时间间隔,周期性地寻找那些不再使用的变量,然后将其清楚或释放内存。但是垃圾回收算法是个不完美的方案,因为某块内存是否还可用,属于不可预判的问题,也就意味着单纯依靠算法是解决不了的。还有为什么不是实时的找出无用内存并释放呢?其实很简单,实时开销太大了。

我们知道了垃圾是如何产生的,那么我们应该如何清除呢?在浏览器的发展历史上有两种解决策略:

  • 标记清除
  • 引用计数

标记清除

标记清除分为:标记阶段和清除阶段。

整个标记清除算法大致过程就像下面这样

  • 垃圾收集器找到所有的根,并“标记”(记住)它们。
  • 然后它遍历并“标记”来自它们的所有引用。
  • 然后它遍历标记的对象并标记 它们的 引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。
  • ……如此操作,直到所有可达的(从根部)引用都被访问到。
  • 没有被标记的对象都会被删除。

 

使用标记清除策略的最重要的优点在于简单,无非是标记和不标记的差异。通过标记清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,这就造成出现内存碎片的问题。内存碎片多了后,如果要存储一个新的需要占据较大内存空间的对象,就会造成影响。对于通过标记清除产生的内存碎片,还是需要通过标记整理策略进行解决。

简而言之:

  • 优点:简单
  • 缺点:内存碎片化、分配速度慢

标记整理

经过标记清除策略整理后,老生代内存中因此产生了许多内存碎片,如果不进行清理内存碎片,就会对存储造成影响。

标记整理(Mark-Compact)算法 就可以有效地解决标记清除的两个缺点。它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存。

引用计数

引用计数是一种不常见的垃圾回收策略,其思路就是对每个值都记录其的引用次数。具体的:

  • 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1

  • 如果同一个值又被赋给另一个变量,那么引用数加 1

  • 如果该变量的值被其他的值覆盖了,则引用次数减 1

  • 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存

 
let a = new Object()  // 此对象的引用计数为 1(a引用) 
let b = a   // 此对象的引用计数是 2(a,b引用) 
a = null    // 此对象的引用计数为 1(b引用) 
b = null    // 此对象的引用计数为 0(无引用) 
...   // GC 回收此对象 

 

 
这种回收策略看起来很方便,但是当其进行循环引用时就会出现问题,会造成大量的内存不会被释放。当函数结束后,两个对象都不在作用域中,A 和 B 都会被当作非活动对象来清除掉,相比之下,引用计数则不会释放,也就会造成大量无用内存占用,这也是后来放弃引用计数,使用标记清除的原因之一。

4 V8对于垃圾回收机制的优化

大多数浏览器都是基于标记清除算法,不同的只是在运行垃圾回收的频率具有差异。V8 对其进行了一些优化加工处理,那接下来我们主要就来看 V8 中对垃圾回收机制的优化。

分代式垃圾回收

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。

新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量,而老生代的对象为存活事件较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大。

V8 整个堆内存的大小就等于新生代加上老生代的内存,对于新老两块内存区域的垃圾回收,V8 采用了两个垃圾回收器来管控。

新生代和老生代

新生代内存回收

在64操作系统下分配为32MB,因为新生代中的变量存活时间短,不太容易产生太大的内存压力,因此不够大也是能够理解。

对于新生代内存的回收,通常是通过Scavenge 的算法进行垃圾回收,就是将新生代内存进行一分为二,正在被使用的内存空间称为使用区,而限制状态的内存空间称为空闲区。

新生代内存回收的原理是:

  • 新加入的对象都会存放在使用区,当使用区快写满时就进行一次垃圾清理操作。
  • 在开始进行垃圾回收时,新生代回收器会对使用区内的对象进行标记
  • 标记完成后,需要对使用区内的活动对象拷贝到空闲区进行排序
  • 而后进入垃圾清理阶段,将非活动对象占用的内存空间进行清理
  • 最后对使用区和空闲区进行交换,使用区->空闲区,空闲区->使用区

新生代中的变量如果经过回收之后依然一直存在,那么会放入到老生代内存中,只要是已经经历过一次Scavenge算法回收的,就可以晋升为老生代内存的对象。

老生代内存回收

当然,Scavenge算法也有其适用场景范围,对于内存空间较大的就不适合使用Scavenge算法。此时应该使用Mark-Sweep(标记清除)和Mark-Compact(标记整理)的策略进行老生代内存中的垃圾回收。

首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉。

同样的标记清除策略会产生内存碎片,因此还需要进行标记整理策略进行优化。

5 内存泄漏与优化

内存泄漏,指在JS中已经分配内存地址的对象由于长时间未进行内存释放或无法清除,造成了长期占用内存,使得内存资源浪费,最终导致运行的应用响应速度变慢以及最终崩溃的情况。

在代码中创建对象和变量时会占据内存,但是JS基于自己的内存回收机制是可以确定哪些变量不再需要,并将其进行清除。但是,当你的代码中存在逻辑缺陷时,你以为你已经不需要,但是程序中还存在这引用,这就导致程序运行完后并没有进行合适的回收所占有的内存空间。运行时间越长占用内存越多,随之出现的问题就是:性能不佳、高延迟、频繁崩溃。

造成内存泄漏的常见原因有:

  • 过多的缓存。及时清理过多的缓存。
  • 滥用闭包。尽量避免使用大量的闭包。
  • 定时器或回调太多。与节点或数据相关联的计时器不再需要时,DOM节点对象可以清除,整个回调函数也不再需要。可是,计时器回调函数仍然没有被回收(计时器停止才会被回收)。当不需要setTimeout或setInterval时,定时器没有被清除,定时器的糊掉函数以及其内部依赖的变量都不能被回收,会造成内存泄漏。解决方法:在定时器完成工作时,需要手动清除定时器。
  • 太多无效的DOM引用。DOM删除了,但是节点的引用还在,导致GC无法实现对其所占内存的回收。解决方法:给删除的DOM节点引用设置为null。
  • 滥用全局变量。全局变量是根据定义无法被垃圾回收机制进行收集的,因此需要特别注意临时存储和处理大量信息的全局变量。如果必须使用全局变量来存储数据,请确保将其指定为null或在完成后重新分配它。解决方法:使用严格模式。
  • 从外到内执行appendChild。此时即使调用removeChild也无法进行释放内存。解决方法:从内到外appendChild。
  • 反复重写同一个数据会造成内存大量占用,但是IE浏览器关闭后会被释放。
  • 注意程序逻辑,避免编写『死循环』之类的代码。
  • DOM对象和JS对象相互引用。

关于内存泄漏,如果你想要更好地排查以及提前避免问题的发生,最好的解决方法是通过熟练使用Chrome的内存剖析工具,多分析多定位Chrome帮你分析保留的内存快照,来查看持续占用大量内存的对象。

 

 

那么还有两个问题:

  1. 闭包一定会导致内存不可被回收?
  2. 如何监控一个 Node.js 服务的内存开销,如何处理不可预知的内存泄漏?

 

对于问题1:

js高程原文这样说的是:由于IE9 之前的版本对JScript 对象和COM 对象使用不同的垃圾收集。因此闭包在IE 的这些版本中会导致一些特殊的问题。具体来说,即循环引用无法自动判断。

首先这个问题对于现代浏览器(IE9+)来说已经不是问题了,这个问题其实是出现在IE8的时代了。

js是有自己的一套自动垃圾回收机制的,即前面所说的标记清除和引用计数:

先明确一点现代浏览器采用的是标记清除,而老浏览器采用的是引用计数,然而引用计数这种机制有个很严重的bug即循环引用

因此,对于我们在使用闭包时,应该注意养成如下习惯:

1.在退出函数之前,将不使用的局部变量赋值为null;

2.避免变量的循环赋值和引用。(对于可能还出现的老浏览器的会因为闭包产生的循环引用,可以通过拷贝值,把内外引用脱钩,这样就可回收。)

 

对于问题2:

Node.js 使用 V8 作为 JavaScript 的执行引擎,所以讨论 Node.js 的 GC 情况就等于在讨论 V8 的 GC。在linux上可以使用pidstat对node对应的服务进行监控内存开销情况,其实这类玩意linux上工具挺多。以及借助:Chrome DevTools 和 heapdump。具体排查可以看如下的博客:

https://cloud.tencent.com/developer/article/1683960

本质就是断点记录运行多次的代码看哪里有异常,通过Chrome DevTools查看异常情况。

 

 

参考:https://developer.51cto.com/article/694017.html

https://juejin.cn/post/6981588276356317214

posted @ 2022-08-09 17:11  zzzlight  阅读(729)  评论(0编辑  收藏  举报