String字串效能優化的探究

碼猿手發表於2020-10-28

一.背景

  String 物件是我們使用最頻繁的一個物件型別,但它的效能問題卻是最容易被忽略的。String 物件作為 Java 語言中重要的資料型別,是記憶體中佔用空間最大的一個物件,高效地使用字串,可以提升系統的整體效能,比如百M記憶體輕鬆儲存幾十G資料。

  如果不正確對待 String 物件,則可能導致一些問題的發生,比如因為使用了正規表示式對字串進行匹配,從而導致併發瓶頸。

  接下來我們就從 String 物件的實現特性以及實際使用中的優化三方面入手,深入瞭解。

二.String物件的實現

  在開始之前,先思考一個問題:通過三種不同的方式建立了三個物件,再依次兩兩匹配,每組被匹配的兩個物件是否相等?

        String str1 = "abc";
        String str2 = new String("abc");
        String str3 = str2.intern();
        System.out.println(str1 == str2);
        System.out.println(str2 == str3);
        System.out.println(str1 == str3);

  對於上面的問題,你可以先思考下答案,以及這樣思考的原因。

  現在我們回到正題來:String 物件是如何實現的?

  在Java語言中,Sun 公司的工程師們對String物件做了大量的優化,來節約記憶體空間,提升 String 物件在系統中的效能。如下圖:                                                                                              

  1.在 Java6 以及之前的版本中,String 物件是對 char 陣列進行了封裝實現的物件,主要有4個成員變數: char 陣列、偏移量 offset、字元數量 count、雜湊值 hash。

  String 物件是通過 offset 和 count 兩個屬性來定位 char[] 陣列,獲取字串。這麼做可以高效、快速地共享陣列物件,同時節省記憶體空間,但這種方式很有可能會導致記憶體洩漏。

   2.從 Java7 版本開始到 Java8 版本,Java 對 String 類做了一些改變。String 類中不再有 offset 和 count 兩個變數了。這樣的好處是 String 物件佔用的記憶體稍微少了些,同時 String.substring 方法也不再共享 char[],從而解決了使用該方法可能導致的記憶體洩露問題。

  3.從 Java9 版本開始,工程師將 char[] 欄位改為了 byte[] 欄位,又維護了一個新的屬性 coder,它是一個編碼格式的標識。

  工程師為什麼這樣修改呢?

  我們知道一個 char 字元佔16位,2個位元組。這個情況下,儲存單位元組編碼內的字元(佔一個位元組的字元)就顯得非常浪費。JDK1.9 的 String 類為了節約記憶體空間,於是使用了佔8位,1個位元組的 byte 陣列來存放字串。

  而新屬性 coder 的作用是,在計算字串長度或者使用 indexOf() 函式時,我們需要根據這個欄位,判斷如何計算字串長度。coder 屬性預設有 0 和 1 兩個值,0 代表 Latin-1(單位元組編碼),1 代表 UTF-16。如果 String 判斷字串只包含了 latin-1,而 coder 屬性值為 0, 反之則為 1。

三. String物件的不可變性

   在實現程式碼中 String 類被 final 關鍵字修飾了,而且變數 char 陣列也被 final修飾了。我們知道類被 final 修飾代表該類不可繼承,而 char[] 被 final+private 修飾,代表了 String 物件不可被更改。Java實現的這個特性叫作 String 物件的不可變性,即 String 物件一旦建立成功,就不能再對它進行改變。

  Java 這樣做的好處在哪裡呢?

  1)保證 String物件的安全性。假設 String 物件是可變的,那麼 String 物件將可能被惡意修改。

  2)保證 hash 屬性值不會頻繁變更,確保了唯一性,使得型別 HashMap 容器才能實現相應的 key-value 快取功能。

  3)可以實現字串常量池。在 Java 中,通常有兩種建立字串物件的方式,一種是通過字串常量的方式建立,如 String str = "abc";另一種是字串變數通過 new 形式的建立,如 String str = new String("abc")。

  當程式碼中使用第一種方式建立字串物件時,JVM 首先會檢查該物件是否在字串常量池中,如果在,就返回該物件引用,否則新的字串將在常量池中被建立。這種方式可以減少同一個值的字串物件的重複建立,節約記憶體。

  String str = new String("abc")這種方式,首先在編譯類檔案時,“abc”常量字串將會放入到常量結構中,在類載入時,“abc”將會在常量池中建立;其次,在呼叫 new 時,JVM 命令將會呼叫 String 的建構函式,同時引用常量池中的 “abc” 字串,在堆記憶體中建立一個 String 物件;最後, str 將引用 String 物件。

  說到這裡,將講述一個特殊例子:平常程式設計時,對一個 String 物件 str 賦值 ”hello“,然後又讓 str 賦值為 ”world“,這個時候 str 的值變成了 ”world“,那麼 str 值確實改變了,為什麼還說 String 物件不可變呢?

  在這裡要說明物件和物件引用的區別,在 Java 中要比較兩個物件是否相等,往往要用 == ,而要判斷兩個物件的值是否相等,則需要用 equals 方法來判斷。

  上面的 str 只是 String 物件的引用,並不是物件本身。物件在記憶體中是有一塊記憶體地址,str 則是一個指向該記憶體的引用。所以在前面例子中,第一次賦值的時候,建立了一個 ”hello“物件, str 引用指向 ”hello“ 地址;第二次賦值的時候,又重新建立了一個物件 ”world“,str 引用指向了 ”world“,但 “hello” 物件依然存在於記憶體中。

  也就是說 str 並不是物件,而只是一個物件引用。真正的物件依然在記憶體中,沒有被改變。

 四.String物件的優化

  1.如何構建超大字串?

  程式設計過程中,字串的拼接很常見。前面講過 String 物件是不可變的,如果使用 String 物件相加,拼接想要的字串,是不是就會產生多個物件呢?例如下面程式碼:

String str = "ab" + "cd" + "ef";

  分析程式碼可知:首先會生成 ab 物件,再生成 abcd 物件,最後生成 abcdef 物件,從理論上來說,這段程式碼是低效的。

  但實際執行中,我們發現只有一個物件生成,這是為什麼呢?我們來看看編譯後的程式碼,你會發現編譯器自動優化了這段程式碼,如下:

String str = "abcdef";

  上面講的是字串常量的累計,下面看字串變數的累計:

  String str = "abcdef";

  for(int i = 0; i < 100; i++){

    str = str + i;
  }

  上面的程式碼編譯後,可以看到編譯器同樣對這段程式碼進行了優化,Java 在進行字串的拼接時,偏向使用 StringBuilder,這樣可以提高程式的效率。

  String str = "abcdef";

  for(int i = 0; i < 100; i++){

    str = (new StringBuilder(String.valueOf(str))).append(i).toString();
  }

  綜上已知:即使使用 + 號作為字串的拼接,也一樣可以被編譯器優化成 StringBuilder 的方式。但再細緻些,你會發現在編譯器優化的程式碼中,每次迴圈都會生成一個新的 StringBuilder 例項,同樣也會降低系統的效能。

  所以平時做字串的拼接時,建議顯示地使用 StringBuilder 來提升系統效能。

  如果在多執行緒程式設計中, String 物件的拼接涉及到執行緒安全,可以使用 StringBuffer,但是由於 StringBuffer 是執行緒安全的,涉及到鎖競爭,所以從效能上來說,要比 StringBuilder 差一些。

  2.如何使用 String.intern節省記憶體?

  說完了構建字串,接下來說下 String 物件的儲存問題。先看下面一個案例:

  Twitter 每次釋出訊息狀態的時候,都會產生一個地址資訊,以當時 Twitter 使用者的規模預估,伺服器需要 32G 的記憶體來儲存地址資訊。

public class Location{
    private String city;
    private String region ;
    private String countryCode;
    private double longitude;
    private double latitude;
}

   考慮到其中又很多使用者在地址資訊上是有重合的,比如:國家、省份、城市等,這時可以將這部分資訊單獨列出一個類,以減少重複。

public class ShareLocation{
    private String city;
    private String region ;
    private String countryCode;
}
public class Location{
    private ShareLocation shareLocation;
    private double longitude;
    private double latitude;
}

  通過優化,資料儲存大小減少到了 20G 左右,但對於記憶體儲存這個資料來說,依然很大,怎麼辦?

  這是可以通過使用 String.intern 來節省記憶體空間,從而優化 String 物件的儲存。

  具體做法就是:在每次賦值的時候使用 String 的 intern 方法,如果常量池有相同值,就會重複使用該物件,返回物件引用,這樣一開始的物件就可以被回收掉。這種方式可以使重複性非常高的地址資訊大小從 20G 降到幾百兆。

ShareLocation shareLocation = new ShareLocation();
shareLocation.setCity(messageInfo.getCity().intern());
shareLocation.setRegion(messageInfo.getRegion().intern());
shareLocation.setCountryCode(messageInfo.getCountryCode().intern()):

Location location = new Location();
location.set(shareLocation);
location.set(messageInfo.getLongitude());
location.set(messageInfo.getLatitude());

   為了更好的理解,下面講述一個簡單的例子:

String a = new String("abc").intern();
String b = new String("abc").intern();
if(a == b){
    System.out.println("a == b");
}

執行結果: a == b

   在字串常量池中,預設會將物件放入常量池;在字串變數中,物件是會在堆中建立,同時也會在常量池中建立一個字串物件,String 物件中的 char 陣列將會引用常量池中的 char 陣列,並返回堆記憶體物件引用。

  如果呼叫 intern 方法,會去檢視字串常量池中是否有等於該物件的字串的引用,如果沒有,在 JDK1.6 版本中去複製堆中的字串到常量池中,並返回該字串引用,堆記憶體中原有的字串由於沒有引用指向它,將會通過垃圾回收器回收。

  在 JDK1.7 版本以後,由於常量池合併到了堆中,所以不會再複製具體字串了,只是會把首次遇到的字串的引用新增到常量池中;如果有,就返回常量池的字串引用。

  現在再來看上面的例子,在一開始字串 “abc” 會在載入類時,在常量池中建立一個字串物件。

  建立 a 變數時,呼叫 new String() 會在堆中建立一個 String 物件,String 物件中的 char 陣列將會引用常量池中字串,呼叫 intern 方法之後,會去常量池中查詢是否有等於該字串物件的引用,有就返回引用。

  建立 b 變數時,呼叫 new String() 會在堆中建立一個 String 物件,String 物件中的 char 陣列將會引用常量池中字串,呼叫 intern 方法之後,會去常量池中查詢是否有等於該字串物件的引用,有就返回引用。

  而在堆記憶體中的兩個物件,由於沒有引用指向它,將會被垃圾回收。所以 a 和 b 引用的是同一個物件。

  如果在執行時,建立字串物件,將會直接在堆記憶體中建立,不會在常量池中建立。所以動態建立的字串物件,呼叫 intern 方法,在 JDK1.6 版本中會去常量池中建立執行時常量以及返回字串引用,在 JDK1.7 版本之後,會將堆中的字串常量的引用放入到常量池中,當其他堆中的字串物件通過 intern 方法獲取字串物件時,則會去常量池中判斷是否有相同值的字串的引用,此時有,則返回該常量池中字串引用,跟之前的字串指向同一地址的字串物件。

  以一張圖來總結 String 字串的建立分配記憶體地址情況:                                                                                                                           

  使用 intern 方法需要注意的一點是,一定要結合實際場景,因為常量池的實現是類似於一個 HashTable 的實現方式,HashTable 儲存的資料越大,遍歷的時間複雜度就會增加。如果資料過大,會增加整個字串常量池的負擔。

  3.如何使用字串的分割方法?

  Split() 方法使用了正規表示式實現了其強大的分割功能,而正規表示式的效能是非常不穩定的,使用不恰當會引起回溯問題,很可能導致 CPU 高居不下。

  所以應該慎重使用 split() 方法,可以用 String.indexOf() 方法代替 split() 方法完成字串的分割。如果實在無法滿足需求,在使用 split() 方法時,對回溯問題需要加以重視。

五.總結

  1)做好 String 字串效能優化,可以提高系統的整體效能。在這個理論基礎上,Java 版本在迭代中通過不斷地更改成員變數,節約記憶體空間,對 String 物件優化。

  2)String 物件的不可變性的特性實現了字串常量池,通過減少同一個值的字串物件的重複建立,進一步節約記憶體。

        也是因為這個特性,我們在做長字串拼接時,需要顯示使用 StringBuilder,以提高字串的拼接效能。

  3)使用 intern 方法,讓變數字串物件重複使用常量池中相同值的物件,進而節約記憶體。

 

相關文章