JAVA 7 ForkJoinPool和 Java 8 的并行Stream有助于并行化東西,這在您將 Java 程序部署到多核處理器機器上時非常有用。與跨網絡上的不同機器進行擴展相比,這種并行性的優勢在于您幾乎可以完全消除延遲效應,因為所有內核都可以訪問相同的內存。但是不要被并行的效果所迷惑!記住以下兩點:
- 并行性會吞噬你的核心。這對于批處理非常有用,但對于異步服務器(例如 HTTP)來說則是一場噩夢。在過去的幾十年里,我們使用單線程 servlet 模型是有充分理由的。因此,并行性僅在擴大規模時才有幫助。
- 并行性對算法的Big O Notation沒有影響。如果您的算法是O(n log n),并且您讓該算法在c內核上運行,您仍然會有一個O(n log n / c)算法,因為c在您的算法復雜性中是一個微不足道的常數。您將節省掛鐘時間,但不會降低復雜性!
當然,提高性能的最佳方法是降低算法復雜度。殺手是實現O(1)或準O(1),當然,例如HashMap查找。但這并不總是可能的,更不用說容易了。如果你不能降低復雜性,如果你在真正重要的地方調整你的算法,如果你能找到正確的位置,你仍然可以獲得很多性能。假設以下算法的可視化表示:
算法的整體復雜度是,或者如果我們要處理單個數量級。但是,在分析此代碼時,您可能會發現一個有趣的場景:O(N3)O(N x O x P)
- 在您的開發框中,左分支 ( N -> M -> Heavy operation) 是您可以在分析器中看到的唯一分支,因為 和 的值O在P您的開發示例數據中很小。
- 然而,在生產中,正確的分支(N -> O -> P -> Easy operation或NOPE)確實造成了麻煩。您的運營團隊可能已經使用AppDynamics或DynaTrace或一些類似軟件解決了這個問題。
如果沒有生產數據,您可能會很快得出結論并優化“繁重操作”。你運送到生產環境,你的修復沒有效果。除了以下事實之外,沒有優化的黃金法則:
- 設計良好的應用程序更容易優化
- 過早的優化不會解決任何性能問題,反而會使您的應用程序設計得不那么好,從而使優化變得更加困難
理論夠了。讓我們假設您找到了正確的分支是問題所在。很可能是一個非常簡單的操作在生產中失敗了,因為它被調用了很多次(如果N、O和P很大)。請在不可避免算法的葉節點出現問題的情況下閱讀本文。這些優化不會幫助您擴展。他們將幫助您暫時節省客戶的時間,將整體算法的困難改進推遲到以后!O(N3) 以下是 Java 中最簡單的 10 個性能優化:
1、使用StringBuilder
這應該是幾乎所有 Java 代碼中的默認設置。盡量避免使用+操作符。當然,您可能會爭辯說,它只是StringBuilder的語法糖,例如:
String x = "a" + args.length + "b";
翻譯為:
new java.lang.StringBuilder [16]dupldc <String "a"> [18]invokespecial java.lang.StringBuilder(java.lang.String) [20]aload_0 [args]arraylengthinvokevirtual java.lang.StringBuilder.append(int) : java.lang.StringBuilder [23]ldc <String "b"> [27]invokevirtual java.lang.StringBuilder.append(java.lang.String) : java.lang.StringBuilder [29]invokevirtual java.lang.StringBuilder.toString() : java.lang.String [32]astore_1 [x]
但是,如果稍后您需要使用可選部分修改您的字符串,會發生什么?
String x = "a" + args.length + "b"; if (args.length == 1) x = x + args[0];
您現在將有第二個StringBuilder,這只是不必要地消耗您的堆內存,給您的 GC 施加壓力。改為這樣寫:
StringBuilder x = new StringBuilder("a");x.append(args.length);x.append("b"); if (args.length == 1); x.append(args[0]);
在上面的示例中,如果您使用顯式StringBuilder實例,或者您依賴 Java 編譯器為您創建隱式實例,這可能完全無關緊要。但請記住,我們在N.O.P.E.分支中。我們浪費在像 GC 或分配 aStringBuilder的默認容量這樣愚蠢的事情上的每個 CPU 周期,我們都在浪費N x O x P時間。根據經驗,始終使用 aStringBuilder而不是+運算符。如果可以的話,如果你的構建更復雜,請保留StringBuilder多個方法的引用。只有一個StringBuilder“遍歷”你的整個 SQL AST(抽象語法樹) 對于大聲喊叫,如果您仍然有StringBuffer參考資料,請將它們替換為StringBuilder. 您幾乎不需要同步正在創建的字符串。
2、避免正則表達式
正則表達式相對便宜和方便。但是,如果您在 N.O.P.E. 分支中,那么它們就是您能做的最糟糕的事情。如果您絕對必須在計算密集型代碼部分使用正則表達式,至少緩存Pattern引用而不是一直重新編譯它:
static final Pattern HEAVY_REGEX = Pattern.compile("(((X)*Y)*Z)*");
但是如果你的正則表達式真的很傻
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()版本相比,這是不可維護的。挑戰:讀者中聰明的人可能會發現更快的算法。外賣 正則表達式很有用,但它們是有代價的。如果您深陷于N.O.P.E.分支中,則必須不惜一切代價避免使用正則表達式。請注意各種使用正則表達式的 JDK 字符串方法,例如String.replaceAll(), 或String.split(). 請改用Apache Commons Lang之類的流行庫來進行字符串操作。
3、不要使用iterator()
現在,此建議實際上不適用于一般用例,而僅適用于N.O.P.E.分支的深層。盡管如此,你應該考慮一下。編寫 Java-5 風格的 foreach 循環很方便。您可以完全忘記循環內部,并編寫:
for (String value : strings) { // Do something useful here}
但是,每次遇到此循環時,如果strings是一個Iterable,您將創建一個新Iterator實例。如果您使用的是ArrayList,這將ints在您的堆上分配一個 3 的對象:
private class Itr implements Iterator<E> { int cursor; int lastRet = -1; int expectedModCount = modCount; // ...
相反,您可以編寫以下等效循環并僅“浪費”堆棧上的單個int值,這非常便宜:
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 循環都非常有用。但是,它們會在每次迭代時在堆上創建一個小的新實例。如果你多次運行這個迭代,你要確保避免創建這個無用的實例,而是編寫基于索引的迭代。
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;}
ResultSet.wasNull() 現在,每次您int從結果集中獲得一個時, 都會調用此邏輯。但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、使用原語和堆棧
上面的例子,它使用了很多泛型,因此被迫使用包裝器類型byte, short, int, 和long– 至少在泛型在 Java 10 和項目 Valhalla 中專用之前。但是你的代碼中可能沒有這個約束,所以你應該采取一切措施來替換:
// Goes to the heapInteger i = 817598;
這樣:
// Stays on the stackint i = 817598;
使用數組時情況會變得更糟:
// Three heap objects!Integer[] i = { 1337, 424242 };
這樣:
// One heap object.int[] i = { 1337, 424242 };
當您深入到N.O.P.E.分支時,您應該非常小心使用包裝器類型。很有可能你會給你的 GC 造成很大的壓力,它必須一直在清理你的爛攤子。一個特別有用的優化可能是使用一些原始類型并創建它的大型一維數組,以及幾個分隔符變量來指示您的編碼對象在數組上的確切位置。trove4jint[]是一個優秀的原始集合庫,它比你的平均水平要復雜一些,它與 LGPL 一起提供。例外 此規則有一個例外:和booleanbyte很少有足夠的值完全被 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()時,才不會在調用構造函數時!
永遠不要在包裝類型上調用構造函數,除非你真的想要一個新實例
這個事實也可以幫助你為你的同事寫一個復雜的愚人節笑話 堆 外 當然,你可能還想嘗試堆外庫,盡管它們更多的是戰略決策,而不是本地優化。
6、避免遞歸
像 Scala 這樣的現代函數式編程語言鼓勵使用遞歸,因為它們提供了將尾遞歸算法優化回迭代算法的方法。如果你的語言支持這樣的優化,你可能沒問題。但即便如此,算法的最輕微變化也可能會產生一個分支,阻止你的遞歸是尾遞歸的。希望編譯器會檢測到這一點!否則,您可能會浪費大量堆棧幀,而這些堆棧幀可能僅使用幾個局部變量就可以實現。當你深入N.O.P.E.分支時,總是更喜歡迭代而不是遞歸
7、使用 entrySet()
當您想要遍歷 aMap并且需要鍵和值時,您必須有充分的理由編寫以下內容:
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.分支時,無論如何你都應該警惕地圖,因為大量的O(1)地圖訪問操作仍然是大量的操作。而且訪問也不是免費的。但至少,如果您不能沒有地圖,請使用它entrySet()來迭代它們!無論如何,該Map.Entry實例都在那里,您只需要訪問它。entrySet()在 map 迭代過程中同時需要鍵和值時 始終使用。
8、使用 EnumSet 或 EnumMap
在某些情況下,映射中可能的鍵的數量是預先知道的——例如在使用配置映射時。如果該數字相對較小,您應該真正考慮使用EnumSetor EnumMap,而不是常規HashSetor HashMap。這很容易通過查看來解釋EnumMap.put():
private transient Object[] vals; public V put(K key, V value) { // ... int index = key.ordinal(); vals[index] = maskNull(value); // ...}
這個實現的本質是,我們有一個索引值數組,而不是一個哈希表。當插入一個新值時,為了查找映射條目,我們所要做的就是向enum查詢它的常數序數,該常數序數是由Java編譯器在每個enum類型上生成的。如果這是一個全局配置映射(即只有一個實例),增加的訪問速度將幫助EnumMap大大超過HashMap,它可能使用更少的堆內存,但必須在每個鍵上運行hashCode()和equals()。Enum和EnumMap是非常親密的朋友。當您使用類似于枚舉的結構作為鍵時,請實際考慮將這些結構作為枚舉,并在EnumMap中使用它們作為鍵。
9、優化你的 hashCode() 和 equals() 方法
如果不能使用EnumMap,至少優化hashCode()和equals()方法。一個好的hashCode()方法是必要的,因為它將防止進一步調用開銷大得多的equals(),因為它將為每個實例集生成更多不同的散列桶。在每個類層次結構中,都可能有流行的和簡單的對象。hashCode()最簡單、最快的實現是這樣的:
// AbstractTable, a common Table base implementation: @Overridepublic int hashCode() { // [#1938] This is a much more efficient hashCode() // implementation compared to that of standard // QueryParts return name.hashCode();}
name表名在哪里。我們甚至不考慮表的模式或任何其他屬性,因為表名通常在數據庫中足夠不同。此外,它name是一個字符串,所以它里面已經有一個緩存hashCode()值。注釋很重要,因為AbstractTableextends是任何AST(抽象語法樹)元素AbstractQueryPart的通用基礎實現。通用 AST 元素沒有任何屬性,因此它不能對優化實現做出任何假設。因此,被覆蓋的方法如下所示:hashCode()
// AbstractQueryPart, a common AST element// base implementation: @Overridepublic int hashCode() { // This is a working default implementation. // It should be overridden by concrete subclasses, // to improve performance return create().renderInlined(this).hashCode();}
換句話說,必須觸發整個 SQL 渲染工作流來計算一個普通 AST 元素的哈希碼。事情變得更有趣equals()
// AbstractTable, a common Table base implementation: @Overridepublic boolean equals(Object that) { if (this == that) { return true; } // [#2144] Non-equality can be decided early, // without executing the rather expensive // implementation of AbstractQueryPart.equals() if (that instanceof AbstractTable) { if (StringUtils.equals(name, (((AbstractTable<?>) that).name))) { return super.equals(that); } return false; } return false;}
第一件事:總是(不僅在NOPE 分支中)提前中止每個equals()方法,如果:
- this == argument
- this "incompatible type" argument
請注意,后一個條件包括argument == null, 如果您instanceof用于檢查兼容類型。我們之前在10 Subtle Best Practices when Coding Java中對此進行了博文。現在,在明顯情況下盡早中止比較之后,您可能還希望在可以做出部分決定時盡早中止比較。例如,約定Table.equals()是兩個表被認為是相等的,它們必須具有相同的名稱,而不管具體的實現類型如何。例如,這兩項不可能相等:
- com.example.generated.Tables.MY_TABLE
- DSL.tableByName("MY_OTHER_TABLE")
如果argument 不能等于this,并且我們可以輕松地檢查它,那么讓我們這樣做并在檢查失敗時中止。如果檢查成功,我們仍然可以從super. 鑒于宇宙中的大多數對象都不相等,我們將通過快捷方式節省大量 CPU 時間。
10、在集合中思考,而不是在單個元素
最后但并非最不重要的一點是,有一件事與 Java 無關,但適用于任何語言。此外,我們將離開N.O.P.E.分支,因為此建議可能只會幫助您從 遷移到,或類似的東西。不幸的是,許多程序員從簡單的本地算法的角度來思考。他們正在逐步解決問題,一個分支一個分支,一個循環一個循環,一個方法一個方法。這就是命令式和/或函數式編程風格。雖然在從純命令式到面向對象(仍然是命令式)再到函數式編程時,對“更大的圖景”進行建模變得越來越容易,但所有這些風格都缺乏只有 SQL 和 R 以及類似語言才有的東西:聲明式編程。在 SQL 中(我們喜歡它,因為這是O(N3)O(n log n)) 你可以聲明你想從你的數據庫中得到的結果,而不會對算法產生任何影響。然后,數據庫可以考慮所有可用的元數據(例如約束、鍵、索引等),以找出可能的最佳算法。從理論上講,這從一開始就是SQL 和關系演算背后的主要思想。使用集合的主要優點是您的算法將變得更加簡潔。
而不是:
// Pre-Java 8Set result = new HashSet();for (Object candidate : someSet) if (someOtherSet.contains(candidate)) result.add(candidate); // Even Java 8 doesn't really helpsomeSet.stream() .filter(someOtherSet::contains) .collect(Collectors.toSet());
有些人可能會爭辯說,函數式編程和 Java 8 將幫助您編寫更簡單、更簡潔的算法。這不一定是真的。您可以將命令式 Java-7 循環轉換為功能性 Java-8 Stream 集合,但您仍在編寫相同的算法。編寫類似 SQL 的表達式是不同的。
SomeSet 相交 SomeOtherSet
可以通過實現引擎以 1000 種方式實現。EnumSet正如我們今天所了解的,在運行操作之前將這兩個集合自動轉換為明智的做法INTERSECT。也許我們可以在INTERSECT不進行低級調用的情況下將其并行化Stream.parallel()
11、結論
在本文中,我們討論了在N.O.P.E.分支上進行的優化,即在高復雜度算法的深處。
- 每個查詢僅在單個上生成StringBuilder
- 我們的模板引擎實際上是解析字符,而不是使用正則表達式
- 我們盡可能使用數組,尤其是在迭代偵聽器時
- 我們遠離我們不必調用的 JDBC 方法
- 等等…
作者:終碼一生
鏈接:
https://juejin.cn/post/7078286560034029575