本文深入探讨 JavaScript 语言层面的内存管理机制,重点解析 V8 引擎的新生代与老生代回收策略。通过分析作用域、闭包及引用对内存的影响,提供避免全局变量、合理使用回调与手工解除引用的具体优化方案,并介绍 Node.js 环境下的内存快照排查工具,帮助开发者提升应用性能。
JavaScript 内存优化引言
与 C/C++ 相比,JavaScript 在内存处理上赋予了开发者更多专注于业务逻辑的空间。然而,随着单页面应用、移动 HTML5 应用及 Node.js 程序的日益复杂,由内存问题引发的卡顿和内存溢出现象已不再罕见。
本文将深入探讨 JavaScript 语言层面的内存使用与优化策略,从常见概念到易被忽视的细节,逐一进行剖析。
语言层面的内存管理
1. 语言层面的内存管理
1.1 作用域
作用域(scope)是 JavaScript 编程中至关重要的运行机制。在同步编程中,它往往不被初学者重视,但在异步编程环境下,良好的作用域控制已成为开发者的必备技能。此外,作用域在 JavaScript 内存管理中扮演着核心角色。
在 JavaScript 中,函数调用、with 语句以及全局作用域均可形成独立的作用域。
以下代码为例:
代码如下:
var foo = function() {
var local = {};
};
foo();
console.log(local); //=> undefined
var bar = function() {
local = {};
};
bar();
console.log(local); //=> {}
这里定义了 foo() 和 bar() 两个函数,意图均为定义名为 local 的变量,但最终结果截然不同。
在 foo() 函数中,使用 var 声明了 local 变量。由于函数体内部形成独立作用域,该变量仅存在于该作用域内。因 foo() 未进行作用域延伸处理,函数执行完毕后,local 变量随之销毁,外层作用域无法访问。
而在 bar() 函数内,local 变量未使用 var 声明,而是直接作为全局变量定义,因此外层作用域可以访问到该变量。
代码如下:
local = {};
// 这里的定义等效于
global.local = {};1.2 作用域链
在 JavaScript 编程中,多层函数嵌套是典型的作用域链表现。
以下代码所示:
代码如下:
function foo() {
var val = 'hello';
function bar() {
function baz() {
global.val = 'world;'
}
baz();
console.log(val); //=> hello
}
bar();
}
foo();
基于前文关于作用域的阐述,代码显示的结果可能是 world,但实际结果却是 hello。许多初学者对此感到困惑,让我们分析这段代码的执行逻辑。
JavaScript 中,变量标识符的查找遵循从当前作用域向外直至全局作用域的原则。因此,JavaScript 代码对变量的访问只能向外进行,不能逆向查找。
baz() 函数的执行在全局作用域中定义了一个全局变量 val。而在 bar() 函数中访问 val 标识符时,按照从内到外的查找原则:在 bar 函数的作用域中未找到,便向上一层,即 foo() 函数的作用域中查找。
然而,产生疑惑的关键在于:本次标识符访问在 foo() 函数的作用域中找到了符合的变量,便不再继续向外查找。因此,在 baz() 函数中定义的全局变量 val 并未对本次变量访问产生影响。
闭包与内存泄漏
1.3 闭包
我们知道 JavaScript 中的标识符查找遵循从内到外的原则。但随着业务逻辑复杂化,单一的传递顺序已无法满足日益增多的新需求。
我们先来看看下面的代码:
代码如下:
function foo() {
var local = 'Hello';
return function() {
return local;
};
}
var bar = foo();
console.log(bar()); //=> Hello
这里展示的让外层作用域访问内层作用域的技术即为闭包(Closure)。得益于高阶函数的应用,使 foo() 函数的作用域得到『延伸』。
foo() 函数返回了一个匿名函数,该函数存在于 foo() 函数的作用域内,因此可以访问到 foo() 函数作用域内的 local 变量,并保存其引用。因该函数直接返回了 local 变量,所以在外层作用域中便可直接执行 bar() 函数以获得 local 变量。
闭包是 JavaScript 的高级特性,可借助其实现更复杂的效果以满足不同需求。但需注意,由于将带有内部变量引用的函数带出函数外部,该作用域内的变量在函数执行完毕后不一定会被销毁,直到内部变量的引用被全部解除。因此,闭包的应用极易造成内存无法释放的情况。
V8 引擎内存回收机制
2. JavaScript 的内存回收机制
此处以 Chrome 和 Node.js 所使用的、由 Google 推出的 V8 引擎为例,简要介绍 JavaScript 的内存回收机制。更详尽的内容可参考朴灵的《深入浅出 Node.js》一书,其中『内存控制』一章有相当详细的介绍。
在 V8 中,所有的 JavaScript 对象均通过『堆』进行内存分配。
当我们在代码中声明变量并赋值时,V8 就会在堆内存中分配一部分给这个变量。如果已申请的内存不足以存储这个变量时,V8 就会继续申请内存,直到堆的大小达到了 V8 的内存上限为止。默认情况下,V8 的堆内存的大小上限在 64 位系统中为 1464MB,在 32 位系统中则为 732MB,即约 1.4GB 和 0.7GB。
另外,V8 对堆内存中的 JavaScript 对象进行分代管理:新生代和老生代。新生代即存活周期较短的 JavaScript 对象,如临时变量、字符串等;而老生代则为经过多次垃圾回收仍然存活,存活周期较长的对象,如主控制器、服务器对象等。
垃圾回收算法一直是编程语言研发中的重要环节,而 V8 中所使用的垃圾回收算法主要有以下几种:
1.Scavange 算法:通过复制的方式进行内存空间管理,主要用于新生代的内存空间; 2.Mark-Sweep 算法和 Mark-Compact 算法:通过标记来对堆内存进行整理和回收,主要用于老生代对象的检查和回收。
PS: 更详细的 V8 垃圾回收实现可通过阅读相关书籍、文档和源代码进行学习。
我们再来看看 JavaScript 引擎在什么情况下会对哪些对象进行回收。
2.1 作用域与引用
初学者常误认为当函数执行完毕时,在函数内部所声明的对象就会被销毁。但实际上这样理解并不严谨和全面,极易导致混淆。
引用(Reference)是 JavaScript 编程中十分重要的一个机制,但令人奇怪的是,一般开发者不会刻意注意它,甚至不了解它。引用是指『代码对对象的访问』这一抽象关系,它与 C/C++ 的指针有点相似,但并非同物。引用同时也是 JavaScript 引擎在进行垃圾回收中最关键的一个机制。
以下面代码为例:
代码如下:
// ......
var val = 'hello world';
function foo() {
return function() {
return val;
};
}
global.bar = foo();
// ......
阅读完这段代码,你能否说出这部分代码在执行过后,有哪些对象是依然存活的么?
根据相关原则,这段代码中没有被回收释放的对象有 val 和 bar(),究竟是什么原因使他们无法被回收?
JavaScript 引擎是如何进行垃圾回收的?前面说到的垃圾回收算法只是用在回收时的,那么它是如何知道哪些对象可以被回收,哪些对象需要继续生存呢?答案就是 JavaScript 对象的引用。
JavaScript 代码中,哪怕是简单的写下一个变量名称作为单独一行而不做任何操作,JavaScript 引擎都会认为这是对对象的访问行为,存在了对对象的引用。为了保证垃圾回收的行为不影响程序逻辑的运行,JavaScript 引擎就决不能把正在使用的对象进行回收,不然就乱套了。所以判断对象是否正在使用中的标准,就是是否仍然存在对该对象的引用。但事实上,这是一种妥协的做法,因为 JavaScript 的引用是可以进行转移的,那么就有可能出现某些引用被带到了全局作用域,但事实上在业务逻辑里已经不需要对其进行访问了,应该被回收,但是 JavaScript 引擎仍会死板地认为程序仍然需要它。
如何用正确的姿势使用变量、引用,正是从语言层面优化 JavaScript 的关键所在。
JavaScript 内存优化策略
3. 优化你的 JavaScript
终于进入正题了,非常感谢你秉着耐心看到了这里,经过上面这么多介绍,相信你已经对 JavaScript 的内存管理机制有了不错的理解,那么下面的技巧将会让你如虎添翼。
3.1 善用函数
如果你有阅读优秀 JavaScript 项目的习惯的话,你会发现,很多大牛在开发前端 JavaScript 代码的时候,常常会使用一个匿名函数在代码的最外层进行包裹。
代码如下:
(function() {
// 主业务代码
})();有的甚至更高级一点:
代码如下:
;(function(win, doc, $, undefined) {
// 主业务代码
})(window, document, jQuery);甚至连如 RequireJS, SeaJS, OzJS 等前端模块化加载解决方案,都是采用类似的形式:
代码如下:
// RequireJS
define(['jquery'], function($) {
// 主业务代码
});
// SeaJS
define('module', ['dep', 'underscore'], function($, _) {
// 主业务代码
});如果你说很多 Node.js 开源项目的代码都没有这样处理的话,那你就错了。Node.js 在实际运行代码之前,会把每一个.js 文件进行包装,变成如下的形式:
代码如下:
(function(exports, require, module, __dirname, __filename) {
// 主业务代码
});
这样做有什么好处?我们都知道文章开始的时候就说了,JavaScript 中能形成作用域的有函数的调用、with 语句和全局作用域。而我们也知道,被定义在全局作用域的对象,很有可能是会一直存活到进程退出的,如果是一个很大的对象,那就麻烦了。比如有的人喜欢在 JavaScript 中做模版渲染:
代码如下:
<?php
$db = mysqli_connect(server, user, password, 'myapp');
$topics = mysqli_query($db, "SELECT * FROM topics;");
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>你是猴子请来的逗比么?</title>
</head>
<body>
<ul id="topics"></ul>
<script type="text/tmpl" id="topic-tmpl">
<li>
<h1><%=title%></h1>
<p><%=content%></p>
</li>
</script>
<script type="text/javascript">
var data = <?php echo json_encode($topics); ?>;
var topicTmpl = document.querySelector('#topic-tmpl').innerHTML;
var render = function(tmlp, view) {
var complied = tmlp
.replace(/\n/g, '\\n')
.replace(/<%=([\s\S]+?)%>/g, function(match, code) {
return '" + escape(' + code + ') + "';
});
complied = [
'var res = "";',
'with (view || {}) {',
'res = "' + complied + '";',
'}',
'return res;'
].join('\n');
var fn = new Function('view', complied);
return fn(view);
};
var topics = document.querySelector('#topics');
function init()
data.forEach(function(topic) {
topics.innerHTML += render(topicTmpl, topic);
});
}
init();
</script>
</body>
</html>
这种代码在新手的作品中经常能看得到,这里存在什么问题呢?如果在从数据库中获取到的数据的量是非常大的话,前端完成模板渲染以后,data 变量便被闲置在一边。可因为这个变量是被定义在全局作用域中的,所以 JavaScript 引擎不会将其回收销毁。如此该变量就会一直存在于老生代堆内存中,直到页面被关闭。
可是如果我们作出一些很简单的修改,在逻辑代码外包装一层函数,这样效果就大不同了。当 UI 渲染完成之后,代码对 data 的引用也就随之解除,而在最外层函数执行完毕时,JavaScript 引擎就开始对其中的对象进行检查,data 也就可以随之被回收。
3.2 绝对不要定义全局变量 我们刚才也谈到了,当一个变量被定义在全局作用域中,默认情况下 JavaScript 引擎就不会将其回收销毁。如此该变量就会一直存在于老生代堆内存中,直到页面被关闭。
那么我们就一直遵循一个原则:绝对不要使用全局变量。虽然全局变量在开发中确实很省事,但是全局变量所导致的问题远比其所带来的方便更严重。
使变量不易被回收; 1.多人协作时容易产生混淆; 2.在作用域链中容易被干扰。 3.配合上面的包装函数,我们也可以通过包装函数来处理『全局变量』。
3.3 手工解除变量引用 如果在业务代码中,一个变量已经确切是不再需要了,那么就可以手工解除变量引用,以使其被回收。
代码如下:
var data = { /* some big data */ };
// blah blah blah
data = null;
3.4 善用回调 除了使用闭包进行内部变量访问,我们还可以使用现在十分流行的回调函数来进行业务处理。
代码如下:
function getData(callback) {
var data = 'some big data';
callback(null, data);
}
getData(function(err, data) {
console.log(data);
回调函数是一种后续传递风格(Continuation Passing Style, CPS)的技术,这种风格的程序编写将函数的业务重点从返回值转移到回调函数中去。而且其相比闭包的好处也不少:
1.如果传入的参数是基础类型(如字符串、数值),回调函数中传入的形参就会是复制值,业务代码使用完毕以后,更容易被回收; 2.通过回调,我们除了可以完成同步的请求外,还可以用在异步编程中,这也就是现在非常流行的一种编写风格; 3.回调函数自身通常也是临时的匿名函数,一旦请求函数执行完毕,回调函数自身的引用就会被解除,自身也得到回收。
3.5 良好的闭包管理
当我们的业务需求(如循环事件绑定、私有属性、含参回调等)一定要使用闭包时,请谨慎对待其中的细节。
循环绑定事件可谓是 JavaScript 闭包入门的必修课,我们假设一个场景:有六个按钮,分别对应六种事件,当用户点击按钮时,在指定的地方输出相应的事件。
代码如下:
var btns = document.querySelectorAll('.btn'); // 6 elements
var output = document.querySelector('#output');
var events = [1, 2, 3, 4, 5, 6];
// Case 1
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = function(evt) {
output.innerText += 'Clicked ' + events[i];
};
}
// Case 2
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = (function(index) {
return function(evt) {
output.innerText += 'Clicked ' + events[index];
};
})(i);
}
// Case 3
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = (function(event) {
return function(evt) {
output.innerText += 'Clicked ' + event;
};
})(events[i]);
}
这里第一个解决方案显然是典型的循环绑定事件错误,这里不细说,详细可以参照我给一个网友的回答;而第二和第三个方案的区别就在于闭包传入的参数。
第二个方案传入的参数是当前循环下标,而后者是直接传入相应的事件对象。事实上,后者更适合在大量数据应用的时候,因为在 JavaScript 的函数式编程中,函数调用时传入的参数是基本类型对象,那么在函数体内得到的形参会是一个复制值,这样这个值就被当作一个局部变量定义在函数体的作用域内,在完成事件绑定之后就可以对 events 变量进行手工解除引用,以减轻外层作用域中的内存占用了。而且当某个元素被删除时,相应的事件监听函数、事件对象、闭包函数也随之被销毁回收。
3.6 内存不是缓存
缓存在业务开发中的作用举足轻重,可以减轻时空资源的负担。但需要注意的是,不要轻易将内存当作缓存使用。内存对于任何程序开发来说都是寸土寸金的东西,如果不是很重要的资源,请不要直接放在内存中,或者制定过期机制,自动销毁过期缓存。
内存使用检查工具
4. 检查 JavaScript 的内存使用情况
在平时的开发中,我们也可以借助一些工具来对 JavaScript 中内存使用情况进行分析和问题排查。
4.1 Blink / Webkit 浏览器
在 Blink / Webkit 浏览器中(Chrome, Safari, Opera etc.),我们可以借助其中的 Developer Tools 的 Profiles 工具来对我们的程序进行内存检查。
4.2 Node.js 中的内存检查
在 Node.js 中,我们可以使用 node-heapdump 和 node-memwatch 模块进行内存检查。
代码如下:
var heapdump = require('heapdump');
var fs = require('fs');
var path = require('path');
fs.writeFileSync(path.join(__dirname, 'app.pid'), process.pid);
// ...代码如下:
<span style="font-family: Georgia, 'Times New Roman', 'Bitstream Charter', Times, serif; font-size: 14px; line-height: 1.5em;">在业务代码中引入 node-heapdump 之后,我们需要在某个运行时期,向 Node.js 进程发送 SIGUSR2 信号,让 node-heapdump 抓拍一份堆内存的快照。</span>代码如下:
$ kill -USR2 (cat app.pid)
这样在文件目录下会有一个以 heapdump-<sec>.<usec>.heapsnapshot 格式命名的快照文件,我们可以使用浏览器的 Developer Tools 中的 Profiles 工具将其打开,并进行检查。
总结
5. 小结
很快又来到了文章的结束,这篇分享主要向大家展示了以下几点内容:
1.JavaScript 在语言层面上,与内存使用息息相关的东西; 2.JavaScript 中的内存管理、回收机制; 3.如何更高效地使用内存,以至于让出产的 JavaScript 能更有拓展的活力; 4.如何在遇到内存问题的时候,进行内存检查。
希望通过对这篇文章的学习,你能够出产更优秀的 JavaScript 代码,让妈妈安心、让老板放心。