深入瞭解String物件
1. String類解析
字串廣泛應用 在 Java 程式設計中,在 Java 中字串屬於物件,Java 提供了 String 類來建立和操作字串。
1.1 什麼是不可變特性?
在Java中,不可變特性可分為兩種情況:
- 對於基本型別,初始化後的值不能改變
- 對於引用型別,物件初始化後不能改變其引用的地址,並且物件所有的狀態及屬性在其生命週期內不會發生任何變化。
1.2 為什麼說String是不可變物件?
我們來看下String類的原始碼,在JDK8中,String類內部是用char陣列來儲存資料的,並且value陣列被宣告為final,因此value 陣列初始化之後就不能再引用其它陣列(char陣列是引用型別)。再加上String 內部沒有改變 value 陣列的方法,所以可以保證 String 不可變(暴力反射可使String值改變,下文會做講解)。
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { /** The value is used for character storage. */ private final char value[]; /** Cache the hash code for the string */ private int hash; // Default to 0 }
注: 在 Java 9 之後,String 類的實現改用 byte 陣列儲存字串,同時使用 coder
來標識使用了哪種編碼。
1.3 String不可變的好處
- 執行緒安全,有利於併發程式設計。
- String作為網路連線的引數,它的不可變性提供了安全性。
- 快取hashCode。HashMap 或者 HashSet用String做key時,由於String的不可變保證了hashCode的值不改變。這也就意味著,不用每次使用的時候都要計算其hashCode,這樣更高效。
- 字串常量池成立的基礎。字串常量池是方法區的一個特殊的儲存區。當定義一個變數等於字串字面量的時候,如果此字串字面量在常量池中早已存在,會返回一個已經存在字串的引用,而不是新建一個物件。如果String可變,那麼用某個引用一旦改變了字串的值將會導致其他引用指向錯誤的值。
1.4 String Pool(字串常量池)
字串的分配和其他物件分配一樣,是需要消耗高昂的時間和空間的,而且字串使用的非常多。
JVM為了提高效能和減少記憶體的開銷,在例項化字串的時候進行了一些優化:使用字串常量池。
每當建立字串常量時,JVM會首先檢查字串常量池,如果該字串已經存在常量池中,那麼就直接返回常量池中的例項引用。如果字串不存在常量池中,就會例項化該字串並且將其放到常量池中。由於String字串的不可變性,常量池中一定不存在兩個相同的字串。
例如:
String s1 = "字面量a";
String s2 = "字面量a";
System.out.println(s1 == s2); // true
那如果是以 new String ("字面量a")建立的字串,是否會從字串常量池取已存在的字串呢? 我們來看下例子:
String s1 = "字面量a";
String s2 = new String("字面量a");
System.out.println(s1 == s2); // false
顯然,以new String ("字面量a")建立的字串是不從字串常量池中獲取物件的,那反過來想,以new String ("字面量a")建立的字串會不會存到字串常量池中呢?
public static void main(String[] args) {
String s2 = new String("字面量a");
}
我們用 javap -verbose 進行反編譯看下:
Constant pool:
//...
#2 = Class #16 // java/lang/String
#3 = String #17 // 字面量a
//...
#16 = Utf8 java/lang/String
#17 = Utf8 字面量a
//...
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // String 字面量a
6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
10: return
//...
}
在 Constant Pool 中,#17 儲存這字串字面量 "字面量a",#3 是 String Pool 的字串物件,它指向 #17 這個字串字面量。在 main 方法中,0: 行使用 new #2 在堆中建立一個字串物件,並且使用 ldc #3 將 String Pool 中的字串物件作為 String 建構函式的引數。
通俗點來講就是,使用new String ("字面量a")這種方式一共會建立兩個字串物件(前提是字串常量池中還沒有 "字面量a" 字串)。
- 在編譯期間,如果發現有字串字面量不在字串常量池中,就會在字串常量池中建立一個字串物件,指向這個 "字面量a" 字串字面量;
- 在程式執行期間,使用 new 的方式在堆中建立一個字串物件。
注:在 Java 7 之前,String Pool 被放在執行時常量池中,它屬於永久代。而在 Java 7,String Pool 被移到堆中。這是因為永久代的空間有限,在大量使用字串的場景下會導致 OutOfMemoryError 錯誤。
1.5 String的Intern方法
intern方法的定義:intern 方法是一個native方法,intern方法會從字串常量池中查詢當前字串是否存在,如果存在,就直接返回當前字串的引用;如果不存在就會將當前字串放入常量池中,之後再返回這個新字串的引用。
看下面的程式碼:
String s1 = new String("字面量a");
String s2 = new String("字面量a");
System.out.println(s1 == s2); // false
String s3 = s1.intern();
String s4 = s2.intern();
System.out.println(s3 == s4); // true
s3s4為true的原因是,s1和s2呼叫intern方法去字串常量池查詢“字面量a”這個字串時,不管字串常量池存不存在“字面量a”,只要s1和s2的值相同,那麼intern方法最終返回的引用就是一樣的,所以s3s4為true。
在實際應用場景中,如果有大量相同字串重複使用時,可以用intern方法犧牲一些執行時間,來節省大量的記憶體空間。
1.6 String、StringBuilder和StringBuffer
前面提到過,由於String是不可變的,在對String物件進行拼接、裁剪時,都會建立一個新的String物件。如果拼接和裁剪的次數過多,將會佔用大量的記憶體空間。
和 String 類不同的是,StringBuffer 和 StringBuilder 類的物件能夠被多次的修改,並且不產生新的未使用物件。
我們通過下面的程式碼看一下他們的效率差別:
public static void main(String[] args) {
String s="";
StringBuilder s2=new StringBuilder();
StringBuffer s3=new StringBuffer();
long start = System.currentTimeMillis();
for(int i=1;i<=100000;i++){
s+=i;
//s2.append(i);
//s3.append(i);
}
long end = System.currentTimeMillis();
System.out.println("耗時"+(end-start)+"毫秒");
//String耗時18458毫秒 StringBuilder耗時6毫秒 StringBuffer耗時7毫秒
}
從上面程式碼可以看出,在有大量的字元拼接和裁剪時,String和StringBuilder、StringBuffer的效率天差地別。而
StringBuilder和StringBuffer的區別在於,StringBuilder的效率比StringBuffer高一些。StringBuilder是執行緒不安全的,StringBuffer是執行緒安全的。
對這三個類做個總結:
-
String、StringBuffer是執行緒安全的,StringBuilder 不是執行緒安全的
-
操作少量資料時,使用String
-
單執行緒有大量字串拼接和裁剪操作時,使用StringBuilder
-
多執行緒有大量字串拼接和裁剪操作時,使用StringBuffer
1.7 String物件的值真的不能改變嗎?
我們前面說,String物件是不可變的,但是String物件的值真的不能改變嗎?
其實我們可以通過反射來改變String的值,例子如下:
public static void main(String[] args)
throws NoSuchFieldException, IllegalAccessException {
String str = "123456";
//輸出修改前的String
System.out.println("str = " + str);
System.out.println("hashCode = " + str.hashCode());
Field valueField = String.class.getDeclaredField("value");
//暴力破解私有屬性
valueField.setAccessible(true);
//獲取String類儲存資料的char陣列(JDK8)
char[] valueCharArr = (char[]) valueField.get(str);
valueCharArr[0] = '改';
//輸出修改後的String
System.out.println("str = " + str);
System.out.println("hashCode = " + str.hashCode());
}
上述程式碼的輸出為:
str = 123456
hashCode = 1450575459
str = 改23456
hashCode = 1450575459
我們發現,String的值確實被我們通過反射修改了。既然String可以修改,那麼為什麼說String是不可變物件呢?其實很好理解,在日常生活中,我們經常會看到一些此處禁止停車,否則後果自負的標語,這說明我們一定不能停車在這個地方嗎?實際上我們還是可以停在這個地方,只不過需要承擔一定的後果。String物件也一樣,正常情況下,它是不可變的,你通過反射把String類的值修改了,說明你對修改String值會導致什麼樣的結果十分清楚了。
比較有意思的一個地方是,如果把str的定義加上final宣告,str字串反射修改後與修改前輸出是一樣的。
final String str = "123456";
程式碼輸出為:
str = 123456
hashCode = 1450575459
str = 123456
hashCode = 1450575459
為什麼會導致這樣的結果呢?
其實這是JDK編譯時對String物件的優化,如果對String物件做了final宣告,那麼編譯器就會把宣告為final的String物件變成對應的字串字面量。
即
//輸出修改後的String
System.out.println("str = " + str);
被編譯器優化成了
//輸出修改後的String
System.out.println("str = " + "123456");
實際上str的值已經被我們修改了,我們可以把程式碼改成下面這樣,看下輸出結果:
//輸出修改後的String
System.out.println("str = " + str.toString());
修改後程式輸出如下:
str = 123456
hashCode = 1450575459
str = 改23456
hashCode = 1450575459
可見,str字串已經被我們修改了,只是輸出列印時,被編譯器優化成字串字面量了,自然也就無法在執行時列印修改後的字串了。