String字符串性能優化的探究

一.背景

  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 方法,讓變量字符串對象重複使用常量池中相同值的對象,進而節約內存。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章