前端JS程式碼的效能探究

knag發表於2019-03-20

問題

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

  好和壞都是相對的,一段不那麼好的程式碼經過優化之後,如何標準化的給出重構前後的差異呢?

  我們所有的程式碼都跑在計算機上,計算機的核心是CPU和記憶體。從這個角度來看,效率高的程式碼應當佔用更少的CPU時間,更少的記憶體空間。

  因此,問題就演變為優化一段程式碼,到底優化了多少CPU的使用以及記憶體空間的使用?

CPU-時間複雜度

時間複雜度

  在資料結構與演算法中,常用大O來表示演算法的時間複雜度,常見的時間複雜度如下所示:(來源《演算法》第四版)

前端JS程式碼的效能探究

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

前端JS程式碼的效能探究

  上面是關於時間複雜度的解釋,下面通過具體樣例來看看程式碼的時間複雜度

程式碼一:

(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.

  首先從第二行開始,表示式賦值計為1個avg_time;程式碼的3、4、5行分別要執行10次,其中第三行比較特殊,每次執行需要計算arr.length以及i++,所以這裡需要(2+1+1)*10 個avg_time;總共就是(2+1+1)*10+1=41個avg_time

  接著,我們來對上面的程式碼優化一番,如下所示: 程式碼二

(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的圖

前端JS程式碼的效能探究

靜態記憶體分配

  是指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。

如圖所示:

前端JS程式碼的效能探究

函式呼叫棧

可能你會問怎麼證明函式呼叫棧的存在呢?請看如下程式碼:

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

相關文章