Java中String的應用無處不在,無論是算法題還是面試題,String都獨佔一方,甚至是無數面試者心中難以名狀的痛。本文著重對String(若無特地說明,默認是JDK 1.8版本)常見的問題來進行介紹:
- 字符串的不可變性
- JDK 1.6和JDK 1.7中substring的原理及區別
- replaceFirst、replaceAll、replace區別
- String對“+”的“重載”
- 字符串拼接的幾種方式和區別
- Integer.toString()和String.valueOf()的區別
- switch對String的支持(JDK 1.7及其後版本)
- 字符串常量池、Class常量池、運行時常量池
- String.intern()方法
1. 字符串的不可變性
我們先來看看下面這段代碼:
<code>/** 公眾號:Java後端 */public class Test {
public static void main(String[] args) {
String str1 = new String("abc");
String str2 = new String("abc");
System.out.println("str1 == str2:" + str1 == str2);
}
}/<code>
一般都能看出來,這運行結果肯定是false啊,可是為什麼呢?
在解釋之前,先介紹一下System.identityHashCode():
System.identityHashCode()的作用是用來判斷兩個對象是否是內存中同一個對象,跟用==判斷內存地址是否一樣的效果一樣。
<code>System.out.println("str1:" + System.identityHashCode(str1));
System.out.println("str2:" + System.identityHashCode(str2));/<code>
從關鍵詞new就可以看出,這兩個String變量在堆上不可能是同一塊內存。其表現(本圖是基於JDK1.7,至於字符串常量池後文會介紹):
那麼如果加入以下代碼,其輸出結果會是怎麼樣的呢?
<code>String str3 = str1;
System.out.println("str1 == str3:" + str1 == str3);
str3 += "ny";
System.out.println("str1 == str3:" + str1 == str3);/<code>
第一個結果為true,而第二個結果為false。顯而易見,第二個結果出現不同是因為str3賦值為"ny",那麼這整個過程是怎麼表現的呢?
當str3賦值為str1的時候,實際上是str3與str1指向同一塊內存地址:
而str3賦值為str3+“ny"時,實際上是在常量池重新創建了一個新的常量"abcny”,並且賦予了不同的內存地址,即:
總結一下:字符串一旦創建,虛擬機就會在常量池裡面為此字符串分配一塊內存,所以它不能被改變。所有的字符串方法都是不能改變自身的,而是返回一個新的字符串。
如果需要改變字符串的話,可以考慮使用StringBuffer或StringBuilder來,否則每次改變都會創建一個新的字符串,很浪費內存。
2. JDK 1.6和JDK 1.7中substring的原理及區別
JDK 1.6和JDK 1.7中的substring(int beginIndex, int endIndex)方法的實現是不同的,為簡單起見,後文中用substring()代表(int beginIndex, int endIndex)方法。首先我們先連接一下substring()方法的作用:
<code>String str = "我不是你最愛的小甜甜了嗎?";
str = str.substring(1,3);
System.out.println(str);/<code>
運行結果為:
<code>不是/<code>
我們可以看到,substring()方法的作用是截取字段並返回其[beginIndex, endIndex-1]的內容。
接下來我們來看看JDK 1.6和JDK 1.7在實現substring()時原理上的不同。
JDK 1.6的substring
String是通過字符數組來實現的,我們先來看下源碼:
<code>public String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}
public String substring(int beginIndex, int endIndex) {
return new String(offset + beginIndex, endIndex - beginIndex, value);
}/<code>
可以看到,在JDK 1.6中,String類包含3個重要的成員變量:char value[](存儲真正的字符串數組)、int offset(數組的第一個位置索引)、int count(字符串中包含的字符個數)。
而在虛擬機中,當調用substring方法的時候,堆上會創建一個新的string對象,但是這個string與原先的string一樣,指向同一個字符數組,它們之間只是offset和count不相同而已。
這種結構看上去挺好的,只需要創建一個字符數組,然後可以通過調整offset和count就可以返回不同的字符串了。但事實證明,這種情況還是比較少見的,更常見的是從一個很長很長的字符串中切割出需要用到的一小段字符序列,這種結構會導致很長的字符數組一直在被使用,無法回收,可能導致內存洩露。所以一般都是這麼解決的,原理就是生成一個新的字符並引用它。
<code>str = str.substring(1, 3) + "";/<code>
JDK 1.7的substring
所以在JDK 1.7提出了一個新的substring()截取字符串的實現:
<code>public String(char value[], int offset, int count) {
this.value = Arrays.copyOfRange(value, offset, offset + count);
}
public String substring(int beginIndex, int endIndex) {
int subLen = endIndex - beginIndex;
return new String(value, beginIndex, subLen);
}/<code>
我們可以看到,String構造函數的實現已經換成了Arrays.copyOfRange()方法了,這個方法最後會生成一個新的字符數組。也就是說,
使用substring()方法截取字段,str不會使用之前的字符數組,而是引用新生成的字符數組。總結一下:JDK 1.6與JDK 1.7在實現substring()方法時最大的不同在於,前者沿用了原來的字符數組,而後者引用了新創建的字符數組。
3. replaceFirst、replaceAll、replace區別
從字面上看,這三者的區別在於名稱:replace(替換)、replaceAll(替換全部)、replaceFirst(替換第一個符合條件)。在從功能、源碼上對這三者進行介紹之前,我們先來看看這道題:
<code>public static void main(String[] args) {
String str = "I.am.fine.";
System.out.println(str.replace(".", "\\\"));
System.out.println(str.replaceAll(".", "\\\\\\\"));
System.out.println(str.replaceFirst(".", "\\\\\\\"));
}/<code>
運行結果為:
<code>I\\am\\fine\\
\\\\\\\\\\\\\\\\\\\\
\\.am.fine./<code>
做對了嗎?下面來分別對這三者進行介紹。
replace
結合題目中的執行replace()方法後的輸出結果,我們來看看在Java中的replace()的源碼:
<code>public String replace(CharSequence target, CharSequence replacement) {
return Pattern.compile(target.toString(), Pattern.LITERAL).
matcher(this).replaceAll(Matcher.quoteReplacement(replacement.toString()));
}/<code>
可以看到
replace()只支持入參為字符序列,而且實現的是完全替換,只要符合target的字段都進行替換。replaceAll
在進行介紹之前我們先看看源碼:
<code>public String replaceAll(String regex, String replacement) {
return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}/<code>
我們可以看到,replaceAll()支持入參為正則表達式,而且此方法也是實現字段的完全替換。從運行結果中我們能看到所有的字符都被替換了,其實是因為".“在正則表達式中表示"所有字符”,如果想要只替換"."而非全部字段,則可以這麼寫:
<code>System.out.println(str.replaceAll("\\\\.", "\\\\\\\"));/<code>
replaceFirst
其實從上面的運行結果來看,也知道
replaceFirst也是支持入參為正則表達式,但是此方法實現的是對第一個符合條件的字段進行替換。<code>public String replaceFirst(String regex, String replacement) {
return Pattern.compile(regex).matcher(this).replaceFirst(replacement);
}/<code>
總結一下,replace不支持入參為正則表達式但能實現完全替換;replaceAll支持入參為正則表達式且能實現完全替換;replaceFirst支持入參為正則表達式,但替換動作只發生一次。
4. String對“+”的“重載”
當我們查看String的源碼時,我們可以看到:
<code>private final char value[];/<code>
而且在上文我們已經提到String具有不可變性,可當我們在使用“+”對字符串進行拼接時,卻可以成功。它的原理是什麼呢?舉個栗子:
<code>public static void main(String[] args) {
String str = "abc";
str += "123";
}/<code>
然後我們查看反編譯後的結果:
可以看到,雖然我們沒有用到java.lang.StringBuilder類,但編譯器為了執行上述代碼時會引入StringBuilder類,對字符串進行拼接。
其實很多人認為使用”+“拼接字符串的功能可以理解為運算符重載,但Java是不支持運算符重載的(但C++支持)。
運算符重載:在計算機程序設計中,運算符重載(operator overloading)是多態的一種。運算符重載就是對已有的運算符進行定義,賦予其另一種功能,以適應不同的數據類型。
從反編譯的代碼來看,其實這只是一種Java語法糖。
總結一下,String使用"+"進行拼接的原理是編譯器使用了StringBuilder.append()方法進行拼接,且這是一種語法糖。
5. 字符串拼接的幾種方式和區別
字符串拼接是字符串處理中常用的操作之一,即將多個字符串拼接到一起,但從上文我們已經知道了String具有不可變性,那麼字符串拼接又是怎麼做到的呢?
String.concat()拼接
在介紹concat原理之前,我們先看看concat是怎麼使用的:
<code>public static void main(String[] args) {
String str = "我不是你最愛的小甜甜了嗎?";
str = str.concat("你是個好姑娘");
System.out.println(str);
}/<code>
運行結果為:
我不是你最愛的小甜甜了嗎?你是個好姑娘
我們可以看到,concat()方法是String類的,且是將原本的字符串與參數中的字符串進行拼接。現在我們來看看它的源碼:
<code>public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}/<code>
可以看到,concat()的拼接實際上是,創建一個長度為已有字符串和待拼接字符串的長度之和的字符數組,然後將兩個字符串的值賦值到新的字符數組中,最後利用這個字符數組創建一個新的String對象。
StringBuilder.append()拼接
上文在介紹String的"+"拼接時,StringBuilder已經出來混個臉熟了,現在我們看個例子:
<code>public static void main(String[] args) {
StringBuilder sb = new StringBuilder("我不是你最愛的小甜甜了嗎?");
sb.append("你是個好姑娘");
System.out.println(sb.toString());
}/<code>
運行結果同上,接下來我們來看看StringBuilder的實現原理。StringBuilder內部同String類似,也封裝了一個字符數組:
<code>char[] value;/<code>
與String相比,StringBuilder的字符數組並不是final修飾的,即可修改。而且字符數組中不一定所有位置都已經被使用了,StringBuilder有一個專門記錄使用字符個數的實例變量:
<code>int count;/<code>
而StringBuilder.append()的源碼如下:
<code>public StringBuilder append(String str) {
super.append(str);
return this;
}/<code>
可以看到StringBuilder.append()方法是採用父類AbstractStringBuilder的append()方法 :
<code>public AbstractStringBuilder append(String str) {
if (str == null)
returpublic AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}n appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}/<code>
ensureCapacityInternal()方法用於擴展字符數組長度(有興趣的讀者可以查看其擴展的方法),所以這裡的append方法會直接拷貝字符到內部的字符數組中,如果字符數組長度不夠,則進行擴展。
StringBuffer.append()拼接
StringBuffer和StringBuilder結構類似,且父類都是AbstractStringBuilder,二者最大的區別在於StringBuffer是線程安全的,我們來看下StringBuffer.append()的源碼:
<code>public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}/<code>
可以看到,StringBuffer.append()方法是使用synchronized進行聲明,說明這是一個線程安全的方法,而上文StringBuilder.append()則不是線程安全的方法。
StringUtils.join()拼接
這個拼接方式適用於字符串集合的拼接,舉個栗子:
<code>public static void main(String[] args) {
List<string> list = new ArrayList<>();
list.add("我不是你最愛的小甜甜了嗎?");
list.add("你是個好姑娘");
String s = new String();
s = StringUtils.join(list, s);
System.out.println(s);
}/<string>/<code>
運行結果同上,接下來我們來看一下原理:
<code>public static String join(Collection var0, String var1) {
StringBuffer var2 = new StringBuffer();
for(Iterator var3 = var0.iterator();
var3.hasNext(); var2.append((String)var3.next())) {
if (var2.length() != 0) {
var2.append(var1);
}
}
return var2.toString();
}/<code>
StringUtils.join()方法中依然是使用StringBuffer和Iterator迭代器來實現,而且如果集合類中的數據不是String類型,在遍歷集合的過程中還會強制轉換成String。
總結一下,加上上文介紹的使用“+”進行字符串拼接的方式,此文一共介紹了五種字符串拼接的方式,分別是:使用"+"、使用String.concat()、使用StringBuilder.append()、使用StringBuffer.append()、使用StringUtils.join()。需要強調的是:
- 使用StringBuilder.append()的方式是效率最高的;
- 如果不是在循環體中進行字符串拼接,用"+"方式就行了;
- 如果在併發場景中進行字符串拼接的話,要使用StringBuffer代替StringBuilder。
6. Integer.toString()和String.valueOf()的區別
Integer.toString()方法和String.valueOf()方法來進行int類型轉String,舉個栗子:
<code>public static void main(String[] args) {
int i = 1;
String integerTest = Integer.toString(i);
String stringTest = String.valueOf(i);
}/<code>
平常我們在使用這兩個方法來進行int類型轉String時,並沒有對其加以區分,這次就來深究一下它們之間有何區別,以及使用哪個方法比較好。
Integer.toString()方法
以下為Integer.toString()的實現源碼,其中的stringSize()方法會返回整型數值i的長度,getChars()方法是將整型數值填充字符數組buf:
<code>public static String toString(int i) {
if (i == Integer.MIN_VALUE)
return "-2147483648";
int size = (i char[] buf = new char[size];
getChars(i, size, buf);
return new String(buf, true);
}/<code>
可以看到,Integer.toString()先是通過判斷整型數值的正負性來給出字符數組buf的大小,然後再將整型數值填充到字符數組中,最後返回創建一個新的字符串並返回。
在包裝類中不僅是Integer,同理Double、Long、Float等也有對應的toString()方法。
String.valueOf()方法
String.valueOf()相對於Integer.toString()方法來說,有大量的重載方法,在此列舉出幾個典型的方法。
public static String valueOf(Object obj)
這個方法的入參是Object類型,所以只需要調用Object的toString()方法即可(在編寫類的時候,最好重寫其toString()方法)。
<code>public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}/<code>
public static String valueOf(char data[])
當入參為字符數組時,看過上文的String.concat()方法的原理,我們幾乎可以下意識地反應:這裡的字符數組,應該是用於創建一個新的字符串對象來並返回該字符串了。
<code>public static String valueOf(char data[]) {
return new String(data);
}/<code>
除了字符數組外,字符也是通過轉換成字符數組後,創建一個新的字符串對象來返回字符串。
public static String valueOf(boolean b)
其實布爾型數值的返回結果只有兩種:true或false,所以只要對這兩個數值進行字符處理即可。
<code>public static String valueOf(boolean b) {
return b ? "true" : "false";
}/<code>
public static String valueOf(int i)
上文我們介紹了Integer.toString()方法,這方法String.valueOf()就用到了。而且重載的入參類型不止int,還有long、float、double等。
<code>public static String valueOf(int i) {
return Integer.toString(i);
}/<code>
總結一下,我們看到String.valueOf()有許多重載方法,且關乎於包裝類如Integer等的方法內部還是調用了包裝類自己的方法如Integer.toString()。因其內部重載了不同類型轉換成String的處理,所以推薦使用String.valueOf()方法。
7. switch對String的支持(JDK 1.7及其後版本)
在JDK 1.7之前,switch只支持對int、short、byte、char這四類數據做判斷,而在JVM內部實際上只支持對int類型的處理。因為虛擬機在處理之前,會將如short等類型數據轉換成int型,再進行條件判斷。
在JDK 1.7的中switch增加了對String的支持,照常,先舉個栗子:
<code>public static void main(String[] args) {
String str = "abc";
switch (str) {
case "ab":
System.out.println("ab");
break;
case "abc":
System.out.println("abc");
break;
default:
break;
}
}/<code>
運行結果為:
<code>ab/<code>
因為switch關鍵詞不像是類和方法,可以直接查看源碼,所以這裡採用查看編譯後的Class文件和查看反編譯的方式。首先我們查看編譯後的Class文件:
<code>public static void main(String[] var0) {
String var1 = "abc";
byte var3 = -1;
switch(var1.hashCode()) {
case 3105:
if (var1.equals("ab")) {
var3 = 0;
}
break;
case 96354:
if (var1.equals("abc")) {
var3 = 1;
}
}
......
}/<code>
可以看到,switch的入參為字符串"abc"的hashCode,switch進行判斷的依然還是整數,而且進行判斷的字符串也被轉換成整型數值,在case中還使用了equals()方法對字符串進行判斷,以確認是否進行case內代碼的下一步操作。接下來我們看看反編譯之後的情況:
看到黃色框的代碼,我們可以知道String需要轉換成int類型的整型數據之後才能進行在switch中進行判斷。而紅色框中的代碼中我們可以看到,這個過程不止使用了hashCode()方法,還使用了equals()方法對字符串進行判斷。
但也因switch判斷字符串的實現原理是求出String的hashCode,所以String不能賦值為null,否則會報NullPointerException。
總結一下,switch支持String本質上還是switch在對int類型數值進行判斷。
8. 字符串常量池、Class常量池、運行時常量池
在Java的內存分配中經常聽到關於常量池的描述,但名聲最大的還是運行時常量池,對於字符串常量池和Class常量池近乎沒有印象,甚至是混在一起,在此將這幾個概念進行區分。
字符串常量池
在不知道這個名詞之前,筆者以為字符串會跟類的其他信息一樣存儲在方法區(或永久代)中,但遇到它之後,筆者發覺這事情沒那麼簡單。
我們來看看它和永久代的搬家史:
- JDK 1.7之前,字符串常量池在永久代中
- JDK 1.7,將字符串常量池移出了永久代,搬到了DataSegument中,一個在堆中一個相對特殊的位置(失去唯一引用也不會被回收)
- JDK 1.8,永久代被元空間取代了
字符串常量池中的內容是在字符串對象實例的引用值(字符串常量池中存儲的是引用值,具體的字符串對象實例存放在堆的另一塊空間),而且在HotSpot VM中實現的字符串常量池是一個由C++實現的
StringTable,結構跟Hashtable類似,但區別在於不能自動擴容。這個StringTable在每個HotSpot VM中是被所有的類共享的。這麼說可能有點抽象,不如使用HSDB來親眼看看吧。舉個栗子:
<code>class NY{
String str = "nyfor2020";
}
public class Test {
public static void main(String[] args) {
NY ny1 = new NY();
NY ny2 = new NY();
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
}/<code>
在命令提示符中輸入“jps”查看進程號後,在命令提示符中輸入:
<code>java -classpath "%JAVA_HOME%/lib/sa-jdi.jar" sun.jvm.hotspot.HSDB/<code>
打開HSDB,輸入進程號後使用Object Histogram找到相應類之後,可以找到兩個NY對象引用的字符串的地址是同一個。
Class常量池
在《深入理解Java虛擬機》中對Class常量池的介紹是從這裡引入:
Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後進入方法去的運行時常量池中存放。
字面量即常量概念,如文本字符串、被聲明為final的常量值等。而符號引用即一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用的時候能直接定位到目標即可。
一般所說的類常量有以下三類:
- 類和接口的全限定名
- 字段的名稱和描述符
- 方法的名稱和描述符
關於常量池中的每一個常量表示什麼含義在此就不贅述,想了解的朋友可以參考《深入理解Java虛擬機》的第六章。舉個栗子:
<code>public class Test {
public static void main(String[] args) {
String s1 = "nyfor2020";
}
}/<code>
當我們使用以下命令進行反編譯:
<code>javap -verbose Test.class/<code>
在反編譯之後我們可以直接看到Class常量池中的內容,有類的全限定名、方法的描述符和字段的描述符。
也就是說,當Java文件被編譯成Class文件的過程之後,就會生成Class常量池。那麼運行時常量池又是什麼時候產生的呢?
運行時常量池
運行時常量池是方法區的一部分,用於存放Class文件編譯後生成的Class常量池等信息。
接下來我們結合類加載過程來認識這幾個常量池之間的關係:
在JVM進行類加載過程中必須經過加載、連接、初始化這三個階段(在《Java的繼承(深入版)》中有介紹),而連接過程又包括了驗證、準備和解析這三個階段。
當類加載到內存後,JVM就會將Class常量中的內容存放到運行時常量池中。而在Class常量池中存儲的是字面量和符號引用,而非真正的對象實例,所以在經過解析之後,會將符號引用替換為直接引用,而在解析過程中會去查詢字符串常量池,以保證運行時常量池所應用的字符串與字符串常量池中的信息一致。
9. String.intern()方法
在瞭解三個常量池之間的區別之後,我們來看看與字符串常量池有關的intern()方法.
<code>/**
* Returns a canonical representation for the string object.
*
* A pool of strings, initially empty, is maintained privately by the
* class {@code String}.
*
* When the intern method is invoked, if the pool already contains a
* string equal to this {@code String} object as determined by
* the {@link #equals(Object)} method, then the string from the pool is
* returned. Otherwise, this {@code String} object is added to the
* pool and a reference to this {@code String} object is returned.
*
* It follows that for any two strings {@code s} and {@code t},
* {@code s.intern() == t.intern()} is {@code true}
* if and only if {@code s.equals(t)} is {@code true}.
*/<code>
* All literal strings and string-valued constant expressions are
* interned. String literals are defined in section 3.10.5 of the
* <cite>The Java™ Language Specification/<cite>.
*
* @return a string that has the same contents as this string, but is
* guaranteed to be from a pool of unique strings.
*/
public native String intern();
我們可以看到,intern()方法是一個本地方法,註釋描述的大致意思是:
“當intern()方法被調用時,如果常量池中存在當前字符串,就會直接返回當前字符串;如果常量池中沒有此字符串,會將此字符串放入常量池中後,再返回”。該方法的作用就是把字符串加載到常量池中。
剛剛在介紹字符串常量池時提到它在JDK 1.6和JDK 1.7的內存位置發生了變化,所以在不同版本的JDK中intern()方法的表現也有所差別。舉個栗子:
<code>public static void main(String[] args) {
String str1 = new String("1") + new String("1");
str1.intern();
String str2 = "11";
System.out.println(str1 == str2);
}/<code>
在JDK 1.6中的運行結果為false,在JDK 1.7中的運行結果為true。為什麼會出現這種情況呢?主要是字符串常量池的內存位置變了,導致intern()的內部實現也發生了變化。
在JDK 1.6中的intern()
intern()方法將字符串複製到字符串常量池,然後返回一個該字符串在常量池的引用,但是str1並沒接收到這個應用,所以str1指向的還是堆,但是str2指向的是常量區,所以這兩個地址不一樣 。
在JDK 1.7中的intern()
在JDK 1.7中的intern()方法,(在字符串常量池找不到該字符串時)將該字符串對象在堆裡的引用註冊到常量池,以後在使用
相同字面量聲明的字符串對象則都指向該地址,也就是該字符串在堆中的地址。等等,如果把intern()的位置下移一行之後呢?(基於JDK 1.7)
<code>public static void main(String[] args) {
String str1 = new String("1") + new String("1")
String str2 = "11";
str1.intern();
System.out.println(str1 == str2);
System.out.println(System.identityHashCode(str1));
System.out.println(System.identityHashCode(str2));
}/<code>
運行結果為:
<code>false
22307196
10568834/<code>
可以看到intern()的執行順序改變之後,字符串常量池已經存儲了"1"和"11"引用了,所以str2依然指向的是常量池中的引用,而str1指向的是new出來的字符串對象地址。
結語
在日常使用的時候,我們對於String的態度就像是對待空氣,只有在出問題了才會發現之前沒對它加以瞭解。此文以String問題為契機,對String相關原理進行回顧。
如果本文對你的學習有幫助,請給一個贊吧,這會是我最大的動力
閱讀更多 Java桔煙 的文章