同一份邏輯,不同人的實現的程式碼效能會出現數量級的差異; 同一份程式碼,你可能微調幾個字元或者某行程式碼的順序,就會有數倍的效能提升;同一份程式碼,也可能在不同處理器上執行也會有幾倍的效能差異;十倍程式設計師不是隻存在於傳說中,可能在我們的周圍也比比皆是。十倍體現在程式設計師的方法面面,而程式碼效能卻是其中最直觀的一面。
“如何寫出高效能程式碼”系列源自我在組內做的一次分享,本系列將以我個人之前的經驗為基礎,嘗試幫助大家寫出更高效能的程式碼 。原ppt分享的面有寬也比較淺薄,所以這裡將原ppt拆分成5個獨立的部分,分別成文,也作為對原ppt的擴充套件和補充,本文是第一篇——善用演算法和資料結構。
荀子-勸學中說道:君子生非異也,善假於物也。其大意是君子的資質跟一般人沒什麼不同,只是善於藉助外物罷了。 對於程式猿而已,我們在日常編碼過程中,可能最常用的就是資料結構。現代各語言的開發庫裡基本上都封裝好了各類的資料結構,我們基本不需要自己去實現。但錯誤地使用資料結構可能導致程式碼效能出現大幅的下降。
這裡我舉三個Java中因未考慮到底層實現導致效能損耗的示例。
上面這段程式碼本身功能上沒有任何問題,但Java中ArrayList在新增過程中在容量不足時會觸發擴容,擴容的過程會額外消耗CPU資源。但我在上述程式碼中指定了ArrayList的初始化容量為100後,用JMH壓測發現有了33%的效能提升。
在Java中,很多容器都有動態擴容的特性,而擴容的過程涉及到記憶體的拷貝,很消耗效能。 所以建議如果能預知到資料量大小,在容器初始化的時候給定一個初始容量。這點在現在很多公司的編碼規範中也明確提出了,如下圖來自阿里巴巴Java開發手冊。
再來看一個錯誤使用LinkedList導致的效能問題。
// jdk LinkedList中的get(int index)
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
// 這裡會從前到後遍歷連結串列
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
LinkedList並不受動態擴容的影響,但是它的底層實現是用的連結串列,而連結串列最大的問題在於不支援隨機遍歷,所以LinkedList中get(int index)的底層實現是用了遍歷,時間複雜度是O(n),而ArrayList的底層實現是陣列,它的get時間複雜度是O(1)。在上述程式碼中我將LinkedList改成ArrayList後壓測確實也得到了十倍以上的效能提升。
在Java中,Set和List都提供了contains()方法,其作用就是校驗某個在是否存在於這個集合中,但其contains實現方法完全不一樣。在HashSet中,contains直接是從hash表中查詢,其時間複雜度只有O(1)。而在ArrayList和LinkedList中,都是需要遍歷一次全量資料才能得出結果,時間複雜度是O(n),程式碼這裡就不再贅述,具體可以自行查閱。
在我實際測試是,Set和List的contains效能差異確實也非常明顯。我用JMH測試發現,當有100個元素時,HashSet.contains的效能是ArrayList的10倍,是LinkedList的20倍,當資料量更大時,這個差異會更明顯。
以上3個錯誤的示例其實在我們日常程式碼中經常會遇到,或許你現在去翻閱下專案程式碼,很容易就能找到List和Set使用不當之處。 也許你會反駁,JDK中這些Api的效能都極高,而且這部分也只是業務邏輯中非常非常小的一部分,錯誤得使用可能只會導致整體百分之一甚至千分之一的差異,但是不積跬步無以至千里,不積小流無以成江河。
下圖是各種常用資料結構各種操作的時間、空間複雜度供大家查閱:
演算法和資料結構是一個程式設計師的根基,雖然日常我們很少自己去實現某種具體的演算法或資料結構,但我們卻無時無刻不在使用各種已被封裝好的演算法或資料結構,我們應當做到對各種演算法和資料結構爛熟於心,包括其時間複雜度、空間複雜度、適用範圍。