如何寫出高效能程式碼(二)巧用資料特性

xindoo發表於2022-04-06

導語

  同一份邏輯,不同人的實現的程式碼效能會出現數量級的差異; 同一份程式碼,你可能微調幾個字元或者某行程式碼的順序,就會有數倍的效能提升;同一份程式碼,也可能在不同處理器上執行也會有幾倍的效能差異;十倍程式設計師 不是隻存在於傳說中,可能在我們的周圍也比比皆是。十倍體現在程式設計師的方法面面,而程式碼效能卻是其中最直觀的一面。

  本文是《如何寫出高效能程式碼》系列的第二篇,本文將告訴你如何利用資料的幾個特性以達到提升程式碼效能的目的。

可複用性

  我們在程式碼中所用到的大部分資料,都是可以被重複使用的,這種能被重複使用的資料就不要去反覆去獲取或者初始化了,舉個例子:
在這裡插入圖片描述
  上圖中在for迴圈中呼叫了getSomeThing()函式,而這個函式實際和迴圈無關,它是可以放在迴圈外,其結果也是可以複用的,上面程式碼放在迴圈內白白多呼叫了99次,這裡如果getSomeThing()是個非常耗時或者耗CPU的函式,效能將會查近百倍。
在這裡插入圖片描述
  在Java程式碼中,我們很常用的列舉類,大部分的列舉類可能經常有獲取所有列舉資訊的介面,大部分人可能寫出來的程式碼像上面的getList()這樣。然而這種寫法雖然功能上沒啥問題,但每呼叫一次就會生成一個新的List,如果呼叫頻次很高就會對效能產生顯著的影響。正確的做法應該靜態初始化生成一個不可變的list,之後直接複用就行。

溫馨提示:這裡我特意標註了一個不可變,在物件複用的情況下需要額外關注下是否有地方會改變物件內容,如果物件需要被改變就不能複用了,可以deepcopy之後再更改。當然如果這個物件生來就是會被改變的,就沒必要複用了。

非必要性

  非必要性的意思是有些資料可能沒必要去做初始化。舉個簡單的例子:
在這裡插入圖片描述
  在上面程式碼中sth物件被獲取後,才校驗了引數的合法性,事實上如果引數是不合法的,sth就沒必要初始化了,這裡sth就具備了非必要性。類似上面這種程式碼其實很常見,我在我們公司程式碼庫中就遇到了很多次,基本的模式都是先獲取了某些資料,但在之後有些過濾或者檢查的邏輯導致程式碼跳出,然後這些資料就完全沒有用上。
  應對非必要性的一個解決方案就是延遲初始化,有些地方也叫 懶載入 或者 惰性載入,像上面程式碼中只需要把getSomeThing()移動到引數校驗的後面,就可以避免這個效能問題了。像Java中我們在用的checkstyle外掛,就提供了一個VariableDeclarationUsageDistance 的規則,這個規則的作用強制讓程式碼的宣告和使用不會間隔太多行,從而避免出現上述這種宣告但未使用導致的效能問題。
  事實上,延遲初始化是一個非常常用的機制,比如著名的copy on write其實就是延遲初始化的典範。另外像Jdk中很多集合基本也都是延遲初始化的,就拿HashMap為例,你在執行new HashMap()時,只是建立了一個空殼物件,只有第一次呼叫put()方法時整個map才會初始化。

// new HashMap()只是初始化出來一個空殼hashmap
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                       initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                       loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
    
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 第一次put觸發內部真正的初始化
    if ((tab = table) == null || (n = tab.length) == 0) 
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
       // 省略其它程式碼
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

區域性性

在這裡插入圖片描述
  區域性性也是老生常談的特性了,區域性性有好多種,資料區域性性、空間區域性性、時間區域性性……可以說就是因為區域性性的存在,世界才能更高效地執行。更多關於區域性性的內容,可以參考下我之前寫的一篇文章區域性性原理——各類優化的基石
  這裡先說下資料區域性性,在大多數情況下,只有少量的資料是會被頻繁訪問的,俗稱熱點資料。處理熱點資料最簡單的方法就是給它加快取加分片,具體方案就得看具體問題了。我來舉個在網際網路公司很常見的例子,很多業務資料都是存在資料庫中,然而資料庫在面對超大量的請求就有點力不從心了,因為區域性性的存在,只有少量的資料是被頻繁訪問的,我們可以將這部分資料快取在Redis中,從而減少對資料庫的壓力。
  另外說個大家比較容易忽略的一點,程式碼區域性性。系統中只有少量的程式碼是被反覆執行的,而且如果系統有效能問題,也是少量的程式碼導致的,所以只要找出並優化好這部分程式碼,系統效能就能顯著提升。依賴一些效能分析工具,比如用arthas火焰圖就能很容易找到這部分程式碼(其他工具會在本系列第五篇文章中介紹)。

多讀少寫

  除了區域性性外,資料還有另外一個非常顯著的特性,就是多讀少寫。這個也很符合大家的直覺和習慣,比如大部分人都是看文章而不是寫文章,你到如何網站上也都是看的多,改的少,這是一條几乎放之四海而皆準的規律。 那這個特性對我們寫程式碼有什麼意義? 這個特性意味著大概率你的程式碼區域性性就產生在讀資料的程式碼上,額外關注下這部分程式碼。
  當然也不是說寫資料不重要,這裡就不得不說到多讀少寫的另外一個特點了,那就是寫的成本遠高於讀的成本,而且寫的重要性也遠高於讀的重要性。 重要性不言而喻,去銀行只是看不到餘額可以接受,但取不了錢那肯定就是不行了。 那為什麼寫資料的成本會遠高於讀資料的成本呢? 簡單可以這麼理解,由於資料區域性性的加持,很多讀都可以通過各種手段來優化,而寫就不大行,而且寫可能會產生很多額外的副作用,需要新增很多校驗之類的邏輯避免不必要的副作用。

  以上就是本文的全部內容,希望大家有所收穫。

如何寫出高效能程式碼系列文章

相關文章