Java 效能優化之 String 篇
一般而言,Java 物件在虛擬機器的結構如下:
- 物件頭(object header):8 個位元組
- Java 原始型別資料:如 int, float, char 等型別的資料,各型別資料佔記憶體如 表 1. Java 各資料型別所佔記憶體.
- 引用(reference):4 個位元組
- 填充符(padding)
資料型別 | 佔用記憶體(位元組數) |
---|---|
boolean | 1 |
byte | |
char | 2 |
short | |
int | 4 |
float | |
long | 8 |
double |
然而,一個 Java 物件實際還會佔用些額外的空間,如:物件的 class 資訊、ID、在虛擬機器中的狀態。在 Oracle JDK 的 Hotspot 虛擬機器中,一個普通的物件需要額外 8 個位元組。
如果對於 String(JDK 6)的成員變數宣告如下:
private final char value[]; private final int offset; private final int count; private int hash; |
那麼因該如何計算該 String 所佔的空間?
首先計算一個空的 char 陣列所佔空間,在 Java 裡陣列也是物件,因而陣列也有物件頭,故一個數組所佔的空間為物件頭所佔的空間加上陣列長度,即 8 + 4 = 12 位元組 , 經過填充後為 16 位元組。
那麼一個空 String 所佔空間為:
物件頭(8 位元組)+ char 陣列(16 位元組)+ 3 個 int(3 × 4 = 12 位元組)+1 個 char 陣列的引用 (4 位元組 ) = 40 位元組。
因此一個實際的 String 所佔空間的計算公式如下:
8*( ( 8+2*n+4+12)+7 ) / 8 = 8*(int) ( ( ( (n) *2 )+43) /8 ) |
其中,n 為字串長度。
在我們的大規模文字分析的案例中,程式需要統計一個 300MB 的 csv 檔案所有單詞的出現次數,分析發現共有 20,000 左右的唯一單詞,假設每個單詞平均包含 15 個字母,這樣根據上述公式,一個單詞平均佔用 75 bytes. 那麼這樣 75 * 20,000 = 1500000,即約為 1.5M 左右。但實際發現有上百兆的空間被佔用。 實際使用的記憶體之所以與預估的產生如此大的差異是因為程式大量使用String.split()
String.substring()
來獲取單詞。在 JDK 1.6 中 String.substring(int, int)
的原始碼為:
public String substring(int beginIndex, int endIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } if (endIndex > count) { throw new StringIndexOutOfBoundsException(endIndex); } if (beginIndex > endIndex) { throw new StringIndexOutOfBoundsException(endIndex - beginIndex); } return ((beginIndex == 0) && (endIndex == count)) ? this : new String(offset + beginIndex, endIndex - beginIndex, value); } |
呼叫的 String 建構函式原始碼為:
String(int offset, int count, char value[]) { this.value = value; this.offset = offset; this.count = count; } |
仔細觀察粗體這行程式碼我們發現 String.substring()
所返回的 String 仍然會儲存原始 String, 這就是 20,000 個平均長度的單詞竟然佔用了上百兆的記憶體的原因。 一個 csv 檔案中每一行都是一份很長的資料,包含了上千的單詞,最後被 String.split()
或String.substring()
截取出的每一個單詞仍舊包含了其原先所在的上下文中,因而導致了出乎意料的大量的記憶體消耗。
當然,JDK String 的原始碼設計當然有著其合理之處,對於通過 String.split()
或 String.substring()
截取出大量 String 的操作,這種設計在很多時候可以很大程度的節省記憶體,因為這些 String 都複用了原始 String,只是通過 int 型別的 start, end 等值來標識每一個 String。 而對於我們的案例,從一個巨大的 String 擷取少數 String 為以後所用,這樣的設計則造成大量冗餘資料。 因此有關通過String.split()
或 String.substring()
擷取 String 的操作的結論如下:
- 對於從大文字中擷取少量字串的應用,
String.substring()
將會導致記憶體的過度浪費。 - 對於從一般文字中擷取一定數量的字串,擷取的字串長度總和與原始文字長度相差不大,現有的
String.substring()
設計恰好可以共享原始文字從而達到節省記憶體的目的。
既然導致大量記憶體佔用的根源是 String.substring()
返回結果中包含大量原始 String,那麼一個顯而易見的減少記憶體浪費的的途徑就是去除這些原始 String。辦法有很多種,在此我們採取比較直觀的一種,即再次呼叫 newString
構造一個的僅包含截取出的字串的 String,我們可呼叫 String.
toCharArray
()
方法:
String newString = new String(smallString.toCharArray()); |
舉一個極端例子,假設要從一個字串中獲取所有連續的非空子串,字串長度為 n,如果用 JDK 本身提供的 String.substring() 方
法,則總共的連續非空子串個數為:
n+(n-1)+(n-2)+ … +1 = n*(n+1)/2 =O(n2) |
由於每個子串所佔的空間為常數,故空間複雜度也為 O(n2)。
如果用本文建議的方法,即構造一個內容相同的新的字串,則所需空間正比於子串的長度,則所需空間複雜度為:
1*n+2*(n-1)+3*(n-2)+ … +n*1 = (n3+3*n2+2*n)/6 = O(n3) |
所以,從以上定量的分析看來,當需要擷取的字串長度總和大於等於原始文字長度,本文所建議的方法帶來的空間複雜度反而高了,而現有的 String.substring()
設計恰好可以共享原始文字從而達到節省記憶體的目的。反之,當所需要擷取的字串長度總和遠小於原始文字長度時,用本文所推薦的方法將在很大程度上節省記憶體,在大文字資料處理中其優勢顯而易見。
以上我們描述了在我們的大量文字分析案例中呼叫 String 的 subString
方法
導致記憶體消耗的問題,下面再列舉一些其他將導致記憶體浪費的 String 的 API 的使用:
在拼接靜態字串時,儘量用 +,因為通常編譯器會對此做優化,如:
String test = "this " + "is " + "a " + "test " + "string" |
編譯器會把它視為:
String test = "this is a test string" |
在拼接動態字串時,儘量用 StringBuffer
或 StringBuilder
的 append
,這樣可以減少構造過多的臨時 String 物件。
常見的建立一個 String 可以用賦值操作符"=" 或用 new 和相應的建構函式。初學者一定會想這兩種有何區別,舉例如下:
String a1 = “Hello”; String a2 = new String(“Hello”); |
第一種方法建立字串時 JVM 會檢視內部的快取池是否已有相同的字串存在:如果有,則不再使用建構函式構造一個新的字串,直接返回已有的字串例項;若不存在,則分配新的記憶體給新建立的字串。
第二種方法直接呼叫建構函式來建立字串,如果所建立的字串在字串快取池中不存在則呼叫建構函式建立全新的字串,如果所建立的字串在字串快取池中已有則再拷貝一份到 Java 堆中。
儘管這是一個簡單明顯的例子,然而在實際專案中程式設計者卻不那麼容易洞察因為這兩種方式的選擇而帶來的效能問題。
仍然以之前的從 csv 檔案中擷取 String 為例,先前我們通過用 new String() 去除返回的 String 中附帶的原始 String 的方法優化了subString
導致的記憶體消耗問題。然而,當我們下意識地使用 newString
去構造一個全新的字串而不是用賦值符來建立(重用)一個字串時,就導致了另一個潛在的效能問題,即:重複建立大量相同的字串。說到這裡,您也許會想到使用快取池的技術來解決這一問題,大概有如下兩種方法:
方法一,使用 String 的 intern()
方法返回 JVM 對字串快取池裡相應已存在的字串引用,從而解決記憶體效能問題,但這個方法並不推薦!原因在於:首先,intern()
所使用的池會是 JVM 中一個全域性的池,很多情況下我們的程式並不需要如此大作用域的快取;其次,intern() 所使用的是 JVM heap 中 PermGen 相應的區域,在 JVM 中 PermGen 是用來存放裝載類和建立類例項時用到的元資料。程式執行時所使用的記憶體絕大部分存放在 JVM heap 的其他區域,過多得使用 intern()
將導致 PermGen 過度增長而最後返回OutOfMemoryError
,因為垃圾收集器不會對被快取的 String 做垃圾回收。所以我們建議使用第二種方式。
方法二,使用者自己構建快取,這種方式的優點是更加靈活。建立 HashMap,將需快取的 String 作為 key 和 value 存放入 HashMap。假設我們準備建立的字串為 key,將 Map cacheMap 作為緩衝池,那麼返回 key 的程式碼如下:
private String getCacheWord(String key) { String tmp = cacheMap.get(key); if(tmp != null) { return tmp; } else { cacheMap.put(key, key); return key; } } |
本文通過一個實際專案中遇到的因使用 String 而導致的效能問題講述了 String 在 JVM 中的儲存結構,String 的 API 使用可能造成的效能問題以及解決方法。相信這些建議能對處理大文字分析的朋友有所幫助,同時希望文中提到的某些優化方法能被舉一反三的應用在其他有關 String 的效能優化的場合。