String性能提升10倍的幾個方法!(源碼+原理分析)
<div>
String 類型是我們使用最頻繁的數據類型,沒有之一。那麼提高 String 的運行效率,無疑是提升程序性能的最佳手段。
我們本文將從 String 的源碼入手,一步步帶你實現字符串優化的小目標。 不但教你如何有效的使用字符串,還爲你揭曉這背後的深層次原因 。
本文涉及的知識點,如下圖所示:
在看如何優化 String 之前,我們先來了解一下 String 的特性,畢竟知己知彼,才能百戰不殆。
字符串的特性
想要了解 String 的特性就必須從它的源碼入手,如下所示:// 源碼基於 JDK 1.8 public final class String implements java.io.Serializable, Comparable<String>, CharSequence { // String 值的實際存儲容器 private final char value[]; public String() { this.value = "".value; } public String(String original) { this.value = original.value; this.hash = original.hash; } // 忽略其他信息 }從他的源碼我們可以看出,String 類以及它的
value[]
屬性都被 final
修飾了,其中 value[]
是實現字符串存儲的最終結構,而 final
則表示“最後的、最終的”。
我們知道,被 final
修飾的類是不能被繼承的,也就是說此類將不能擁有子類,而被 final
修飾的變量即爲常量,它的值是不能被改變的。 這也就說當 String 一旦被創建之後,就不能被修改了 。
String 爲什麼不能被修改?
String 的類和屬性value[]
都被定義爲 final
了,這樣做的好處有以下三點:
-
安全性:當你在調用其他方法時,比如調用一些系統級操作指令之前,可能會有一系列校驗,如果是可變類的話,可能在你校驗過後,它的內部的值又被改變了,這樣有可能會引起嚴重的系統崩潰問題,所以迫使 String 設計爲 final 類的一個重要原因就是出於安全考慮;
-
高性能:String 不可變之後就保證 hash 值的唯一性,這樣它就更加高效,並且更適合做 HashMap 的 key- value 緩存;
-
節約內存:String 的不可變性是它實現 字符串常量池 的基礎,字符串常量池指的是字符串在創建時,先去“常量池”查找是否有此“字符串”,如果有,則不會開闢新空間創作字符串,而是直接把常量池中的引用返回給此對象,這樣就能更加節省空間。例如,通常情況下 String 創建有兩種方式, 直接賦值 的方式,如 String str="Java";另一種是 new 形式的創建 ,如 String str = new String("Java")。當代碼中使用第一種方式創建字符串對象時,JVM 首先會檢查該對象是否在字符串常量池中,如果在,就返回該對象引用,否則新的字符串將在常量池中被創建。這種方式可以 減少同一個值的字符串對象的重複創建,節約內存 。 String str = new String("Java") 這種方式,首先在編譯類文件時,“Java”常量字符串將會放入到常量結構中,在類加載時,“Java”將會在常量池中創建;其次,在調用 new 時,JVM 命令將會調用 String 的構造函數,同時引用常量池中的“Java”字符串,在堆內存中創建一個 String 對象,最後 str 將引用 String 對象。
1.不要直接+=字符串
通過上面的內容,我們知道了 String 類是不可變的,那麼在使用 String 時就不能頻繁的 += 字符串了。優化前代碼:
public static String doAdd() { String result = ""; for (int i = 0; i < 10000; i++) { result += (" i:" + i); } return result; }有人可能會問,我的業務需求是這樣的,那我該如何實現? 官方爲我們提供了兩種字符串拼加的方案:
StringBuffer
和 StringBuilder
,其中 StringBuilder
爲非線程安全的,而 StringBuffer
則是線程安全的, StringBuffer
的拼加方法使用了關鍵字 synchronized
來保證線程的安全,源碼如下:
@Override public synchronized StringBuffer append(CharSequence s) { toStringCache = null; super.append(s); return this; }也因爲使用
synchronized
修飾,所以 StringBuffer
的拼加性能會比 StringBuilder
低。
那我們就用 StringBuilder
來實現字符串的拼加, 優化後代碼 :
public static String doAppend() { StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(" i:" + i); } return sb.toString(); }我們通過代碼測試一下,兩個方法之間的性能差別:
public class StringTest { public static void main(String[] args) { for (int i = 0; i < 5; i++) { // String long st1 = System.currentTimeMillis(); // 開始時間 doAdd(); long et1 = System.currentTimeMillis(); // 開始時間 System.out.println("String 拼加,執行時間:" + (et1 - st1)); // StringBuilder long st2 = System.currentTimeMillis(); // 開始時間 doAppend(); long et2 = System.currentTimeMillis(); // 開始時間 System.out.println("StringBuilder 拼加,執行時間:" + (et2 - st2)); System.out.println(); } } public static String doAdd() { String result = ""; for (int i = 0; i < 10000; i++) { result += ("Java中文社羣:" + i); } return result; } public static String doAppend() { StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append("Java中文社羣:" + i); } return sb.toString(); } }以上程序的執行結果如下:
String 拼加,執行時間:429 StringBuilder 拼加,執行時間:1 String 拼加,執行時間:325 StringBuilder 拼加,執行時間:1 String 拼加,執行時間:287 StringBuilder 拼加,執行時間:1 String 拼加,執行時間:265 StringBuilder 拼加,執行時間:1 String 拼加,執行時間:249 StringBuilder 拼加,執行時間:1從結果可以看出,優化前後的性能相差很大。 注意:此性能測試的結果與循環的次數有關,也就是說循環的次數越多,他們性能相除的結果也越大。 接下來,我們要思考一個問題: 爲什麼 StringBuilder.append() 方法比 += 的性能高?而且拼接的次數越多性能的差距也越大? 當我們打開 StringBuilder 的源碼,就可以發現其中的“小祕密”了,StringBuilder 父類 AbstractStringBuilder 的實現源碼如下:
abstract class AbstractStringBuilder implements Appendable, CharSequence { char[] value; int count; @Override public AbstractStringBuilder append(CharSequence s, int start, int end) { if (s == null) s = "null"; if ((start < 0) || (start > end) || (end > s.length())) throw new IndexOutOfBoundsException( "start " + start + ", end " + end + ", s.length() " + s.length()); int len = end - start; ensureCapacityInternal(count + len); for (int i = start, j = count; i < end; i++, j++) value[j] = s.charAt(i); count += len; return this; } // 忽略其他信息... }而 StringBuilder 使用了父類提供的
char[]
作爲自己值的實際存儲單元,每次在拼加時會修改 char[]
數組,StringBuilder toString()
源碼如下:
@Override public String toString() { // Create a copy, don't share the array return new String(value, 0, count); }綜合以上源碼可以看出: StringBuilder 使用了
char[]
作爲實際存儲單元,每次在拼加時只需要修改 char[]
數組即可,只是在 toString()
時創建了一個字符串;而 String 一旦創建之後就不能被修改,因此在每次拼加時,都需要重新創建新的字符串,所以 StringBuilder.append() 的性能就會比字符串的 += 性能高很多 。
2.善用 intern 方法
善用 String.intern() 方法可以有效的節約內存並提升字符串的運行效率,先來看intern()
方法的定義與源碼:
/** * Returns a canonical representation for the string object. * <p> * A pool of strings, initially empty, is maintained privately by the * class {@code String}. * <p> * When the intern method is invoked, if the pool already contains a * string equal to this {@code String} object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, this {@code String} object is added to the * pool and a reference to this {@code String} object is returned. * <p> * It follows that for any two strings {@code s} and {@code t}, * {@code s.intern() == t.intern()} is {@code true} * if and only if {@code s.equals(t)} is {@code true}. * <p> * All literal strings and string-valued constant expressions are * interned. String literals are defined in section 3.10.5 of the * <cite>The Java™ Language Specification</cite>. * * @return a string that has the same contents as this string, but is * guaranteed to be from a pool of unique strings. */ public native String intern();可以看出
intern()
是一個高效的本地方法,它的定義中說的是,當調用 intern
方法時,如果字符串常量池中已經包含此字符串,則直接返回此字符串的引用,如果不包含此字符串,先將字符串添加到常量池中,再返回此對象的引用。
那什麼情況下適合使用 intern()
方法?
Twitter 工程師曾分享過一個 String.intern()
的使用示例,Twitter 每次發佈消息狀態的時候,都會產生一個地址信息,以當時 Twitter 用戶的規模預估,服務器需要 32G 的內存來存儲地址信息。
public class Location { private String city; private String region; private String countryCode; private double longitude; private double latitude; }考慮到其中有很多用戶在地址信息上是有重合的,比如,國家、省份、城市等,這時就可以將這部分信息單獨列出一個類,以減少重複,代碼如下:
public class SharedLocation { private String city; private String region; private String countryCode; } public class Location { private SharedLocation sharedLocation; double longitude; double latitude; }通過優化,數據存儲大小減到了 20G 左右。但對於內存存儲這個數據來說,依然很大,怎麼辦呢? Twitter 工程師使用
String.intern()
使重複性非常高的地址信息存儲大小從 20G 降到幾百兆,從而優化了 String 對象的存儲。
實現的核心代碼如下:
SharedLocation sharedLocation = new SharedLocation(); sharedLocation.setCity(messageInfo.getCity().intern()); sharedLocation.setCountryCode(messageInfo.getRegion().intern()); sharedLocation.setRegion(messageInfo.getCountryCode().intern());從 JDK1.7 版本以後, 常量池已經合併到了堆中 ,所以不會複製字符串副本,只是會把首次遇到的字符串的引用添加到常量池中。此時只會判斷常量池中是否已經有此字符串,如果有就返回常量池中的字符串引用。 這就相當於以下代碼:
String s1 = new String("Java中文社羣").intern(); String s2 = new String("Java中文社羣").intern(); System.out.println(s1 == s2);執行的結果爲:true 此處如果有人問爲什麼不直接賦值(使用 String s1 = "Java中文社羣"),是因爲這段代碼是簡化了上面 Twitter 業務代碼的語義而創建的,他使用的是對象的方式,而非直接賦值的方式。更多關於
intern()
的內容可以查看 《別再問我new字符串創建了幾個對象了!我來證明給你看!》 這篇文章。
3.慎重使用 Split 方法
之所以要勸各位慎用Split
方法,是因爲 Split
方法大多數情況下使用的是正則表達式,這種分割方式本身沒有什麼問題,但是 由於正則表達式的性能是非常不穩定的,使用不恰當會引起回溯問題,很可能導致 CPU 居高不下 。
例如以下正則表達式:
String badRegex = "^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\\\\/])+$"; String bugUrl = "http://www.apigo.com/dddp-web/pdf/download?request=6e7JGxxxxx4ILd-kExxxxxxxqJ4-CHLmqVnenXC692m74H38sdfdsazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf"; if (bugUrl.matches(badRegex)) { System.out.println("match!!"); } else { System.out.println("no match!!"); }執行效果如下圖所示:
可以看出,此代碼導致了 CPU 使用過高。
Java 正則表達式使用的引擎實現是 NFA(Non deterministic Finite Automaton,不確定型有窮自動機)自動機,這種正則表達式引擎在進行字符匹配時會發生回溯(backtracking),而一旦發生回溯,那其消耗的時間就會變得很長,有可能是幾分鐘,也有可能是幾個小時,時間長短取決於回溯的次數和複雜度。 爲了更好地解釋什麼是回溯,我們使用以下面例子進行解釋:text = "abbc"; regex = "ab{1,3}c";上面的這個例子的目的比較簡單,匹配以 a 開頭,以 c 結尾,中間有 1-3 個 b 字符的字符串。 NFA 引擎對其解析的過程是這樣子的:
-
首先,讀取正則表達式第一個匹配符
a
和 字符串第一個字符a
比較,匹配上了,於是讀取正則表達式第二個字符; -
b{1,3} b{1,3} b b{1,3} b b{1,3} c
-
發生回溯後,我們已經讀取的字符串第四個字符
c
將被吐出去,指針回到第三個字符串的位置,之後程序讀取正則表達式的下一個操作符c
,然後再讀取當前指針的下一個字符c
進行對比,發現匹配上了,於是讀取下一個操作符,然後發現已經結束了。