String 在 JVM 的存儲結構
一般而言,Java 對象在虛擬機的結構如下:
對象頭(object header):8 個字節
Java 原始類型數據:如 int, float, char 等類型的數據,各類型數據占內存如 表 1. Java 各數據類型所占內存.
引用(reference):4 個字節
填充符(padding)
表 1. Java 各數據類型所占內存
然而,一個 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 使用的優化建議
以上我們描述了在我們的大量文本分析案例中調用 String 的 subString方法導致內存消耗的問題,下面再列舉一些其他將導致內存浪費的 String 的 API 的使用:
String 拼接的方法選擇
在拼接靜態字符串時,盡量用 +,因為通常編譯器會對此做優化,如:
String test = "this " + "is " + "a " + "test " + "string"
編譯器會把它視為:
String test = "this is a test string"
在拼接動態字符串時,盡量用 StringBuffer 或 StringBuilder的 append,這樣可以減少構造過多的臨時 String 對象。
String 構造的方法選擇
常見的創建一個 String 可以用賦值操作符"=" 或用 new 和相應的構造函數。初學者一定會想這兩種有何區別,舉例如下:
String a1 = “Hello”;
String a2 = new String(“Hello”);
第一種方法創建字符串時 JVM 會查看內部的緩存池是否已有相同的字符串存在:如果有,則不再使用構造函數構造一個新的字符串,直接返回已有的字符串實例;若不存在,則分配新的內存給新創建的字符串。
第二種方法直接調用構造函數來創建字符串,如果所創建的字符串在字符串緩存池中不存在則調用構造函數創建全新的字符串,如果所創建的字符串在字符串緩存池中已有則再拷貝一份到 Java 堆中。
盡管這是一個簡單明顯的例子,然而在實際項目中編程者卻不那麼容易洞察因為這兩種方式的選擇而帶來的性能問題。
使用構造函數 string() 帶來的內存性能隱患和緩解
仍然以之前的從 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 的性能優化的場合。