Java中的JVM字符串性能優(yōu)化
一、引言
String 對象是我們使用很頻繁的一個對象類型,但它的性能問題卻是很容易被忽略的。String 對象作為 Java 語言中重要的數(shù)據(jù)類型,是內(nèi)存中占據(jù)空間較大的一個對象。高效地使用字符串,可以提升系統(tǒng)的整體性能。
二、String 對象的實現(xiàn)
在 Java 語言中,Sun 公司的工程師們對 String 對象做了大量的優(yōu)化,來節(jié)約內(nèi)存空間,提升 String 對象在系統(tǒng)中的性能。
1. 在 Java6 以及之前的版本中,String 對象是對 char 數(shù)組進行了封裝實現(xiàn)的對象,主要有四個成員變量:char 數(shù)組、偏移量 offset、字符數(shù)量 count、哈希值 hash。String 對象是通過 offset 和 count 兩個屬性來定位 char[] 數(shù)組,獲取字符串。這么做可以高效、快速地共享數(shù)組對象,同時節(jié)省內(nèi)存空間,但這種方式很有可能會導(dǎo)致內(nèi)存泄漏。
2. 從 Java7 版本開始到 Java8 版本,Java 對 String 類做了一些改變。String 類中不再有 offset 和 count 兩個變量了。這樣的好處是 String 對象占用的內(nèi)存稍微少了些,同時,String.substring 方法也不再共享 char[],從而解決了使用該方法可能導(dǎo)致的內(nèi)存泄漏問題。
3. 從 Java9 版本開始,工程師將 char[] 字段改為了 byte[] 字段,又維護了一個新的屬性 coder,它是一個編碼格式的標識。
工程師為什么這樣修改呢?
我們知道一個 char 字符占 16 位,2 個字節(jié)。這個情況下,存儲單字節(jié)編碼內(nèi)的字符(占一個字節(jié)的字符)就顯得非常浪費。JDK1.9 的 String 類為了節(jié)約內(nèi)存空間,于是使用了占 8 位,1 個字節(jié)的 byte 數(shù)組來存放字符串。
而新屬性 coder 的作用是,在計算字符串長度或者使用 indexOf()函數(shù)時,我們需要根據(jù)這個字段,判斷如何計算字符串長度。coder 屬性默認有 0 和 1 兩個值,0 代表 Latin-1(單字節(jié)編碼),1 代表 UTF-16。如果 String 判斷字符串只包含了 Latin-1,則 coder 屬性值為 0,反之則為 1。
三、String 對象的不可變性起因
了解了 String 對象的實現(xiàn)后,你有沒有發(fā)現(xiàn)在實現(xiàn)代碼中 String 類被 final 關(guān)鍵字修飾了,而且變量 char 數(shù)組也被 final 修飾了。
我們知道類被 final 修飾代表該類不可繼承,而 char[] 被 final+private 修飾,代表了 String 對象不可被更改。Java 實現(xiàn)的這個特性叫作 String 對象的不可變性,即 String 對象一旦創(chuàng)建成功,就不能再對它進行改變。
四、String 對象的不可變性好處
1.保證 String 對象的安全性。假設(shè) String 對象是可變的,那么 String 對象將可能被惡意修改。
2.保證 hash 屬性值不會頻繁變更,確保了唯一性,使得類似 HashMap 容器才能實現(xiàn)相應(yīng)的 key-value 緩存功能。
3.可以實現(xiàn)字符串常量池。在 Java 中,通常有兩種創(chuàng)建字符串對象的方式,一種是通過字符串常量的方式創(chuàng)建,如 String str=“abc”;另一種是字符串變量通過 new 形式的創(chuàng)建,如 String str = new String(“abc”)。
當代碼中使用第一種方式創(chuàng)建字符串對象時,JVM 首先會檢查該對象是否在字符串常量池中,如果在,就返回該對象引用,否則新的字符串將在常量池中被創(chuàng)建。這種方式可以減少同一個值的字符串對象的重復(fù)創(chuàng)建,節(jié)約內(nèi)存。
String str = new String(“abc”) 這種方式,首先在編譯類文件時,“abc”常量字符串將會放入到常量結(jié)構(gòu)中,在類加載時,“abc”將會在常量池中創(chuàng)建;其次,在調(diào)用 new 時,JVM 命令將會調(diào)用 String 的構(gòu)造函數(shù),同時引用常量池中的“abc” 字符串,在堆內(nèi)存中創(chuàng)建一個 String 對象;最后,str 將引用 String 對象。
五、String 對象的優(yōu)化
1. 如何構(gòu)建超大字符串?
編程過程中,字符串的拼接很常見。前面我講過 String 對象是不可變的,如果我們使用 String 對象相加,拼接我們想要的字符串,是不是就會產(chǎn)生多個對象呢?例如以下代碼:
- String str= "ab" + "cd" + "ef";
分析代碼可知:首先會生成 ab 對象,再生成 abcd 對象,最后生成 abcdef 對象,從理論上來說,這段代碼是低效的。
但實際運行中,我們發(fā)現(xiàn)只有一個對象生成,這是為什么呢?難道我們的理論判斷錯了?我們再來看編譯后的代碼,你會發(fā)現(xiàn)編譯器自動優(yōu)化了這行代碼,如下:
- String str= "abcdef";
上面介紹的是字符串常量的累計,再來看看字符串變量的累計又是怎樣的呢?
- String str = "abcdef";
- for(int i=0; i<1000; i++) {
- str = str + i;
- }
上面的代碼編譯后,你可以看到編譯器同樣對這段代碼進行了優(yōu)化。不難發(fā)現(xiàn),Java 在進行字符串的拼接時,偏向使用 StringBuilder,這樣可以提高程序的效率。
- String str = "abcdef";
- for(int i=0; i<1000; i++) {
- str = (new StringBuilder(String.valueOf(str))).append(i).toString();
- }
綜上已知:即使使用 + 號作為字符串的拼接,也一樣可以被編譯器優(yōu)化成 StringBuilder 的方式。但再細致些,你會發(fā)現(xiàn)在編譯器優(yōu)化的代碼中,每次循環(huán)都會生成一個新的 StringBuilder 實例,同樣也會降低系統(tǒng)的性能。
所以平時做字符串拼接的時候,我建議你還是要顯示地使用 String Builder 來提升系統(tǒng)性能。
如果在多線程編程中,String 對象的拼接涉及到線程安全,你可以使用 StringBuffer。但是要注意,由于 StringBuffer 是線程安全的,涉及到鎖競爭,所以從性能上來說,要比 StringBuilder 差一些。
2. 如何使用 String.intern 節(jié)省內(nèi)存?
講完了構(gòu)建字符串,我們再來討論下 String 對象的存儲問題。先看一個案例。
Twitter 每次發(fā)布消息狀態(tài)的時候,都會產(chǎn)生一個地址信息,以當時 Twitter 用戶的規(guī)模預(yù)估,服務(wù)器需要 32G 的內(nèi)存來存儲地址信息。
- public class Location {
- private String city;
- private String region;
- private String countryCode;
- private double longitude;
- private double latitude;
- }
考慮到其中有很多用戶在地址信息上是有重合的,比如,國家、省份、城市等,這時就可以將這部分信息單獨列出一個類,以減少重復(fù),代碼如下:
- public class SharedLocation {
- private String city;
- private String region;
- private String countryCode;
- }
- public class Location {
- private SharedLocation sharedLocation;
- double longitude;
- double latitude;
- }
通過優(yōu)化,數(shù)據(jù)存儲大小減到了 20G 左右。但對于內(nèi)存存儲這個數(shù)據(jù)來說,依然很大,怎么辦呢?
這個案例來自一位 Twitter 工程師在 QCon 全球軟件開發(fā)大會上的演講,他們想到的解決方法,就是使用 String.intern 來節(jié)省內(nèi)存空間,從而優(yōu)化 String 對象的存儲。
具體做法就是,在每次賦值的時候使用 String 的 intern 方法,如果常量池中有相同值,就會重復(fù)使用該對象,返回對象引用,這樣一開始的對象就可以被回收掉。這種方式可以使重復(fù)性非常高的地址信息存儲大小從 20G 降到幾百兆。
- SharedLocation sharedLocation = new SharedLocation();
- sharedLocation.setCity(messageInfo.getCity().intern()); sharedLocation.setCountryCode(messageInfo.getRegion().intern());
- 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");
- }
輸出結(jié)果:
- a==b
在字符串常量中,默認會將對象放入常量池;在字符串變量中,對象是會創(chuàng)建在堆內(nèi)存中,同時也會在常量池中創(chuàng)建一個字符串對象,復(fù)制到堆內(nèi)存對象中,并返回堆內(nèi)存對象引用。
如果調(diào)用 intern 方法,會去查看字符串常量池中是否有等于該對象的字符串的引用,如果沒有,在 JDK1.6 版本中會復(fù)制堆中的字符串到常量池中,并返回該字符串引用,堆內(nèi)存中原有的字符串由于沒有引用指向它,將會通過垃圾回收器回收。
在 JDK1.7 版本以后,由于常量池已經(jīng)合并到了堆中,所以不會再復(fù)制具體字符串了,只是會把首次遇到的字符串的引用添加到常量池中;如果有,就返回常量池中的字符串引用。
了解了原理,我們再一起看下上邊的例子。
在一開始字符串“abc”會在加載類時,在常量池中創(chuàng)建一個字符串對象。
創(chuàng)建 a 變量時,調(diào)用 new String() 會在堆內(nèi)存中創(chuàng)建一個 String 對象,String 對象中的 char 數(shù)組將會引用常量池中字符串。在調(diào)用 intern 方法之后,會去常量池中查找是否有等于該字符串對象的引用,有就返回引用。
創(chuàng)建 b 變量時,調(diào)用 new String() 會在堆內(nèi)存中創(chuàng)建一個 String 對象,String 對象中的 char 數(shù)組將會引用常量池中字符串。在調(diào)用 intern 方法之后,會去常量池中查找是否有等于該字符串對象的引用,有就返回引用。
而在堆內(nèi)存中的兩個對象,由于沒有引用指向它,將會被垃圾回收。所以 a 和 b 引用的是同一個對象。
如果在運行時,創(chuàng)建字符串對象,將會直接在堆內(nèi)存中創(chuàng)建,不會在常量池中創(chuàng)建。所以動態(tài)創(chuàng)建的字符串對象,調(diào)用 intern 方法,在 JDK1.6 版本中會去常量池中創(chuàng)建運行時常量以及返回字符串引用,在 JDK1.7 版本之后,會將堆中的字符串常量的引用放入到常量池中,當其他堆中的字符串對象通過 intern 方法獲取字符串對象引用時,則會去常量池中判斷是否有相同值的字符串的引用,此時有,則返回該常量池中字符串引用,跟之前的字符串指向同一地址的字符串對象。
以一張圖來總結(jié) String 字符串的創(chuàng)建分配內(nèi)存地址情況:
使用 intern 方法需要注意的一點是,一定要結(jié)合實際場景。因為常量池的實現(xiàn)是類似于一個 HashTable 的實現(xiàn)方式,HashTable 存儲的數(shù)據(jù)越大,遍歷的時間復(fù)雜度就會增加。如果數(shù)據(jù)過大,會增加整個字符串常量池的負擔。
3. 如何使用字符串的分割方法?
Split() 方法使用了正則表達式實現(xiàn)了其強大的分割功能,而正則表達式的性能是非常不穩(wěn)定的,使用不恰當會引起回溯問題,很可能導(dǎo)致 CPU 居高不下。
所以我們應(yīng)該慎重使用 Split() 方法,我們可以用 String.indexOf() 方法代替 Split() 方法完成字符串的分割。如果實在無法滿足需求,你就在使用 Split() 方法時,對回溯問題加以重視就可以了。
六、總結(jié)
我們認識到做好 String 字符串性能優(yōu)化,可以提高系統(tǒng)的整體性能。在這個理論基礎(chǔ)上,Java 版本在迭代中通過不斷地更改成員變量,節(jié)約內(nèi)存空間,對 String 對象進行優(yōu)化。
我們還特別提到了 String 對象的不可變性,正是這個特性實現(xiàn)了字符串常量池,通過減少同一個值的字符串對象的重復(fù)創(chuàng)建,進一步節(jié)約內(nèi)存。
但也是因為這個特性,我們在做長字符串拼接時,需要顯示使用 StringBuilder,以提高字符串的拼接性能。最后,在優(yōu)化方面,我們還可以使用 intern 方法,讓變量字符串對象重復(fù)使用常量池中相同值的對象,進而節(jié)約內(nèi)存。
最后再分享一個個人觀點。那就是千里之堤,潰于蟻穴。日常編程中,我們往往可能就是對一個小小的字符串了解不夠深入,使用不夠恰當,從而引發(fā)線上事故。
比如,在我之前的工作經(jīng)歷中,就曾因為使用正則表達式對字符串進行匹配,導(dǎo)致并發(fā)瓶頸,這里也可以將其歸納為字符串使用的性能問題。
【本文是51CTO專欄機構(gòu)“AiChinaTech”的原創(chuàng)文章,微信公眾號( id: tech-AI)”】