問題
團隊中做code review有一段時間了,最近一直在思考一個問題,拋開業務邏輯,單純從程式碼層面如何評價一段程式碼的好壞?
好和壞都是相對的,一段不那麼好的程式碼經過優化之後,如何標準化的給出重構前後的差異呢?
我們所有的程式碼都跑在計算機上,計算機的核心是CPU和記憶體。從這個角度來看,效率高的程式碼應當佔用更少的CPU時間,更少的記憶體空間。
因此,問題就演變為優化一段程式碼,到底優化了多少CPU的使用以及記憶體空間的使用?
CPU-時間複雜度
時間複雜度
在資料結構與演算法中,常用大O來表示演算法的時間複雜度,常見的時間複雜度如下所示:(來源《演算法》第四版)
時間複雜度這個東西,是描述一個演算法在問題規模不斷增大時對應的時間增長曲線。所以,這些增長數量級並不是一個準確的效能評價,可以理解為一個近似值,時間的增長近似於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.
首先從第二行開始,表示式賦值計為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的圖
靜態記憶體分配
是指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.注意閉包
閉包裡依賴了主函式的資料,為了讓閉包續繼訪問到資料,必須避免當主函式退出時,回收閉包依賴主函式的變數所對應的資料,從而帶來記憶體溢位風險。
資料: