前端JS代碼的性能探究

問題

  團隊中作code review有一段時間了,最近一直在思考一個問題,拋開業務邏輯,單純從代碼層面如何評價一段代碼的好壞?javascript

  好和壞都是相對的,一段不那麼好的代碼通過優化以後,如何標準化的給出重構先後的差別呢?html

  咱們全部的代碼都跑在計算機上,計算機的核心是CPU和內存。從這個角度來看,效率高的代碼應當佔用更少的CPU時間,更少的內存空間。java

  所以,問題就演變爲優化一段代碼,到底優化了多少CPU的使用以及內存空間的使用?算法

CPU-時間複雜度

時間複雜度

  在數據結構與算法中,經常使用大O來表示算法的時間複雜度,常見的時間複雜度以下所示:(來源《算法》第四版) json

  時間複雜度這個東西,是描述一個算法在問題規模不斷增大時對應的時間增加曲線。因此,這些增加數量級並非一個準確的性能評價,能夠理解爲一個近似值,時間的增加近似於logN、NlogN的曲線。以下圖所示:數組

  上面是關於時間複雜度的解釋,下面經過具體樣例來看看代碼的時間複雜度瀏覽器

代碼一:緩存

(function count(arr=[1,2,3,4,5,6,7,8,9,10]){
  let num = 0
  for(let i=0;i<arr.length;i++){
    let item = arr[i]
    num = num + item
  }
  return num
})()
複製代碼

  這是一段求數組中數字總和的代碼,咱們粗略估計上述代碼在CPU中表達式運算的時間都是同樣的,計爲avg_time,那麼咱們來算一下上面的代碼須要多少個avg_time.bash

  首先從第二行開始,表達式賦值計爲1個avg_time;代碼的三、四、5行分別要運行10次,其中第三行比較特殊,每次運行須要計算arr.length以及i++,因此這裏須要(2+1+1)*10 個avg_time;總共就是(2+1+1)*10+1=41個avg_timesession

  接着,咱們來對上面的代碼優化一番,以下所示: 代碼二

(function count(arr=[1,2,3,4,5,6,7,8,9,10]){
  let num = 0
  let len = arr.length
  while(len--){
    num = num + arr[len]
  }
  return num
})()
複製代碼

  不難算出,優化後的代碼只耗費了1+1+(1+1)*10=22個avg_time,代碼二相對於代碼一,節約了41-22=19個avg_time,代碼性能提高19/41=46.3%!

如何寫出低時間複雜度的代碼?

1.靈活使用break、continue、return

  這三個關鍵字通常用在減小循環次數,達到目的,當即退出。以下所示:

(function check(arr=[1,2,3],target=2){
      let len = arr.length
      while(len--){
        if(arr[len]===target){
          // 再也不繼續後續循環
          return len
        }
      }
      return -1
    })()
複製代碼

2.空間換時間

常見的作法是利用緩存,把上次的計算結果存起來,避免重複計算。

3.更優的數據結構與算法

根據不一樣的狀況選擇合適的數據結構與算法,例如,若是須要頻繁的從一組數據中經過關鍵key查詢出數據,若是要從json對象和數組中選擇,那麼能夠優先考慮使用json對象來避免數組的遍歷查詢。

內存-空間複雜度

  評價一段代碼,除了看它執行須要多少時間,還須要看看須要多少空間,談到代碼的空間佔用,必須就得知道JS的內存管理

  JS的內存管理分爲三部分:

  • 內存分配。
      這裏包含包含代碼自己以及靜態數據與動態數據所須要的內存,其中代碼自己與靜態數據會分配在stack上,可變的動態數據會分配在heap上

  • 使用分配的內存。

  • 內存回收。

這裏,放一張JS Runtime的圖

靜態內存分配

  是指stack中內存的分配,基礎數據類型的數據就放在stack中。另外,stack是有固定大小的,超過stack的長度,就會報錯,因此必須得節約着用。

爆棧

// 故意來一次爆棧體驗
function foo(){
  foo()
}
foo()
// 結果
VM201:1 Uncaught RangeError: Maximum call stack size exceeded
    at foo (<anonymous>:1:13)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
複製代碼

  咱們是怎麼達到爆棧目的的呢?由於全部的函數調用,在內存中都存在一個函數調用棧,咱們不斷無結束條件的遞歸調用,最終撐破了stack。

如圖所示:

函數調用棧

可能你會問怎麼證實函數調用棧的存在呢?請看以下代碼:

function second() {
    throw new Error('function call stack');
}
function first() {
    second();
}
function start() {
    first();
}
start();
// 結果以下
VM266:2 Uncaught Error: function call stack at second (<anonymous>:2:11) at first (<anonymous>:5:5) at start (<anonymous>:8:5) at <anonymous>:10:1 複製代碼

  從上面的運行結果能夠看出函數調用棧的順序,start先入棧,接着first,最後second;打印順序爲首選打印second,最後打印start;知足棧的先進後出的數據結構特性。

內存佔用

  瞭解上面知識點的核心目的仍是在於指導咱們寫出更優的代碼,咱們知道基本數據類型都放在棧中,對象都放在堆中。另外,經過《JavaScript權威指南》第六版第三章能夠知道,js中的數字都是雙精度類型,佔64位8個字節的空間,字符佔16位2個字節的空間。

  有了這個知識,咱們就能夠估算出咱們的代碼大體佔用了多少內存空間。

  這些畢竟都是理論知識,不由要懷疑一下,的確是這樣的嗎?下面咱們利用爆棧的原理,經過代碼實際瞧瞧

let count = 0
try{
  function foo() {
    count++
    foo()
  }
  foo()
}finally{
  console.log(count)
}
// 最終的打印結果爲:15662
複製代碼

咱們知道一個數字佔8個字節,棧的大小固定;稍微變動一下代碼

let count = 0
try{
  function foo() {
    let local = 58 //數字,佔8個字節
    count++
    foo()
  }
  foo()
}finally{
  console.log(count)
}
// 最終的打印結果爲:13922
複製代碼

那麼咱們能夠利用以下方法算一下棧的總大小

N = 棧中單個元素的大小
15662 * N = 13922 * (N + 8) // 兩次函數調用,棧的總大小相等
(15662 - 13922) * N = 13922 * 8
1740 * N = 111376
N = 111376 / 1740 = 64 bytes
Total stack size = 15662 * 64 = 1002368 = 0.956 MB
複製代碼

注:不通環境可能結果不太同樣

接下來,咱們來肯定一下數字類型是否佔8個字節空間

let count = 0
try{
  function foo() {
    //數字,佔8個字節,這裏就佔16個字節
    let local = 58
    let local2 = 85
    count++
    foo()
  }
  foo()
}finally{
  console.log(count)
}
// 最終的打印結果爲:12530
複製代碼

計算一下Number的內存佔用大小

// 總的棧內存空間/棧中元素數量 = 單個棧元素大小
1002368/12530 = 80
// 對比不帶任何額外變量的代碼,單個棧元素大小是64,這裏新增兩個16,加起來正好爲80
80 = 64+8+8
複製代碼

經實際驗證,在Chrome、Safari、Node環境下,不論變量的值是什麼類型,在stack中都佔8個字節。對於字符串貌似跟預期不太同樣,不論多長的字符串實踐代表在stack中都佔8個字節,懷疑瀏覽器默認把字符串轉換爲了對象,最終佔用heap空間

動態內存分配

  是指heap中內存的分配,全部對象都放在heap中,stack中只放對象的引用。

這裏有一篇數組佔用多少內存空間的文章:How much memory do JavaScript arrays take up in Chrome?

如何寫出低內存佔用的代碼?

  低內存佔用,從靜態內存分配方面能夠考慮,儘可能少的使用基礎類型變量;從動態內存分配的角度,讓代碼更簡潔、不要毫無節制的new一個對象、少在對象放東西;

下面是一些小技巧:
1.三目運算符

// 條件賦值
    if(a===1){
      b = 'aa'
    }else{
      b = 'bb'
    }
    // 可簡化爲
    b = a===1 ? 'aa' : 'bb'
複製代碼

2.直接返回結果

if(a===1){
     return true
   }else{
     return false
   }
   // 可簡化爲
   return a===1
複製代碼

一時半會兒想不到好的樣例,上面的樣例至少節約了代碼的空間佔用!......歡迎評論補充......

內存回收

  個人理解是,當函數調用棧爲空時,佔用的佔內存隨之清空;只有堆內存中的數據才須要經過垃圾回收機制來回收。

常見的垃圾回收算法以下:

  • 引用計數
    對沒有對象的引用計數,若是沒有任何外部引用時,則清除該對象;引用計數算法有一個弊端就是沒法清除循壞依賴的對象

  • 標記清除:
    每次回收,從根對象開始遍歷,能遍歷到的對象則記爲可用,不能遍歷到的對象則爲須要垃圾回收的對象。此種算法可以解決對象循環依賴的問題。

  • 綜合算法:
    實際上垃圾回收是一個很複雜的過程,垃圾回收器會根據內存的不通狀況採起不一樣的垃圾回收算法,來實現效率的最大化。

這裏有一篇垃圾回收的文章:A tour of V8: Garbage Collection 已經被翻譯爲了中文,點進去就知道了。

如何避免內存溢出?

  從上面的垃圾回收機制不難看出,當某些狀況內存沒法被回收且不斷增長時,內存溢出就會產生。下面是幾種常見的會有內存溢出風險的代碼。

1.控制全局變量
從垃圾回收的原理咱們能夠知道,全局變量確定是不會被回收的。因此咱們應當儘可能把數據綁定到全局變量上,更應該避免經過用戶操做持續的增長全局變量數據的大小。
另外還須要特別注意意外的全局變量產生,例如:

function foo(arg) {
    a = "some text";
    this.b = "some text";
}
// 會在window對象上新增a,b屬性
foo()
複製代碼

2.setInterval注意內存佔用
因爲setInterval一直處於活動狀態,形成它所依賴的數據一直沒法回收。特別容易出現數據越積越多狀況

3.注意閉包
閉包裏依賴了主函數的數據,爲了讓閉包續繼訪問到數據,必須避免當主函數退出時,回收閉包依賴主函數的變量所對應的數據,從而帶來內存溢出風險。

資料:

  1. JS內存管理
  2. How JavaScript works: an overview of the engine, the runtime, and the call stack
  3. JavaScript stack size
相關文章
相關標籤/搜索