JavaScript也談內(nèi)存優(yōu)化
相對C/C++ 而言,我們所用的JavaScript 在內(nèi)存這一方面的處理已經(jīng)讓我們在開發(fā)中更注重業(yè)務(wù)邏輯的編寫。但是隨著業(yè)務(wù)的不斷復(fù)雜化,單頁面應(yīng)用、移動HTML5 應(yīng)用和Node.js 程序等等的發(fā)展,JavaScript 中的內(nèi)存問題所導(dǎo)致的卡頓、內(nèi)存溢出等現(xiàn)象也變得不再陌生。
這篇文章將從JavaScript 的語言層面進(jìn)行內(nèi)存的使用和優(yōu)化的探討。從大家熟悉或略有耳聞的方面,到大家大多數(shù)時候不會注意到的地方,我們一一進(jìn)行剖析。
1. 語言層面的內(nèi)存管理
1.1 作用域
作用域(scope)是JavaScript 編程中一個非常重要的運(yùn)行機(jī)制,在同步JavaScript 編程中它并不能充分引起初學(xué)者的注意,但在異步編程中,良好的作用域控制技能成為了JavaScript 開發(fā)者的必備技能。另外,作用域在JavaScript 內(nèi)存管理中起著至關(guān)重要的作用。
在JavaScript中,能形成作用域的有函數(shù)的調(diào)用、with語句和全局作用域。
如以下代碼為例:
var foo = function() {
var local = {};
};
foo();
console.log(local); //=> undefined
var bar = function() {
local = {};
};
bar();
console.log(local); //=> {}
這里我們定義了foo()函數(shù)和bar()函數(shù),他們的意圖都是為了定義一個名為local的變量。但最終的結(jié)果卻截然不同。
在foo()函數(shù)中,我們使用var語句來聲明定義了一個local變量,而因為函數(shù)體內(nèi)部會形成一個作用域,所以這個變量便被定義到該作用域中。而且foo()函數(shù)體內(nèi)并沒有做任何作用域延伸的處理,所以在該函數(shù)執(zhí)行完畢后,這個local變量也隨之被銷毀。而在外層作用域中則無法訪問到該變量。
而在bar()函數(shù)內(nèi),local變量并沒有使用var語句進(jìn)行聲明,取而代之的是直接把local作為全局變量來定義。故外層作用域可以訪問到這個變量。
local = {};
// 這里的定義等效于
global.local = {};
1.2 作用域鏈
在JavaScript編程中,你一定會遇到多層函數(shù)嵌套的場景,這就是典型的作用域鏈的表示。
如以下代碼所示:
function foo() {
var val = 'hello';
function bar() {
function baz() {
global.val = 'world;'
}
baz();
console.log(val); //=> hello
}
bar();
}
foo();
根據(jù)前面關(guān)于作用域的闡述,你可能會認(rèn)為這里的代碼所顯示的結(jié)果是world,但實際的結(jié)果卻是hello。很多初學(xué)者在這里就會開始感到困惑了,那么我們再來看看這段代碼是怎么工作的。
由于JavaScript 中,變量標(biāo)識符的查找是從當(dāng)前作用域開始向外查找,直到全局作用域為止。所以JavaScript 代碼中對變量的訪問只能向外進(jìn)行,而不能逆而行之。
baz()函數(shù)的執(zhí)行在全局作用域中定義了一個全局變量val。而在bar()函數(shù)中,對val這一標(biāo)識符進(jìn)行訪問時,按照從內(nèi)到外厄德查找原則:在bar函數(shù)的作用域中沒有找到,便到上一層,即foo()函數(shù)的作用域中查找。
然而,使大家產(chǎn)生疑惑的關(guān)鍵就在這里:本次標(biāo)識符訪問在foo()函數(shù)的作用域中找到了符合的變量,便不會繼續(xù)向外查找,故在baz()函數(shù)中定義的全局變量val并沒有在本次變量訪問中產(chǎn)生影響。
1.3 閉包
我們知道JavaScript 中的標(biāo)識符查找遵循從內(nèi)到外的原則。但隨著業(yè)務(wù)邏輯的復(fù)雜化,單一的傳遞順序已經(jīng)遠(yuǎn)遠(yuǎn)不能滿足日益增多的新需求。
我們先來看看下面的代碼:
function foo() {
var local = 'Hello';
return function() {
return local;
};
}
var bar = foo();
console.log(bar()); //=> Hello
這里所展示的讓外層作用域訪問內(nèi)層作用域的技術(shù)便是閉包(Closure)。得益于高階函數(shù)的應(yīng)用,使foo()函數(shù)的作用域得到『延伸』。
foo()函數(shù)返回了一個匿名函數(shù),該函數(shù)存在于foo()函數(shù)的作用域內(nèi),所以可以訪問到foo()函數(shù)作用域內(nèi)的local變量,并保存其引用。而因這個函數(shù)直接返回了local變量,所以在外層作用域中便可直接執(zhí)行bar()函數(shù)以獲得local變量。
閉包是JavaScript 的高級特性,我們可以借助它來實現(xiàn)更多更復(fù)雜的效果來滿足不同的需求。但是要注意的是因為把帶有內(nèi)部變量引用的函數(shù)帶出了函數(shù)外部,所以該作用域內(nèi)的變量在函數(shù)執(zhí)行完畢后的并不一定會被銷毀,直到內(nèi)部變量的引用被全部解除。所以閉包的應(yīng)用很容易造成內(nèi)存無法釋放的情況。
2. JavaScript 的內(nèi)存回收機(jī)制
這里我將以Chrome 和Node.js 所使用的,由Google 推出的V8 引擎為例,簡要介紹一下JavaScript 的內(nèi)存回收機(jī)制,更詳盡的內(nèi)容可以購買我的好朋友樸靈的書《深入淺出Node.js 》進(jìn)行學(xué)習(xí),其中『內(nèi)存控制』一章中有相當(dāng)詳細(xì)的介紹。
在V8 中,所有的JavaScript 對象都是通過『堆』來進(jìn)行內(nèi)存分配的。
當(dāng)我們在代碼中聲明變量并賦值時,V8 就會在堆內(nèi)存中分配一部分給這個變量。如果已申請的內(nèi)存不足以存儲這個變量時,V8 就會繼續(xù)申請內(nèi)存,直到堆的大小達(dá)到了V8 的內(nèi)存上限為止。默認(rèn)情況下,V8 的堆內(nèi)存的大小上限在64位系統(tǒng)中為1464MB,在32位系統(tǒng)中則為732MB,即約1.4GB 和0.7GB。
另外,V8 對堆內(nèi)存中的JavaScript 對象進(jìn)行分代管理:新生代和老生代。新生代即存活周期較短的JavaScript 對象,如臨時變量、字符串等;而老生代則為經(jīng)過多次垃圾回收仍然存活,存活周期較長的對象,如主控制器、服務(wù)器對象等。
垃圾回收算法一直是編程語言的研發(fā)中是否重要的一環(huán),而V8 中所使用的垃圾回收算法主要有以下幾種:
1.Scavange 算法:通過復(fù)制的方式進(jìn)行內(nèi)存空間管理,主要用于新生代的內(nèi)存空間;
2.Mark-Sweep 算法和Mark-Compact 算法:通過標(biāo)記來對堆內(nèi)存進(jìn)行整理和回收,主要用于老生代對象的檢查和回收。
PS: 更詳細(xì)的V8 垃圾回收實現(xiàn)可以通過閱讀相關(guān)書籍、文檔和源代碼進(jìn)行學(xué)習(xí)。
我們再來看看JavaScript 引擎在什么情況下會對哪些對象進(jìn)行回收。
2.1 作用域與引用
初學(xué)者常常會誤認(rèn)為當(dāng)函數(shù)執(zhí)行完畢時,在函數(shù)內(nèi)部所聲明的對象就會被銷毀。但實際上這樣理解并不嚴(yán)謹(jǐn)和全面,很容易被其導(dǎo)致混淆。
引用(Reference)是JavaScript 編程中十分重要的一個機(jī)制,但奇怪的是一般的開發(fā)者都不會刻意注意它、甚至不了解它。引用是指『代碼對對象的訪問』這一抽象關(guān)系,它與C/C++ 的指針有點(diǎn)相似,但并非同物。引用同時也是JavaScript 引擎在進(jìn)行垃圾回收中最關(guān)鍵的一個機(jī)制。
以下面代碼為例:
// ......
var val = 'hello world';
function foo() {
return function() {
return val;
};
}
global.bar = foo();
// ......
閱讀完這段代碼,你能否說出這部分代碼在執(zhí)行過后,有哪些對象是依然存活的么?
根據(jù)相關(guān)原則,這段代碼中沒有被回收釋放的對象有val和bar(),究竟是什么原因使他們無法被回收?
JavaScript 引擎是如何進(jìn)行垃圾回收的?前面說到的垃圾回收算法只是用在回收時的,那么它是如何知道哪些對象可以被回收,哪些對象需要繼續(xù)生存呢?答案就是JavaScript 對象的引用。
JavaScript 代碼中,哪怕是簡單的寫下一個變量名稱作為單獨(dú)一行而不做任何操作,JavaScript 引擎都會認(rèn)為這是對對象的訪問行為,存在了對對象的引用。為了保證垃圾回收的行為不影響程序邏輯的運(yùn)行,JavaScript 引擎就決不能把正在使用的對象進(jìn)行回收,不然就亂套了。所以判斷對象是否正在使用中的標(biāo)準(zhǔn),就是是否仍然存在對該對象的引用。但事實上,這是一種妥協(xié)的做法,因為JavaScript 的引用是可以進(jìn)行轉(zhuǎn)移的,那么就有可能出現(xiàn)某些引用被帶到了全局作用域,但事實上在業(yè)務(wù)邏輯里已經(jīng)不需要對其進(jìn)行訪問了,應(yīng)該被回收,但是JavaScript 引擎仍會死板地認(rèn)為程序仍然需要它。
如何用正確的姿勢使用變量、引用,正是從語言層面優(yōu)化JavaScript 的關(guān)鍵所在。
3. 優(yōu)化你的JavaScript
終于進(jìn)入正題了,非常感謝你秉著耐心看到了這里,經(jīng)過上面這么多介紹,相信你已經(jīng)對JavaScript 的內(nèi)存管理機(jī)制有了不錯的理解,那么下面的技巧將會讓你如虎添翼。
3.1 善用函數(shù)
如果你有閱讀優(yōu)秀JavaScript 項目的習(xí)慣的話,你會發(fā)現(xiàn),很多大牛在開發(fā)前端JavaScript 代碼的時候,常常會使用一個匿名函數(shù)在代碼的最外層進(jìn)行包裹。
(function() {
// 主業(yè)務(wù)代碼
})();
有的甚至更高級一點(diǎn):
;(function(win, doc, $, undefined) {
// 主業(yè)務(wù)代碼
})(window, document, jQuery);
甚至連如RequireJS, SeaJS, OzJS 等前端模塊化加載解決方案,都是采用類似的形式:
// RequireJS
define(['jquery'], function($) {
// 主業(yè)務(wù)代碼
});
// SeaJS
define('module', ['dep', 'underscore'], function($, _) {
// 主業(yè)務(wù)代碼
});
如果你說很多Node.js 開源項目的代碼都沒有這樣處理的話,那你就錯了。Node.js 在實際運(yùn)行代碼之前,會把每一個.js 文件進(jìn)行包裝,變成如下的形式:
(function(exports, require, module, __dirname, __filename) {
// 主業(yè)務(wù)代碼
});
這樣做有什么好處?我們都知道文章開始的時候就說了,JavaScript中能形成作用域的有函數(shù)的調(diào)用、with語句和全局作用域。而我們也知道,被定義在全局作用域的對象,很有可能是會一直存活到進(jìn)程退出的,如果是一個很大的對象,那就麻煩了。比如有的人喜歡在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>
這種代碼在新手的作品中經(jīng)常能看得到,這里存在什么問題呢?如果在從數(shù)據(jù)庫中獲取到的數(shù)據(jù)的量是非常大的話,前端完成模板渲染以后,data變量便被閑置在一邊??梢驗檫@個變量是被定義在全局作用域中的,所以JavaScript引擎不會將其回收銷毀。如此該變量就會一直存在于老生代堆內(nèi)存中,直到頁面被關(guān)閉。
可是如果我們作出一些很簡單的修改,在邏輯代碼外包裝一層函數(shù),這樣效果就大不同了。當(dāng)UI渲染完成之后,代碼對data的引用也就隨之解除,而在最外層函數(shù)執(zhí)行完畢時,JavaScript引擎就開始對其中的對象進(jìn)行檢查,data也就可以隨之被回收。
3.2 絕對不要定義全局變量
我們剛才也談到了,當(dāng)一個變量被定義在全局作用域中,默認(rèn)情況下JavaScript 引擎就不會將其回收銷毀。如此該變量就會一直存在于老生代堆內(nèi)存中,直到頁面被關(guān)閉。
那么我們就一直遵循一個原則:絕對不要使用全局變量。雖然全局變量在開發(fā)中確實很省事,但是全局變量所導(dǎo)致的問題遠(yuǎn)比其所帶來的方便更嚴(yán)重。
使變量不易被回收;
1.多人協(xié)作時容易產(chǎn)生混淆;
2.在作用域鏈中容易被干擾。
3.配合上面的包裝函數(shù),我們也可以通過包裝函數(shù)來處理『全局變量』。
3.3 手工解除變量引用
如果在業(yè)務(wù)代碼中,一個變量已經(jīng)確切是不再需要了,那么就可以手工解除變量引用,以使其被回收。
var data = { /* some big data */ };
// blah blah blah
data = null;
3.4 善用回調(diào)
除了使用閉包進(jìn)行內(nèi)部變量訪問,我們還可以使用現(xiàn)在十分流行的回調(diào)函數(shù)來進(jìn)行業(yè)務(wù)處理。
function getData(callback) {
var data = 'some big data';
callback(null, data);
}
getData(function(err, data) {
console.log(data);
回調(diào)函數(shù)是一種后續(xù)傳遞風(fēng)格(Continuation Passing Style, CPS)的技術(shù),這種風(fēng)格的程序編寫將函數(shù)的業(yè)務(wù)重點(diǎn)從返回值轉(zhuǎn)移到回調(diào)函數(shù)中去。而且其相比閉包的好處也不少:
1.如果傳入的參數(shù)是基礎(chǔ)類型(如字符串、數(shù)值),回調(diào)函數(shù)中傳入的形參就會是復(fù)制值,業(yè)務(wù)代碼使用完畢以后,更容易被回收;
2.通過回調(diào),我們除了可以完成同步的請求外,還可以用在異步編程中,這也就是現(xiàn)在非常流行的一種編寫風(fēng)格;
3.回調(diào)函數(shù)自身通常也是臨時的匿名函數(shù),一旦請求函數(shù)執(zhí)行完畢,回調(diào)函數(shù)自身的引用就會被解除,自身也得到回收。
3.5 良好的閉包管理
當(dāng)我們的業(yè)務(wù)需求(如循環(huán)事件綁定、私有屬性、含參回調(diào)等)一定要使用閉包時,請謹(jǐn)慎對待其中的細(xì)節(jié)。
循環(huán)綁定事件可謂是JavaScript 閉包入門的必修課,我們假設(shè)一個場景:有六個按鈕,分別對應(yīng)六種事件,當(dāng)用戶點(diǎn)擊按鈕時,在指定的地方輸出相應(yīng)的事件。
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]);
}
這里第一個解決方案顯然是典型的循環(huán)綁定事件錯誤,這里不細(xì)說,詳細(xì)可以參照我給一個網(wǎng)友的回答;而第二和第三個方案的區(qū)別就在于閉包傳入的參數(shù)。
第二個方案傳入的參數(shù)是當(dāng)前循環(huán)下標(biāo),而后者是直接傳入相應(yīng)的事件對象。事實上,后者更適合在大量數(shù)據(jù)應(yīng)用的時候,因為在JavaScript的函數(shù)式編程中,函數(shù)調(diào)用時傳入的參數(shù)是基本類型對象,那么在函數(shù)體內(nèi)得到的形參會是一個復(fù)制值,這樣這個值就被當(dāng)作一個局部變量定義在函數(shù)體的作用域內(nèi),在完成事件綁定之后就可以對events變量進(jìn)行手工解除引用,以減輕外層作用域中的內(nèi)存占用了。而且當(dāng)某個元素被刪除時,相應(yīng)的事件監(jiān)聽函數(shù)、事件對象、閉包函數(shù)也隨之被銷毀回收。
3.6 內(nèi)存不是緩存
緩存在業(yè)務(wù)開發(fā)中的作用舉足輕重,可以減輕時空資源的負(fù)擔(dān)。但需要注意的是,不要輕易將內(nèi)存當(dāng)作緩存使用。內(nèi)存對于任何程序開發(fā)來說都是寸土寸金的東西,如果不是很重要的資源,請不要直接放在內(nèi)存中,或者制定過期機(jī)制,自動銷毀過期緩存。
4. 檢查JavaScript 的內(nèi)存使用情況
在平時的開發(fā)中,我們也可以借助一些工具來對JavaScript 中內(nèi)存使用情況進(jìn)行分析和問題排查。
4.1 Blink / Webkit 瀏覽器
在Blink / Webkit 瀏覽器中(Chrome, Safari, Opera etc.),我們可以借助其中的Developer Tools 的Profiles 工具來對我們的程序進(jìn)行內(nèi)存檢查。
4.2 Node.js 中的內(nèi)存檢查
在Node.js 中,我們可以使用node-heapdump 和node-memwatch 模塊進(jìn)行內(nèi)存檢查。
var heapdump = require('heapdump');
var fs = require('fs');
var path = require('path');
fs.writeFileSync(path.join(__dirname, 'app.pid'), process.pid);
// ...
這樣在文件目錄下會有一個以heapdump-<sec>.<usec>.heapsnapshot格式命名的快照文件,我們可以使用瀏覽器的Developer Tools中的Profiles工具將其打開,并進(jìn)行檢查。
5. 小結(jié)
很快又來到了文章的結(jié)束,這篇分享主要向大家展示了以下幾點(diǎn)內(nèi)容:
1.JavaScript 在語言層面上,與內(nèi)存使用息息相關(guān)的東西;
2.JavaScript 中的內(nèi)存管理、回收機(jī)制;
3.如何更高效地使用內(nèi)存,以至于讓出產(chǎn)的JavaScript 能更有拓展的活力;
4.如何在遇到內(nèi)存問題的時候,進(jìn)行內(nèi)存檢查。
希望通過對這篇文章的學(xué)習(xí),你能夠出產(chǎn)更優(yōu)秀的JavaScript 代碼,讓媽媽安心、讓老板放心。
- js內(nèi)存泄露的幾種情況詳細(xì)探討
- 跟我學(xué)習(xí)javascript的垃圾回收機(jī)制與內(nèi)存管理
- js 內(nèi)存釋放問題
- 解決js函數(shù)閉包內(nèi)存泄露問題的辦法
- 理解Javascript_01_理解內(nèi)存分配原理分析
- IE JS編程需注意的內(nèi)存釋放問題
- 容易造成JavaScript內(nèi)存泄露幾個方面
- js變量、作用域及內(nèi)存詳解
- Javascript 閉包引起的IE內(nèi)存泄露分析
- 深入理解JavaScript程序中內(nèi)存泄漏
- 詳解Nodejs內(nèi)存治理
- javascript 內(nèi)存模型實例詳解
相關(guān)文章
JavaScript實現(xiàn)打印星型金字塔功能實例分析
這篇文章主要介紹了JavaScript實現(xiàn)打印星型金字塔功能,結(jié)合具體實例形式分析了javascript針對輸出任意給定行數(shù)星型金字塔圖形的原理與相關(guān)實現(xiàn)技巧,需要的朋友可以參考下2017-09-09JS組件Bootstrap實現(xiàn)彈出框和提示框效果代碼
這篇文章主要介紹了JS組件Bootstrap實現(xiàn)彈出框和提示框效果代碼,對彈出框和提示框感興趣的小伙伴們可以參考一下2015-12-12通過js腳本復(fù)制網(wǎng)頁上的一個表格的不錯實現(xiàn)方法
通過js腳本復(fù)制網(wǎng)頁上的一個表格的不錯實現(xiàn)方法...2006-12-12最好用的省市二級聯(lián)動 原生js實現(xiàn)你值得擁有
省市二級聯(lián)動效果,實現(xiàn)方法有很多,不過其他文章中介紹的都比較籠統(tǒng),在本文有一個詳細(xì)的實現(xiàn)過程,使用原生js很容易理解,希望大家可以參考下2013-09-09JS localStorage實現(xiàn)本地緩存的方法
JS localStorage實現(xiàn)本地緩存的方法,需要的朋友可以參考一下2013-06-06Javascript實現(xiàn)Web顏色值轉(zhuǎn)換
這篇文章主要介紹了Javascript實現(xiàn)Web顏色值轉(zhuǎn)換,需要的朋友可以參考下2015-02-02