整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          JavaScript 中內存泄漏的原因以及對策

          JavaScript 中內存泄漏的原因以及對策

          比過去的網頁,今天流行的 SPA 需要開發人員更加關注程序中的內存泄漏情況。因為以前的網站在瀏覽時會不斷刷新頁面,可是 SPA 網站往往只有少數幾個頁面,很少完全重新加載。這篇文章主要探討 JS 代碼中容易導致內存泄漏的模式,并給出改進對策。

          本文最初發布于 ditdot.hr 網站,經作者授權由 InfoQ 中文站編譯并分享。

          什么是內存泄漏?

          內存泄漏指由于疏忽或錯誤造成程序未能釋放已經不再使用的內存的情況。內存泄漏并非指內存在物理上的消失,而是應用程序分配某段內存后,由于設計錯誤,失去了對該段內存的控制,因而造成了內存的浪費。

          JavaScript 是一個有垃圾回收機制的語言,我們不需要手動回收內存。當本應在垃圾回收周期中清理的內存中的對象,通過另一個對象的無意引用從根保持可訪問狀態時,就會發生內存泄漏,并可能導致性能下降的后果。

          內存泄漏通常很難發現和定位。泄漏的 JavaScript 代碼從任何層面都不會被認為是無效的,并且瀏覽器在運行時不會引發任何錯誤。

          檢查內存使用情況的最快方法是查看瀏覽器的 任務管理器(不是操作系統的那個任務管理器)。在 Linux 和 Windows 上按 Shift+Esc 來訪問 Chrome 的任務管理器;Firefox 則在地址欄中鍵入 about:performance。我們能用它查看每個選項卡的 JavaScript 內存占用量。如果發現異常的內存使用量持續增長,就很可能出現了泄漏。

          開發工具 提供了更高級的內存管理方法。通過 Chrome 的性能工具,我們可以直觀地分析頁面在運行時的性能。像下面這種模式就是內存泄漏的典型表現:

          除此之外,Chrome 和 Firefox 的開發工具都可以用“內存”工具進一步探索內存使用情況。

          JS 代碼中常見的幾個內存泄漏源

          全局變量

          全局變量總是從根可用,并且永遠不會回收垃圾。在非嚴格模式下,一些錯誤會導致變量從本地域泄漏到全局域:

          • 將值分配給未聲明的變量;
          • 使用“this”指向全局對象。
          function createGlobalVariables() {
              leaking1= 'I leak into the global scope'; // assigning value to the undeclared variable
              this.leaking2= 'I also leak into the global scope'; // 'this' points to the global object
          };
          createGlobalVariables();
          window.leaking1; // 'I leak into the global scope'
          window.leaking2; // 'I also leak into the global scope'

          預防措施:使用嚴格模式("use strict")。

          閉包

          函數作用域內的變量將在函數退出調用棧后清除,并且如果函數外部沒有其他指向它們的引用,則將清理它們。但閉包將保留引用的變量并保持活動狀態。

          function outer() {
              const potentiallyHugeArray=[];
              return function inner() {
                  potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
                  console.log('Hello');
              };
          };
          const sayHello=outer(); // contains definition of the function inner
          function repeat(fn, num) {
              for (let i= 0; i < num; i++){
                  fn();
              }
          }
          repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray
          
          // now imagine repeat(sayHello, 100000)

          在此示例中,從任何一個函數都不會返回 potentialHugeArray,并且無法到達它,但它的大小可以無限增加,具體取決于我們調用函數 inner() 的次數。

          預防措施:閉包是肯定會用到的,所以重要的是:

          • 了解何時創建了閉包,以及它保留了哪些對象;
          • 了解閉包的預期壽命和用法(尤其是用作回調時)。

          計時器

          如果我們在代碼中設置了遞歸計時器(recurring timer),則只要回調可調用,計時器回調中對該對象的引用就將保持活動狀態。

          在下面的示例中,由于我們沒有對 setInterval 的引用,因此它永遠不會被清除,并且 data.hugeString 會一直保留在內存中。

          function setCallback() {
              const data={
                  counter: 0,
                  hugeString: new Array(100000).join('x')
              };
              return function cb() {
                  data.counter++; // data object is now part of the callback's scope
                  console.log(data.counter);
              }
          }
          setInterval(setCallback(), 1000); // how do we stop it?

          預防措施:尤其是在回調的生命周期不確定或 undefined 的情況下:

          • 了解從計時器的回調中引用了哪些對象;
          • 使用計時器返回的句柄在必要時取消它。
          function setCallback() {
              // 'unpacking' the data object
              let counter= 0;
              const hugeString= new Array(100000).join('x'); // gets removed when the setCallback returns
              return function cb() {
                  counter++; // only counter is part of the callback's scope
                  console.log(counter);
              }
          }
          const timerId=setInterval(setCallback(), 1000); // saving the interval ID
          // doing something ...
          clearInterval(timerId); // stopping the timer i.e. if button pressed

          事件偵聽器

          添加后,事件偵聽器將一直保持有效,直到:

          • 使用 removeEventListener() 顯式刪除它;
          • 關聯的 DOM 元素被移除。

          對于某些類型的事件,應該一直保留到用戶離開頁面為止。但是,有時我們希望事件偵聽器執行特定的次數。

          const hugeString= new Array(100000).join('x');
          document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
              doSomething(hugeString); // hugeString is now forever kept in the callback's scope
          });

          在上面的示例中,用一個匿名內聯函數作為事件偵聽器,這意味著無法使用 removeEventListener() 將其刪除。同樣,該文檔也無法刪除,因此即使我們只需要觸發它一次,它和它域中的內容就都刪不掉了。

          預防措施:我們應該始終創建指向事件偵聽器的引用并將其傳遞給 removeEventListener(),來注銷不再需要的事件偵聽器。

          function listener() {
              doSomething(hugeString);
          }
          document.addEventListener('keyup', listener); // named function can be referenced here...
          document.removeEventListener('keyup', listener); // ...and here

          如果事件偵聽器僅執行一次,則 addEventListener() 可以使用第三個參數。假設{once: true}作為第三個參數傳遞給 addEventListener(),則在處理一次事件后,將自動刪除偵聽器函數。

          document.addEventListener('keyup', function listener() {
              doSomething(hugeString);
          }, {once: true}); // listener will be removed after running once

          緩存

          如果我們不刪除未使用的對象且不控制對象大小,那么緩存就會失控。

          let user_1={ name: "Peter", id: 12345 };
          let user_2={ name: "Mark", id: 54321 };
          const mapCache= new Map();
          function cache(obj){
              if (!mapCache.has(obj)){
                  const value=`${obj.name} has an id of ${obj.id}`;
                  mapCache.set(obj, value);
                  return [value, 'computed'];
              }
              return [mapCache.get(obj), 'cached'];
          }
          cache(user_1); // ['Peter has an id of 12345', 'computed']
          cache(user_1); // ['Peter has an id of 12345', 'cached']
          cache(user_2); // ['Mark has an id of 54321', 'computed']
          console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321")
          user_1= null; // removing the inactive user
          // Garbage Collector
          console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // first entry is still in cache

          在上面的示例中,緩存仍保留在 user_1 對象上。因此,我們還需要清除不會再重用的條目的緩存。

          可能的解決方案:我們可以使用 WeakMap。它的數據結構中,鍵名是對象的弱引用,它僅接受對象作為鍵名,所以其對應的對象可能會被自動回收。當對象被回收后,WeakMap 自動移除對應的鍵值對。在以下示例中,在使 user_1 對象為空后,下一次垃圾回收后關聯的條目會自動從 WeakMap 中刪除。

          let user_1={ name: "Peter", id: 12345 };
          let user_2={ name: "Mark", id: 54321 };
          const weakMapCache= new WeakMap();
          function cache(obj){
              // ...same as above, but with weakMapCache
              return [weakMapCache.get(obj), 'cached'];
          }
          cache(user_1); // ['Peter has an id of 12345', 'computed']
          cache(user_2); // ['Mark has an id of 54321', 'computed']
          console.log(weakMapCache); // ((…)=> "Peter has an id of 12345", (…)=> "Mark has an id of 54321"}
          user_1= null; // removing the inactive user
          // Garbage Collector
          console.log(weakMapCache); // ((…)=> "Mark has an id of 54321") - first entry gets garbage collected

          分離的 DOM 元素

          如果 DOM 節點具有來自 JavaScript 的直接引用,則即使從 DOM 樹中刪除了該節點,也不會對其垃圾回收。

          在以下示例中,我們創建了一個 div 元素并將其附加到 document.body。removeChild() 無法正常工作,并且由于仍然存在指向 div 的變量,所以堆快照將顯示分離的 HTMLDivElement。

          function createElement() {
              const div= document.createElement('div');
              div.id= 'detached';
              return div;
          }
          // this will keep referencing the DOM element even after deleteElement() is called
          const detachedDiv=createElement();
          document.body.appendChild(detachedDiv);
          function deleteElement() {
              document.body.removeChild(document.getElementById('detached'));
          }
          deleteElement(); // Heap snapshot will show detached div#detached

          怎么預防呢?一種方案是將 DOM 引用移入本地域。在下面的示例中,在函數 appendElement() 完成之后,將刪除指向 DOM 元素的變量。

          function createElement() {...} // same as above
          // DOM references are inside the function scope
          function appendElement() {
              const detachedDiv=createElement();
              document.body.appendChild(detachedDiv);
          }
          appendElement();
          function deleteElement() {
               document.body.removeChild(document.getElementById('detached'));
          }
          deleteElement(); // no detached div#detached elements in the Heap Snapshot

          關注我并轉發此篇文章,私信我“領取資料”,即可免費獲得InfoQ價值4999元迷你書!

          者:京東零售 謝天

          在任何語言開發的過程中,對于內存的管理都非常重要,JavaScript 也不例外。

          然而在前端瀏覽器中,用戶一般不會在一個頁面停留很久,即使有一點內存泄漏,重新加載頁面內存也會跟著釋放。而且瀏覽器也有自己的自動回收內存的機制,所以前端并沒有特別關注內存泄漏的問題。

          但是如果我們對內存泄漏沒有什么概念,有時候還是有可能因為內存泄漏,導致頁面卡頓。了解內存泄漏,如何避免內存泄漏,都是不可缺少的。

          什么是內存

          在硬件級別上,計算機內存由大量觸發器組成。每個觸發器包含幾個晶體管,能夠存儲一個位。單個觸發器可以通過唯一標識符尋址,因此我們可以讀取和覆蓋它們。因此,從概念上講,我們可以把我們的整個計算機內存看作是一個巨大的位數組,我們可以讀和寫。

          這是內存的底層概念,JavaScript 作為一個高級語言,不需要通過二進制進行內存的讀寫,而是相關的 JavaScript 引擎做了這部分的工作。

          內存的生命周期

          內存也會有生命周期,不管什么程序語言,一般可以按照順序分為三個周期:

          • 分配期:分配所需要的內存
          • 使用期:使用分配的內存進行讀寫
          • 釋放期:不需要時將其釋放和歸還

          內存分配 -> 內存使用 -> 內存釋放

          什么是內存泄漏

          在計算機科學中,內存泄漏指由于疏忽或錯誤造成程序未能釋放已經不再使用的內存。內存泄漏并非指內存在物理上的消失,而是應用程序分配某段內存后,由于設計錯誤,導致在釋放該段內存之前就失去了對該段內存的控制,從而造成了內存的浪費。

          如果內存不需要時,沒有經過生命周期的的釋放期,那么就存在內存泄漏

          內存泄漏的簡單理解:無用的內存還在占用,得不到釋放和歸還。比較嚴重時,無用的內存會持續遞增,從而導致整個系統的卡頓,甚至崩潰。

          JavaScript 內存管理機制

          像 C 語言這樣的底層語言一般都有底層的內存管理接口,但是 JavaScript 是在創建變量時自動進行了內存分配,并且在不使用時自動釋放,釋放的過程稱為“垃圾回收”。然而就是因為自動回收的機制,讓我們錯誤的感覺開發者不必關心內存的管理。

          JavaScript 內存管理機制和內存的生命周期是一致的,首先需要分配內存,然后使用內存,最后釋放內存。絕大多數情況下不需要手動釋放內存,只需要關注對內存的使用(變量、函數、對象等)。

          內存分配

          JavaScript 定義變量就會自動分配內存,我們只需要了解 JavaScript 的內存是自動分配的就可以了。

          let num=1;
          const str="名字";
          const obj={
            a: 1,
            b: 2
          }
          const arr=[1, 2, 3];
          function func (arg) { ... }
          

          內存使用

          使用值的過程實際上是對分配的內存進行讀寫的操作,讀取和寫入的操作可能是寫入一個變量或者一個對象的屬性值,甚至傳遞函數的參數。

          // 繼續上部分
          // 寫入內存
          num=2;
          // 讀取內存,寫入內存
          func(num);
          

          內存回收

          垃圾回收被稱為 GC(Garbage Collection)

          內存泄漏一般都是發生在這一步,JavaScript 的內存回收機制雖然可以回收絕大部分的垃圾內存,但是還是存在回收不了的情況,如果存在這些情況,需要我們自己手動清理內存。

          以前一些老版本的瀏覽器的 JavaScript 回收機制沒有那么完善,經常出現一些 bug 的內存泄漏,不過現在的瀏覽器一般都沒有這個問題了。

          這里了解下現在 JavaScript 的垃圾內存的兩種回收方式,熟悉一下這兩種算法可以幫助我們理解一些內存泄漏的場景。

          引用計數

          這是最初級的垃圾收集算法。此算法把“對象是否不再需要”簡化定義為“對象有沒有其他對象引用到它”。如果沒有引用指向該對象(零引用),對象將被垃圾回收機制回收。

          // “對象”分配給 obj1
          var obj1={
            a: 1,
            b: 2
          }
          // obj2 引用“對象”
          var obj2=obj1;
          // “對象”的原始引用 obj1 被 obj2 替換
          obj1=1;
          

          當前執行環境中,“對象”內存還沒有被回收,需要手動釋放“對象”的內存(在沒有離開當前執行環境的前提下)

          obj2=null;
          // 或者 obj2=1;
          // 只要替換“對象”就可以了
          

          這樣引用的“對象”內存就被回收了。

          ES6 中把引用分為強引用弱引用,這個目前只有在 Set 和 Map 中才存在。

          強引用才會有引用計數疊加,只有引用計數為 0 的對象的內存才會被回收,所以一般需要手動回收內存(手動回收的前提在于標記清除法還沒執行,還處于當前的執行環境)。

          而弱引用沒有觸發引用計數疊加,只要引用計數為 0,弱引用就會自動消失,無需手動回收內存。

          標記清除

          當變量進入執行時標記為“進入環境”,當變量離開執行環境時則標記為“離開環境”,被標記為“進入環境”的變量是不能被回收的,因為它們正在被使用,而標記為“離開環境”的變量則可以被回收。

          環境可以理解為我們的執行上下文,全局作用域的變量只會在頁面關閉時才會被銷毀。

          // 假設這里是全局上下文
          var b=1; // b 標記進入環境
          function func() {
            var a=1;
            return a + b; // 函數執行時,a 被標記進入環境
          }
          func();
          // 函數執行結束,a 被標記離開環境,被回收
          // 但是 b 沒有標記離開環境
          

          JavaScript 內存泄漏的一些場景

          JavaScript 的內存回收機制雖然能回收絕大部分的垃圾內存,但是還是存在回收不了的情況。程序員要讓瀏覽器內存泄漏,瀏覽器也是管不了的。

          下面有些例子是在執行環境中,沒離開當前執行環境,還沒觸發標記清除法。所以你需要讀懂上面 JavaScript 的內存回收機制,才能更好的理解下面的場景。

          意外的全局變量

          // 在全局作用域下定義
          function count(num) {
            a=1; // a 相當于 window.a=1;
            return a + num;
          }
          

          不過在 eslint 幫助下,這種場景現在基本沒人會犯了,eslint 會直接報錯,了解下就好。

          遺忘的計時器

          無用的計時器忘記清理,是最容易犯的錯誤之一。

          拿一個 vue 組件舉個例子。

          <script>
          export default {
            mounted() {
              setInterval(()=> {
                this.fetchData();
              }, 2000);
            },
            methods: {
              fetchData() { ... }
            }
          }
          </script>
          

          上面的組件銷毀的時候,setInterval 還是在運行的,里面涉及到的內存都是沒法回收的(瀏覽器會認為這是必須的內存,不是垃圾內存),需要在組件銷毀的時候清除計時器。

          <script>
          export default {
            mounted() {
              this.timer=setInterval(()=> { ... }, 2000);
            },
            beforeDestroy() {
              clearInterval(this.timer);
            }
          }
          </script>
          

          遺忘的事件監聽

          無用的事件監聽器忘記清理也是最容易犯的錯誤之一。

          還是使用 vue 組件舉個例子。

          <script>
          export default {
            mounted() {
              window.addEventListener('resize', ()=> { ... });
            }
          }
          </script>
          

          上面的組件銷毀的時候,resize 事件還是在監聽中,里面涉及到的內存都是沒法回收的,需要在組件銷毀的時候移除相關的事件。

          <script>
          export default {
            mounted() {
              this.resizeEvent=()=> { ... };
              window.addEventListener('resize', this.resizeEvent);
            },
            beforeDestroy() {
              window.removeEventListener('resize', this.resizeEvent);
            }
          }
          </script>
          

          遺忘的 Set 結構

          Set 是 ES6 中新增的數據結構,如果對 Set 不熟,可以看這里。

          如下是有內存泄漏的(成員是引用類型,即對象):

          let testSet=new Set();
          let value={ a: 1 };
          testSet.add(value);
          value=null;
          

          需要改成這樣,才會沒有內存泄漏:

          let testSet=new Set();
          let value={ a: 1 };
          testSet.add(value);
          
          testSet.delete(value);
          value=null;
          

          有個更便捷的方式,使用 WeakSet,WeakSet 的成員是弱引用,內存回收不會考慮這個引用是否存在。

          let testSet=new WeakSet();
          let value={ a: 1 };
          testSet.add(value);
          value=null;
          

          遺忘的 Map 結構

          Map 是 ES6 中新增的數據結構,如果對 Map 不熟,可以看這里。

          如下是有內存泄漏的(成員是引用類型,即對象):

          let map=new Map();
          let key=[1, 2, 3];
          map.set(key, 1);
          key=null;
          

          需要改成這樣,才會沒有內存泄漏:

          let map=new Map();
          let key=[1, 2, 3];
          map.set(key, 1);
          
          map.delete(key);
          key=null;
          

          有個更便捷的方式,使用 WeakMap,WeakMap 的鍵名是弱引用,內存回收不會考慮到這個引用是否存在。

          let map=new WeakMap();
          let key=[1, 2, 3];
          map.set(key, 1);
          key=null
          

          遺忘的訂閱發布

          和上面事件監聽器的道理是一樣的。

          建設訂閱發布事件有三個方法,emit、on、off 三個方法。

          還是繼續使用 vue 組件舉例子:

          <template>
            <div @click="onClick"></div>
          </template>
          <script>
          import EventEmitter from 'event';
          
          export default {
            mounted() {
              EventEmitter.on('test', ()=> { ... });
            },
            methods: {
              onClick() {
                EventEmitter.emit('test');
              }
            }
          }
          </script>
          

          上面組件銷毀的時候,自定義 test 事件還是在監聽中,里面涉及到的內存都是沒辦法回收的,需要在組件銷毀的時候移除相關的事件。

          <template>
            <div @click="onClick"></div>
          </template>
          <script>
          import EventEmitter from 'event';
          
          export default {
            mounted() {
              EventEmitter.on('test', ()=> { ... });
            },
            methods: {
              onClick() {
                EventEmitter.emit('test');
              }
            },
            beforeDestroy() {
              EventEmitter.off('test');
            }
          }
          </script>
          

          遺忘的閉包

          閉包是經常使用的,閉包能提供很多的便利,

          首先看下下面的代碼:

          function closure() {
            const name='名字';
            return ()=> {
              return name.split('').reverse().join('');
            }
          }
          const reverseName=closure();
          reverseName(); // 這里調用了 reverseName
          

          上面有沒有內存泄漏?是沒有的,因為 name 變量是要用到的(非垃圾),這也是從側面反映了閉包的缺點,內存占用相對高,數量多了會影響性能。

          但是如果 reverseName 沒有被調用,在當前執行環境未結束的情況下,嚴格來說,這樣是有內存泄漏的,name 變量是被 closure 返回的函數調用了,但是返回的函數沒被使用,在這個場景下 name 就屬于垃圾內存。name 不是必須的,但是還是占用了內存,也不可被回收。

          當然這種也是極端情況,很少人會犯這種低級錯誤。這個例子可以讓我們更清楚的認識內存泄漏。

          DOM 的引用

          每個頁面上的 DOM 都是占用內存的,建設有一個頁面 A 元素,我們獲取到了 A 元素 DOM 對象,然后賦值到了一個變量(內存指向是一樣的),然后移除了頁面上的 A 元素,如果這個變量由于其他原因沒有被回收,那么就存在內存泄漏,如下面的例子:

          class Test {
            constructor() {
              this.elements={
                button: document.querySelector('#button'),
                div: document.querySelector('#div')
              }
            }
            removeButton() {
              document.body.removeChild(this.elements.button);
              // this.elements.button=null
            }
          }
          
          const test=new Test();
          test.removeButton();
          

          上面的例子 button 元素雖然在頁面上移除了,但是內存指向換成了 this.elements.button,內存占用還是存在的。所以上面的代碼還需要這么寫:this.elements.button=null,手動釋放內存。

          如何發現內存泄漏

          內存泄漏時,內存一般都是周期性的增長,我們可以借助谷歌瀏覽器的開發者工具進行判斷。

          這里針對下面的例子進行一步步的的排查和找到問題點:

          <html>
            <body>
              <div id="app">
                <button id="run">運行</button>
                <button id="stop">停止</button>
              </div>
              <script>
                const arr=[]
                for (let i=0; i < 200000; i++) {
                  arr.push(i)
                }
                let newArr=[]
          
                function run() {
                  newArr=newArr.concat(arr)
                }
          
                let clearRun
          
                document.querySelector('#run').onclick=function() {
                  clearRun=setInterval(()=> {
                    run()
                  }, 1000)
                }
          
                document.querySelector('#stop').onclick=function() {
                  clearInterval(clearRun)
                }
              </script>
            </body>
          </html>
          

          確實是否是內存泄漏問題

          訪問上面的代碼頁面,打開開發者工具,切換至 Performance 選項,勾選 Memory 選項。

          在頁面上點擊運行按鈕,然后在開發者工具上面點擊左上角的錄制按鈕,10 秒后在頁面上點擊停止按鈕,5 秒停止內存錄制。得到內存走勢如下:

          由上圖可知,10 秒之前內存周期性增長,10 秒后點擊了停止按鈕,內存平穩,不再遞增。我們可以使用內存走勢圖判斷是否存在內存泄漏。

          查找內存泄漏的位置

          上一步確認內存泄漏問題后,我們繼續利用開發者工具進行問題查找。

          訪問上面的代碼頁面,打開開發者工具,切換至 Memory 選項。頁面上點擊運行按鈕,然后點擊開發者工具左上角的錄制按鈕,錄制完成后繼續點擊錄制,直到錄制完成三個為止。然后點擊頁面上的停止按鈕,在連續錄制三次內存(不要清理之前的錄制)。

          從這里也可以看出,點擊運行按鈕之后,內存在不斷的遞增。點擊停止按鈕之后,內存就平穩了。雖然我們也可以用這種方式來判斷是否存在內存泄漏,但是沒有第一步的方法便捷,走勢圖也更加直觀。

          然后第二步的主要目的是為了記錄 JavaScript 堆內存,我們可以看到哪個堆占用的內存更高。

          從內存記錄中,發現 array 對象占用最大,展開后發現,第一個 object elements 占用最大,選擇這個 object elements 后可以在下面看到 newArr 變量,然后點擊后面的高亮鏈接,就可以跳轉到 newArr 附近。

          是 JavaScript 工作原理的第三章。

          我們將會討論日常使用中另一個被開發者越來越忽略的重要話題,這都是日益成熟和復雜的編程語言的鍋,即內存管理問題。我們將會提供在創建 SessionStack 的時候所遵循的處理 JavaScript 內存泄漏的幾條小技巧,因為我們需要保證 SessionStack 不會引起內存泄漏或者不會增加我們所集成的 web 應用程序的內存消耗。

          概述

          像 C 語言擁有底層的內存管理原語如 malloc() 和 free()。開發者使用這些原語來顯式從操作系統分配和釋放內存。

          與此同時,當創建事物(對象,字符串等)的時候,JavaScript 分配內存并且當它們不再使用的時候 "自動釋放" 內存,這一過程稱為內存垃圾回收。這個乍看起來本質上是 "自動化釋放內存" 的釋放資源是引起混亂的原因,并且給予 JavaScript(及其它高級語言)開發者一個錯誤的印象即他們可以選擇忽略內存管理。這是一個巨大的錯誤。

          即使是當使用高級語言的時候,開發者也應該要理解內存管理(或者至少是一些基礎)。有時候自動化內存管理會存在一些問題(比如垃圾回收中的 bugs 或者實施的局限性等等),為了能夠合理地處理內存泄漏問題(或者以最小代價和代碼缺陷來尋找一個合適的方案),開發者就必須理解內存管理。

          內存生命周期

          不管你使用哪種編程語言,內存生命周期幾乎是一樣的:

          以下是每一步生命周期所發生事情的一個概述:

          • 分配內存-內存是由操作系統分配,這樣程序就可以使用它。在底層語言(例如 C 語言),開發者可以顯式地操作內存。而在高級語言中,操作系統幫你處理。
          • 使用內存-這是程序實際使用之前分配的內存的階段。當你在代碼中使用已分配的變量的時候,就會發生內存讀寫的操作。
          • 釋放內存-該階段你可以釋放你不再使用的整塊內存,該內存就可以被釋放且可以被再利用。和內存分配操作一樣,該操作也是用底層語言顯式編寫的。

          為快速瀏覽調用堆棧和動態內存管理的概念,你可以閱讀第一篇文章。

          啥是內存?

          在直接跳向 JavaScript 內存管理之前,先來簡要地介紹一下內存及其工作原理。

          從硬件層面看,計算機內存是由大量的 flip flops 所組成的(這里大概查了下,即大量的二進制電路所組成的)。每個 flip flop 包含少量晶體管并能夠存儲一個比特位。單個的 flip flops 可以通過一個唯一標識符尋址,所以就可以讀和覆寫它們。因此,理論上,我們可以把整個計算機內存看成是由一個巨大的比特位數組所組成的,這樣就可以進行讀和寫。

          作為猿類,我們并不擅長用位來進行所有的邏輯思考和計算,所以我們把位組織成一個更大的組,這樣就可以用來表示數字。8 位稱為一字節。除了字節還有字(16 或 32 位)。

          內存中存儲著很多東西:

          • 所有變量及所有程序使用的其它數據。
          • 程序代碼,包括操作系統的代碼。

          編譯器和操作系統一起協作來為你進行內存管理,但是建議你了解一下底層是如何實現的。

          當編譯代碼的時候,編譯器會檢查原始數據類型并提前計算出程序運行所需要的內存大小。在所謂的靜態堆棧空間中,所需的內存大小會被分配給程序。這些變量所分配到的內存所在的空間之所以被稱為靜態內存空間是因為當調用函數的時候,函數所需的內存會被添加到現存內存的頂部。當函數中斷,它們被以 LIFO(后進先出) 的順序移出內存。比如,考慮如下代碼:

          int n; // 4 字節
          int x[4]; // 4 個元素的數組,每個數組元素 4 個字節
          double m; // 8 字節
          

          編譯器會立即計算出代碼所需的內存:4 + 4 x 4 + 8=28 字節。

          編譯器是這樣處理當前整數和浮點數的大小的。大約 20 年前,整數一般是 2 字節而 浮點數是 4 字節。代碼不用依賴于當前基礎數據類型的字節大小。

          編譯器會插入標記,標記會和操作系統協商從堆棧中獲取所需要的內存大小,以便在堆棧中存儲變量。

          在以上示例中,編譯知道每個變量的準確內存地址。事實上,當你編寫變量 n 的時候,會在內部把它轉換為類似 "內存地址 412763" 的樣子。

          注意到這里當我們試圖訪問 x[4] 時候,將會訪問到 m 相關的數據。這是因為我們訪問了數組中不存在的數組元素-它超過了最后一個實際分配到內存的數組元素 x[3] 4 字節,并且有可能會讀取(或者覆寫) m 的位。這幾乎可以確定會產生其它程序所預料不到的后果。

          當函數調用其它函數的時候,各個函數都會在被調用的時候取得其在堆棧中的各自分片內存地址。函數會把保存它所有的本地變量,但也會有一個程序計數器用來記住函數在其執行環境中的地址。當函數運行結束時,其內存塊可以再次被用作其它用途。

          動態內存分配

          不幸的是,想要知道編譯時一個變量需要多少內存并沒有想象中那般容易。設想一下若要做類似如下事情:

          int n=readInput(); // 從用戶讀取信息
          ...
          // 創建一個含有 n 個元素的數組
          

          這里,編譯器并不知道編譯時數組需要多少內存,因為這是由用戶輸入的數組元素的值所決定的。

          因此,就不能夠在堆棧中為變量分配內存空間。相反,程序需要在運行時顯式地從操作系統分配到正確的內存空間。這里的內存是由動態內存空間所分配的。靜態和動態內存分配的差異總結如下圖表:

          *靜態和動態分配內存的區別*

          為了完全理解動態內存分配的工作原理,我們需要花點時間了解指針,這個就可能有點跑題了 ^.^。如果你對指針感興趣,請留言,然后我們將會在以后的章節中討論更多關于指針的內容。

          JavaScript 中的內存分配

          現在,我們將會介紹在 JavaScript 中是如何分配內存的((第一步)。

          JavaScript 通過聲明變量值,自己處理內存分配工作而不需要開發者干涉。

          var n=374; // 為數字分配內存
          var s='sessionstack'; // 為字符串分配內存
          var o={
           a: 1,
           b: null
          }; // 為對象及其值分配內存
          var a=[1, null, 'str']; // (類似對象)為數組及其數組元素值分配內存
          function f(a) {
           return a + 3;
          } // 分配一個函數(一個可調用對象)
          // 函數表達式也分配一個對象
          someElement.addEventListener('click', function() {
           someElement.style.backgroundColor='blue';
          }, false);
          

          一些函數調用也會分配一個對象:

          var d=new Date(); // 分配一個日期對象
          var e=document.createElement('div'); // 分配一個 DOM 元素
          

          可以分配值或對象的方法:

          var s1='sessionstack';
          var s2=s1.substr(0, 3); // s2 為一個新字符串
          // 因為字符串是不可變的,所以 JavaScript 可能會選擇不分配內存而只是存儲數組 [0, 3] 的內存地址范圍。
          var a1=['str1', 'str2'];
          var a2=['str3', 'str4'];
          var a3=a1.concat(a2);
          // 包含 4 個元素的新數組由 a1 和 a2 數組元素所組成
          

          JavaScript 中的內存使用

          JavaScript 中使用分配的內存主要指的是內存讀寫。

          可以通過為變量或者對象屬性賦值,亦或是為函數傳參來使用內存。

          釋放不再使用的內存

          大多數的內存管理問題是出現在這一階段。

          痛點在于檢測出何時分配的內存是閑置的。它經常會要求開發者來決定程序中的這段內存是否已經不再使用,然后釋放它。

          高級程序語言集成了一塊稱為垃圾回收器的軟件,該軟件的工作就是追蹤內存分配和使用情況以便找出并自動釋放閑置的分配內存片段。

          不幸的是,這是個近似的過程,因為判定一些內存片段是否閑置的普遍問題在于其不可判定性(不能為算法所解決)。

          大多數的垃圾回收器會收集那些不再被訪問的內存,比如引用該內存的所有變量超出了內存尋址范圍。然而還是會有低于近似值的內存空間被收集,因為在任何情況下仍然可能會有變量在內存尋址范圍內引用該內存地址,即使該內存是閑置的。

          內存垃圾回收

          由于找出 "不再使用" 的內存的不可判定性,針對這一普遍問題,垃圾回收實現了一個有限的解決方案。本小節將會闡述必要的觀點來理解主要的內存垃圾回收算法及其局限性。

          內存引用

          引用是內存垃圾回收算法所依賴的主要概念之一。

          在內存管理上下文中,如果對象 A 訪問了另一個對象 B 表示 A 引用了對象 B(可以隱式或顯式)。舉個栗子,一個 JavaScript 對象有引用了它的原型(隱式引用)和它的屬性值(顯式引用)。

          在這個上下文中,"對象" 的概念被拓展超過了一般的 JavaScript 對象并且包含函數作用域(或者全局詞法作用域)。

          詞法作用域定義了如何在嵌套函數中解析變量名。即使父函數已經返回,內部的函數仍然會包含父函數的作用域。

          垃圾回收引用計數

          這是最簡單的內存垃圾回收算法。當一個對象被 0 引用,會被標記為 "可回收內存垃圾"。

          看下如下代碼:

          var o1={
           o2: {
           x: 1
           }
          };
          // 創建兩個對象。
          // 'o1' 引用對象 'o2' 作為其屬性。全部都是不可回收的。
          // 'o3' 是第二個引用 'o1' 對象的變量
          var o3=o1;
          o1=1; // 現在,原先在 'o1' 中的對象只有一個單一的引用,以變量 'o3' 來表示
          // 引用對象的 'o2' 屬性。
          // 該對象有兩個引用:一個是作為屬性,另一個是 'o4' 變量
          var o4=o3.o2;
          // 'o1' 對象現在只有 0 引用,它可以被作為內存垃圾回收。
          // 然而,其 'o2' 屬性仍然被變量 'o4' 所引用,所以它的內存不能夠被釋放。
          o3='374';
          o4=null;
          // 'o1' 中的 'o2' 屬性現在只有 0 引用了。所以 'o1' 對象可以被回收。
          

          循環引用是個麻煩事

          循環引用會造成限制。在以下的示例中,創建了兩個互相引用的對象,這樣就會造成循環引用。函數調用之后他們將會超出范圍,所以,實際上它們是無用且可以釋放對他們的引用。然而,引用計數算法會認為由于兩個對象都至少互相引用一次,所以他們都不可回收的。

          function f() {
           var o1={};
           var o2={};
           o1.P=O2; // O1 引用 o2
           o2.p=o1; // o2 引用 o1. 這就造成循環引用
          }
          f();
          

          標記-清除算法

          為了判斷是否需要釋放對對象的引用,算法會確定該對象是否可獲得。

          標記-清除算法包含三個步驟:

          • 根:一般來說,根指的是代碼中引用的全局變量。就拿 JavaScript 來說,window 對象即是根的全局變量。Node.js 中相對應的變量為 "global"。垃圾回收器會構建出一份所有根變量的完整列表。
          • 隨后,算法會檢測所有的根變量及他們的后代變量并標記它們為激活狀態(表示它們不可回收)。任何根變量所到達不了的變量(或者對象等等)都會被標記為內存垃圾。
          • 最后,垃圾回收器會釋放所有非激活狀態的內存片段然后返還給操作系統。

          標記-清除算法的動態圖示

          該算法比之前的算法要好,因為對象零引用可以讓對象不可獲得。反之則不然,正如之前所看到的循環引用。

          從 2012 年起,所有的現代瀏覽器都內置了一個標記-清除垃圾回收器。前些年所有對于 JavaScript 內存垃圾收集(分代/增量/并發/并行 垃圾收集)的優化都是針對標記-清除算法的實現的優化,但既沒有提升垃圾收集算法本身,也沒有提升判定對象是否可獲得的能力。

          你可以查看這篇文章 來了解追蹤內存垃圾回收的詳情及包含優化了的標記-清除算法。

          循環引用不再讓人蛋疼

          在之前的第一個示例中,當函數返回,全局對象不再引用這兩個對象。結果,內存垃圾回收器發現它們是不可獲得的。

          即使兩個對象互相引用,也不能夠從根變量獲得他們。

          內存垃圾回收器的反直觀行為

          雖然內存垃圾回收器很方便,但是它們也有其一系列的代價。其中之一便是不確定性。意思即內存垃圾回收具有不可預見性。你不能確定內存垃圾收集的確切時機。這意味著在某些情況下,程序會使用比實際需要更多的內存。在其它情況下,在特定的交互敏感的程序中,你也許需要注意那些內存垃圾收集短暫停時間。雖然不確定性意味著不能夠確定什么時候可以進行內存垃圾收集,但是大多數 GC 的實現都是在內存分配期間進行內存垃圾回收的一般模式。如果沒有進行內存分配,大多數的內存垃圾回收就會保持閑置狀態。考慮以下情況:

          • 分配一段固定大小的內存。
          • 大多數的元素(或所有)被標記為不可獲得(假設我們賦值我們不再需要的緩存為 null )
          • 不再分配其它內存。

          在該情況下,大多數的內存垃圾回收器不會再運行任何的內存垃圾回收。換句話說,即使可以對該不可獲得的引用進行垃圾回收,但是內存收集器不會進行標記。雖然這不是嚴格意義上的內存泄漏,但是這會導致高于平常的內存使用率。

          內存泄漏是啥?

          正如內存管理所說的那樣,內存泄漏即一些程序在過去時使用但處于閑置狀態,卻沒有返回給操作系統或者可用的內存池。

          編程語言喜歡多種內存管理方法。然而,某個內存片段是否被使用是一個不確定的問題。換句話說,只有開發人員清楚某個內存片段是否可以返回給操作系統。

          某些編程語言會為開發者提供功能函數來解決這個問題。其它的編程語言完全依賴于開發者全權掌控哪個內存片段是可回收的。維其百科上有關于手動和自動內存管理的好文章。

          四種常見的 JavaScript 內存泄漏

          1: 全局變量

          JavaScript 以一種有趣的方式來處理未聲明變量:當引用一個未聲明的變量,會在全局對象上創建一個新的變量。在瀏覽器中,全局對象是 window,這意味著如下代碼:

          function foo(arg) {
           bar="some text";
          }
          

          等同于:

          function foo(arg) {
           window.bar="some text";
          }
          

          變量 bar 本意是只能在 foo 函數中被引用。但是如果你沒有用 var 來聲明變量,那么將會創建一個多余的全局變量。在上面的例子中,并不會造成大的事故。但你可以很自然地想象一個更具破壞性的場景。

          你也可以使用 this 關鍵字不經意地創建一個全局變量。

          function foo() {
           this.var1="potential accidental global";
          }
          // 調用 foo 函數自身,this 會指向全局對象(window)而不是未定義
          

          你可以通過在 JavaScript 文件的頂部添加 'use strict' 來避免以上的所有問題,'use strict' 會切換到更加嚴格的 JavaScript 解析模式,這樣就可以防止創建意外的全局變量。

          意外的全局變量的確是個問題,而代碼經常會被顯式定義的全局變量所污染,根據定義這些全局變量是不會被內存垃圾回收器所收集的。你需要特別注意的是使用全局變量來臨時存儲和處理大型的位信息。只有在必要的時候使用全局變量來存儲數據,記得一旦你不再使用的時候,把它賦值為 null 或者對其再分配。

          2:定時器及被遺忘的回調函數

          因為經常在 JavaScript 中使用 setInterval,所以讓我們以它為例。

          框架中提供了觀察者和接受回調的其它指令通常會確保當他們的實例不可獲得的時候,所有對回調的引用都會變成不可獲得。很容易找到如下代碼:

          var serverData=loadData();
          setInterval(function() {
           var renderer=document.getElementById('renderer');
           if (renderer) {
           renderer.innerHTML=JSON.stringify(serverData);
           }
          }, 5000); // 這將會每隔大約 5 秒鐘執行一次
          

          以上代碼片段展示了使用定時器來引用不再需要的節點或數據的后果。

          renderer 對象會在某些時候被替換或移除,這樣就會導致由定時處理程序封裝的代碼變得冗余。當這種情況發生的時候,不管是定時處理程序還是它的依賴都不會被垃圾回收,這是由于需要先停止定時器(記住,定時器仍然處于激活狀態)。這可以歸結為保存和處理數據加載的 serverData 變量也不會被垃圾回收。

          當使用觀察者的時候,你需要確保一旦你不再需要它們的時候顯式地移除它們(不再需要觀察者或者對象變得不可獲得)。

          幸運的是,大多數現代瀏覽器都會替你進行處理:當被觀察者對象變得不可獲得時,即使你忘記移除事件監聽函數,瀏覽器也會自動回收觀察者處理程序。以前,一些老掉牙的瀏覽器處理不了這些情況(如老舊的 IE6)。

          那么,最佳實踐是當對象被廢棄的時候,移除觀察者處理程序。查看如下例子:

          var element=document.getElementById('launch-button');
          var counter=0;
          function onClick(event) {
           counter++;
           element.innerHTML='text' + counter;
          }
          element.addEventListener('click', onClick);
          // Do stuff
          element.removeEventListener('click', onClick);
          element.parentNode.removeChild(element);
          // 現在當元素超出范圍
          // 即使在不能很好處理循環引用的瀏覽器中也會回收元素和 onClick 事件
          

          在讓一個 DOM 節點不可獲得之前,你不再需要調用 removeEventListener,因為現代瀏覽器支持用內存垃圾回收器來檢測并適當地處理 DOM 節點的生命周期。

          如果你使用 jQuery API(其它的庫和框架也支持的 API),你可以在廢棄節點之前移除事件監聽函數。jQuery 也會確保即使在老舊的瀏覽器之中,也不會產生內存泄漏。


          主站蜘蛛池模板: 99热门精品一区二区三区无码 | 91在线一区二区| 无码8090精品久久一区| 国产精品成人一区无码| 国产在线精品一区二区 | 国产精品无码亚洲一区二区三区 | 亚洲一区二区三区成人网站| 无码AV中文一区二区三区| 亚洲AV无码一区二区三区人| 成人在线观看一区| 亚洲AⅤ视频一区二区三区| 不卡一区二区在线| 无码国产精品一区二区免费虚拟VR | 毛片一区二区三区无码| 午夜在线视频一区二区三区| 国产美女视频一区| 亚洲AV无码一区东京热久久| 亚洲av综合av一区二区三区| 深夜福利一区二区| 色窝窝无码一区二区三区成人网站| 亚洲AV噜噜一区二区三区| 杨幂AV污网站在线一区二区| 久久久精品人妻一区亚美研究所| 中文字幕无线码一区2020青青| 国产天堂在线一区二区三区| 国产精品特级毛片一区二区三区 | 日韩精品一区二区三区影院| 一区二区视频在线观看| 国产主播一区二区三区在线观看 | 久久无码人妻一区二区三区| 美女视频黄a视频全免费网站一区| 国产无人区一区二区三区| 亚洲熟女一区二区三区| 天堂一区二区三区精品| 丝袜人妻一区二区三区网站| 四虎成人精品一区二区免费网站| 精品一区二区久久久久久久网站| 国模精品视频一区二区三区| 少妇激情av一区二区| 中文字幕一区二区三区在线播放| 成人h动漫精品一区二区无码|