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