轉載請註明出處:http://blog.csdn.net/tang9140/article/details/43982887
引言
在java編程中,幾乎每天都會跟String打交道,因此,深入理解String及其用法十分有必要。下面分三方面來詳細說明下String相關的特點及用法
- Immutable(不可變)特性
- 連接符號+的本質
- 相等判斷兩種方式(==/equals)說明
一、 Immutable特性
Java設計人員爲了方便大家對字符串的各種操作,抽象出String類,該類封裝了對字符串的查找、拼接、替換、截取等一系列操作。查看java.lang.String的源碼,首先就能看到如下描述:
The String class represents character strings. All string literals in Java programs, such as “abc”, are implemented as instances of this class.
Strings are constant; their values cannot be changed after they are created. String buffers support mutable strings. Because String objects are immutable they can be shared.
大意是:String類代表着字符序列。Java語言中所有的字符串字面量,如“abc”,都實現爲String類的實例。
String對象都是常量,其值在創建之後就不能改變。字符串緩衝區支持可變的字符序列。由於String對象的不可變性,他們可以被共享。
String的immutable體現在兩個方面:
String類被final關鍵字修飾,意味着該類不能被繼承。由於String類不能有子類,保證了String類的靜態和實例方法不可能被繼承修改,保證了安全性。
String類內部的私有成員變量,如value(char[]),offset(int),count(int)都被final關鍵字修飾。當String對象創建後,offset跟count字段的值不可改變(因爲是基本數據類型int),value變量不能再指向其它的字符數組(因爲其是引用類型變量)
看到這裏,可能有人會說:雖然value屬性不能指向其它的字符數組,但其指向的字符數組內容還是可以改變,如果能夠改變其內容,那不意味着String對象是可變的了。
上面的說法沒錯,但是String類本身沒有提供修改字符數組的方法,除非你用非常規手段(例如反射)去改變私有屬性的值(後面會附上代碼實現)。雖然從代碼上完全是可以改變創建後String對象的各屬性的值(即使屬性被private final修飾),但畢竟是採用反射這種非常規手段。按照正常使用方式,我們是不能改變String對象的值,所以還是認爲String對象是不可變的。
注意:這裏說String不變性,是指String對象在創建後其值不能改變。對於引用類型的變量,是可以指向不同的String對象來改變其所代表的值。如
String s = "abc";
s = "def";
引用類型變量s指向值爲‘abc’的String對象,然後s又指向了值爲‘def’的String對象。雖然s所代表的字符串確實改變了,但是對於String對象abc和def並沒有改變,僅僅是s指向了不同的String對象而已。
關於String設計爲immutable,至少有兩方面的好處:
一是安全。String類被final修飾,意味着不可能有子類繼承String類而改變其原有行爲。並且,生成的String對象是不變的,在多線程環境也是安全的。
二是效率。String類被final修飾,隱含着該類的所有方法都是final的,編譯器可以進行一些優化。另外,由於String對象是不變的,可以被多處共享且不需要進行多線程之間的同步,提高了效率。
由於String對象的不變性,在用+號進行字符串連接時,可能會造成效率低下,下面詳細說明下連接符號+的本質是什麼?底層是如何進行字符串拼接的?什麼情況下用+號進行字符串連接效率較低?
二、 連接符號+本質
要了解+號的本質,先從java編譯說起。衆所周知,java代碼在運行前都需要先編譯成Class文件(關於Class文件的結構,由於篇幅有限,這裏不作詳細說明)。在Class文件中,有一部分是叫屬性表集合,其中包括Code屬性,簡單說,Code屬性包含的就是方法體裏面的代碼經過編譯後對應的字節碼指令。因此,我們可以直接查看Class文件中的字節碼指令來了解+的本質。示例代碼如下
public class StringTest {
public static void main(String[] args) {
String s = "Hello";
s = s + " world!";
}
}
由於我們不熟悉Class文件結構,而且字節碼非常不容易看懂,在這裏不直接查看編譯生成的StringTest.class文件的內容,而是通過jad工具反編譯字節碼查看結果。該工具下載地址爲http://download.csdn.net/detail/tang9140/8426571(本人的csdn資源下載地址,打下廣告非喜勿噴)。在cmd下執行jad命令jad -o -a -sjava StringTest.class
成功執行上述命令後,會發現StringTest.class文件所在目錄下會多出源文件StringTest.java,內容如下:
public class StringTest
{
public StringTest()
{
// 0 0:aload_0
// 1 1:invokespecial #8 <Method void Object()>
// 2 4:return
}
public static void main(String args[])
{
String s = "Hello";
// 0 0:ldc1 #16 <String "Hello">
// 1 2:astore_1
s = (new StringBuilder(String.valueOf(s))).append(" world!").toString();
// 2 3:new #18 <Class StringBuilder>
// 3 6:dup
// 4 7:aload_1
// 5 8:invokestatic #20 <Method String String.valueOf(Object)>
// 6 11:invokespecial #26 <Method void StringBuilder(String)>
// 7 14:ldc1 #29 <String " world!">
// 8 16:invokevirtual #31 <Method StringBuilder StringBuilder.append(String)>
// 9 19:invokevirtual #35 <Method String StringBuilder.toString()>
// 10 22:astore_1
// 11 23:return
}
}
上述反編譯出的源代碼包含了註釋行,代表與源代碼相對應的字節碼指令。很顯然,源代碼中並沒有字符串連接符+,也就是說,+號在經過編譯後,已經被替換成StringBuilder的append方法調用(實現上在jdk1.5版本之前,+號在編譯器編譯後是替換爲StringBuffer的append方法調用)。所謂的+號連接字符串,本質上是通過new StringBuilder對象後調用其append方法進行字符串拼接。
java通過在編譯階段重載字符串操作符+,在方便對字符串的操作同時也帶來了一定的副作用,比如由於程序員不清楚+號的本質而編寫出效率低下的代碼,請看如下代碼:
public String concat(){
String result = "";
for (int i = 0; i < 1000; i++) {
result += i;
}
return result;
}
在for循環體內,出現+號的地方,編譯後都會被替換爲如下調用:
result = (new StringBuilder(String.valueOf(result))).append(i).toString();
顯然,每次循環都需要在構造StringBuilder對象時對result中的字符數組進行拷貝,而在調用toString方法時,又要拷貝StringBuilder中的字符數組來構建String對象。相當於每次for循環,要進行兩次對象創建及兩次字符數組拷貝,因而程序效率低下。更高效的代碼如下:
public String concat(){
StringBuilder result = new StringBuilder();
for (int i = 0; i < 1000; i++) {
result.append(i);
}
return result.toString();
}
至此,我相信大家已經知道了+號的本質,以及如何避免低效的使用+號。下面我們就來深入瞭解下String對象相等判斷的兩種方式(經常出現在java面試題中)。
三、 相等判斷兩種方式(==/equals)說明
- ==:當兩個操作數是基本數據類型時,比較值是否相等;當兩個操作數是引用類型時,比較是否指向同一個對象。
- equals方法用來比較兩個對象的內容是否相等。
由於String是引用類型,當用==判斷時,比較的是兩個String變量是否指向同一個String對象;當用equals方法判斷時,纔會比較兩個String對象的內容是否相等。在實際項目中進行字符串比較時,基本是比較兩個String對象的內容是否相等,因此建議大家全部使用equals方法進行比較。
用==進行字符串比較,經常出現在面試題中,而不是項目代碼中,對於實際工作考覈的意義不大,只是作爲大家對String瞭解程度的一個考覈。下面列舉一些面試題如下:
題1
String a = "a1";
String b = "a" + 1;
System.out.println(a == b);
答案:true
說明:當兩個字面量進行連接時,實際上在java編譯器的編譯期,已經進行了字面量的拼接。也就是說編譯生成的Class文件中並不存在String b = “a” + 1
對應的字節碼指令,已經被優化爲String b = “a1”
對應的字節碼指令。我想這步優化大家應該很能理解,在編譯期間能夠確定結果並進行計算,就能有效減少Class文件中的字節碼指令,即減少了程序運行時需要執行的指令,提高了程序效率(大家可以用上述jad命令反編譯Class文件進行驗證)。同理,對於基本數據類型字面量的算術操作,也在編譯期間進行了計算,例如int day = 24 * 60 * 60
,編譯後被替換成代碼int day = 0x15180
。由於在編譯期間已經進行了拼接,這樣局部變量a和b都指向了常量池中的’a1’對象,因此a == b輸出爲true。
題2
String hw = "Hello world!";
String h = "Hello";
h = h + " world!";
System.out.println(h == hw);
答案:false
說明:通過之前關於字符串連接符+分析,我們知道h = h + " world!"
經過編譯後會被替換成h = (new StringBuilder(String.valueOf(h))).append(" world!").toString()
。查看下StringBuilder的toString方法,可以看到該方法實際就是return new String(value, 0, count)
,也就是h將指向java堆上的對象,而hw是指向常量池中的對象。雖然h和hw的內容相同,但由於指向不同的String對象,所以輸出爲false。
題3
public static final String h2 = "Hello";
public static final String h4 = getH();
private static String getH() {
return "Hello";
}
public static void main(String[] args) {
String hw = "Hello world!";
final String h1 = "Hello";
final String h3 = getH();
String hw1 = h1 + " world!";
String hw2 = h2 + " world!";
String hw3 = h3 + " world!";
String hw4 = h4 + " world!";
System.out.println(hw == hw1);
System.out.println(hw == hw2);
System.out.println(hw == hw3);
System.out.println(hw == hw4);
}
答案:true,true,false,false
說明:局部變量h1被final修飾,意味着h1是常量,同時h1被直接賦值爲字符串字面量”Hello”,這樣java編譯器在編譯期就能確定h1的值,從而將h1出現的地方直接替換成字面量”Hello”(類似c/c++用define定義的常量),再聯繫之前關於字面量會在編譯期直接拼接說明,因此代碼String hw1 = h1 + " world!"
編譯後優化爲String hw1 = "Hello world!"
,hw、hw1都指向了常量池中的String對象,輸出爲true。同理h2是靜態常量,且是直接字面量賦值方式,h2出現的地方也會在編譯後直接被字面量”Hello”替換,最終,hw2也是指向常量池中的String對象,輸出爲true。
局部變量h3也被final修飾,爲常量,但是其是通過方法調用進行賦值的,編譯期無法確定其具體值(此時代碼都沒執行,是無法通過靜態分析得到方法的返回值的,即使方法體中只是簡單的返回字符串常量,如上述例子),再聯繫之前關於+的本質分析,因此String hw3 = h3 + " world!"
編譯後爲String hw3 = (new StringBuilder(String.valueOf(h3))).append(" world!").toString()
,hw3將指向java堆上的String對象,hw == hw3輸出爲false。同理,hw4也指向java堆上的String對象,hw == hw4輸出爲false。
補充知識點
關於String類型變量的賦值,有兩種方式:
- 其一、直接字面量賦值,即
String str = “abc”
; - 其二、new方式賦值,即
String str = new String(“abc”)
;
方式一中,變量str直接指向字符串常量池1中字面量爲”abc”的String對象,即指向常量池中的String對象。
方式二中,變量str通過new構造函數String(String original)賦值,即指向java堆中的String對象。該構造函數接收String類型參數,而實參”abc”指向常量池中的String對象。
上面兩種給String類型變量賦值的方式,除了它們指向不同的String對象外,其它並沒有什麼區別。從程序效率的角度看,推薦使用方式一給String類型變量賦值,因爲方式二多了一次java堆的String對象分配。
前面說過,字符串字面量直接被看作String類的一個實例,實際是其在編譯期就存放在Class文件的常量池中,當Class文件被jvm加載時,其就進入到方法區的運行時常量池中。如果想在運行期間將新的常量加入常量池中,可調用String的intern()方法。
當調用 intern方法時,如果常量池已經包含一個等於此String對象的字符串(用equals(Object)方法確定),則返回常池中的字符串。否則,將此String 對象添加到池中,並返回此String對象的引用。
附反射修改String對象代碼:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
String name = "angel";
String name1 = "angel";
Field strField = String.class.getDeclaredField("value");
strField.setAccessible(true);
char[] data = (char[])strField.get(name);
data[4] = 'r';
System.out.println(name);
System.out.println(name1);
System.out.println(name == name1);
strField = String.class.getDeclaredField("count");
strField.setAccessible(true);
strField.setInt(name, 10);
int i = (Integer)strField.get(name);
System.out.println(i);
System.out.println(name.length());
}
- 字符串常量池是由String類管理,屬於方法區的運行時常量池,也就是上文中所說的常量池 ↩