C/C++에 비해 우리가 사용하는 JavaScript의 메모리 처리 덕분에 개발 과정에서 비즈니스 로직 작성에 더 집중할 수 있었습니다. 그러나 비즈니스의 지속적인 복잡성과 단일 페이지 애플리케이션, 모바일 HTML5 애플리케이션, Node.js 프로그램 등의 개발로 인해 JavaScript의 메모리 문제로 인한 지연 및 메모리 오버플로와 같은 현상은 더 이상 낯설지 않습니다.
이 기사에서는 JavaScript 언어 수준의 메모리 사용 및 최적화에 대해 설명합니다. 누구나 익숙하거나 조금이라도 들어본 부분부터 대부분의 사람들이 인지하지 못하는 부분까지 하나씩 분석해보겠습니다.
1. 언어 수준의 메모리 관리
1.1 범위
범위는 JavaScript 프로그래밍에서 매우 중요한 작업 메커니즘으로 사용됩니다. 초보자의 관심을 끌지만 비동기 프로그래밍에서는 좋은 범위 제어 기술이 JavaScript 개발자에게 필수적인 기술이 되었습니다. 또한 범위는 JavaScript 메모리 관리에서 중요한 역할을 합니다.
JavaScript에서는 명령문과 전역 범위를 사용하여 함수 호출을 통해 범위를 구성할 수 있습니다.
다음 코드를 예로 들어 보겠습니다.
var foo = function() { var local = {}; }; foo(); console.log(local); //=> undefined var bar = function() { local = {}; }; bar(); console.log(local); //=> {}
여기서 foo() 함수와 bar() 함수를 정의하고, 그 의도는 local이라는 변수를 정의하는 것입니다. 그러나 최종 결과는 완전히 달랐습니다.
foo() 함수에서는 var 문을 사용하여 지역 변수를 선언하고 정의합니다. 범위는 함수 본문 내부에 형성되므로 이 변수는 범위에 정의됩니다. 게다가 foo() 함수의 본문에서는 범위 확장 처리를 하지 않으므로 함수가 실행된 후 지역 변수도 소멸됩니다. 외부 범위에서는 변수에 액세스할 수 없습니다.
bar() 함수에서는 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 코드의 변수에 대한 액세스는 외부에서만 수행될 수 있으며 그 반대의 경우는 불가능합니다.
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() 함수 범위 내 지역 변수에 액세스하고 해당 참조를 저장할 수 있습니다. 이 함수는 지역 변수를 직접 반환하므로 bar() 함수를 외부 범위에서 직접 실행하여 지역 변수를 얻을 수 있습니다.
클로저는 JavaScript의 고급 기능으로 다양한 요구 사항을 충족하기 위해 더 복잡한 효과를 얻을 수 있습니다. 그러나 내부 변수 참조가 있는 함수는 함수에서 제거되기 때문에 내부 변수에 대한 모든 참조가 해제될 때까지 함수가 실행된 후 범위 내의 변수가 반드시 소멸되지는 않는다는 점에 유의해야 합니다. 따라서 클로저를 적용하면 쉽게 메모리를 해제할 수 없게 될 수 있습니다.
2. JavaScript의 메모리 재활용 메커니즘
여기에서는 Chrome과 Node.js에서 사용되는 Google에서 출시한 V8 엔진을 예로 들어 JavaScript의 메모리 재활용 메커니즘을 간략하게 소개하겠습니다. 자세한 정보는 내 좋은 친구인 Pu Ling의 책 "간단하고 쉬운 방법으로 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 的关键所在。
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> <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-
5. 小结
很快又来到了文章的结束,这篇分享主要向大家展示了以下几点内容:
1. JavaScript는 언어 수준에서 메모리 사용과 밀접한 관련이 있습니다.
2. JavaScript의 메모리 관리 및 재활용 메커니즘
3. 생성된 JavaScript가 더 활력을 가질 수 있도록 하는 방법.
4. 메모리 문제 발생 시 메모리 검사를 수행하는 방법.
메모리 최적화에 대한 더 많은 JavaScript 관련 기사를 보려면 PHP 중국어 웹사이트를 주목하세요!