Java基礎--理解 Java 的字符串,String、StringBuffer、StringBuilder 有什麼區別?

理解 Java 的字符串,String、StringBuffer、StringBuilder 有什麼區別?

1.定義

String 是 Java 語言非常基礎和重要的類,提供了構造和管理字符串的各種基本邏輯。它是典型的 Immutable 類,被聲明成爲 final class,所有屬性也都是 final 的。也由於它的不可變性,類似拼接、裁剪字符串等動作,都會產生新的 String 對象。由於字符串操作的普遍性,所以相關操作的效率往往對應用性能有明顯影響。
在這裏插入圖片描述

StringBuffer 是爲解決上面提到拼接產生太多中間對象的問題而提供的一個類,我們可以用 append 或者 add 方法,把字符串添加到已有序列的末尾或者指定位置。StringBuffer 本質是一個線程安全的可修改字符序列,它保證了線程安全,也隨之帶來了額外的性能開銷,所以除非有線程安全的需要,不然還是推薦使用它的後繼者,也就是 StringBuilder。
在這裏插入圖片描述
StringBuilder 是 Java 1.5 中新增的,在能力上和 StringBuffer 沒有本質區別,但是它去掉了線程安全的部分,有效減小了開銷,是絕大部分情況下進行字符串拼接的首選。
在這裏插入圖片描述

2. 字符串設計和實現考量

String 是 Immutable 類的典型實現,原生的保證了基礎線程安全,因爲你無法對它內部數據進行任何修改,這種便利甚至體現在拷貝構造函數中,由於不可變,Immutable 對象在拷貝時不需要額外複製數據。

再來看看 StringBuffer 實現的一些細節,它的線程安全是通過把各種修改數據的方法都加上 synchronized 關鍵字實現的,非常直白。其實,這種簡單粗暴的實現方式,非常適合我們常見的線程安全類實現,不必糾結於 synchronized 性能之類的,有人說“過早優化是萬惡之源”,考慮可靠性、正確性和代碼可讀性纔是大多數應用開發最重要的因素。

爲了實現修改字符序列的目的,StringBuffer 和 StringBuilder 底層都是利用可修改的(char,JDK 9 以後是 byte)數組,二者都繼承了 AbstractStringBuilder,裏面包含了基本操作,區別僅在於最終的方法是否加了 synchronized。
在這裏插入圖片描述

在這裏插入圖片描述

在這裏插入圖片描述
在這裏插入圖片描述
這個內部數組應該創建成多大的呢?如果太小,拼接的時候可能要重新創建足夠大的數組;如果太大,又會浪費空間。目前的實現是,構建時初始字符串長度加 16(這意味着,如果沒有構建對象時輸入最初的字符串,那麼初始值就是 16)。我們如果確定拼接會發生非常多次,而且大概是可預計的,那麼就可以指定合適的大小,避免很多次擴容的開銷。擴容會產生多重開銷,因爲要拋棄原有數組,創建新的(可以簡單認爲是倍數)數組,還要進行 arraycopy。
在這裏插入圖片描述

在沒有線程安全問題的情況下,全部拼接操作是應該都用 StringBuilder 實現嗎?


String strByBuilder  = new
StringBuilder().append("aa").append("bb").append("cc").append
            ("dd").toString();
             
String strByConcat = "aa" + "bb" + "cc" + "dd";

把下面一段代碼,利用不同版本的 JDK 編譯,然後再反編譯


public class StringConcat {
     public static String concat(String str) {
       return str + “aa” + “bb”;
     }
}

先編譯再反編譯,比如使用不同版本的 JDK:


${JAVA_HOME}/bin/javac StringConcat.java
${JAVA_HOME}/bin/javap -v StringConcat.class

JDK 8 :


         0: new           #2                  // class java/lang/StringBuilder
         3: dup
         4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
         7: aload_0
         8: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        11: ldc           #5                  // String aa
        13: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        16: ldc           #6                  // String bb
        18: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        21: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;

JDK9:


         // concat method
         1: invokedynamic #2,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
         
         // ...
         // 實際是利用了MethodHandle,統一了入口
         0: #15 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;

非靜態的拼接邏輯在 JDK 8 中會自動被 javac 轉換爲 StringBuilder 操作;而在 JDK 9 裏面,則是體現了思路的變化。Java 9 利用 InvokeDynamic,將字符串拼接的優化與 javac 生成的字節碼解耦,假設未來 JVM 增強相關運行時實現,將不需要依賴 javac 的任何修改。

在日常編程中,保證程序的可讀性、可維護性,往往比所謂的最優性能更重要

3.字符串緩存

有人粗略統計過,把常見應用進行堆轉儲(Dump Heap),然後分析對象組成,會發現平均 25% 的對象是字符串,並且其中約半數是重複的。如果能避免創建重複字符串,可以有效降低內存消耗和對象創建開銷。
String 在 Java 6 以後提供了 intern() 方法,目的是提示 JVM 把相應字符串緩存起來,以備重複使用。在我們創建字符串對象並調用 intern() 方法的時候,如果已經有緩存的字符串,就會返回緩存裏的實例,否則將其緩存起來。一般來說,JVM 會將所有的類似“abc”這樣的文本字符串,或者字符串常量之類緩存起來。
但實際情況估計會讓你大跌眼鏡。一般使用 Java 6 這種歷史版本,並不推薦大量使用 intern,爲什麼呢?魔鬼存在於細節中,被緩存的字符串是存在所謂 PermGen 裏的,也就是臭名昭著的 “永久代”,這個空間是很有限的,也基本不會被 FullGC 之外的垃圾收集照顧到。所以,如果使用不當OOM 就會光顧。
在後續版本中,這個緩存被放置在堆中,這樣就極大避免了永久代佔滿的問題,甚至永久代在 JDK 8 中被 MetaSpace(元數據區)替代了。而且,默認緩存大小也在不斷地擴大中,從最初的 1009,到 7u40 以後被修改爲 60013。
Intern 是一種顯式地排重機制,但是它也有一定的副作用,因爲需要開發者寫代碼時明確調用,一是不方便,每一個都顯式調用是非常麻煩的;另外就是我們很難保證效率,應用開發階段很難清楚地預計字符串的重複情況,有人認爲這是一種污染代碼的實踐。
幸好在 Oracle JDK 8u20 之後,推出了一個新的特性,也就是 G1 GC 下的字符串排重。它是通過將相同數據的字符串指向同一份數據來做到的,是 JVM 底層的改變,並不需要 Java 類庫做什麼修改。
注意這個功能目前是默認關閉的,你需要使用下面參數開啓,並且記得指定使用 G1 GC:


-XX:+UseStringDeduplication

前面說到的幾個方面,只是 Java 底層對字符串各種優化的一角,在運行時,字符串的一些基礎操作會直接利用 JVM 內部的 Intrinsic 機制,往往運行的就是特殊優化的本地代碼,而根本就不是 Java 代碼生成的字節碼。Intrinsic 可以簡單理解爲,是一種利用 native 方式 hard-coded 的邏輯,算是一種特別的內聯,很多優化還是需要直接使用特定的 CPU 指令。當然,你也可以在啓動實驗應用時,使用下面參數,瞭解 intrinsic 發生的狀態。


-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
    //樣例輸出片段    
        180    3       3       java.lang.String::charAt (25 bytes)  
                                  @ 1   java.lang.String::isLatin1 (19 bytes)   
                                  ...  
                                  @ 7 java.lang.StringUTF16::getChar (60 bytes) intrinsic 

4.String 自身的演化

仔細觀察過 Java 的字符串,在歷史版本中,它是使用 char 數組來存數據的,這樣非常直接。但是 Java 中的 char 是兩個 bytes 大小,拉丁語系語言的字符,根本就不需要太寬的 char,這樣無區別的實現就造成了一定的浪費。密度是編程語言平臺永恆的話題,因爲歸根結底絕大部分任務是要來操作數據的。

其實在 Java 6 的時候,Oracle JDK 就提供了壓縮字符串的特性,但是這個特性的實現並不是開源的,而且在實踐中也暴露出了一些問題,所以在最新的 JDK 版本中已經將它移除了。

在 Java 9 中,我們引入了 Compact Strings 的設計,對字符串進行了大刀闊斧的改進。將數據存儲方式從 char 數組,改變爲一個 byte 數組加上一個標識編碼的所謂 coder,並且將相關字符串操作類都進行了修改。另外,所有相關的 Intrinsic 之類也都進行了重寫,以保證沒有任何性能損失。

雖然底層實現發生了這麼大的改變,但是 Java 字符串的行爲並沒有任何大的變化,所以這個特性對於絕大部分應用來說是透明的,絕大部分情況不需要修改已有代碼。

當然,在極端情況下,字符串也出現了一些能力退化,比如最大字符串的大小。你可以思考下,原來 char 數組的實現,字符串的最大長度就是數組本身的長度限制,但是替換成 byte 數組,同樣數組長度下,存儲能力是退化了一倍的!還好這是存在於理論中的極限,還沒有發現現實應用受此影響。
(中文?)

在通用的性能測試和產品實驗中,我們能非常明顯地看到緊湊字符串帶來的優勢,即更小的內存佔用、更快的操作速度。

5.思考

下面的代碼輸出內存中數據是如何存儲的?
基於jdk8

String one = "one";//字符串常量池
String two = "one";//字符串常量池已存在
System.out.println(one == two);
//true
String two = new String("one");//1.堆空間創建對象
String one = new String("one");//1.堆空間創建對象
System.out.println(one == two);//堆空間中的不同對象
//false
String one = "one";//1.字符串常量池
String two = new String("one");//1.堆空間創建對象
System.out.println(one == two);
//false
String two = new String("one");//1.堆空間創建對象
String one = "one";//1.字符串常量池
System.out.println(one == two);
//false
String two = new String("one");//1.堆空間創建對象
String three = two.intern();//1.字符串常量池;2.返回字符串常量池引用
String one = "one";//1.字符串常量池
System.out.println(two.intern() == two);//字符串常量池引用 != 堆空間引用
//false
System.out.println(one == three);//都是字符串引用
//true
String one = "o" + "n" + "e";//編譯優化 => one = "one"
String two = "one";
System.out.println(one == two);
//true
String one = "o" + "n" + "e";// => one = "one"
String two = new String("o") + new String("n") + new String("e");
String three = new StringBuilder().append("o").append("n").append("e").toString();//three = "o" + "n" + "e"
System.out.println(one.intern() == one);//常量池與常量池比較
//true
System.out.println(one == two);//常量池與堆空間比較
//false
System.out.println(one.intern() == two);//常量池與堆空間比較
//false
System.out.println(one == three);//常量池與堆空間比較
//false
System.out.println(one.intern() == three);//常量池與堆空間比較
//false
System.out.println(two.intern() == two);//常量池與堆空間比較
//false
System.out.println(two == three);//堆空間與堆空間比較
//false
System.out.println(two.intern() == three);//常量池與堆空間比較
//false
System.out.println(three.intern() == three);//常量池與堆空間比較
//false
System.out.println(one.intern() == two.intern());//常量池與常量池比較
//true
System.out.println(one.intern() == three.intern());//常量池與常量池比較
//true
System.out.println(two.intern() == three.intern());//常量池與常量池比較
//true
String two = new String("one") + new String("two");
//堆空間:"one","two","onetwo"
String one = "onetwo";
//=> 直接取字符串常量池 "onetwo"
System.out.println(two.intern() == two);//1.字符串常量池已有,2.字符串常量池與堆空間比較
//false
System.out.println(two.intern() == one);//1.常量池已有;2.字符串常量池與字符串常量池
//true
System.out.println(one == two);//字符串常量池與堆空間
//false
String two = new String("one") + new String("two");
//堆空間創建:"one","two","onetwo"
two.intern();//將"onetwo"放入字符串常量池(引用)
String one = "onetwo";//字符串常量池已有(引用)
System.out.println(two.intern() == two);//字符串常量池中存儲的是引用,引用堆空間地址
//true
System.out.println(one == two);//字符串常量池中的引用,引用的對象是堆空間對象
//true
String two = new String("one") + new String("two");
//堆空間創建:"one","two","onetwo"
String one = "onetwo";//將"onetwo"放入字符串常量池
two.intern();//字符串常量池已有,返回字符串常量池中的引用
System.out.println(two.intern() == two);//字符串常量池中
//false
System.out.println(two.intern() == one);//字符串常量池中值的引用
//true
System.out.println(one == two);//字符串常量池中的引用,引用的對象是堆空間對象
//false
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章