内存管理

前言

像C语言这样的底层语言一般都有底层的内存管理接口,比如malloc()和free()用于分配和释放内存。而对于JavaScript来说,会在创建变量时分配内存,并且在不再使用它们时自动释放内存,这个自动释放内存的过程称为垃圾回收,因为自动垃圾回收机制的存在,让大多JavaScript开发者感觉他么可以不关系内存的管理,所以会在一些情况下导致内存泄漏

内存的生命周期

分配内存 -> 使用内存 -> 释放内存

JavaScript环境中分配的内存有如下生命周期:

  1. 内存分配:当我们声明变量、函数、对象的时候,系统会自动为它们分配内存
  2. 内存使用:即读写内存,也就是使用变量、函数等
  3. 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存

JavaScript的内存分配

为了不让开发人员费心分配内存,JavaScript在定义变量时就完成了内存分配

var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存

var o = {
  a: 1,
  b: null
}; // 给对象及其包含的值分配内存

// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"]; 

function f(a){
  return a + 2;
} // 给函数(可调用的对象)分配内存

// 函数表达式也能分配一个对象
someElement.addEventListener(‘click‘, function(){
  someElement.style.backgroundColor = ‘blue‘;
}, false);

有些函数调用结果是分配对象内存:

var d = new Date(); // 分配一个 Date 对象
var e = document.createElement(‘div‘); // 分配一个 DOM 元素

有些方法分配新变量或者新对象

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新数组有四个元素,是 a 连接 a2 的结果

JavaScript的内存使用

使用值的过程实际上是对分配的内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

var a = 10; // 分配内存
console.log(a); // 对内存的使用

JavaScript的内存回收

JavaScript有自动垃圾回收机制,那么这个自动垃圾回收机制的原理是什么?其实就是找出那些不再继续使用的值,然后释放其占用的内存。

大多数内存管理的问题都在这个阶段。在这里最艰难的任务就是找到不在需要使用的变量。

不再需要使用的变量也就是生命周期结束的变量,是局部变量,局部变量只在函数的执行过程中存在,当函数运行结束,没有其他引用(闭包)那么该变量会被标记回收。

全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收。

因为自动垃圾回收机制的存在,开发人员可以不关心不注意内存释放的相关问题,但对于无用内存的释放是客观存在的。不幸的是,即使不考虑垃圾回收机制对性能的影响,目前最新的来及回收算法也无法只能回收所有的极端情况。

垃圾回收

引用

垃圾回收算法主要依赖于引用的概念。

在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),就叫做一个对象引用另一个对象。

引用计数垃圾收集

引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。 如果没有其他对象指向它了,说明该对象已经不再需了。

var o = { 
  a: {
    b:2
  }
}; 
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集


var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1;      // 现在,“这个对象”的原始引用o被o2替换了

var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 最初的对象现在已经是零引用了
           // 他可以被垃圾回收了
           // 然而它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
           // 它可以被垃圾回收了

由上面可以看出,引用技术算法是个简单有效的算法。但它却存在一个致命的问题:循环引用

如果两个对象相互引用,尽管他们已经不再使用,垃圾回收不会进行回收,导致内存泄漏。

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o  这里

  return "azerty";
}

f();

上面的函数f,其中包含两个相互引用的对象。在调用函数结束后,对象实际航已经离开函数范围,即不在需要了。但根据引用计数原则,他们之间相互引用依然存在,因此这部分内存不会被回收,内存泄漏不可避免

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
}; 

 创建一个DOM元素并绑定一个点击事件,此时变量div有事件处理函数的引用,同时事件处理函数也有div的引用,这也造成了循环引用,该部分的内存无法避免的泄露了

*标记清除算法*(重要)

标记清除算法将”不再使用的对象“定义为”无法达到的对象“。就是从根部(JavaScript全局对象)出发定时扫描内存中的对象。凡是能从根部到达的对象都是还需要使用的。那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

工作流程:

  1. 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
  2. 从根部出发将能触及到的对象的标记清除。
  3. 那些还存在标记的变量被视为准备删除的变量
  4. 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并且收回他们所占用的内存空间。  

标记清除算法分为两个阶段:标记阶段(mark);清除阶段(sweep)。

  1. 标记阶段:垃圾回收器会从根对象开始遍历。每一个可以从根对象访问到的对象都会被添加一个标识,于是这个对象就被表示为可到达对象。
  2. 清除阶段:垃圾回收器会对堆内存从头到尾进行线性遍历,如果发现有对象没有被标识为可到达对象,那么就将此对象占用的内存回收,并且将原来编辑为可到达对象的标识清除,以便进行下一次垃圾回收操作。

ChromeV8垃圾回收算法分代回收(Generation GC)

这个和 Java 回收策略思想是一致的。目的是通过区分「临时」与「持久」对象;多回收「临时对象区」(young generation),少回收「持久对象区」(tenured generation),减少每次需遍历的对象,从而减少每次GC的耗时。Chrome 浏览器所使用的 V8 引擎就是采用的分代回收策略。
 
「临时」与「持久」对象也被叫做作「新生代」与「老生代」对象
 

V8内存限制

在node中javascript能使用的内存是有限制的.

  1. 64位系统下约为1.4GB。
  2. 32位系统下约为0.7GB。

对应到分代内存中,默认情况下。

  1. 32位系统新生代内存大小为16MB,老生代内存大小为700MB。
  2. 64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。

新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。

这个限制在node启动的时候可以通过传递--max-old-space-size 和 --max-new-space-size来调整,如:

node --max-old-space-size=1700 app.js //单位为MB
node --max-new-space-size=1024 app.js //单位为MB

V8为什么会有内存限制

  • 表面上的原因是V8最初是作为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景。
  • 而深层次的原因则是由于V8的垃圾回收机制的限制。由于V8需要保证JavaScript应用逻辑与垃圾回收器所看到的不一样,V8在执行垃圾回收时会阻塞JavaScript应用逻辑,直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。
  • 若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。
  • 这样浏览器将在1s内失去对用户的响应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。

V8新生代算法(Scavenge)

新生代中的对象主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用Cheney算法。

  • Cheney算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,这两个空间中只有一个处于使用中,一个处于闲置状态。
  • 处于使用状态的空间称为From空间,处于闲置的空间称为To空间。
  • 分配对象时,先是在From空间中进行分配,当开始垃圾回收时,会检查From空间中的存活对象,并将这些存活对象复制到To空间中,而非存活对象占用的空间被释放。
  • 完成复制后,From空间和To空间的角色互换。
  • 简而言之,垃圾回收过程中,就是通过将存活对象在两个空间中进行复制。

 Scavenge算法的缺点是只能使用堆内存中的一半,但由于它只复制存活的对象,对于生命周期短的场景存活对象只占少部分,所以在时间效率上有着优异的表现。

晋升

以上所说的是在纯Scavenge算法中,但是在分代式垃圾回收的前提下,From空间中存活的对象在复制到To空间之前需要进行检查,在一定条件下,需要将存活周期较长的对象移动到老生代中,这个过程称为对象晋升。

对象晋升的条件有两个:一种是对象是否经历过Scacenge回收;另外一种情况是当To空间的使用应超过25%时,则这个对象直接晋升到老生代空间中。

V8老生代算法(Mark-Sweep,Mark-Compact)

在老生代中的对象,由于存活对象占比较大,再采用Scavenge方式会有两个问题:

  • 一个是存活对象就较多,复制存活对象的效率将会降低;
  • 另一个依然是浪费一半空间的问题。为此,V8在老生代中主要采用Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。

Mark-Sweep(标记- 清除算法)

这个算法上文有提到过,这里再说一下。

  • 与Scavenge不同,Mark-Sweep并不会将内存分为两份,所以不存在浪费一半空间的行为。Mark-Sweep在标记阶段遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象。
  • 也就是说,Scavenge只复制活着的对象,而Mark-Sweep只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因。
  • 但是这个算法有个比较大的问题是,内存碎片太多。如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
  • 所以在此基础上提出Mark-Compact算法。

Mark-Compact(标记-整理算法)

Mark-Compact在标记完存活对象以后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存(也就是说将老生代对象中的内存碎片全部清除)。

内存泄漏

什么是内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须共给内存。

对于持续运行的服务进程,必须及时释放不再使用的内存,否则内存占用的越来越高,轻则影响系统性能,重则导致进程崩溃。

本质上讲,内存泄漏就是由于疏忽或错误造成程序未能释放那些已经不再使用的内存,造成内存的浪费。

内存泄漏的识别方法

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。 这就要求实时查看内存的占用情况。

在 Chrome 浏览器中,我们可以这样查看内存占用情况

  1. 打开开发者工具,选择 Performance 面板
  2. 在顶部勾选 Memory
  3. 点击左上角的 record 按钮
  4. 在页面上进行各种操作,模拟用户的使用情况
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况

我们有两种方式来判定当前是否有内存泄漏:

  1. 多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏
  2. 某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏

常见的内存泄露案例

意外的全局变量

被遗忘的定时器和回调函数

闭包

DOM 引用

如何避免内存泄漏

记住一个原则:不用的东西,及时归还。

  1. 减少不必要的全局变量,使用严格模式避免意外创建全局变量。
  2. 在你使用完数据后,及时解除引用(闭包中的变量,dom引用,定时器清除)。
  3. 组织好你的逻辑,避免死循环等造成浏览器卡顿,崩溃的问题。

感谢掘金作者!!!

https://juejin.im/post/5bbeef03e51d450e7a252707

https://juejin.im/post/5d0706a6f265da1bc23f77a9 

相关推荐