1. 淺談JS內(nèi)存機制

        共 2823字,需瀏覽 6分鐘

         ·

        2022-04-23 17:11

        術(shù)????

        前言

        隨著web的發(fā)展與普及,前端頁面不僅只加載在瀏覽器上,也慢慢流行于各種app的webview里。尤其在如今設(shè)備性能越來越好的條件下,前端頁面更是開始在app中擔任重要的角色。如此一來,前端頁面的停留時間變得更長,我們理應(yīng)越發(fā)重視前端的內(nèi)存管理,防止內(nèi)存泄露,提高頁面的性能。

        想要打造高性能前端應(yīng)用,防止崩潰,就必須得搞清楚JS的內(nèi)存機制,其實就是弄清楚JS內(nèi)存的分配與回收。

        JS數(shù)據(jù)存儲機制

        內(nèi)存空間

        從圖中可以看出, 在 JavaScript 的執(zhí)行過程中, 主要有三種類型內(nèi)存空間,分別是代碼空間、??臻g和堆空間。

        代碼空間:用來存放可執(zhí)行代碼

        棧空間:一塊連續(xù)的內(nèi)存區(qū)域,容量較小,讀取速度快,被設(shè)計成先進后出結(jié)構(gòu)

        堆空間:不連續(xù)的內(nèi)存區(qū)域,容量較大,用于儲存大數(shù)據(jù),讀取速度慢

        數(shù)據(jù)類型

        JavaScript 發(fā)展至今總共有八種數(shù)據(jù)類型,其中 Object 類型稱為引用類型,其余七種稱為基本類型,Object 是由其余七種基本類型組成的kv結(jié)構(gòu)數(shù)據(jù)。

        棧空間和堆空間

        ??臻g其實就是 JavaScript 中的調(diào)用棧,是用來儲存執(zhí)行上下文,以及存儲執(zhí)行上下文中的一些基本類型中的小數(shù)據(jù),如下圖所示:

        image.png

        變量環(huán)境: 存放var聲明與函數(shù)聲明的變量空間,編譯時就能確定,不受塊級作用域影響

        詞法環(huán)境: 存放let與const聲明的變量空間,編譯時不能完全確定,受塊級作用域影響

        而堆空間,則是用來儲存大數(shù)據(jù)如引用類型,然后把他們的引用地址保存到??臻g的變量中,所以多了這一道中轉(zhuǎn),JavaScript 對堆空間數(shù)據(jù)的讀取自然會比??臻g數(shù)據(jù)的要慢,可以用下圖表示兩者關(guān)系:

        通常情況下,??臻g都不會設(shè)置太大,這是因為 JavaScript 引擎需要用棧來維護程序執(zhí)行期間上下文的狀態(tài),如果??臻g大了的話,所有的數(shù)據(jù)都存放在棧空間里面,那么會影響到上下文切換的效率,進而又影響到整個程序的執(zhí)行效率。

        閉包

        內(nèi)部函數(shù)總是可以訪問其外部函數(shù)中聲明的變量,當通過調(diào)用一個外部函數(shù)返回一個內(nèi)部函數(shù)后,即使該外部函數(shù)已經(jīng)執(zhí)行結(jié)束了,但是內(nèi)部函數(shù)引用外部函數(shù)的變量依然保存在內(nèi)存中,我們就把這些變量的集合稱為閉包

        閉包中的數(shù)據(jù)會組成一個對象,然后保存在堆空間中,如:

        可以利用開發(fā)者工具查看閉包情況,其中括號中的名稱就是產(chǎn)生閉包的函數(shù)名。一般我們會認為閉包是返回的內(nèi)部函數(shù)引用的變量集合,但閉包有一個較為迷惑的情況,如下:

        可以理解為,如果函數(shù)存在閉包,其所有內(nèi)部函數(shù)都會擁有一個指向這個閉包的引用,即所有內(nèi)部函數(shù)會共享同一個閉包,只要任意內(nèi)部函數(shù)有引用外部函數(shù)中聲明的變量,這個變量都會被納入閉包內(nèi),而且最內(nèi)部的函數(shù)會持有所有外部的閉包。

        堆棧存放的數(shù)據(jù)類型

        原始類型的數(shù)據(jù)是存放在棧中,引用類型的數(shù)據(jù)是存放在堆中?

        上面這句話是用來描述棧中數(shù)據(jù)的存儲情況,調(diào)用棧中的引用類型存放在堆中,相信大家都沒有問題,但是原始類型真的都存放在棧中嗎?

        數(shù)字

        V8把數(shù)字分成兩種類型:smi 和 heapNumber

        smi是范圍為 :-231 到 231-1的整數(shù),在棧中直接存值;除了smi,其余數(shù)字類型都是heapNumber,需要另外開辟堆空間進行儲存,變量保存其引用。

        var?times?=?50000;
        var?smi_in_stack?=?1;
        var?heap_number?=?1.1;

        //?about?1.5~1.6ms,?fast
        console.time('smi_in_stack');
        for?(let?i?=?0;?i?times;?i++)?{
        ??smi_in_stack++;
        }
        console.timeEnd('smi_in_stack');

        //?about?2.1~2.5ms,?slow
        console.time('heap_number');
        for?(let?i?=?0;?i?times;?i++)?{
        ??heap_number++;
        }
        console.timeEnd('heap_number');

        同時我們可以通過heap snapshots觀察到heap_number的存在,所以驗證了棧中的heapNumber值是存在堆中,smi值是直接存在棧中。

        更基本的基本類型

        V8定義了一種 oddball[1] 類型,屬于 oddball 類型的有null、undefined、true和false

        function?BasicType()?{
        ??this.oddBall1?=?true;
        ??this.oddBall2?=?false;
        ??this.oddBall3?=?undefined;
        ??this.oddBall4?=?null;
        ??this.oddBall5?=?'';
        }
        const?obj1?=?new?BasicType();
        const?obj2?=?new?BasicType();

        這里可以看到oddball類型以及空字符串的堆引用全部都是一個固定值,代表在V8跑起來的第一時間,不管我們有沒有聲明這些基本類型,他們都已經(jīng)在堆中被創(chuàng)建完畢了。由此猜想棧中這些類型使用的也是堆中的地址。

        function?Obj()?{
        ??this.string?=?'str';
        ??this.num1?=?1;
        ??this.num2?=?1.1;
        ??this.bigInt?=?BigInt('1');
        ??this.symbol?=?Symbol('1');
        }
        const?obj?=?new?Obj();
        debugger;
        obj.string?=?'other?str';
        obj.num1?=?2;
        obj.num2?=?1;
        obj.bigInt?=?BigInt('2');
        obj.symbol?=?Symbol('2');

        debugger后內(nèi)存快照

        其中bigInt、string、symbol的內(nèi)存地址都進行了更換,由此可以猜想是因為這三種類型占用的內(nèi)存大小不是一個固定值,需要根據(jù)其值進行動態(tài)分配,所以內(nèi)存地址會進行更換;而heapNumber的內(nèi)存地址并沒有發(fā)生變化,這個更換值的操作還是在原來的內(nèi)存空間中進行。因為棧是一塊連續(xù)的內(nèi)存空間,不希望運行中會產(chǎn)生內(nèi)存碎片,由此可以得出bigInt、string、symbol這些內(nèi)存大小不固定的類型在棧中也是保存其堆內(nèi)存的引用。同時我們在棧中可以聲明很大的string,如果string存放在棧中明顯也不合理

        故??臻g中的基本類型儲存位置如下:

        類型儲存位置
        Numbersmi儲存棧中,heapNumber儲存堆中
        String
        Boolean
        Null
        undefined
        BigInit
        Symbol

        上述結(jié)論主要是從heap snapshots和棧的特性中得出,畢竟最正確的答案是在源碼中獲得,如有不當,請指正。

        JS內(nèi)存回收

        棧內(nèi)存回收

        function?fn1()?{
        ??//....
        ??function?fn2()?{
        ????//...
        ??}
        ??fn2();
        }
        fn1();

        調(diào)用棧中有一個記錄當前執(zhí)行狀態(tài)的指針(稱為 ESP),隨著函數(shù)的執(zhí)行,函數(shù)執(zhí)行上下文被壓入調(diào)用棧中,執(zhí)行上下文中的數(shù)據(jù)會按照前面說的JS數(shù)據(jù)存儲機制被分配到堆棧中,ESP會指向最后壓棧的執(zhí)行上下文,如左圖所示的fn2函數(shù)。當fn2函數(shù)調(diào)用完畢,JS 會把ESP指針下移至fn1函數(shù),這個指針下移的操作就是銷毀fn1函數(shù)執(zhí)行上下文的過程。最后fn1函數(shù)執(zhí)行上下文所占用的區(qū)域會變成無效區(qū)域,下一個函數(shù)執(zhí)行上下文壓入調(diào)用棧的時候會直接覆蓋其內(nèi)存空間。簡而言之,只要函數(shù)調(diào)用結(jié)束,該棧內(nèi)存就會自動被回收,不需要我們操心。剛剛我們也聊到閉包,如果出現(xiàn)閉包的情況,閉包的數(shù)據(jù)就會組成一個對象保存在堆空間里。

        堆內(nèi)存回收

        內(nèi)存垃圾回收領(lǐng)域中有個重要術(shù)語:代際假說,其有以下兩個特點:

        1. 大部分對象在內(nèi)存中存在的時間很短,簡單來說,就是很多對象一經(jīng)分配內(nèi)存,很快就變得不可訪問;
        1. 不死的對象,會活得更久。

        基于代際假說,JS 把堆空間分成新生代和老生代兩個區(qū)域,新生代中存放的是生存時間短的對象,通常只支持 1~8M 的容量;老生代中存放的生存時間長的對象,一些大的數(shù)據(jù)也會被直接分配到老生區(qū)中。而針對這兩個區(qū)域,JS 存在兩個垃圾回收器:主垃圾處理器和副垃圾處理器。這里先說說垃圾回收一般都有相同的執(zhí)行流程:

        1. 標記空間中活動對象和非活動對象
        1. 回收非活動對象所占據(jù)的內(nèi)存
        1. 內(nèi)存整理,這步是可選的,因為有的垃圾回收器工作過程會產(chǎn)生內(nèi)存碎片,這時就需要內(nèi)存整理防止不夠連續(xù)空間分配給大數(shù)據(jù)

        副垃圾回收器

        副垃圾回收器主要是采用 Scavenge 算法進行新生區(qū)的垃圾回收,它把新生區(qū)劃分為兩個區(qū)域:對象區(qū)域和空閑區(qū)域,新加入的對象都會存放到對象區(qū)域,當對象區(qū)域快被寫滿時,會對對象區(qū)域進行垃圾標記,把存活對象復(fù)制并有序排列至空閑區(qū)域,完成后讓這兩個區(qū)域角色互轉(zhuǎn),由此便能無限循環(huán)進行垃圾回收。同時存在對象晉升策略,也就是經(jīng)過兩次垃圾回收依然還存活的對象,會被移動到老生區(qū)中。

        主垃圾回收器

        由于老生區(qū)空間大,數(shù)據(jù)大,所以不適用 Scavenge 算法,主要是采用標記-整理算法,其工作流程是從一組根元素開始,遞歸遍歷這組根元素,在這個遍歷過程中,能到達的元素稱為活動對象,沒有到達的元素就可以判斷為垃圾數(shù)據(jù)。接著讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內(nèi)存。垃圾回收工作是需要占用主線程的,必須暫停JS腳本執(zhí)行等待垃圾回收完成后恢復(fù),這種行為稱為全停頓。 由于老生代內(nèi)存大,全停頓對性能的影響非常大,所以出現(xiàn)了增量標記的策略進行老生區(qū)的垃圾回收。

        JS內(nèi)存泄漏

        由于棧內(nèi)存會隨著函數(shù)調(diào)用結(jié)束而被釋放(覆蓋),所以JS中的內(nèi)存泄漏一般發(fā)生在堆中。之前有同學(xué)分享過一篇關(guān)于內(nèi)存泄漏的文章 ,里面講到一些常見內(nèi)存泄漏的原因和監(jiān)測手段,這里我就不贅述,但是可以根據(jù)最近的IM工作講一些實踐:

        確認是否有內(nèi)存泄漏的情況

        1. 本地打包一個去掉壓縮、擁有sourcemap及沒有任何console的生產(chǎn)版本(console會保留對象引用,阻礙銷毀;去掉壓縮和保留sourcemap有利于定位源碼)
        1. 啟動本地服務(wù)器,使cef訪問本地項目
        1. 不斷操作和記錄heap snapshots,觀察snapshots和timeline情況
        1. 最終內(nèi)存從22.5m上升至34.6m,conversation實例從443上升至1117,message實例從443上升至1287,而該用戶實際只有221個會話
        1. 不斷在會話間切換,通過timeline看到有內(nèi)存沒被釋放,而且生成detached dom

        通過上述觀測,可以判斷為有內(nèi)存泄漏情況。

        確定內(nèi)存泄漏排查方式

        IM頁分為:會話列表,會話頂欄,消息列表,輸入框四部分。使用逐一排查法縮小排查范圍,排查各個部分內(nèi)存情況。如:先保留會話列表,注釋其余三個部分,操作會話列表并使用timeline和heap snapshots進行內(nèi)存排查。按照這一方法逐步排查四個部分組件,并針對各個組件進行優(yōu)化??梢院唵螝w納成一個通用步驟:

        1. 使用timeline進行錄制,觀察是否像上面那樣有不被釋放的內(nèi)存區(qū)域
        1. 選擇不被釋放的區(qū)域進行查看,先找自己項目中的錨點物:像我們IM數(shù)據(jù)都是用conversation和messsage對象進行儲存,所以可以先進行這兩個對象的搜索查看
        1. 如果沒有好的錨點物也沒關(guān)系,接著查看detached dom(畢竟很多事件綁定在dom中,事件中引用著數(shù)據(jù),造成無法被釋放)和 string

        有些detached dom可能是react虛擬dom的數(shù)據(jù),但像上面的Detached HTMLAudioElement會隨著操作一直增加,所以這個是不正常的。

        像這里string的重復(fù),經(jīng)排查是有相同conversation和message對象引起

        堆快照里包含太多運行時、上下文等信息,實在太難從中找到有用的信息,所以會把目標放在錨點物、detached dom和string上

        1. 利用heap snapshot 的comparison模式過濾出操作階段內(nèi)存變更情況,更有利于查找影響位置

        上面是個人進行內(nèi)存泄露排查整理的方法,如果你有更好的方法,歡迎交流∠(°ゝ°)

        React中一個需要注意的內(nèi)存泄漏問題

        現(xiàn)象: 當組件被銷毀后,仍有一些異步事件調(diào)用組件中setState方法

        原理: 組件銷毀后,再調(diào)用setstate方法會保留相關(guān)引用,造成內(nèi)存泄漏

        //?測試代碼
        const?[test,?setTest]?=?useState(null);
        useEffect(()?=>?{
        ??(async?()?=>?{
        ????//?這里表達一個異步操作如:xhr、fetch、promise等等
        ????await?sleep(3000);
        ????const?obj?=?new?TestObj();
        ????setTest(obj);
        ??})();
        },?[]);

        如果把代碼改成這樣,就不會造成內(nèi)存泄漏:

        const?[test,?setTest]?=?useState(null);
        useEffect(()?=>?{
        ??let?unMounted?=?false;
        ??(async?()?=>?{
        ????await?sleep(3000);
        ????if?(unMounted)?return;
        ????const?obj?=?new?TestObj();
        ????setTest(obj);
        ??})();
        ??return?()?=>?{
        ????unMounted?=?true;
        ??};
        },?[]);

        這是在開發(fā)環(huán)境測試的,翻看源碼發(fā)現(xiàn)react只會在開發(fā)模式保留這些引用,然后拋出warning來提醒開發(fā)者這里可能有內(nèi)存泄漏的問題(如這些setState是注冊在全局事件里或者setInterval里的調(diào)用),生產(chǎn)環(huán)境是不會對其進行引用,所以不需要額外進行處理也不會造成內(nèi)存泄漏

        react18更是直接把這個報錯給干掉,以免誤導(dǎo)開發(fā)者使用剛剛說的類似手段來進行避免報錯,這里有做解釋:https://github.com/facebook/react/pull/22114

        總結(jié)

        本文先是講述js類型在內(nèi)存空間的儲存位置,接著探討堆棧中的內(nèi)存是如何進行回收,最后描述內(nèi)存泄漏確定和排查的方法,也補充一個react中有關(guān)setState造成“內(nèi)存泄漏”的例子。內(nèi)存泄漏在復(fù)雜應(yīng)用中是難以避免的,個人排查也只能是解決一些比較明顯的內(nèi)存泄漏現(xiàn)象。所以為了更好地解決這個應(yīng)用內(nèi)內(nèi)存泄漏問題,必須做好線上監(jiān)控,利用廣大用戶操作數(shù)據(jù),發(fā)現(xiàn)內(nèi)存泄漏問題,進而不斷改善應(yīng)用的性能。

        參考資料

        1. https://developer.chrome.com/docs/devtools/memory-problems/memory-101/
        1. https://www.cnblogs.com/goloving/p/15352261.html
        1. https://hashnode.com/post/does-javascript-use-stack-or-heap-for-memory-allocation-or-both-cj5jl90xl01nh1twuv8ug0bjk
        1. https://www.ditdot.hr/en/causes-of-memory-leaks-in-javascript-and-how-to-avoid-them

        參考資料

        [1]

        oddball: https://github.com/v8/v8/blob/c736a452575f406c9a05a8c202b0708cb60d43e5/src/objects.h#L9368

        - END -

        ???

        便內(nèi)^_^

        ?、、?~。

        關(guān)號?趣談前端?~

        瀏覽 32
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
          
          

            1. 大几把久久 | 痞子gay大猛—xnxx2 | 成人电影999 | 免费观看一级黄片 | 性生交大片免费看野外做 |