老大,不好了,記憶體漏失了!

2020-10-15 12:00:23

在實際專案中,像記憶體漏失、遞迴爆棧等這樣的問題,對於一個前端來說,屬於家常便飯了,你會經常遇到的。遇到了,也別慌,從原理開始分析造成問題的原因,這篇文章會結合實戰來由淺入深的分享記憶體漏失那些事。

老大,不好了,我頁面卡死了,線上專案記憶體飆到了 3萬多 M 測試小姐姐在呼喚我們老大。3萬多 M 所指是瀏覽器記憶體佔用空間如下圖所示的地方。

在這裡插入圖片描述

為了處於好奇,我就過去瞅了一眼,整個頁面處於卡死狀態,記憶體飆到這麼高,肯定是長時間執行專案,程式中有些佔用的記憶體無法得到回收,導致了記憶體不斷的洩漏,最終頁面出現卡死狀態。


1、什麼是記憶體漏失?

一句話總結,不再用到的記憶體,沒有及時釋放,就叫做記憶體漏失。

當記憶體漏失時,內部是通過垃圾回收機制來解決的,但是不同語言的垃圾回收策略不通,有的語言自動可以管理記憶體,比如 JavaScript;有的語言卻需要手動的去釋放,比如 C 語言。

舉個例子:

char *p = (char*)malloc(10); // 常見 10 個位元組區域在堆區

free(p);//釋放

2、什麼造成記憶體漏失?

記憶體儲存資料無非最常用的是兩種資料結構,分別是棧和堆,還有一種不常打交道的池結構。

記憶體漏失無非就是棧記憶體和堆記憶體,這個在文章後邊會展開分析。先說說在 JS 中,是什麼原因會造成記憶體的洩漏?

在 JS 中,記憶體漏失是指我們已經無法再通過 js 程式碼來參照到某個物件,但垃圾回收器卻認為這個物件還在被參照,因此在回收的時候不會釋放它。導致了分配的這塊記憶體永遠也無法被釋放出來。如果這樣的情況越來越多,會導致記憶體不夠用而系統崩潰。

嗯,這樣以來,確實和我上邊在實戰中所提到的情況差不多。為了更好的去了解如何檢視是否是記憶體漏失,我去網上搜集了一些檢測記憶體洩露的方法。


3、如何檢測記憶體漏失?

第一種方式,我們可以使用工作管理員去檢視程式的佔用情況。在谷歌瀏覽器種,開啟設定 —> 更多工具 —> 工作管理員。

第二種方式,適合程式執行的時候,那個階段記憶體的佔用情況。

開啟谷歌控制檯,切換到 Preformance 面板,在 Memory 核取方塊打勾,點選左上角的開始或者重新整理按鈕,我在上圖示出的紅色區域就是記憶體的在每個階段的實時佔用情況。

如果記憶體的佔用情況基本穩定,那麼說明不存在記憶體漏失的情況,如果記憶體隨著時間的推移,不斷的進行上升,說明記憶體有洩漏的可能。


4、垃圾回收機制

對於 JS 的垃圾回收機制,主要做三件事,分別是標記、回收、整理。

標記的是用不到的記憶體,回收的是已標記的記憶體,整理的是回收後的零碎不連續的記憶體空間。

那麼回收的棧記憶體和堆記憶體,垃圾回收器是如何進行不同的方式進行回收的呢?

首先我們要知道棧記憶體和堆記憶體分別儲存是什麼型別的資料,分別是怎樣儲存的,這個在小冊子中具體也提到過,如下(不理解的建議可以先看小冊子內容):


4.1 棧記憶體的資料如何被回收的?

我們知道,程式的執行遇到函數是在呼叫棧中依次入棧執行的,每個函數都有一個執行上下文環境,當函數執行完成,該函數的執行上下文就會出棧,因此,存在每個執行上下文環境中的棧記憶體中的變數也會被釋放,具體動畫過程可以看下邊這篇文章。

動畫:一個底層執行函數的自白!

舉個例子,如下程式碼:

 1function fn1(){
 2  let num = 1;
 3  let obj1 = {name:"小鹿"}
 4  function fn2() {
 5    let str = "xiaolu";
 6    let obj2 = {name:" 小鹿動畫學程式設計"}
 7  }
 8  fn2();
 9}
10fn1();

執行示意圖如下:

在這裡插入圖片描述

不對,你說清楚,出棧的這塊執行上下文中的棧記憶體如何銷燬的?難道出棧就銷燬了嗎?雖然是出棧了,那塊記憶體確實還存在呼叫棧中呀?

在這裡插入圖片描述

沒錯,確實這塊記憶體並沒有銷燬,而是變成了無效的記憶體的狀態。當另一個函數的執行上下文進入呼叫棧的時候,就會把這個無效記憶體給覆蓋掉,那麼我們認為之前存在的棧記憶體被銷燬或者重新利用了。


4.2 堆記憶體中的資料如何回收?

上邊講到的棧記憶體,根本用不到咱們的垃圾回收器,因為它會被下一個執行上下文的函數所覆蓋或者說重新利用起來。

但是不要忘記,在堆記憶體中,大多數儲存的是參照型別,而參照型別的地址是儲存在棧記憶體中,棧記憶體這時候已經銷燬,無法參照到該參照型別,那麼這個無法參照這塊堆記憶體空間又是如何銷燬和回收的呢?

在這裡插入圖片描述

這不得不派出我們 V8 的垃圾回收器了。但是在堆記憶體中,V8 主要分把堆記憶體為兩塊區域,分別為新生代區域和老生代區域,咱們先主要了解一下這兩塊區域是幹嘛的。

在這裡插入圖片描述

新生代區域主要存放時是存放時間比較短的而且佔用記憶體比較小參照型別資料,而老生代區域存放時間比較長佔用記憶體比較大的參照型別資料。而且新生代區域佔據的記憶體比較小,反而老生區佔用的記憶體很大。

所以呢,V8 引擎不得不用兩個垃圾回收器分別回收對應區域無效資料。

對於垃圾回收機制的執行過程,小冊也具體寫到。

小冊獲取方式:關-注-公-眾-號:小鹿動畫學程式設計 後臺回覆:「pdf」

但是具體如何進行標記和回收的沒有提到過,先看看新生代區域的垃圾資料是如何回收的。

具體的回收過程如下:

垃圾回收機制

從以上動畫中,可以看出,新生代區域被一分為二,一邊存放的是資料(稱它為 Form 空間),另一邊是空閒區域(稱它為To空間)。當資料區域空間快被佔滿的時候,就會執行一次垃圾回收機制。

對用不到的資料進行打標記。然後將沒有被標記的資料進行復制到 To 空間,然後對標記的資料進行回收,回收之後 Form 空間就沒有任何資料了,然後兩個空間位置就會互換,To 空間就變成了 Form 空間,而此時有資料的成為了 To 空間。

在這裡插入圖片描述

此時用到的資料在複製的過程中已經被整理好,那麼新生代區域的垃圾回收是這樣進行回收的,再次進行垃圾回收的時候,會依次執行上述的動畫。這個垃圾回收演演算法被稱為 Scavenge 演演算法。

然而這個演演算法並不適合老生區域的資料回收,我們上邊提到,老生區域資料的特點,資料佔用記憶體大,當我們複製的時候,非常耗時,這也是為什麼只有新生代區域設定空間小的原因,為了保證垃圾回收的執行效率嘛。

在老生代區域,垃圾回收機制使用的是標記-清除法。

我們先來說說如何進行對可回收的資料如何標記的,首先我們從一個根資料開始進行一次迴圈遍歷,看看哪些資料物件沒有被參照使用到。然後要進行標記,當下次進行垃圾回收的時候對標記的物件進行銷燬。

那麼問題來了,經過幾次垃圾回收之後,雖然這些沒有被參照到的資料銷燬了,但是記憶體中的空間很零碎,連續的記憶體空間逐漸變小,比如我們想宣告連續一塊比較大的記憶體空間的時候,突然發現記憶體都是零碎的,從而會導致申請失敗,那我們改怎麼辦呢?

那麼以上的標記-清除法子就不管用了,V8 引擎垃圾回收是這麼改進的。

JS 標記-整理

當進行從根元素進行遍歷的時候,發現可以被參照到的資料我們就將它進行移動到頭部位置,然後依次排列,最後末尾剩下的都是參照不到的資料,也就是我們所說的要回收的資料,我們一次性進行回收,同時我們已存在的物件也已經在記憶體中被整理好了。


小結

以上就是我們今天所有內容了,總結一下。

我們由淺入深的瞭解了一下記憶體漏失的問題,以及如何檢視記憶體漏失。

分別從棧記憶體和堆記憶體角度用動畫形式分享了 V8 的兩種垃圾回收機制演演算法。