10種簡單的Java效能最佳化,java效能最佳化
最近“全網域(Web Scale)”一詞被炒得火熱,人們也正在通過擴充他們的應用程式架構來使他們的系統變得更加“全網域”。但是究竟什麼是全網域?或者說如何確保全網域?
擴充的不同方面
全網域被炒作的最多的是擴充負載(Scaling load),比如支援單個使用者訪問的系統也可以支援10 個、100個、甚至100萬個使用者訪問。在理想情況下,我們的系統應該保持儘可能的“無狀態化(stateless)”。即使必須存在狀態,也可以在網路的不同處理終端上轉化並進行傳輸。當負載成為瓶頸時候,可能就不會出現延遲。所以對於單個請求來說,耗費50到100毫秒也是可以接受的。這就是所謂的橫向擴充(Scaling out)。
擴充在全網域最佳化中的表現則完全不同,比如確保成功處理一條資料的演算法也可成功處理10條、100條甚至100萬條資料。無論這種度量類型是是否可行,事件複雜度(大O符號)是最佳描述。延遲是效能擴充殺手。你會想盡辦法將所有的運算處理在同一台機器上進行。這就是所謂的縱向擴充(Scaling up)。
如果天上能掉餡餅的話(當然這是不可能的),我們或許能把橫向擴充和縱向向外延展群組合起來。但是,今天我們只打算介紹下面幾條提升效率的簡單方法。
大O符號
Java 7的 ForkJoinPool 和Java8 的並行資料流(parallel Stream) 都對平行處理有所協助。當在多核處理器上部署Java程式時表現尤為明顯,因所有的處理器都可以訪問相同的記憶體。
所以,這種平行處理較之在跨網路的不同機器上進行擴充,根本的好處是幾乎可以完全消除延遲。
但不要被平行處理的效果所迷惑!請謹記下面兩點:
- 平行處理會吃光處理器資源。平行處理為批處理帶來了極大的好處,但同時也是非同步伺服器(如HTTP)的噩夢。有很多原因可以解釋,為什麼在過去的幾十年中我們一直在使用單線程的Servlet模型。平行處理僅在縱向擴充時才能帶來實際的好處。
- 平行處理對演算法複雜度沒有影響。如果你的演算法的時間複雜度為 O(nlogn),讓演算法在 c 個處理器上運行,事件複雜度仍然為 O(nlogn/c), 因為 c 只是演算法中的一個無關緊要的常量。你節省的僅僅是時鐘時間(wall-clock time),實際的演算法複雜度並沒有降低。
降低演算法複雜度毫無疑問是改善效能最行之有效辦法。比如對於一個 HashMap 執行個體的 lookup() 方法來說,事件複雜度 O(1) 或者空間複雜度 O(1) 是最快的。但這種情況往往是不可能的,更別提輕易地實現。
如果你不能降低演算法的複雜度,也可以通過找到演算法中的關鍵點並加以改善的方法,來起到改善效能的作用。假設我們有下面這樣的演算法:
該演算法的整體時間複雜度為 O(N3),如果按照單獨訪問順序計算也可得出複雜度為 O(N x O x P)。但是不管怎樣,在我們分析這段代碼時會發現一些奇怪的情境:
- 在開發環境中,通過測試資料可以看到:左分支(N->M->Heavy operation)的時間複雜度 M 的值要大於右邊的 O 和 P,所以在我們的分析器中僅僅看到了左分支。
- 在生產環境中,你的維護團隊可能會通過 AppDynamics、DynaTrace 或其它小工具發現,真正導致問題的罪魁禍首是右分支(N -> O -> P -> Easy operation or also N.O.P.E.)。
在沒有生產資料參照的情況下,我們可能會輕易的得出要最佳化“高開銷操作”的結論。但我們做出的最佳化對交付的產品沒有起到任何效果。
最佳化的金科玉律不外乎以下內容:
- 良好的設計將會使最佳化變得更加容易。
- 過早的最佳化並不能解決多有的效能問題,但是不良的設計將會導致最佳化難度的增加。
理論就先談到這裡。假設我們已經發現了問題出現在了右分支上,很有可能是因產品中的簡單處理因耗費了大量的時間而失去響應(假設N、O和 P 的值非常大), 請注意文章中提及的左分支的時間複雜度為 O(N3)。這裡所做出的努力並不能擴充,但可以為使用者節省時間,將困難的效能改善延遲到後面再進行。
這裡有10條改善Java效能的小建議:
1、使用StringBuilder
StingBuilder 應該是在我們的Java代碼中預設使用的,應該避免使用 + 操作符。或許你會對 StringBuilder 的文法糖(syntax sugar)持有不同意見,比如:
String x = "a" + args.length + "b";
將會被編譯為:
0 new java.lang.StringBuilder [16] 3 dup 4 ldc <String "a"> [18] 6 invokespecial java.lang.StringBuilder(java.lang.String) [20] 9 aload_0 [args]10 arraylength11 invokevirtual java.lang.StringBuilder.append(int) : java.lang.StringBuilder [23]14 ldc <String "b"> [27]16 invokevirtual java.lang.StringBuilder.append(java.lang.String) : java.lang.StringBuilder [29]19 invokevirtual java.lang.StringBuilder.toString() : java.lang.String [32]22 astore_1 [x]
但究竟發生了什嗎?接下來是否需要用下面的部分來對 String 進行改善呢?
String x = "a" + args.length + "b"; if (args.length == 1) x = x + args[0];
現在使用到了第二個 StringBuilder,這個 StringBuilder 不會消耗堆中額外的記憶體,但卻給 GC 帶來了壓力。
StringBuilder x = new StringBuilder("a");x.append(args.length);x.append("b"); if (args.length == 1); x.append(args[0]);
小結
在上面的範例中,如果你是依靠Java編譯器來隱式產生執行個體的話,那麼編譯的效果幾乎和是否使用了 StringBuilder 執行個體毫無關係。請記住:在 N.O.P.E 分支中,每次CPU的迴圈的時間到白白的耗費在GC或者為 StringBuilder 分配預設空間上了,我們是在浪費 N x O x P 時間。
一般來說,使用 StringBuilder 的效果要優於使用 + 操作符。如果可能的話請在需要跨多個方法傳遞引用的情況下選擇 StringBuilder,因為 String 要消耗額外的資源。JOOQ在產生複雜的SQL語句便使用了這樣的方式。在整個抽象文法樹(AST Abstract Syntax Tree)SQL傳遞過程中僅使用了一個 StringBuilder 。
更加悲劇的是,如果你仍在使用 StringBuffer 的話,那麼用 StringBuilder 代替 StringBuffer 吧,畢竟需要同步字串的情況真的不多。
2、避免使用Regex
Regex給人的印象是快捷簡便。但是在 N.O.P.E 分支中使用Regex將是最糟糕的決定。如果萬不得已非要在計算密集型代碼中使用Regex的話,至少要將 Pattern 緩衝下來,避免反覆編譯Pattern。
static final Pattern HEAVY_REGEX = Pattern.compile("(((X)*Y)*Z)*");
如果僅使用到了如下這樣簡單的Regex的話:
String[] parts = ipAddress.split("\\.");
這是最好還是用普通的 char[] 數組或者是基於索引的操作。比如下面這段可讀性比較差的代碼其實起到了相同的作用。
int length = ipAddress.length();int offset = 0;int part = 0;for (int i = 0; i < length; i++) { if (i == length - 1 || ipAddress.charAt(i + 1) == '.') { parts[part] = ipAddress.substring(offset, i + 1); part++; offset = i + 2; }}
上面的代碼同時表明了過早的最佳化是沒有意義的。雖然與 split() 方法相比較,這段代碼的可維護性比較差。
挑戰:聰明的小夥伴能想出更快的演算法嗎?
小結
Regex是十分有用,但是在使用時也要付出代價。尤其是在 N.O.P.E 分支深處時,要不惜一切代碼避免使用Regex。還要小心各種使用到Regex的JDK字串方法,比如 String.replaceAll() 或 String.split()。可以選擇用比較流行的開發庫,比如 Apache Commons Lang 來進行字串操作。
3、不要使用iterator()方法
這條建議不適用於一般的場合,僅適用於在 N.O.P.E 分支深處的情境。儘管如此也應該有所瞭解。Java 5格式的迴圈寫法非常的方便,以至於我們可以忘記內部的迴圈方法,比如:
for (String value : strings) { // Do something useful here}
當每次代碼運行到這個迴圈時,如果 strings 變數是一個 Iterable 的話,代碼將會自動建立一個Iterator 的執行個體。如果使用的是 ArrayList 的話,虛擬機器會自動在堆上為對象分配3個整數類型大小的記憶體。
private class Itr implements Iterator<E> { int cursor; int lastRet = -1; int expectedModCount = modCount; // ...
也可以用下面等價的迴圈方式來替代上面的 for 迴圈,僅僅是在棧上“浪費”了區區一個整形,相當划算。
int size = strings.size();for (int i = 0; i < size; i++) { String value : strings.get(i); // Do something useful here}
如果迴圈中字串的值是不怎麼變化,也可用數組來實現迴圈。
for (String value : stringArray) { // Do something useful here}
小結
無論是從易讀寫的角度來說,還是從API設計的角度來說迭代器、Iterable介面和 foreach 迴圈都是非常好用的。但代價是,使用它們時是會額外在堆上為每個迴圈子建立一個對象。如果迴圈要執行很多很多遍,請注意避免產生無意義的執行個體,最好用基本的指標迴圈方式來代替上述迭代器、Iterable介面和 foreach 迴圈。
討論
一些與上述內容持反對意見的看法(尤其是用指標操作替代迭代器)詳見Reddit上的討論。
4、不要調用高開銷方法
有些方法的開銷很大。以 N.O.P.E 分支為例,我們沒有提到葉子的相關方法,不過這個可以有。假設我們的JDBC驅動需要排除萬難去計算 ResultSet.wasNull() 方法的傳回值。我們自己實現的SQL架構可能像下面這樣:
if (type == Integer.class) { result = (T) wasNull(rs, Integer.valueOf(rs.getInt(index)));} // And then...static final <T> T wasNull(ResultSet rs, T value)throws SQLException { return rs.wasNull() ? null : value;}
在上面的邏輯中,每次從結果集中取得 int 值時都要調用 ResultSet.wasNull() 方法,但是 getInt() 的方法定義為:
傳回型別:變數值;如果SQL查詢結果為NULL,則返回0。
所以一個簡單有效改善方法如下:
static final <T extends Number> T wasNull( ResultSet rs, T value)throws SQLException { return (value == null || (value.intValue() == 0 && rs.wasNull())) ? null : value;}
這是輕而易舉事情。
小結
將方法調用緩衝起來替代在葉子節點的高開銷方法,或者在方法約定允許的情況下避免調用高開銷方法。
5、使用原始類型和棧
上面介紹了來自 jOOQ的例子中使用了大量的泛型,導致的結果是使用了 byte、 short、 int 和 long 的封裝類。但至少泛型在Java 10或者Valhalla項目中被專門化之前,不應該成為代碼的限制。因為可以通過下面的方法來進行替換:
//儲存在堆上Integer i = 817598;
……如果這樣寫的話:
// 儲存在棧上int i = 817598;
在使用數組時情況可能會變得更加糟糕:
//在堆上產生了三個對象Integer[] i = { 1337, 424242 };
……如果這樣寫的話:
// 僅在堆上產生了一個對象int[] i = { 1337, 424242 };
小結
當我們處於 N.O.P.E. 分支的深處時,應該極力避免使用封裝類。這樣做的壞處是給GC帶來了很大的壓力。GC將會為清除封裝類產生的對象而忙得不可開交。
所以一個有效最佳化方法是使用基礎資料型別 (Elementary Data Type)、定長數組,並用一系列分割變數來標識對象在數組中所處的位置。
遵循LGPL協議的 trove4j 是一個Java集合類庫,它為我們提供了優於整形數組 int[] 更好的效能實現。
例外
下面的情況對這條規則例外:因為 boolean 和 byte 類型不足以讓JDK為其提供緩衝方法。我們可以這樣寫:
Boolean a1 = true; // ... syntax sugar for:Boolean a2 = Boolean.valueOf(true); Byte b1 = (byte) 123; // ... syntax sugar for:Byte b2 = Byte.valueOf((byte) 123);
其它整數基本類型也有類似情況,比如 char、short、int、long。
不要在調用構造方法時將這些整型基本類型自動裝箱或者調用 TheType.valueOf() 方法。
也不要在封裝類上調用構造方法,除非你想得到一個不在堆上建立的執行個體。這樣做的好處是為你為同事獻上一個巨坑的愚人節笑話。
非堆儲存
當然了,如果你還想體驗下堆外函數庫的話,儘管這可能參雜著不少戰略決策,而並非最樂觀的本地方案。一篇由Peter Lawrey和 Ben Cotton撰寫的關於非堆儲存的很有意思文章請點擊: OpenJDK與HashMap——讓老手安全地掌握(非堆儲存!)新技巧。
6、避免遞迴
現在,類似Scala這樣的函數式程式設計語言都鼓勵使用遞迴。因為遞迴通常意味著能分解到單獨個體最佳化的尾遞迴(tail-recursing)。如果你使用的程式設計語言能夠支援那是再好不過。不過即使如此,也要注意對演算法的細微調整將會使尾遞迴變為普通遞迴。
希望編譯器能自動探測到這一點,否則本來我們將為只需使用幾個本地變數就能搞定的事情而白白浪費大量的堆棧架構(stack frames)。
小結
這節中沒什麼好說的,除了在 N.O.P.E 分支盡量使用迭代來代替遞迴。
7、使用entrySet()
當我們想遍曆一個用索引值對形式儲存的 Map 時,必須要為下面的代碼找到一個很好的理由:
for (K key : map.keySet()) { V value : map.get(key);}
更不用說下面的寫法:
for (Entry<K, V> entry : map.entrySet()) { K key = entry.getKey(); V value = entry.getValue();}
在我們使用 N.O.P.E. 分支應該慎用map。因為很多看似時間複雜度為 O(1) 的訪問操作其實是由一系列的操作組成的。而且訪問本身也不是免費的。至少,如果不得不使用map的話,那麼要用 entrySet() 方法去迭代!這樣的話,我們要訪問的就僅僅是Map.Entry的執行個體。
小結
在需要迭代索引值對形式的Map時一定要用 entrySet() 方法。
8、使用EnumSet或EnumMap
在某些情況下,比如在使用配置map時,我們可能會預Crowdsourced Security Testing道儲存在map中索引值。如果這個索引值非常小,我們就應該考慮使用 EnumSet 或 EnumMap,而並非使用我們常用的 HashSet 或 HashMap。下面的代碼給出了很清楚的解釋:
private transient Object[] vals; public V put(K key, V value) { // ... int index = key.ordinal(); vals[index] = maskNull(value); // ...}
上段代碼的關鍵實現在於,我們用數組代替了雜湊表。尤其是向map中插入新值時,所要做的僅僅是獲得一個由編譯器為每個枚舉類型產生的常量序號。如果有一個全域的map配置(例如只有一個執行個體),在增加訪問速度的壓力下,EnumMap 會獲得比 HashMap 更加傑出的表現。原因在於 EnumMap 使用的堆記憶體比 HashMap 要少 一位(bit),而且 HashMap 要在每個索引值上都要調用 hashCode() 方法和 equals() 方法。
小結
Enum 和 EnumMap 是親密的小夥伴。在我們用到類似枚舉(enum-like)結構的索引值時,就應該考慮將這些索引值用聲明為枚舉類型,並將之作為 EnumMap 鍵。
9、最佳化自訂hasCode()方法和equals()方法
在不能使用EnumMap的情況下,至少也要最佳化 hashCode() 和 equals() 方法。一個好的 hashCode() 方法是很有必要的,因為它能防止對高開銷 equals() 方法多餘的調用。
在每個類的繼承結構中,需要容易接受的簡單對象。讓我們看一下jOOQ的 org.jooq.Table 是如何?的?
最簡單、快速的 hashCode() 實現方法如下:
// AbstractTable一個通用Table的基礎實現: @Overridepublic int hashCode() { // [#1938] 與標準的QueryParts相比,這是一個更加高效的hashCode()實現 return name.hashCode();}
name即為表名。我們甚至不需要考慮schema或者其它表屬性,因為表名在資料庫中通常是唯一的。並且變數 name 是一個字串,它本身早就已經緩衝了一個 hashCode() 值。
這段代碼中注釋十分重要,因繼承自 AbstractQueryPart 的 AbstractTable 是任意抽象文法樹元素的基本實現。普通抽象文法樹元素並沒有任何屬性,所以不能對最佳化 hashCode() 方法實現抱有任何幻想。覆蓋後的 hashCode() 方法如下:
// AbstractQueryPart一個通用抽象文法樹基礎實現: @Overridepublic int hashCode() { // 這是一個可工作的預設實現。 // 具體實現的子類應當覆蓋此方法以提高效能。 return create().renderInlined(this).hashCode();}
換句話說,要觸發整個SQL渲染工作流程(rendering workflow)來計算一個普通抽象文法樹元素的hash代碼。
equals() 方法則更加有趣:
// AbstractTable通用表的基礎實現: @Overridepublic boolean equals(Object that) { if (this == that) { return true; } // [#2144] 在調用高開銷的AbstractQueryPart.equals()方法前, // 可以及早知道對象是否不相等。 if (that instanceof AbstractTable) { if (StringUtils.equals(name, (((AbstractTable<?>) that).name))) { return super.equals(that); } return false; } return false;}
首先,不要過早使用 equals() 方法(不僅在N.O.P.E.中),如果:
- this == argument
- this“不相容:參數
注意:如果我們過早使用 instanceof 來檢驗相容類型的話,後面的條件其實包含了argument == null。我在以前的部落格中已經對這一點進行了說明,請參考10個精妙的Java編碼最佳實務。
在我們對以上幾種情況的比較結束後,應該能得出部分結論。比如jOOQ的 Table.equals() 方法說明是,用來比較兩張表是否相同。不論具體實作類別型如何,它們必須要有相同的欄位名。比如下面兩個元素是不可能相同的:
- com.example.generated.Tables.MY_TABLE
- DSL.tableByName(“MY_OTHER_TABLE”)
如果我們能方便地判斷傳入參數是否等於執行個體本身(this),就可以在返回結果為 false 的情況下放棄操作。如果返回結果為 true,我們還可以進一步對父類(super)實現進行判斷。在比較過的大多數對象都不等的情況下,我們可以儘早結束方法來節省CPU的執行時間。
一些對象的相似性比其它對象更高。
在jOOQ中,大多數的表執行個體是由jOOQ的代碼產生器產生的,這些執行個體的 equals() 方法都經過了深度最佳化。而數十種其它的表類型(衍生表 (derived tables)、資料表值函式(table-valued functions)、數組表(array tables)、串連表(joined tables)、樞紐分析表(pivot tables)、通用資料表運算式(common table expressions)等,則保持 equals() 方法的基本實現。
10、考慮使用set而並非單個元素
最後,還有一種情況可以適用於所有語言而並非僅僅同Java有關。除此以外,我們以前研究的 N.O.P.E. 分支也會對瞭解從 O(N3) 到 O(n log n)有所協助。
不幸的是,很多程式員的用簡單的、本地演算法來考慮問題。他們習慣按部就班地解決問題。這是命令式(imperative)的“是/或”形式的函數式編程風格。這種編程風格在由純粹命令式編程向面對象式編程向函數式編程轉換時,很容易將“更大的情境(bigger picture)”模型化,但是這些風格都缺少了只有在SQL和R語言中存在的:
聲明式編程。
在SQL中,我們可以在不考慮演算法影響下聲明要求資料庫得到的效果。資料庫可以根據資料類型,比如約束(constraints)、鍵(key)、索引(indexes)等不同來採取最佳的演算法。
在理論上,我們最初在SQL和關係演算(relational calculus)後就有了基本的想法。在實踐中,SQL的供應商們在過去的幾十年中已經實現了基於開銷的高效最佳化器CBOs (Cost-Based Optimisers) 。然後到了2010版,我們才終於將SQL的所有潛力全部挖掘出來。
但是我們還不需要用set方式來實現SQL。所有的語言和庫都支援Sets、collections、bags、lists。使用set的主要好處是能使我們的代碼變的簡潔明了。比如下面的寫法:
SomeSet INTERSECT SomeOtherSet
而不是
// Java 8以前的寫法Set result = new HashSet();for (Object candidate : someSet) if (someOtherSet.contains(candidate)) result.add(candidate); // 即使採用Java 8也沒有很大協助someSet.stream() .filter(someOtherSet::contains) .collect(Collectors.toSet());
有些人可能會對函數式編程和Java 8能協助我們寫出更加簡單、簡潔的演算法持有不同的意見。但這種看法不一定是對的。我們可以把命令式的Java 7迴圈轉換成Java 8的Stream collection,但是我們還是採用了相同的演算法。但SQL風格的運算式則是不同的:
SomeSet INTERSECT SomeOtherSet
上面的代碼在不同的引擎上可以有1000種不同的實現。我們今天所研究的是,在調用 INTERSECT 操作之前,更加智能地將兩個set自動的轉化為 EnumSet 。甚至我們可以在不需要調用底層的 Stream.parallel() 方法的情況下進行並行 INTERSECT 操作。總結
在這篇文章中,我們討論了關於N.O.P.E.分支的最佳化。比如深入高複雜性的演算法。作為jOOQ的開發人員,我們很樂於對SQL的產生進行最佳化。
- 每條查詢都用唯一的StringBuilder來產生。
- 模板引擎實際上處理的是字元而並非Regex。
- 選擇儘可能的使用數組,尤其是在對監聽器進行迭代時。
- 對JDBC的方法敬而遠之。
- 等等。
jOOQ處在“食物鏈的底端”,因為它是在離開JVM進入到DBMS時,被我們電腦程式所調用的最後一個API。位於食物鏈的底端意味著任何一條線路在jOOQ中被執行時都需要 N x O x P 的時間,所以我要儘早進行最佳化。
我們的商務邏輯可能沒有N.O.P.E.分支那麼複雜。但是基礎架構有可能十分複雜(本地SQL架構、本地庫等)。所以需要按照我們今天提到的原則,用Java Mission Control 或其它工具進行複查,確認是否有需要最佳化的地方。