Java程序員應(yīng)知道的十條Java優(yōu)化策略,讓你的系統(tǒng)健步如飛
1、使用StringBuilder(技術(shù)文)
StingBuilder 應(yīng)該是在我們的Java代碼中默認(rèn)使用的,應(yīng)該避免使用 + 操作符。或許你會對 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 arraylength
- 11 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]
但究竟發(fā)生了什么?接下來是否需要用下面的部分來對 String 進(jìn)行改善呢?
- String x = "a" + args.length + "b";
- if (args.length == 1)
- xx = x + args[0];
現(xiàn)在使用到了第二個 StringBuilder,這個 StringBuilder 不會消耗堆中額外的內(nèi)存,但卻給 GC 帶來了壓力。
- StringBuilder x = new StringBuilder("a");
- x.append(args.length);
- x.append("b");
- if (args.length == 1);
- x.append(args[0]);
小結(jié)
在上面的樣例中,如果你是依靠Java編譯器來隱式生成實(shí)例的話,那么編譯的效果幾乎和是否使用了 StringBuilder 實(shí)例毫無關(guān)系。請記?。涸?N.O.P.E 分支中,每次CPU的循環(huán)的時間到白白的耗費(fèi)在GC或者為 StringBuilder 分配默認(rèn)空間上了,我們是在浪費(fèi) N x O x P 時間。
一般來說,使用 StringBuilder 的效果要優(yōu)于使用 + 操作符。如果可能的話請?jiān)谛枰缍鄠€方法傳遞引用的情況下選擇 StringBuilder,因?yàn)?String 要消耗額外的資源。JOOQ在生成復(fù)雜的SQL語句便使用了這樣的方式。在整個抽象語法樹(AST Abstract Syntax Tree)SQL傳遞過程中僅使用了一個 StringBuilder 。
更加悲劇的是,如果你仍在使用 StringBuffer 的話,那么用 StringBuilder 代替 StringBuffer 吧,畢竟需要同步字符串的情況真的不多。
2、避免使用正則表達(dá)式(好技術(shù)文)
正則表達(dá)式給人的印象是快捷簡便。但是在 N.O.P.E 分支中使用正則表達(dá)式將是最糟糕的決定。如果萬不得已非要在計(jì)算密集型代碼中使用正則表達(dá)式的話,至少要將 Pattern 緩存下來,避免反復(fù)編譯Pattern。
- static final Pattern HEAVY_REGEX =
- Pattern.compile("(((X)*Y)*Z)*");
如果僅使用到了如下這樣簡單的正則表達(dá)式的話:
- String[] parts = ipAddress.split("\.");
這是最好還是用普通的 char[] 數(shù)組或者是基于索引的操作。比如下面這段可讀性比較差的代碼其實(shí)起到了相同的作用。
- 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;
- }
- }
上面的代碼同時表明了過早的優(yōu)化是沒有意義的。雖然與 split() 方法相比較,這段代碼的可維護(hù)性比較差。
挑戰(zhàn):聰明的小伙伴能想出更快的算法嗎?歡迎留言。
小結(jié)
正則表達(dá)式是十分有用,但是在使用時也要付出代價。尤其是在 N.O.P.E 分支深處時,要不惜一切代碼避免使用正則表達(dá)式。還要小心各種使用到正則表達(dá)式的JDK字符串方法,比如 String.replaceAll() 或 String.split()??梢赃x擇用比較流行的開發(fā)庫,比如 Apache Commons Lang 來進(jìn)行字符串操作。
3、不要使用iterator()方法
這條建議不適用于一般的場合,僅適用于在 N.O.P.E 分支深處的場景。盡管如此也應(yīng)該有所了解。Java 5格式的循環(huán)寫法非常的方便,以至于我們可以忘記內(nèi)部的循環(huán)方法,比如:
- for (String value : strings) {
- // Do something useful here
- }
當(dāng)每次代碼運(yùn)行到這個循環(huán)時,如果 strings 變量是一個 Iterable 的話,代碼將會自動創(chuàng)建一個Iterator 的實(shí)例。如果使用的是 ArrayList 的話,虛擬機(jī)會自動在堆上為對象分配3個整數(shù)類型大小的內(nèi)存。
- private class Itr implements Iterator<E> {
- int cursor;
- int lastRet = -1;
- int expectedModCount = modCount;
- // ...
也可以用下面等價的循環(huán)方式來替代上面的 for 循環(huán),僅僅是在棧上“浪費(fèi)”了區(qū)區(qū)一個整形,相當(dāng)劃算。
- int size = strings.size();
- for (int i = 0; i < size; i++) {
- String value : strings.get(i);
- // Do something useful here
- }
如果循環(huán)中字符串的值是不怎么變化,也可用數(shù)組來實(shí)現(xiàn)循環(huán)。
- for (String value : stringArray) {
- // Do something useful here
- }
小結(jié)
無論是從易讀寫的角度來說,還是從API設(shè)計(jì)的角度來說迭代器、Iterable接口和 foreach 循環(huán)都是非常好用的。但代價是,使用它們時是會額外在堆上為每個循環(huán)子創(chuàng)建一個對象。如果循環(huán)要執(zhí)行很多很多遍,請注意避免生成無意義的實(shí)例,最好用基本的指針循環(huán)方式來代替上述迭代器、Iterable接口和 foreach 循環(huán)。
討論
一些與上述內(nèi)容持反對意見的看法(尤其是用指針操作替代迭代器)詳見Reddit上的討論。
4、不要調(diào)用高開銷方法
有些方法的開銷很大。以 N.O.P.E 分支為例,我們沒有提到葉子的相關(guān)方法,不過這個可以有。假設(shè)我們的JDBC驅(qū)動需要排除萬難去計(jì)算 ResultSet.wasNull() 方法的返回值。我們自己實(shí)現(xiàn)的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;
- }
在上面的邏輯中,每次從結(jié)果集中取得 int 值時都要調(diào)用 ResultSet.wasNull() 方法,但是 getInt() 的方法定義為:
返回類型:變量值;如果SQL查詢結(jié)果為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;
- }
這是輕而易舉的事情。
小結(jié)
將方法調(diào)用緩存起來替代在葉子節(jié)點(diǎn)的高開銷方法,或者在方法約定允許的情況下避免調(diào)用高開銷方法。
5、使用原始類型和棧
上面介紹了來自 jOOQ的例子中使用了大量的泛型,導(dǎo)致的結(jié)果是使用了 byte、 short、 int 和 long 的包裝類。但至少泛型在Java 10或者Valhalla項(xiàng)目中被專門化之前,不應(yīng)該成為代碼的限制。因?yàn)榭梢酝ㄟ^下面的方法來進(jìn)行替換:
- //存儲在堆上
- Integer i = 817598;
- ……如果這樣寫的話:
- // 存儲在棧上
- int i = 817598;
在使用數(shù)組時情況可能會變得更加糟糕:
- //在堆上生成了三個對象
- Integer[] i = { 1337, 424242 };
……如果這樣寫的話:
- // 僅在堆上生成了一個對象
- int[] i = { 1337, 424242 };
小結(jié)
當(dāng)我們處于 N.O.P.E. 分支的深處時,應(yīng)該極力避免使用包裝類。這樣做的壞處是給GC帶來了很大的壓力。GC將會為清除包裝類生成的對象而忙得不可開交。
所以一個有效的優(yōu)化方法是使用基本數(shù)據(jù)類型、定長數(shù)組,并用一系列分割變量來標(biāo)識對象在數(shù)組中所處的位置。
遵循LGPL協(xié)議的 trove4j 是一個Java集合類庫,它為我們提供了優(yōu)于整形數(shù)組 int[] 更好的性能實(shí)現(xiàn)。
例外
下面的情況對這條規(guī)則例外:因?yàn)?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);
其它整數(shù)基本類型也有類似情況,比如 char、short、int、long。
不要在調(diào)用構(gòu)造方法時將這些整型基本類型自動裝箱或者調(diào)用 TheType.valueOf() 方法。
也不要在包裝類上調(diào)用構(gòu)造方法,除非你想得到一個不在堆上創(chuàng)建的實(shí)例。
6、避免遞歸(真技術(shù)文)
現(xiàn)在,類似Scala這樣的函數(shù)式編程語言都鼓勵使用遞歸。因?yàn)檫f歸通常意味著能分解到單獨(dú)個體優(yōu)化的尾遞歸(tail-recursing)。如果你使用的編程語言能夠支持那是再好不過。不過即使如此,也要注意對算法的細(xì)微調(diào)整將會使尾遞歸變?yōu)槠胀ㄟf歸。
希望編譯器能自動探測到這一點(diǎn),否則本來我們將為只需使用幾個本地變量就能搞定的事情而白白浪費(fèi)大量的堆??蚣埽╯tack frames)。
小結(jié)
這節(jié)中沒什么好說的,除了在 N.O.P.E 分支盡量使用迭代來代替遞歸。
7、使用entrySet()
當(dāng)我們想遍歷一個用鍵值對形式保存的 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. 分支應(yīng)該慎用map。因?yàn)楹芏嗫此茣r間復(fù)雜度為 O(1) 的訪問操作其實(shí)是由一系列的操作組成的。而且訪問本身也不是免費(fèi)的。至少,如果不得不使用map的話,那么要用 entrySet() 方法去迭代!這樣的話,我們要訪問的就僅僅是Map.Entry的實(shí)例。
小結(jié)
在需要迭代鍵值對形式的Map時一定要用 entrySet() 方法。
8、使用EnumSet或EnumMap(真是技術(shù)文)
在某些情況下,比如在使用配置map時,我們可能會預(yù)先知道保存在map中鍵值。如果這個鍵值非常小,我們就應(yīng)該考慮使用 EnumSet 或 EnumMap,而并非使用我們常用的 HashSet 或 HashMap。下面的代碼給出了很清楚的解釋:
- private transient Object[] vals;
- public V put(K key, V value) {
- // ...
- int index = key.ordinal();
- vals[index] = maskNull(value);
- // ...
- }
上段代碼的關(guān)鍵實(shí)現(xiàn)在于,我們用數(shù)組代替了哈希表。尤其是向map中插入新值時,所要做的僅僅是獲得一個由編譯器為每個枚舉類型生成的常量序列號。如果有一個全局的map配置(例如只有一個實(shí)例),在增加訪問速度的壓力下,EnumMap 會獲得比 HashMap 更加杰出的表現(xiàn)。原因在于 EnumMap 使用的堆內(nèi)存比 HashMap 要少 一位(bit),而且 HashMap 要在每個鍵值上都要調(diào)用 hashCode() 方法和 equals() 方法。
小結(jié)
Enum 和 EnumMap 是親密的小伙伴。在我們用到類似枚舉(enum-like)結(jié)構(gòu)的鍵值時,就應(yīng)該考慮將這些鍵值用聲明為枚舉類型,并將之作為 EnumMap 鍵。
9、優(yōu)化自定義hasCode()方法和equals()方法(技術(shù)好文)
在不能使用EnumMap的情況下,至少也要優(yōu)化 hashCode() 和 equals() 方法。一個好的 hashCode() 方法是很有必要的,因?yàn)樗芊乐箤Ω唛_銷 equals() 方法多余的調(diào)用。
在每個類的繼承結(jié)構(gòu)中,需要容易接受的簡單對象。讓我們看一下jOOQ的 org.jooq.Table 是如何實(shí)現(xiàn)的?
最簡單、快速的 hashCode() 實(shí)現(xiàn)方法如下:
- // AbstractTable一個通用Table的基礎(chǔ)實(shí)現(xiàn):
- @Override
- public int hashCode() {
- // [#1938] 與標(biāo)準(zhǔn)的QueryParts相比,這是一個更加高效的hashCode()實(shí)現(xiàn)
- return name.hashCode();
- }
name即為表名。我們甚至不需要考慮schema或者其它表屬性,因?yàn)楸砻跀?shù)據(jù)庫中通常是唯一的。并且變量 name 是一個字符串,它本身早就已經(jīng)緩存了一個 hashCode() 值。
這段代碼中注釋十分重要,因繼承自 AbstractQueryPart 的 AbstractTable 是任意抽象語法樹元素的基本實(shí)現(xiàn)。普通抽象語法樹元素并沒有任何屬性,所以不能對優(yōu)化 hashCode() 方法實(shí)現(xiàn)抱有任何幻想。覆蓋后的 hashCode() 方法如下:
- // AbstractQueryPart一個通用抽象語法樹基礎(chǔ)實(shí)現(xiàn):
- @Override
- public int hashCode() {
- // 這是一個可工作的默認(rèn)實(shí)現(xiàn)。
- // 具體實(shí)現(xiàn)的子類應(yīng)當(dāng)覆蓋此方法以提高性能。
- return create().renderInlined(this).hashCode();
- }
換句話說,要觸發(fā)整個SQL渲染工作流程(rendering workflow)來計(jì)算一個普通抽象語法樹元素的hash代碼。
equals() 方法則更加有趣:
// AbstractTable通用表的基礎(chǔ)實(shí)現(xiàn):
- @Override
- public boolean equals(Object that) {
- if (this == that) {
- return true;
- }
- // [#2144] 在調(diào)用高開銷的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“不兼容:參數(shù)
注意:如果我們過早使用 instanceof 來檢驗(yàn)兼容類型的話,后面的條件其實(shí)包含了argument == null。
在我們對以上幾種情況的比較結(jié)束后,應(yīng)該能得出部分結(jié)論。比如jOOQ的 Table.equals() 方法說明是,用來比較兩張表是否相同。不論具體實(shí)現(xiàn)類型如何,它們必須要有相同的字段名。比如下面兩個元素是不可能相同的:
-
com.example.generated.Tables.MY_TABLE
-
DSL.tableByName(“MY_OTHER_TABLE”)
如果我們能方便地判斷傳入?yún)?shù)是否等于實(shí)例本身(this),就可以在返回結(jié)果為 false 的情況下放棄操作。如果返回結(jié)果為 true,我們還可以進(jìn)一步對父類(super)實(shí)現(xiàn)進(jìn)行判斷。在比較過的大多數(shù)對象都不等的情況下,我們可以盡早結(jié)束方法來節(jié)省CPU的執(zhí)行時間。
一些對象的相似度比其它對象更高。
在jOOQ中,大多數(shù)的表實(shí)例是由jOOQ的代碼生成器生成的,這些實(shí)例的 equals() 方法都經(jīng)過了深度優(yōu)化。而數(shù)十種其它的表類型(衍生表 (derived tables)、表值函數(shù)(table-valued functions)、數(shù)組表(array tables)、連接表(joined tables)、數(shù)據(jù)透視表(pivot tables)、公用表表達(dá)式(common table expressions)等,則保持 equals() 方法的基本實(shí)現(xiàn)。
10、考慮使用set而并非單個元素(技術(shù)文)
最后,還有一種情況可以適用于所有語言而并非僅僅同Java有關(guān)。除此以外,我們以前研究的 N.O.P.E. 分支也會對了解從 O(N3) 到 O(n log n)有所幫助。
不幸的是,很多程序員的用簡單的、本地算法來考慮問題。他們習(xí)慣按部就班地解決問題。這是命令式(imperative)的“是/或”形式的函數(shù)式編程風(fēng)格。這種編程風(fēng)格在由純粹命令式編程向面對象式編程向函數(shù)式編程轉(zhuǎn)換時,很容易將“更大的場景(bigger picture)”模型化,但是這些風(fēng)格都缺少了只有在SQL和R語言中存在的:
聲明式編程。
在SQL中,我們可以在不考慮算法影響下聲明要求數(shù)據(jù)庫得到的效果。數(shù)據(jù)庫可以根據(jù)數(shù)據(jù)類型,比如約束(constraints)、鍵(key)、索引(indexes)等不同來采取最佳的算法。
在理論上,我們最初在SQL和關(guān)系演算(relational calculus)后就有了基本的想法。在實(shí)踐中,SQL的供應(yīng)商們在過去的幾十年中已經(jīng)實(shí)現(xiàn)了基于開銷的高效優(yōu)化器CBOs (Cost-Based Optimisers) 。然后到了2010版,我們才終于將SQL的所有潛力全部挖掘出來。
但是我們還不需要用set方式來實(shí)現(xiàn)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());
有些人可能會對函數(shù)式編程和Java 8能幫助我們寫出更加簡單、簡潔的算法持有不同的意見。但這種看法不一定是對的。我們可以把命令式的Java 7循環(huán)轉(zhuǎn)換成Java 8的Stream collection,但是我們還是采用了相同的算法。但SQL風(fēng)格的表達(dá)式則是不同的:
- SomeSet INTERSECT SomeOtherSet
上面的代碼在不同的引擎上可以有1000種不同的實(shí)現(xiàn)。我們今天所研究的是,在調(diào)用 INTERSECT 操作之前,更加智能地將兩個set自動的轉(zhuǎn)化為 EnumSet 。甚至我們可以在不需要調(diào)用底層的 Stream.parallel() 方法的情況下進(jìn)行并行 INTERSECT 操作。
總結(jié)
在這篇文章中,我們討論了關(guān)于N.O.P.E.分支的優(yōu)化。比如深入高復(fù)雜性的算法。作為jOOQ的開發(fā)者,我們很樂于對SQL的生成進(jìn)行優(yōu)化。
-
每條查詢都用唯一的StringBuilder來生成。
-
模板引擎實(shí)際上處理的是字符而并非正則表達(dá)式。
-
選擇盡可能的使用數(shù)組,尤其是在對監(jiān)聽器進(jìn)行迭代時。
-
對JDBC的方法敬而遠(yuǎn)之。
-
等等。
jOOQ處在“食物鏈的底端”,因?yàn)樗窃陔x開JVM進(jìn)入到DBMS時,被我們電腦程序所調(diào)用的最后一個API。位于食物鏈的底端意味著任何一條線路在jOOQ中被執(zhí)行時都需要 N x O x P 的時間,所以我要盡早進(jìn)行優(yōu)化。
我們的業(yè)務(wù)邏輯可能沒有N.O.P.E.分支那么復(fù)雜。但是基礎(chǔ)框架有可能十分復(fù)雜(本地SQL框架、本地庫等)。所以需要按照我們今天提到的原則,用Java Mission Control 或其它工具進(jìn)行復(fù)查,確認(rèn)是否有需要優(yōu)化的地方。