[Java]String之尋根問底

轉載請註明出處: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());
    }

  1. 字符串常量池是由String類管理,屬於方法區的運行時常量池,也就是上文中所說的常量池
發佈了59 篇原創文章 · 獲贊 62 · 訪問量 34萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章