淺談js的記憶體與閉包

lhyt發表於2019-03-01

本文來自於我的github

0.前言

主要結合了記憶體的概念講了js的一些的很簡單、但是又不小心就犯錯的地方。 結論:js執行順序,先定義,後執行,從上到下,就近原則。閉包可以讓外部訪問某函式內部變數,而且會導致記憶體洩漏。

1.先說型別

在ECMAscript資料型別有基本型別和引用型別,基本型別有Undefined、Null、Boolean、Number、String,引用型別有Object,所有的的值將會是6種的其中之一(資料型別具有動態性,沒有定義其他資料型別的必要了) 引用型別的值,也就是物件,一個物件是某個引用型別的一個例項,用new操作符建立也可以用字面量的方式(物件字面量建立var obj ={ })。ECMA裡面有很多原生的引用型別,就是查文件的時候看見的那些:Function、Number (是對於原始型別Number的引用型別)、String(是對於原始型別String的引用型別)、Date、Array、Boolean(...)、Math、RegExp等等。 在程式執行的時候,整塊記憶體可以劃分為常量池(存放基本型別的值)、棧(存放變數)、很大的堆(存放物件)、執行時環境(函式執行時)

1

基本資料型別的值是直接在常量池裡面可以拿到,而引用型別是拿到的是物件的引用

var a = 1;
var b = 'hello';
var c = a;
複製程式碼

c = a,這種基本資料型別的複製,只是重新複製一份獨立的副本,在變數的物件上建立一個新的值,再把值複製到新變數分配的位置上,a、c他們自己的操作不會影響到對方。

a++;console.log(a);console.log(c)
複製程式碼

顯然是輸出2、1

obj1和obj2,拿到的是新建立的物件的引用(也就是家裡的鑰匙,每個人帶一把),當操作物件的時候,物件發生改變,另一個obj訪問的時候,發現物件也會改。就像,家裡有一個人回去搞衛生了,另一個回家發現家裡很乾淨了。

var obj1 = new Object();
obj1.name = 'obj1'
var obj2 = obj1
console.log(obj2)  //{name: "obj1"}
複製程式碼

2

對於vue,為什麼data必須是一個返回一個物件的函式,也是這個道理,避免所有的vue例項共用一套data。所以對於類似於這種情況,我們可以像vue那樣處理

//data是一個物件的時候,共用一套data
function D(){}
D.prototype.data =  {a:1,b:2}
var a = new D()
var b = new D()
a.data.a = 666
b.data.a //666
//data是一個函式的時候,各自維護自己的data
function D(){
	this.data = this.data()
}
D.prototype.data = function () {
	return {
		a:1,b:2
	}
}
var a = new D()
var b = new D()
a.data.a = 666
b.data.a //1
複製程式碼

同樣的身為引用型別的函式也是同理

var a = function(){console.log(1)}
var b = a;
a = null;
b();a()
//b輸出1,a報錯:Uncaught TypeError: a is not a function
//a指向函式,b拿到和a一樣的指標,然後讓a指向空
複製程式碼

把a變成null,只是切斷了a和函式之間的引用關係,對b沒有影響

2.再說順序

大家常聽說的先定義後執行,其實就是在棧中先開闢一塊記憶體空間,然後在拿到他所對應的值,基本型別去常量池,引用型別去堆拿到他的引用。大家常說的原始型別值在棧,其實就是這種效果。

3

2.1 為什麼引用型別值要放在堆中,而原始型別值要放在棧

在計算機的資料結構中,棧比堆的運算速度快,Object是一個複雜的結構且可以擴充套件:陣列可擴充,物件可新增屬性,都可以增刪改查。將他們放在堆中是為了不影響棧的效率。而是通過引用的方式查詢到堆中的實際物件再進行操作。 因此又引出另一個話題,查詢值的時候先去棧查詢再去堆查詢。

2.2 為什麼先去棧查詢再去堆查詢

既然都講了,棧比堆的運算速度,堆存放的是複雜資料型別。那麼簡單來說,寧願大海撈針呢還是碗裡撈針呢?

3.然後到了函式

先丟擲一個問題

function a(){console.log(2)};
var a  = function(){console.log(1)};
a()
複製程式碼

覆蓋?那麼交換的結果又是什麼呢?

var a  = function(){console.log(1)};
function a(){console.log(2)};
a()
複製程式碼

都是1,然後有的人就說了,var優先。好的,那為什麼var優先?

4

先定義後執行,先去棧查詢

變數提升,其實也是如此。先定義(開闢一塊記憶體空間,此時值可以說是undefined)後執行(從上到下,該賦值的就賦值,該執行操作的就去操作),就近原則 函式宣告和函式表示式,有時候不注意,就不小心出錯了

 a(); function a(){console.log(666)}//666
複製程式碼

另一種情況:

a(); var a = function (){console.log(666)}//a  is not a function
複製程式碼

雖然第一種方法有變數提升,不會出錯,正常來說,還是按順序寫,定義語句放前面。如果想嚴格要求自己,就手動來個嚴格模式‘use strict’吧。對於框架的開發,需要嚴謹遵守規則,所以一般會用嚴格模式。

4.接著是臨時空間

函式執行的時候,會臨時開闢一塊記憶體空間,這塊記憶體空間長得和外面這個一樣,也有自己的棧堆,當函式執行完就銷燬。

4.1 eg1:

var a = 10;
function() {
console.log(a);//undefined
var a = 1;
console.log(a)//1
}
複製程式碼

巨集觀來說,只有2步一和二,當執行第二步,就跳到函式內部執行②-⑧

5
函式外部的a=10完全就沒有關係,這裡面造成undefined主要因為變數提升,其實準確的順序是:

var a
console.log(a);//undefined
a = 1;
console.log(a)//1
複製程式碼

為什麼不出去找全域性的a? 就近原則。為什麼就近原則?都確定函式內部有定義了,就不會再去外面白費力氣。其實是,函式在自己的作用域內找到就不會再再繼續找,類似原型鏈一樣,在建構函式裡面找到某個屬性就不會去原型找,找不到才去,再找不到就再往上。函式也是,沿著作用域鏈查詢。類似的一個例子,我們用函式宣告定義一個函式f,再用一個變數g拿到這個函式的引用,然後在外面用f是訪問不了這個函式的,但是在函式內部是能找到f這個名字的:

var g = function f(){
   console.log(f)
    }
g()//列印整個函式
f()//報錯
複製程式碼

4.2 eg2

function f(){
return function f1(){
       console.log(1)
   }
};
var res = f();
res();
f1()
複製程式碼

res(),返回的是裡面的函式,如果直接f1()就報錯,因為這是window.f1()

6

  • 函式宣告後,可以通過引用名稱查詢或者記憶體地址查詢
  • 區域性作用域用function宣告,宣告不等於建立,只有呼叫函式的時候才建立
  • 函式f有記憶體地址的話,通過棧找f的記憶體空間,如果找不到棧中f這個變數,就去堆中找

5.垃圾回收

進行前端開發時幾乎不需要關心記憶體問題,V8限制的記憶體幾乎不會出現用完的情況,而且我們只要關閉了瀏覽器,一切都結束。如果是node後端,後端程式往往進行更加複雜的操作,加上長期執行在伺服器不重啟,如果不關注記憶體管理,積少成多就會導致記憶體洩漏。 node中的記憶體第一個部分還是和上面的一樣,有棧、堆、執行時環境,另外還有一個緩衝區存放Buffer。你可以通過process.memoryUsage()檢視node裡面程式記憶體使用情況。堆中的物件,被劃分為新生代和老生代,他們會被不同的垃圾回收機制清理掉。

5.1新生代

新生代用Scavenge演算法進行垃圾回收,利用複製的方式實現記憶體回收的演算法。 他的過程是:

  • 將新生代的總空間一分為二,只使用其中一個,另一個處於閒置,等待垃圾回收時使用。使用中的那塊空間稱為From,閒置的空間稱為To
  • 當觸發垃圾回收時,V8將From空間中所有存活下來的物件複製到To空間。
  • From空間所有應該存活的物件都複製完成後,原本的From空間將被釋放,成為閒置空間,原本To空間則成為使用中空間,也就是功能交換。
  • 如果某物件已經經歷一次新生代垃圾回收而且第二次依舊存活,或者To空間已經使用了25%,都會晉升至老生代

1

5.2老生代

老生代利用了標記-清除(後面又加上了標記-整理)的方式進行垃圾回收。 在標記階段(週期比較大)遍歷堆中的所有物件,標記活著的物件,在隨後的清除階段中,只清除沒有被標記的物件。每個記憶體頁有一個用來標記物件的點陣圖。這個點陣圖另外有兩位用來標記物件的狀態,這個狀態一共有三種:未被垃圾回收器發現、被垃圾回收器發現但鄰接物件尚未全部處理、不被垃圾回收器發現但鄰接物件全部被處理。分別對應著三種顏色:白、灰、黑。

遍歷的時候,主要是利用DFS。剛剛開始的時候,所有的物件都是白色。從根物件開始遍歷,遍歷過的物件會變成灰色,放入一個額外開闢的雙端佇列中。標記階段的每次迴圈,垃圾回收器都會從雙端佇列中取出一個物件染成黑物件,並將鄰接的物件染色為灰,然後把其鄰接物件放入雙端佇列。一直迴圈,最後所有的物件只有黑和白,白色的將會被清理。 假設全域性根物件是root,那麼活物件必然是被連線在物件樹上面的,如果是死物件,比如var a = {};a=null我們建立了一個物件,但把他從物件樹上面切斷聯絡。這樣子,DFS必然找不到他,他永遠是白色。 此外,在過程中把垃圾物件刪除後,記憶體空間是一塊一塊地零星散亂地分佈,如果是遇到一個需要很大記憶體空間的物件,需要連續一大片記憶體儲存的物件,那就有問題了。所以還有一個整理的階段,把物件整理到在記憶體上連續分佈。

5.3 對比

  • 新生代是經常發生的,老生代發生的週期長
  • 新生代佔用的記憶體小,老生代佔用了大部分記憶體
  • 新生代需要把記憶體分成兩塊進行操作,老生代不需要
  • 新生代是基於物件複製,如果物件太多,複製消耗也會很大,所以需要和老生代相互合作。老生代基於DFS,深度遍歷每一個活物件
  • 顯然老生代花銷大,所以他的週期也長,但是比較徹底

6.IIFE和閉包

6.1 IIFE

立即執行函式,形成一個沙盒環境,防止變數汙染內部,是做各種框架的好方法 先手寫一段假的jQuery

(function(root){
 var $ = function(){
//程式碼
}
root.$ = $
})(this)
複製程式碼

這樣子在內部函式裡面寫相關的表示式,我們就可以用美元符號使用jQuery(實際上jQuery第一個括號是全域性環境判斷,真正的函式體放在第二個括號裡面,號稱世界上最強的選擇器sizzle也裡面)

7

6.2閉包

閉包的概念各有各的說法,平時人家問閉包是什麼,大概多數人都是說在函式中返回函式、函式外面能訪問到裡面的變數,這些顯而易見的現象,或者把一些長篇大論搬出來。簡單來說,就是外部訪問內部變數,而且內部臨時開闢的記憶體空間不會被垃圾回收。查詢值的時候沿著作用域鏈查詢,找到則停止。 對於js各種庫,是一個龐大的IIFE包裹著,如果他被垃圾回收了,我們肯定不能利用了。而我們實際上就是能利用他,就是因為他暴露了介面,使得全域性環境保持對IIFE內部的函式和變數的引用,我們才得以利用。 各種書對於閉包的解釋: 《權威指南》:函式物件通過作用域鏈相互關聯起來,函式內部變數都可以保持在函式的作用域中,有權訪問另一個函式作用域中的變數 《忍者祕籍》:一個函式建立時允許自身訪問並操作該自身函式以外的變數所建立的作用域 《你不知道的js》:是基於詞法的作用域書寫程式碼時所產生的結果,當函式記住並訪問所在的詞法作用域,閉包就產生了 閉包的產生,會導致記憶體洩漏。 前面已經說到,js具有垃圾回收機制,如果發現變數被不使用將會被回收,而閉包相互引用,讓他不會被回收,一直佔據著一塊記憶體,長期持有一塊記憶體的引用,所以導致記憶體洩漏。

var b = 10
function a(){
	var b = 1
	return function c(){//暴露內部函式的介面
		console.log(b)
	}
}
a()()//1,外部拿到內部的引用,臨時開闢的記憶體空間不會被回收

//改寫成IIFE形式
var b = 10
var a = (function(){
	var b = 1
	return function c(){
		console.log(b)
	}
})()
a()//1

//改成window物件的一個引用
var b = 10
(function(){
	var b = 1
	window.c =  function(){
		console.log(b)
	}
})()
c()//1

//多個閉包
function a(){
	var s = 1
	return function count(){
		s++
		console.log(s)
	}
}
var b = a()//相當於賦值
var c = a()
b()//2
b()//3
c()//2,各自保持各自的”賦值結果”,互相不干擾

//r被垃圾回收
function a(){
        var r = 1
	var s = 1
	return function count(){
		s++
		console.log(s)
	}
}
var b = a()//我們可以打個斷點,在谷歌瀏覽器看他的呼叫棧,發現閉包裡面沒有r了
複製程式碼

對於最後一個例子,r、s並不是像一些人認為的那樣,有閉包了,r、s都會留下,其實是r已經被回收了。在執行的函式時候,將會為這個函式建立一個上下文ctx,最開始這個ctx是空的,從上到下執行到函式a的閉包宣告b時,由於b函式依賴變數s ,因此會將 s 加入b的ctx——ctx2。a內部所有的閉包,都會持有這個ctx2。(所以說,閉包之所以閉包,就是因為持有這個ctx) 每一個閉包都會引用其外部函式的ctx(這裡是b的ctx2),讀取變數s的時候,被閉包捕捉,加入ctx中的變數,接著被分配到堆。而真正的區域性變數是r ,儲存在棧,當b執行完畢後出棧並且被垃圾回收。而a的ctx被閉包引用,如果有任何一個閉包存活,他對應的ctx都將存活,變數也不會被銷燬。

image

我們也聽說一句話,儘量避免全域性變數。其實也是這樣的道理,一個函式返回另一個函式,也就是分別把兩個函式按順序壓入呼叫棧。我們知道棧是先進後出,那全域性的變數(也處於棧底),越是不能得到垃圾回收,存活的時間越長。但也許全域性變數在某個時候開始就沒有作用了,就不能被回收,造成了記憶體洩漏。所以又引出另一個常見的注意事項:不要過度利用閉包。用得越多,棧越深,變數越不能被回收。

瀏覽器的全域性物件為window,關閉瀏覽器自然一切結束。Node中全域性物件為global,如果global中有屬性已經沒有用處了,一定要設定為null,因為只有等到程式停止執行,才會銷燬。而我們的伺服器當然是長期不關機的,記憶體洩漏積少成多,爆記憶體是早晚的事情。

Node中,當一個模組被引入,這個模組就會被快取在記憶體中,提高下次被引用的速度(快取代理)。一般情況下,整個Node程式中對同一個模組的引用,都是同一個例項(instance),這個例項一直存活在記憶體中。所以,如果任意模組中有變數已經不再需要,最好手動設定為null,不然會白白佔用記憶體

相關文章