跳到主要内容

V8-内存

JavaScript是一种自动垃圾回收语言.

内存模型

在 JS 中, 基本类型普遍被存放在栈中, 而复杂类型则被存放在堆中. 栈是执行栈, 堆是内存堆.

实际上, 执行栈的函数上下文会保存一个内存堆对应复杂类型对象的内存地址, 通过应用来使用复杂类型对象.

内存回收

V8 的垃圾回收策略基于分代回收机制, 该机制基于世代假说, 假说的主要内容有两点:

  • 大部分新生对象倾向于早死
  • 不死的对象, 会活的更久

基于此, 现代垃圾回收算法根据对象的存活时间将内存进行了分代, 并对不同分代的内存采用不同的高效算法进行垃圾回收.

V8 的内存分代

在 V8 中, 将内存分为了新生代(new space)和老生代(old space):

  • 新生代: 对象的存活时间较短. 新生对象或只经过一次垃圾回收的对象.
  • 老生代: 对象存活时间较长. 经历过一次或多次垃圾回收的对象.

Stop the world(全停顿)

为了避免应用逻辑与垃圾回收器看到的情况不一致, 垃圾回收算法在执行的时候, 需要停止应用逻辑. 垃圾回收算法在执行前, 会将应用暂停, 执行完后再恢复, 这种行为称为"全停顿". 如果一次 GC 需要 50ms, 则应用逻辑会暂停 50ms

Scavenge 算法

Scavenge 算法的缺点是,它的算法机制决定了只能利用一半的内存空间。但是新生代中的对象生存周期短、存活对象少,进行对象复制的成本不是很高,因而非常适合这种场景。

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

image

Cheney 算法采用复制的方法进行垃圾回收. 它将堆内存一分为二, 每一部分称为semispace, 这两个空间, 只有一个空间处于使用中, 另一个则处于闲置. 使用中的semispace称为"From 空间", 闲置的 semispace 称为"To 空间".

过程如下:

  • 从 from 空间分配对象, 若 semispace 被分配满, 则执行 Scavenge 算法进行垃圾回收
  • 检查 From 空间的存活对象, 若对象存活, 则检查对象是否符合晋升条件, 若符合晋升条件则晋升到老生代, 否则将对象从 From 空间复制到 To 空间
  • 若对象不存活, 则释放不存活对象的空间
  • 完成复制后, 将 from 空间和 to 空间进行角色翻转(flip)

对象晋升的判断条件

  1. 对象是否经历过 Scavenge 回收。对象从 From 空间复制 To 空间时,会检查对象的内存地址来判断对象是否已经经过一次 Scavenge 回收。若经历过,则将对象从 From 空间复制到老生代中;若没有经历,则复制到 To 空间。
  2. To 空间的内存使用占比是否超过限制。当对象从 From 空间复制到 To 空间时,若 To 空间使用超过 25%,则对象直接晋升到老生代中。设置为 25%的比例的原因是,当完成 Scavenge 回收后,To 空间将翻转成 From 空间,继续进行对象内存的分配。若占比过大,将影响后续内存分配。

对象晋升到老生代后,将接受新的垃圾回收算法处理。下图为 Scavenge 算法中,对象晋升流程图。

image

老生代回收算法: Mark-Sweep & Mark-Compact

老生代中的对象有两个特点, 一个是存活对象多, 一个是存活时间长. 若在老生代中使用 Scavenge 算法进行垃圾回收,将会导致复制存活对象的效率不高,且还会浪费一半的空间。因而,V8 在老生代采用 Mark-Sweep 和 Mark-Compact 算法进行垃圾回收。

Mark-Sweep,是标记清除的意思。它主要分为标记和清除两个阶段。

  • 标记阶段,它将遍历堆中所有对象,并对存活的对象进行标记;
  • 清除阶段,对未标记对象的空间进行回收。

与 Scavenge 算法不同,Mark-Sweep 不会对内存一分为二,因此不会浪费空间。但是,经历过一次 Mark-Sweep 之后,内存的空间将会变得不连续,这样会对后续内存分配造成问题。比如,当需要分配一个比较大的对象时,没有任何一个碎片内支持分配,这将提前触发一次垃圾回收,尽管这次垃圾回收是没有必要的。

image

为了解决内存碎片的问题,提高对内存的利用,引入了 Mark-Compact (标记整理)算法。Mark-Compact 是在 Mark-Sweep 算法上进行了改进,标记阶段与 Mark-Sweep 相同,但是对未标记的对象处理方式不同。与 Mark-Sweep 是对未标记的对象立即进行回收,Mark-Compact 则是将存活的对象移动到一边,然后再清理端边界外的内存。

image

由于 Mark-Compact 需要移动对象,所以执行速度上,比 Mark-Sweep 要慢。所以,V8 主要使用 Mark-Sweep 算法,然后在当空间内存分配不足时,采用 Mark-Compact 算法。

Incremental Marking (增量标记)

在新生代中,由于存活对象少,垃圾回收效率高,全停顿时间短,造成的影响小。但是老生代中,存活对象多,垃圾回收时间长,全停顿造成的影响大。为了减少全停顿的时间,V8 对标记进行了优化,将一次停顿进行的标记过程,分成了很多小步。每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成标记。如下图所示:

image

长时间的 GC,会导致应用暂停和无响应,将会导致糟糕的用户体验。从 2011 年起,v8 就将「全暂停」标记换成了增量标记。改进后的标记方式,最大停顿时间减少到原来的 1/6。

lazy sweeping(延迟清理)

  • 发生在增量标记之后
  • 堆确切地知道有多少空间能被释放
  • 延迟清理是被允许的,因此页面的清理可以根据需要进行清理
  • 当延迟清理完成后,增量标记将重新开始

垃圾收集器的直观行为

尽管垃圾收集器是便利的, 但是使用它们也需要有一些利弊权衡, 其中之一就是不确定性. 也就是说, GC 的行为是不可预测的. 通常情况下都不能确定什么时候会发生垃圾回收. 这意味着在一些情形下, 程序会使用比实际需要更多的内存. 有些情况下, 在很敏感的应用中可以观察到明显的卡顿. 尽管不确定性意味着你无法确定什么时候垃圾回收会发生, 不过绝大多数的 GC 实现都会在内存分配是遵循通用的垃圾回收过程模式.

如果没有内存分配发生, 大部分的 GC 都会保持静默. 考虑一下情形:

  • 大量内存分配发生时
  • 大部分的元素都被标记为不可达
  • 没有进一步的内存分配发生

这个情形下, GC 将不会运行任何进一步的回收过程. 也就是说, 尽管有不可达的引用可以出发回收, 但是收集器并不要求回收它们, 严格来说这些不是内存泄漏, 但任然导致高于正常情况的内存空间使用.

内存泄漏

什么是内存泄漏

内存泄漏可以定义为: 应用程序不在需要占用内存的时候, 由于某些原因, 内存没有被操作系统或可用内存池回收.

JavaScript 是一种垃圾回收语言. 垃圾回收语言通过周期性地检查先前分配的内存是否可达, 帮助开发者管理内存. 换言之, 垃圾回收语言减轻了"内存仍可用"以及"内存仍可达"问题, 两者的区别微妙但是重要: 仅有开发者了解哪些内存在将来会使用, 而不可达内存通过算法确定和标记, 适时地被操作系统回收.

JavaScript 内存泄漏

垃圾回收语言的内存泄漏主因是不需要的引用. 理解这个之前, 还需要了解垃圾回收语言如何辨别内存的可达与不可达.

常见的JS内存泄漏

意外的全局变量

JS 处理未定义变量的方式比较宽松: 未定义的变量会在全局对象创建一个新变量, 在浏览器中, 全局对象是 window:

function foo(arg) {
bar = 'this is a hidden global variable';
}

实际上就变成了:

function foo(arg) {
window.bar = 'this is a hidden global variable';
}

此时就在函数内存意外创建了一个全局变量, 还有比较糟糕的情况:

function foo(){
this.variable='potential accidental global';
}

//Foo 调用自己, this指向全局对象window
//而不是undefined
foo()

启用严格模式"use strict"可以避免此类错误发生. 启动严格模式解析 JavaScript, 避免意外的全局变量.

不仅是这些意外的全局变量, 还有一些明确的全局变量产生的垃圾, 它们被定义为不可回收. 尤其是当全局变量用于临时存储和处理大量信息时, 需要多加小心, 确保用完后把它设置为 null 或者重新定义.

常见和全局变量相关的引发内存消耗增长的原因就是缓存, 缓存存储着可服用的数据. 为了让这种做法更高效, 必须为缓存的容量规定一个上界. 由于缓存不能及时被回收, 缓存无限制增长会导致很高的内存消耗.

被遗忘的计时器或回调

在 js 中, setInterval 非常常见, 例如:

var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
// 处理 node 和 someResource
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);

引用节点或者数据的定时器已经没用了. 那些表示节点的对象将来可能会被移除. 所以将整块代码放在周期函数中不是必要的. 但是周期函数一直在运行, 处理函数并不会被回收(而是等周期函数停止运行之后才开始内存回收), 如果周期处理函数不能被回收, 它的依赖程序同样也无法被回收. 这意味着相当一部分数据和资源无法被回收.

下面是一个监听的例子, 当它们不再被需要的时候, 显示的移除是十分重要的. 在 IE6 时代, 这是一个至关重要的步骤, 因为它们不能很好的管理循环引用. 现在, 当监听事件的对象失效的时候就会被回收(即便 listener 没有明确的移除), 绝大多数的浏览器可以支持这个特性. 尽管如此, 在对象被销毁之前移除监听事件依然是一个不错的实践.

var element = docuemnt.getElementById('button');

function onClick(event) {
element.innerHtml = 'text';
}

element.addEventListener('click', onClick);

//Do Stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

被遗忘的ES6 Set/Map成员

如下是有内存泄漏的(成员是引用类型的,即对象):

let map = new Set();
let value = { test: 22};
map.add(value);

value= null;

需要修改成这样:

let map = new Set();
let value = { test: 22};
map.add(value);

map.delete(value);
value = null;

有个更简单的方式, 是用WeakSet, 其成员是弱引用, 内存回收不会考虑这个引用是否存在:

let map = new WeakSet();
let value = { test: 22};
map.add(value);

value = null;

同样的, Map也是类似的:

let map = new Map();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);
key = null; // 内存泄露了 !

被遗忘的事件监听器

无用的事件监听器忘记清理是新手最容易犯的错误之一。

用 vue 组件做例子:

<template>
<div></div>
</template>

<script>
export default {
mounted() {
window.addEventListener('resize', () => {
// 这里做一些操作
})
},
}
</script>

上面的组件销毁的时候,resize 事件还是在监听中,里面涉及到的内存都是没法回收的(浏览器会认为这是必须的内存,不是垃圾内存),需要在组件销毁的时候移除相关的事件,如下:

<template>
<div></div>
</template>

<script>
export default {
mounted() {
this.resizeEventCallback = () => {
// 这里做一些操作
}
window.addEventListener('resize', this.resizeEventCallback)
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeEventCallback)
},
}
</script>

被遗忘的订阅发布事件监听器

<template>
<div @click="onClick"></div>
</template>

<script>
import customEvent from 'event'

export default {
methods: {
onClick() {
customEvent.emit('test', { type: 'click' })
},
},
mounted() {
customEvent.on('test', data => {
// 一些逻辑
console.log(data)
})
},
}
</script>

和上面的事件监听相似, 需要手动的off关闭监听:

<template>
<div @click="onClick"></div>
</template>

<script>
import customEvent from 'event'

export default {
methods: {
onClick() {
customEvent.emit('test', { type: 'click' })
},
},
mounted() {
customEvent.on('test', data => {
// 一些逻辑
console.log(data)
})
},
beforeDestroy() {
customEvent.off('test')
},
}
</script>

DOM 以外的引用

某些情况下将 DOM 节点存储到数据结构中会非常有用, 假设想要快速的更新一个表格中的几行, 如果把每一行的引用都存储在一个字典或者数据里面会起到很大作用. 如果这样做, 程序中会保留同一个节点的两个引用: 一个引用存在于 DOM 树中, 另一个被保留在字典中. 在未来的某个时刻如要移除这些行, 就必须将所有的引用清除.

var elements = {
button: document.getElementById('button'),
image: document.getElementById('image'),
text: document.getElementById('text')
};

function doStuff() {
image.src = 'http://some.url/image';
button.click();
console.log(text.innerHTML);
// Much more logic
}

function removeButton() {
// The button is a direct child of body.
document.body.removeChild(document.getElementById('button'));

// At this point, we still have a reference to #button in the global
// elements dictionary. In other words, the button element is still in
// memory and cannot be collected by the GC.
}

还有另一种情况, 就是对 DOM 树子节点的引用. 假设你在 js 代码中保留一个表格中特定单元格的引用(比如一个<td>标签), 在将来你决定将这个表格从 DOM 中移除, 但是仍然保留这个单元格的引用. 并直觉, 你可能会认为 GC 回收了除了这个单元格之外的所有东西, 但是实际上这并不会发生, 单元格是表格的一个子节点并且所有子节点都保留这它们父节点的引用. 换句话说, js 代码中对单元格的引用会导致整个表格都被保留在内存中.

闭包

js 中的闭包可以获取父级作用域的变量引用. Meteor 的开发者发现在一种特殊情况下有可能会有一种很微妙的方式产生内存泄漏, 这取决于 js 运行时的实现细节.

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() {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);
  • 每次调用replaceThing时, theThing都会得到一个新的包含一个大数组和新的闭包(someMethod)的对象
  • 同时, 没有用到的那个变量持有一个 引用了originalThing(replaceThing调用之前的theThine)闭包.
  • 问题的关键在于每当同一个父作用域下创建闭包作用域的时候, 这个作用域是被共享. 在这种情况下, someMethod的闭包作用域和unused的作用域是共享的.
  • unused持有一个originalThing的应用. 尽管unused从未被使用, 它对originalThing的引用还是保持了它的活跃状态(阻止它被回收). 当这段代码重复运行是, 将可以观察到内存消耗稳定的上涨, 并且不会因为 GC 的存在而下降. 本质上来讲, 创建了一个闭包链表(根节点是theThing形式的变量), 而且每个闭包作用域都持有一个对大数组的间接引用, 就导致了一个巨大的内存泄漏.

小结

引起内存泄漏, 总归是这样几种情况:

  • 滥用全局变量:直接用全局变量赋值,在函数中滥用 this 指向全局对象
  • 不销毁定时器和回调
  • DOM 引用不规范
  • 滥用闭包

Chrome 内存分析工具

Chrome 为 js 的内存分析提供一套很好的工具, 比较重要的内存相关的视图: Performance 视图和 Memory 视图.

测试代码:

var x = [];

function createSomeNodes() {
var div,
i = 100,
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 的时候, 它会创建 div 节点并且把他们追加到 dom 上, 它将会分配一个大数组并将它追加到全局数据中. 这将会导致内存的稳定增长.

点击 perfomance 视图, 点击开始记录, 在页面中点击按钮开始内存泄漏, 一段时间后停止记录, 观察结果:

image

有两个比较明显的标志告诉我们正在产生内存泄漏: 节点的图标(绿色的线)和 JS 堆内存(蓝色的线). 节点数稳定的增长并且从不减少, 这是一个明显的警告标志.

JS 堆内存表现出稳定的内存用量增长, 由于垃圾回收器的作用, 实际上不是很明显.

如何确定内存泄漏的位置, 我们需要借助另一个 chrome 的开发者工具: Memory

首先刷新页面, 选择 memory 选项卡, 选择 Heap snapshot 选项卡, 点击 Take spanshot

点击开始内存泄漏按钮, 一段时间后, 再拍摄一张快照.

image

image

有两种方法来查看两个快照之间的内存分配情况, 其中一种方法需要选择 Summary, 然后再右边选取在快照 1 和快照 2 之间分配的对象, 另一种方法, 选择 Comparison 而不是 Summary, 两种方法下, 我们都会看到一个列表:

image

或者使用另外的选项也能很好的定位内存泄漏的位置

image

参考链接