1. 程式人生 > >Java中String的設計

Java中String的設計

字節對齊 執行 詳細 做了 永久 字符串變量 分析 擴展 mem

String應用簡介

前言

String字符串在Java應用中使用非常頻繁,只有理解了它在虛擬機中的實現機制,才能寫出健壯的應用,本文使用的JDK版本為1.8.0_111

技術分享

常量池

Java代碼被編譯成class文件時,會生成一個常量池(Constant pool)的數據結構,用以保存字面常量和符號引用(類名、方法名、接口名和字段名等)。

技術分享

很簡單的一段代碼,通過命令 javap -verbose 查看class文件中 Constant pool 實現:

技術分享

通過反編譯出來的字節碼可以看出字符串 "test" 在常量池中的定義方式:

技術分享

在main方法字節碼指令中,0 ~ 2行對應代碼

String test = "test"; 由兩部分組成:ldc #16astore_1

技術分享

1Test類加載到虛擬機時,"test"字符串在Constant pool中使用符號引用symbol表示,當調用 ldc #16 指令時,如果Constant pool中索引 #16 symbol還未解析,則調用C++底層的 StringTable::intern 方法生成char數組,並將引用保存在StringTable和常量池中,當下次調用 ldc #16 時,可以直接從Constant pool根據索引 #16獲取 "test" 字符串的引用,避免再次到StringTable中查找。

2astore_1指令將"test"字符串的引用保存在局部變量表中。

常量池的內存分配 JDK678中有不同的實現:

1JDK6及之前版本中,常量池的內存在永久代PermGen進行分配,所以常量池會受到PermGen內存大小的限制。

2JDK7中,常量池的內存在Java堆上進行分配,意味著常量池不受固定大小的限制了。

3JDK8中,虛擬機團隊移除了永久代PermGen

字符串初始化

字符串可以通過兩種方式進行初始化:字面常量和String對象。

字面常量

技術分享

通過 "javap -c" 命令查看字節碼指令實現:

技術分享

其中ldc指令將int

floatString類型的常量值從常量池中推送到棧頂,所以ab都指向常量池的"java"字符串。通過指令實現可以發現:變量abc都指向常量池的 "java" 字符串,表達式 "ja" + "va" 在編譯期間會把結果值"java"直接賦值給c(編譯後指向常量)。

String對象

技術分享

這種情況下,a == c 成立麽?字節碼實現如下:

技術分享

其中3 ~ 9行指令對應代碼 String c = new String("java"); 實現:

1、第3new指令,在Java堆上為String對象申請內存;

2、第7ldc指令,嘗試從常量池中獲取"java"字符串,如果常量池中不存在,則在常量池中新建"java"字符串,並返回;

3、第9invokespecial指令,調用構造方法,初始化String對象。

其中String對象中使用char數組存儲字符串,變量a指向常量池的"java"字符串,變量c指向Java堆的String對象,且該對象的char數組指向常量池的"java"字符串,所以很顯然 a != c,如下圖所示:

技術分享

通過 "字面量 + String對象" 進行賦值會發生什麽?

技術分享

這種情況下,c == d成立麽?字節碼實現如下:

技術分享

其中6 ~ 21行指令對應代碼 String c = a + b; 實現:

1、第6new指令,在Java堆上為StringBuilder對象申請內存;

2、第10~14aload_1指令,裝載a字符串hello,調用構造方法,初始化StringBuilder對象;(這裏本應是兩次append,是不是在init時做了優化?)

3、第18invokevirtual指令,調用append方法,添加b字符串;

4、第21invokevirtual指令,調用toString方法,生成String對象。

通過指令實現可以發現,字符串變量的連接動作,在編譯階段會被轉化成StringBuilderappend操作,變量c最終指向Java堆上新建String對象,變量d指向常量池的"hello world"字符串,所以 c != d

不過有種特殊情況,當final修飾的變量發生連接動作時,虛擬機會進行優化,將表達式結果直接賦值給目標變量:

技術分享

指令實現如下:

技術分享

如果其中有不是final的字符串

技術分享

技術分享

String.intern()原理

String.intern()是一個Native方法,底層調用C++ StringTable::intern 方法,源碼註釋:當調用 intern 方法時,如果常量池中已經該字符串,則返回池中的字符串;否則將此字符串添加到常量池中,並返回字符串的引用。

技術分享

JDK6 JDK7 中結果不一樣:

1JDK6的執行結果:false false
對於這個結果很好理解。在JDK6中,常量池在永久代分配內存,永久代和Java堆的內存是物理隔離的,執行intern方法時,如果常量池不存在該字符串,虛擬機會在常量池中復制該字符串,並返回引用,所以需要謹慎使用intern方法,避免常量池中字符串過多,導致性能變慢,甚至發生PermGen內存溢出。

技術分享

2JDK7的執行結果:true false
對於這個結果就有點懵了。在JDK7中,常量池已經在Java堆上分配內存,執行intern方法時,如果常量池已經存在該字符串,則直接返回字符串引用,否則復制該字符串對象的引用到常量池中並返回,所以在JDK7中,可以重新考慮使用intern方法,減少String對象所占的內存空間。

技術分享

對於變量s1,常量池中沒有 "StringTest" 字符串,s1.intern() s1都是指向Java對象上的String對象。
對於變量s2,常量池中一開始就已經存在 "java" 字符串,所以 s2.intern() 返回常量池中 "java" 字符串的引用。

Attila Szegedis 在他講述 JVM 知識的文檔中一直強調,清楚知道內存中存儲的數據量是非常重要的。我一開始感到十分驚訝,因為一般情況下,在企業開發中並不是經常需要關註對象的大小。他對此給出了 Twitter 的一個例子。

先思考一個內存占用的問題:字符串 "Hello World" 會占用多少字節內存?

答案:在 32 位虛擬機上是 62 字節,在 64 位虛擬機上是 86 字節。

分別為 8/16 (字符串的對象頭) + 11 * 2 (字符) + [8/16 (字符數組的對象頭) + 4 (數組長度),加上字節對齊所需的填充,共為 16/24 字節] + 4 (偏移) + 4 (偏移長度) + 4 (哈希碼) + 4/8 (指向字符數組的引用)【在 64 位虛擬機上,String 對象的內存占用會因為字節對齊而填充為 40 字節】

使用 String intern() 方法

intern 的目的在於復用字符串對象以節省內存。

在明確知道一個字符串會出現多次時才使用 intern(),並且只用它來節省內存。

如何確定 intern 的效率

最好的方法是對整個堆執行一次堆轉儲。堆轉儲也會在發生 OutOfMemoryError 時執行。

MAT (內存分析工具,譯者註)中打開轉儲文件,然後選擇 java.lang.String,依次點擊"Java Basics"、"Group By Value"。

技術分享

根據堆的大小,上面的操作可能耗費比較長的時間。最後可以看到類型這樣的結果。按 "Retained Heap" 或者是 "Objects" 列進行排序,可以發現一些有趣的東西:

技術分享

從這快照中我們可以看到,空的字符串占用了大量的內存!兩百萬個空字符串對象占用了總共 130 MB 的空間。另外可以看到一部分被加載的 JavaScript 腳本,一些作為鍵的字符串,它們被用於定位。另外,還有一些與業務邏輯相關的字符串。

這些與業務邏輯相關的字符串是最容易進行 intern 操作的,因為我們清楚地知道它們是在什麽地方被加載進內存的。對於其他字符串,可以通過 "Merge shortest Path to GC Root" 選項來找到它們被存儲的位置,這個信息也許能夠幫助我們找到該使用 intern 的地方。

intern 的利弊

既然 intern() 方法有這些好處,為什麽不經常使用呢?原因在於它會降低代碼效率。下面給出一個例子:

技術分享

代碼中使用了字符串數組來維護到字符串對象的強引用,另外我們還打印了數組的第一個元素來避免數組由於代碼優化而將數組給銷毀了。接著從數據庫加載 10 個不同的字符串,但在這裏我使用了 new String() 來創建一個臨時的字符串,這和從數據庫裏讀是一樣的。最後我們調用了系統的 GC() 方法,這樣就能排除其他不相關對象的影響,保證結果的正確。 在 64 位,8 G 內存,i5-2520M 處理器的 Windows 系統上運行上面的代碼, 環境為 JDK 1.6.0_27,指定虛擬機參數 -XX:+PrintGCDetails -Xmx6G -Xmn3G 記錄垃圾回收日誌。結果如下:

沒有使用 intern() 方法的結果:

技術分享使用了 intern() 方法的結果:

技術分享可以看到結果差別十分的大。在使用 intern() 方法的時候,程序耗時多了 3 秒,但節省了很大一塊內存。使用 intern() 方法的程序占用了 253472K(250M) 內存,而不使用的占用了 2397635K (2.4G)。從這些可以看出使用 intern 的利弊。

String in JDK6,7 and 8

String.intern() in Java 6

JAVA6中字符串池最大的問題是他的位置-永久代.永久代具有固定尺寸並且在運行時不能被擴展.它能使用參數 -XX:MaxPermSize=96m。 據我所知, 永久代默認的大小位於32M96M間依賴平臺.你能增大尺寸.但是字符串池的尺寸依然是固定的.這個限制需要我我們小心的使用String.intern-你最好對不能控制的字符不要使用intern這方法. 這是為什麽在JAVA6中大部分使用手動管理map來實現字符串池

String.intern() in Java 7

Oracle 工程師在java7中對字符串池作了一個極其重要的決定-把字符串池移動到堆中.

字符串池是使用一個擁有固定容量的hashmap 默認的池大小是1009.(出現在上面提及的bug 報告的源碼中).是一個常量在JAVA6早期版本中,隨後在java6_30java6_41中開始為可配置的.而在java 7中一開始就是可以配置的(至少在java7_02中是可以配置的).你需要指定參數 -XX:StringTableSize=N, N是字符串池map的大小. 確寶他是一個為更好的性能預先準備的數字.(不要使用1,000,000 作為-XX:StringTaleSize 的值 - 它不是質數;使用1,000,003代替)

String.intern() in Java 8

java8依然接受 -XX:StringTableSize. 提供可以與JAVA7媲美的性能. 唯一不同的是默認的池大小增加到25-50K

使用-XX:StringTableSize 參數在JAVA78中設置字符串池的大小.它是固定的.因為他的實現是一個由桶帶鏈表組成的hashmap.靠近這個數並且設置池的大小等於靠近這個數的質數.他會使String.intern運行在一個常量時間裏並且只需要消耗相當小的內存(同樣的任務,使用java WeakHashMap將消耗4-5倍的內存)

intern 原理

intern() 方法需要傳入一個字符串對象(已存在於堆上),然後檢查 StringTable 裏是不是已經有一個相同的拷貝。StringTable 可以看作是一個 HashSet,它將字符串分配在永久代上。StringTable 存在的唯一目的就是維護所有存活的字符串的一個對象。如果在 StringTable 裏找到了能夠找到所傳入的字符串對象,那就直接返回它,否則,把它加入 StringTable

技術分享

  • 自Java 1.7.0_06版本起,String.substring方法會為每個子串創建一個新的char[] value(而不是共享母串的char[] value)。這意味著String.substring方法的時間復雜度由常數階變為線性階。這種變化的好處是String對象占用的內存稍微少了一些(比以前少8個字節),同時確保String.substring方法不會導致內存泄漏(有關Java對象內存布局的詳細信息,請見Stringpackingpart1:convertingcharacterstobytes)。
  • Java 7u6+版本中的功能,在Java 8中被刪除。自Java 1.7.0_06版本起,String類有了第二個哈希函數:hash32。該方法目前還不是公有的,只能通過使用反射機制或者是調用sun.misc.Hashing.stringHash32(String)來訪問該方法。只有當那7種哈希相關的JDK容器的大小超過系統變量jdk.map.althashing.threshold所設定的閥值時,該方法才會被使用。這是一個試驗性質的功能,目前我不推薦在代碼中使用這一功能。
  • Java 7u6 (包含Java 7u6)至Java 7u40(不包含Java 7u40)版本中的功能,不適用於Java 8新的哈希實現引入了一個性能上的bug,這個bug涉及Java 7u6 (包含Java 7u6)到Java 7u40(不包含Java 7u40)之間所有版本中所有標準的非concurrent的MapSet容器。這個bug只影響多線程應用每秒鐘創建Map實例的效率。詳情請見本文第三章節。Java 7u40版本已修復這個bug

Java中String的設計