切換語言為:簡體

最佳化字串 String 效能,實現百M記憶體輕鬆儲存幾十G資料

  • 爱糖宝
  • 2024-07-22
  • 2066
  • 0
  • 0

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 物件在系統中的效能。 一起來看看最佳化過程,如下圖所示:

最佳化字串 String 效能,實現百M記憶體輕鬆儲存幾十G資料

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 字串的建立分配記憶體地址情況:

最佳化字串 String 效能,實現百M記憶體輕鬆儲存幾十G資料

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

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

最後我想跟你聊聊字串的分割,這種方法在編碼中也很最常見。Split() 方法使用了正規表示式實現了其強大的分割功能,而正規表示式的效能是非常不穩定的,使用不恰當會引起回溯問題,很可能導致 CPU居高不下。

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

總結

這一講中,我們認識到做好 String 字串效能最佳化,可以提高系統的整體效能。在這個理論基礎上,Java 版本在迭代中透過不斷地更改成員變數,節約記憶體空間,對 String 物件進行最佳化。

我們還特別提到了 String 物件的不可變性,正是這個特性實現了字串常量池,透過減少 同一個值的字串物件的重複建立,進一步節約記憶體。

但也是因為這個特性,我們在做長字串拼接時,需要顯示使用 StringBuilder,以提高字 符串的拼接效能。最後,在最佳化方面,我們還可以使用 intern 方法,讓變數字串物件重 復使用常量池中相同值的物件,進而節約記憶體。

0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.