String 是日常開發非常頻繁的類,此外我們常用的操作還有字元串連接配接操作符等等。String對象是不可變的,檢視JDK文檔,我們不難發現String類的每個修改值的方法,其實都是建立了一個新的String對象,以包含修改後的字元串内容。我們分析String源碼,除了要了解它提供的方法是如何被使用,如果結合JVM記憶體結構的設計思路來一起分析,可以舉一反三。
開講前,我們先回顧下JVM的基本結構。根據《Java虛拟機規範(Java SE 7版)》。(這章重點是堆、方法區、運作時常量池)

- 程式計數器(Program Counter Register):目前線程執行的位元組碼訓示器
- Java虛拟機棧(Java Virtual Machine Stacks):Java方法執行的記憶體模型,每個方法會建立一個棧幀用于存儲局部變量表、操作數棧、動态連結、方法出口等資訊
- 本地方法棧(Native Method Stack):(虛拟機使用到的)本地方法執行的記憶體模型
- Java堆(Java Heap):虛拟機啟動時建立的記憶體區域,唯一目的是存放對象執行個體,處于邏輯連續但實體不連續記憶體空間中
- 方法區(Method Area):堆的一個邏輯部分。存儲被虛拟機加載的Class資訊:類名、通路修飾符、常量池(靜态變量/常量)、字段描述、方法描述等資料
- 運作時常量池(Runtime Constant Pool):方法區的一部分,存放:編譯器生成的各種字面值和符号引用,這部分内容會在類加載後進入方法區的運作時常量池中存放
String類
且看JDK8下,String的類源碼,我們能對其全貌了解一二了:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence{ /** The value is used for character storage. */ private final char value[]; }
- final 修飾類名:String 作為不可重寫類它保證了線程安全。
- Serializable 實作接口:String 預設支援序列化。
- Comparable<String> 實作接口:String 支援與同類型對象的比較與排序。
- CharSequence 實作接口:String 支援字元标準接口,具備以下行為:length/charAt/subSequence/toString,在jdk8之後,CharSequence 接口預設實作了chars()/codePoints() 方法:傳回 String對象的輸入流。
另外,JDK9與JDK8的類聲明比較也有差異,下面是JDK9的類描述源碼部分:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { /** The value is used for character storage. */ @Stable private final byte[] value; private final byte coder; @Native static final byte LATIN1 = 0; @Native static final byte UTF16 = 1; static final boolean COMPACT_STRINGS; static { COMPACT_STRINGS = true; } }
- 在JDK8中:String 底層最終使用字元數組 char[] 來存儲字元值;但在JDK9之後,JDK維護者将其改為了 byte[] 數組作為底層存儲(究其原因是JDK開發人員調研了成千上萬的應用程式的heap dump資訊,然後得出結論:大部分的String都是以Latin-1字元編碼來表示的,隻需要一個位元組存儲就夠了,兩個位元組完全是浪費)。
-
在JDK9之後,String 類多了一個成員變量 coder,它代表編碼的格式,目前String支援兩種編碼格式LATIN1和UTF16。LATIN1需要用一個位元組來存儲。而UTF16需要使用2個位元組或者4個位元組來存儲。
而實際上,JDK對String類的存儲優化由來已久了:
String類常用方法清單
String 類(JDK8)提供了很多實用方法,礙于篇幅,這裡以清單形式概括總結:
方法 | 作用 | 備注 |
length() | 字元串的長度 | |
charAt() | 截取一個字元 | |
getChars() | 截取多個字元到目标數組 | |
getBytes() | 傳回字元串的位元組數組 | 以平台預設的編碼字元集 |
toCharArray() | 完整拷貝到一個新字元數組 | |
equals()和 equalsIgnoreCase() | 比較兩個字元串 | equals() 覆寫重寫了Object類的方法 |
regionMatches() | 用于比較一個字元串中特定區域與另一特定區域,它有一個重載的形式允許在比較中忽略大小寫。 | Sring提供了兩個同名重載方法 |
startsWith()和 endsWith() | startsWith()方法決定是否以特定字元串開始,endWith()方法決定是否以特定字元串結束 | |
equals()和== | equals()方法比較字元串對象中的字元,==運算符比較兩個對象是否引用同一執行個體。 | equals() 覆寫重寫了Object類的方法 |
concat() | 連接配接兩個字元串 | |
replace() | 替換:第一種形式用一個字元在調用字元串中所有出現某個字元的地方進行替換; 第二種形式是用一個字元序列替換另一個字元序列; | |
trim() | 去掉起始和結尾的空格 | |
valueOf() | 轉換為字元串 | Sring提供了九個同名重載方法 |
toLowerCase() | 轉換為小寫 | |
toUpperCase() | 轉換為大寫 | |
intern() | 傳回字元串常量池的String對象(詳見下文) | String類中的一個native方法,底層是用c++來實作的 |
編譯器優化字元串拼接
我們看個例子1:
/** * <p>"+" 和 "+=" 是Java重載過的操作符,編譯器會自動優化引用StringBuilder,更高效</p > */ public class Concatenation { public static void main(String[] args) { String mango = "mango"; String s = "abc" + mango + "def" + 47; System.out.print(s); } }
我們使用javac編譯結果:
得出結論:在java檔案中,進行字元串拼接時,編譯器會幫我們進行一次優化:new一個StringBuilder,再調用append方法對之後拼接的字元串進行連接配接。低版本的java編譯器,是通過不斷建立StringBuilder來實作新的字元串拼接。
實際上:
- 字元串拼接從jdk5開始就已經完成了優化,并且沒有進行新的優化。
- 我們java循環内的String拼接,在編譯器解析之後,都會每次循環中new一個StringBuilder,再調用append方法;這樣的弊端是多次循環之後,産生大量的失效對象(即使GC會回收)。
- 我們編寫java代碼時,如果有循環體的話,好的做法是在循環外聲明StringBuilder對象,在循環内進行手動append。這樣不論外面循環多少層,編譯器優化之後都隻有一個StringBuilder對象。
字元串與JVM記憶體配置設定
不同版本的JVM的記憶體配置設定設計略有差異。目前主流jdk版本是jdk7和jdk8,結合JVM記憶體配置設定圖,我們可以從底層上剖析字元串在JVM的記憶體配置設定流程。
不過首先,我們得捋順3種常量池的關系和存在:
- 全局字元串常量池(string pool,也做string literal pool)
- class檔案常量池(class constant pool)
- 運作時常量池(runtime constant pool)
一、全局字元串常量池(String Pool)-- 位于方法區
全局字元串池裡的内容是,string pool中存的是引用值而不是具體的執行個體對象,具體的執行個體對象是在堆中開辟的一塊空間存放的。
在HotSpot VM裡實作的string pool功能的是一個StringTable類,它是一個哈希表,裡面存的是駐留字元串(也就是我們常說的用雙引号括起來,如"java")的引用,也就是說在堆中的某些字元串執行個體被這個StringTable引用之後,就等同被賦予了”駐留字元串”的身份。
這個StringTable在每個HotSpot VM的執行個體隻有一份,被所有的類共享。
字元串常量池的作用:為了提高比對速度,也就是為了更快地查找某個字元串是否在常量池中,Java在設計常量池的時候,還搞了張stringTable,這個有點像我們的hashTable,根據字元串的hashCode定位到對應的桶,然後周遊數組查找該字元串對應的引用。如果找得到字元串,則傳回引用,找不到則會把字元串常量放到常量池中,并把引用儲存到stringTable了裡面。
在JDK7、8中,可以通過-XX:StringTableSize參數StringTable大小
二、class檔案常量池(Constant Pool Table)--位于本地
class檔案常量池(constant pool table):用于存放編譯器生成的各種字面量(Literal)和符号引用(Symbolic References)。
1、字面量就是我們所說的常量概念,如文本字元串、被聲明為final的常量值等。
2、符号引用是一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時能無歧義地定位到目标即可(它與直接引用區分一下,直接引用一般是指向方法區的本地指針,相對偏移量或是一個能間接定位到目标的句柄)。
符号引用一般包括下面三類常量:
2.1、 類和接口的全限定名 2.2、 字段的名稱和描述符 2.3、 方法的名稱和描述符
常量池是最繁瑣的資料,因為下面的14種常量類型各自均有自己的結構,下面僅列出類型清單,每種類型的常量結構可以參考《深入了解Java虛拟機》(P169)。
結合我們以上面例1的類檔案為例,看下class檔案常量池有以下資訊:
三、運作時常量池 -- 與JVM版本相關
運作時常量池,在JVM1.6記憶體模型中位于方法區,JVM1.7記憶體模型中位于堆,在JVM1.8記憶體模型中位于元空間(堆的另一種實作方式)。
而永久代是Hotspot虛拟機特有的概念,是方法區的一種實作,别的JVM都沒有這個東西。
字元串常量池和運作時常量池邏輯上屬于方法區,但是實際存放在堆記憶體中,是以既可以說兩者存放在堆中,也可以說兩則存在于方法區中,這就是造成誤解的地方。
- 在Java 6中,方法區中包含的資料,除了JIT編譯生成的代碼存放在native memory的CodeCache區域,其他都存放在永久代;
- 在Java 7中,Symbol的存儲從PermGen移動到了native memory,并且把靜态變量從instanceKlass末尾(位于PermGen内)移動到了java.lang.Class對象的末尾(位于普通Java heap内);
- 在Java 8中,永久代被徹底移除,取而代之的是另一塊與堆不相連的本地記憶體——元空間(Metaspace);
四、總結字元串的生命周期
總結一下字元串的生命周期(JVM version>= 1.7):1、java檔案中聲明一個字元串常量:“java”;
2、經過編譯,“java” 字元串進入到 類檔案常量池裡;3、類檔案加載到JVM後,“java”字元串會被加載到運作時常量池(儲存的是内容);
4、在JVM啟動之後,随着業務進行,對于後續動态生成的字元串,它們通過建立一個對象(new的對象存在于堆,運作時常量池保留的是new的對象的位址,儲存的是對象位址);5、字元串作為常量長期駐留在JVM記憶體模型的某個角落,或是永久代,或是元空間;(它們)或許會被GC所回收,或許永遠不會被回收,這就取決于不同版本JVM的垃圾回收政策和記憶體管理算法了。
圖解String.intern() 底層原理
String 類的 intern() 方法跟JVM記憶體模型設計息息相關: JDK6:intern()方法,會把首次遇到的字元串執行個體複制到字元串常量池(永久代)中,傳回的也是字元串常量池(永久代)中這個字元串執行個體的引用。
JDK6,常量池和堆是實體隔離的,常量池在永久代配置設定記憶體,永久代和Java堆的記憶體是實體隔離的。
此處的 intern() ,是将在堆上對象存的内容"abc"拷貝到常量池中。
JDK7及之後:intern()方法,如果字元串常量池中已經包含一個等于此String對象的字元串,則傳回代表池中這個字元串的String對象,否則将此String對象包含的字元添加到常量池中,并傳回此String對象的引用。
JDK7,常量池和堆已經不是實體分割了,字元串常量池已經被轉移到了java Heap中了。
此處的 intern() 則是将在堆上的位址引用拷貝到常量池裡。
我們得出結論,比較上面兩者的差異是:String 的 intern() 方法分别拷貝了堆對象的内容和位址。我們通過例子2,可以更好了解 intern() 方法的底層原理:
- 我們建立了一個 String 對象,并調用構造器,用字元串字面量初始化它
- 我們建立了一個 String 對象,并調用構造器,用字元數組初始化它
public class TestIntern { public static void main(String[] args){ testIntern(); } private static void testIntern() { String x =new String("def"); String y = x.intern(); System.out.println(x == y); String a =new String(new char[]{'a','b','c'}); String b = a.intern(); System.out.println(a == b); } }
(JDK7/8)運作結果:
false true
如何解析這個運作結果呢?
1)且先看 java檔案 的編譯結果:
結論:在類檔案常量池中,存在字面量“def”,未存在數組 {'a','b','c'} 。也正是因為這個差異,在類加載過程中,前者會首先加載到字元串常量池中,而後者則是在對象建立後,才将拷貝對象的位址資訊到字元串常量池。
2)兩種初始化方式有何差別?
- 字元串 "def",編譯期後放在類檔案常量池,是以會被自動加載到JVM的方法區的常量池内。調用 x.intern() 方法傳回的是編譯器已經建立好的對象,跟x不是一個對象。是以結果是false。
-
字元數組 new char[]{'a','b','c'},是動态建立的字元串類,此前并未提前加載到JVM的方法區的常量池内。
是以String對象a建立完成之後,再将該字元串對象的引用拷貝常量池内(a對象的引用),調用 a.intern() 傳回的是JVM的方法區的常量池内(a對象的引用)。是以結果是true。
總結
上文我們介紹了String類常用方法清單,結合JVM記憶體結構和案例分析了3個底層原理,希望大家有所收益:
- 編譯器如何優化了字元串的拼接;
- 圖解分析字元串與JVM記憶體配置設定之間的關系;
- 不同虛拟機版本下,String.intern() 的相同點與不同點。
—END—
掃描二維碼
擷取技術幹貨
背景技術彙