JavaScript的垃圾回收机制和内存泄漏以及chrome的排查方法

1.垃圾回收机制

《JavaScript权威指南(第四版)》:由于字符串、对象和数组没有固定大小,所有当他们的大小已知时,才能对他们进行动态的存储分配。JavaScript程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。

这段话解释了为什么需要系统需要垃圾回收,JavaScript不像C/C++,它有自己的一套垃圾回收机制。

JavaScript垃圾回收的机制:找出不再使用的变量,然后释放掉其占用的内存,但是这个过程不是时时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。

var a = "掘金";
var b = "淘金";
var a = b;

这段代码运行之后,“掘金”这个字符串失去了引用(之前是被a引用),系统检测到这个事实之后,就会释放该字符串的存储空间以便这些空间可以被再利用。

那是怎么进行垃圾回收的呢?

其实垃圾回收有两种方法:标记清除、引用计数。引用计数不太常用,标记清除较为常用。

1.1 标记清除

这是javascript中最常用的垃圾回收方式。当变量进入执行环境是,就标记这个变量为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们。当变量离开环境时,则将其标记为“离开环境”。

垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量以及被环境中的变量引用的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后。垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收他们所占用的内存空间。

我们用个例子,解释下这个方法:

var m = 0,n = 19 // 把 m,n,add() 标记为进入环境。
add(m, n) // 把 a, b, c标记为进入环境。
console.log(n) // a,b,c标记为离开环境,等待垃圾回收。
function add(a, b) {
  a++
  var c = a + b
  return c
} 

1.2 引用计数

所谓”引用计数”是指语言引擎有一张”引用表”,保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放

如果一个值不再需要了,引用数却不为0,垃圾回收机制无法释放这块内存,从而导致内存泄漏。

var arr = [1, 2, 3, 4];
arr = [2, 4, 5]
console.log('浪里行舟'); 

上面代码中,数组[1, 2, 3, 4]是一个值,会占用内存。变量arr是仅有的对这个值的引用,因此引用次数为1。尽管后面的代码没有用到arr,它还是会持续占用内存。至于如何释放内存,我们下文介绍。

第三行代码中,数组[1, 2, 3, 4]引用的变量arr又取得了另外一个值,则数组[1, 2, 3, 4]的引用次数就减1,此时它引用次数变成0,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。

但是引用计数有个最大的问题: 循环引用

function func() {
    let obj1 = {};
    let obj2 = {};

    obj1.a = obj2; // obj1 引用 obj2
    obj2.a = obj1; // obj2 引用 obj1
} 

当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。

要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。上面的例子可以这么做:

obj1 = null;
obj2 = null; 

2.什么是内存泄漏

2.1 JavaScript内存分配和回收的关键词:GC根、作用域

GC根:一般指全局且不会被垃圾回收的对象,比如:window、document或者是页面上存在的dom元素。JavaScript的垃圾回收算法会判断某块对象内存是否是GC根可达(存在一条由GC根对象到该对象的引用),如果不是那这块内存将会被标记回收。

作用域:在JavaScript的作用域里,我们能够新建对象来分配内存。比如说调用函数,函数执行的过程中就会创建一块作用域,如果是创建的是作用域内的局部对象,当作用域运行结束后,所有的局部对象(GC根无法触及)都会被标记回收,在JavaScript中能引起作用域分配的有函数调用、with和全局作用域。

我们知道浏览器会把object保存在堆内存中,它们通过索引链可以被访问到。GC(Garbage Collector) 是一个JavaScript引擎的后台进程,它可以鉴别哪些对象是已经处于无用的状态,移除它们,释放占用的内存。

本该被GC回收的变量,如果被其他对象索引,而且可以通过root访问到,这就意味着内存中存在了冗余的内存占用,会导致应用的性能降级,这时也就发生了内存泄漏。

总结:所谓的内存泄漏简单来说就是不再用到的内存,没有得到及时的释放。

3.常见的几种内存泄漏的方式

3.1 未被注意的全局变量

全局变量可以被root访问,不会被GC回收。一些非严格模式下的局部变量可能会变成全局变量,导致内存泄漏。

  • 给没有声明的变量赋值
  • this 指向全局对象
function createGlobalVariables() {
  leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
  this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'

如何避免?使用严格模式。

3.2 闭包

闭包函数执行完成后,作用域中的变量不会被回收,可能会导致内存泄漏:

function outer() {
  const potentiallyHugeArray = [];

  return function inner() {
    potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
    console.log('Hello');
  };
};
const sayHello = outer(); // contains definition of the function inner

function repeat(fn, num) {
  for (let i = 0; i < num; i++){
    fn();
  }
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray 
 
// now imagine repeat(sayHello, 100000)

3.3 定时器

使用setTimeout 或者 setInterval:

function setCallback() {
  const data = {
    counter: 0,
    hugeString: new Array(100000).join('x')
  };

  return function cb() {
    data.counter++; // data object is now part of the callback's scope
    console.log(data.counter);
  }
}

setInterval(setCallback(), 1000); // how do we stop it?

只有当定时器被清理掉的时候,它回调函数内部的data才会被从内存中清理,否则在应用退出前一直会被保留。

如何避免?

function setCallback() {
  // 'unpacking' the data object
  let counter = 0;
  const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns
  
  return function cb() {
    counter++; // only counter is part of the callback's scope
    console.log(counter);
  }
}

const timerId = setInterval(setCallback(), 1000); // saving the interval ID

// doing something ...

clearInterval(timerId); // stopping the timer i.e. if button pressed

定时器赋值给timerId,使用clearInterval(timerId)手动清理。

3.4Event listeners

addEventListener 也会一直保留在内存中无法回收,直到我们使用了 removeEventListener,或者添加监听事件的DOM被移除。

const hugeString = new Array(100000).join('x');

document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
  doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});

如何避免?

function listener() {
  doSomething(hugeString);
}

document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here

// 或者
document.addEventListener('keyup', function listener() {
  doSomething(hugeString);
}, {once: true}); // listener will be removed after running once

4.使用chrome devtools的排查方法

下面使用几个案例来展示在chrome devtools如何查看内存泄漏。

4.1 用全局变量缓存数据

将全局变量作为缓存数据的一种方式,将之后要用到的数据都挂载到全局变量上,用完之后也不手动释放内存(因为全局变量引用的对象,垃圾回收机制不会自动回收),全局变量逐渐就积累了一些不用的对象,导致内存泄漏

   var x = [];
    function createSomeNodes() {
        var div;
        var i = 10000;
        var frag = document.createDocumentFragment();
        for (; i > 0; i--) {
            div = document.createElement("div");
            div.appendChild(document.createTextNode(i + " - " + new Date().toTimeString()));
            frag.appendChild(div);
        }
        document.getElementById("nodes").appendChild(frag);
    }
    function grow() {
        x.push(new Array(1000000).join('x'));
        createSomeNodes();
        setTimeout(grow, 1000);
    }
    grow()

上面的代码贴一张 timeline的截图

JavaScript的垃圾回收机制和内存泄漏以及chrome的排查方法
主要看memory区域,通过分析代码我们可以知道页面上的dom节点是不断增加的,所以memory里绿色的线(代表dom nodes)也是不断升高的;而代表js heap的蓝色的线是有升有降,当整体趋势是逐渐升高,这是因为js 有内存回收机制,每当内存回收的时候蓝色的线就会下降,但是存在部分内存一直得不到释放,所以蓝色的线逐渐升高

4.2 js错误引用DOM元素

    var nodes = '';
    (function () {
        var item = {
            name:new Array(1000000).join('x')
        }
        nodes = document.getElementById("nodes")
        nodes.item = item
        nodes.parentElement.removeChild(nodes)
    })()

这里的dom元素虽然已经从页面上移除了,但是js中仍然保存这对该dom元素的引用。
因为这段代码是只执行一次的,所以用timeline视图会很难分析出来是否存在内存泄漏,所以我们可以用 chrome dev tool 的 profile tab里的heap snapshot 工具来分析。
上面的代码贴一张 heap snapshot 的summary模式的截图

JavaScript的垃圾回收机制和内存泄漏以及chrome的排查方法

通过constructor的filter功能,我们把上面代码中创建的长字符串找出来,可以看到代码运行结束后,内存中的长字符串依然没有被垃圾回收掉。
顺带提一下的是右边红框里的shadow size和 retainer size的含义

  • shadow size 指的是对象本地的大小
  • retainer size 指的是对象所引用内存的大小,回收该对象是会将他引用的内存也一并回收,所以retainer size 指代的是回收内存后会释放出来的内存大小

上面我们可以看到 长字符串本身的shadow size和retainer size是一样大的,这是引用长字符串没有引用其他的对象,如果有引用其他对象,那shadow size 和retainer size将不一致。

4.3 闭包循环引用

(function(){
    var theThing = null
    var replaceThing = function () {
        var originalThing = theThing
        var unused = function () {
            if (originalThing)
                console.log("hi")
        }
        theThing = {
            longStr: new Array(1000000).join('*'),
            someMethod: function someMethod() {
                console.log('someMessage')
            }
        };
    };
    setInterval(replaceThing,100)
})()

首先我们明确一下,unused是一个闭包,因为它引用了自由变量 originalThing,虽然它被没有使用,但v8引擎并不会把它优化掉,因为 JavaScript里存在eval函数,所以v8引擎并不会随便优化掉暂时没有使用的函数。

theThing 引用了someMethod,someMethod这个函数作用域隐式的和unused这个闭包共享一个闭包上下文。所以someMethod也引用了originalThing这个自由变量。

这里面的引用链是:

GCHandler -> replaceThing -> theThing -> someMethod -> originalThing -> someMethod(old) -> originalThing(older)-> someMethod(older)

随着setInterval的不断执行,这条引用链是不会断的,所以内存会不断泄漏,直致程序崩溃。
因为是闭包作用域引起的内存泄漏,这时候最好的选择是使用 chrome的heap snapshot的container视图,我们通过container视图能清楚的看到这条不断泄漏内存的引用链

JavaScript的垃圾回收机制和内存泄漏以及chrome的排查方法

原文链接:https://juejin.cn/post/7231608749588873272 作者:白哥学前端

(0)
上一篇 2023年5月11日 上午10:26
下一篇 2023年5月11日 上午10:36

相关推荐

发表回复

登录后才能评论