String 物件是我們使用最頻繁的一個物件型別,但它的效能問題卻是最容易被忽略的。
String 物件作為 Java 語言中重要的資料型別,是記憶體中佔據空間最大的一個物件。 高效地使用字串,可以提升系統的整體效能。
接下來我們就從 String 物件的實現、特性以及實際使用中的最佳化這三個方面入手,深入瞭解。
在開始之前,我想先問你一個小問題,也是我在招聘時,經常會問到面試者的一道題。雖是老生常談了,但錯誤率依然很高,當然也有一些面試者答對了,但能解釋清楚答案背後原理的人少之又少。問題如下:
透過三種不同的方式建立了三個物件,再依次兩兩匹配,每組被匹配的兩個物件是否相等? 程式碼如下:
String str1= "abc"; String str2= new String("abc"); String str3= str2.intern(); assertSame(str1==str2); assertSame(str2==str3); assertSame(str1==str3);
String 物件是如何實現的?
在 Java 語言中,Sun 公司的工程師們對 String 物件做了大量的最佳化,來節約記憶體空間, 提升 String 物件在系統中的效能。 一起來看看最佳化過程,如下圖所示:
1. 在 Java 6 以及之前的版本中,String 物件是對 char 陣列進行了封裝實現的物件,主要有四個成員變數:char 陣列、偏移量 offset、字元數量 count、雜湊值 hash。
String 物件是透過 offset 和 count 兩個屬性來定位 char[] 陣列,獲取字串。這麼做可以高效、快速地共享陣列物件,同時節省記憶體空間,但這種方式很有可能會導致記憶體洩漏。
2. 從 Java 7 版本開始到 Java 8 版本,Java 對 String 類做了一些改變。String 類中不再有 offset 和 count 兩個變數了。這樣的好處是 String 物件佔用的記憶體稍微少了些,同時,String.substring 方法也不再共享 char[],從而解決了使用該方法可能導致的記憶體洩漏問題。
3. 從 Java 9 版本開始, 工程師將 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 物件的實現後,你有沒有發現在實現程式碼中 String 類被 final 關鍵字修飾了,而且變數 char 陣列也被 final 修飾了。
我們知道類被 final 修飾代表該類不可繼承,而 char[] 被 final+private 修飾,代表了
String 物件不可被更改。Java 實現的這個特性叫作 String 物件的不可變性,即 String 對 象一旦建立成功,就不能再對它進行改變。
Java 這樣做的好處在哪裏呢?
第一,保證 String 物件的安全性。假設 String 物件是可變的,那麼 String 物件將可能被惡意修改。
第二,保證 hash 屬性值不會頻繁變更,確保了唯一性,使得類似 HashMap 容器才能實現相應的 key-value 快取功能。
第三,可以實現字串常量池。在 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 初學者往往對此存在誤區,特別是一些從 PHP 轉 Java 的同學。在 Java 中要比較兩個物件是否相等,往往是用 ==,而要判斷兩個物件的值是否相等,則需要用 equals 方法來判斷。
這是因為 str 只是 String 物件的引用,並不是物件本身。物件在記憶體中是一塊記憶體地址, str 則是一個指向該記憶體地址的引用。所以在剛剛我們說的這個例子中,第一次賦值的時候,建立了一個“hello”物件,str 引用指向“hello”地址;第二次賦值的時候,又重新建立了一個物件“world”,str 引用指向了“world”,但“hello”物件依然存在於記憶體中。
也就是說 str 並不是物件,而只是一個物件引用。真正的物件依然還在記憶體中,沒有被改變。
String 物件的最佳化
瞭解了 String 物件的實現原理和特性,接下來我們就結合實際場景,看看如何最佳化 String 物件的使用,最佳化的過程中又有哪些需要注意的地方。
1. 如何構建超大字串?
程式設計過程中,字串的拼接很常見。前面我講過 String 物件是不可變的,如果我們使用 String 物件相加,拼接我們想要的字串,是不是就會產生多個物件呢?例如以下程式碼:
String str= "ab" + "cd" + "ef";
分析程式碼可知:首先會生成 ab 物件,再生成 abcd 物件,最後生成 abcdef 物件,從理論上來說,這段程式碼是低效的。
但實際執行中,我們發現只有一個物件生成,這是為什麼呢?難道我們的理論判斷錯了?我們再來看編譯後的程式碼,你會發現編譯器自動最佳化了這行程式碼,如下:
String str= "abcdef";
上面我介紹的是字串常量的累計,我們再來看看字串變數的累計又是怎樣的呢?
String str = "abcdef"; for(int i=0; i<1000; i++) { str = str + i; 5 }
上面的程式碼編譯後,你可以看到編譯器同樣對這段程式碼進行了最佳化。不難發現,Java 在進行字串的拼接時,偏向使用 StringBuilder,這樣可以提高程式的效率。
String str = "abcdef"; for(int i=0; i<1000; i++) { str = (new StringBuilder(String.valueOf(str))).append(i).toString(); }
綜上已知: 即使使用 + 號作為字串的拼接,也一樣可以被編譯器最佳化成 StringBuilder 的方式。但再細緻些,你會發現在編譯器最佳化的程式碼中,每次迴圈都會生成一個新的StringBuilder 例項,同樣也會降低系統的效能。
所以平時做字串拼接的時候,我建議你還是要顯示地使用 String Builder 來提升系統性能。
如果在多執行緒程式設計中,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 SharedLocation { private String city; private String region; private String countryCode; } public class Location { private SharedLocation sharedLocation; double longitude; double latitude; }
透過最佳化,資料儲存大小減到了 20G 左右。但對於記憶體儲存這個資料來說,依然很大,怎麼辦呢?
這個案例來自一位 Twitter 工程師在 QCon 全球軟件開發大會上的演講,他們想到的解決方法,就是使用 String.intern 來節省記憶體空間,從而最佳化 String 物件的儲存。
具體做法就是,在每次賦值的時候使用 String 的 intern 方法,如果常量池中有相同值,就 會重複使用該物件,返回物件引用,這樣一開始的物件就可以被回收掉。這種方式可以使重 復性非常高的地址資訊儲存大小從 20G 降到幾百兆。
SharedLocation sharedLocation = new SharedLocation();sharedLocation.setCity(messageInfo.getCity().intern()); sharedLocation.setCount sharedLocation.setRegion(messageInfo.getCountryCode().intern()); Location location = new Location(); location.set(sharedLocation); 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.print("a==b"); }
輸出結果:
a==b
在字串常量中,預設會將物件放入常量池;在字串變數中,物件是會建立在堆記憶體中, 同時也會在常量池中建立一個字串物件,複製到堆記憶體物件中,並返回堆記憶體物件引用。
如果呼叫 intern 方法,會去檢視字串常量池中是否有等於該物件的字串,如果沒有,
就在常量池中新增該物件,並返回該物件引用;如果有,就返回常量池中的字串引用。堆記憶體中原有的物件由於沒有引用指向它,將會透過垃圾回收器回收。
瞭解了原理,我們再一起看看上邊的例子。
在一開始建立 a 變數時,會在堆記憶體中建立一個物件,同時會在載入類時,在常量池中建立一個字串物件,在呼叫 intern 方法之後,會去常量池中查詢是否有等於該字串的物件,有就返回引用。
在建立 b 字串變數時,也會在堆中建立一個物件,此時常量池中有該字串物件,就不再建立。呼叫 intern 方法則會去常量池中判斷是否有等於該字串的物件,發現有等於"abc"字串的物件,就直接返回引用。而在堆記憶體中的物件,由於沒有引用指向它,將會被垃圾回收。所以 a 和 b 引用的是同一個物件。
下面我用一張圖來總結下 String 字串的建立分配記憶體地址情況:
使用 intern 方法需要注意的一點是, 一定要結合實際場景。因為常量池的實現是類似於一個 HashTable 的實現方式,HashTable 儲存的資料越大,遍歷的時間複雜度就會增加。如果資料過大,會增加整個字串常量池的負擔。
3. 如何使用字串的分割方法?
最後我想跟你聊聊字串的分割,這種方法在編碼中也很最常見。Split() 方法使用了正規表示式實現了其強大的分割功能,而正規表示式的效能是非常不穩定的,使用不恰當會引起回溯問題,很可能導致 CPU居高不下。
所以我們應該慎重使用 Split() 方法,我們可以用 String.indexOf() 方法代替 Split() 方法完 成字串的分割。如果實在無法滿足需求,你就在使用 Split() 方法時,對回溯問題加以重視就可以了。
總結
這一講中,我們認識到做好 String 字串效能最佳化,可以提高系統的整體效能。在這個理論基礎上,Java 版本在迭代中透過不斷地更改成員變數,節約記憶體空間,對 String 物件進行最佳化。
我們還特別提到了 String 物件的不可變性,正是這個特性實現了字串常量池,透過減少 同一個值的字串物件的重複建立,進一步節約記憶體。
但也是因為這個特性,我們在做長字串拼接時,需要顯示使用 StringBuilder,以提高字 符串的拼接效能。最後,在最佳化方面,我們還可以使用 intern 方法,讓變數字串物件重 復使用常量池中相同值的物件,進而節約記憶體。