Java基礎小技巧回顧–關於String點點滴滴

來源:互聯網
上載者:User

其實本文非常簡單,不過有很多朋友經常問,網上很多例子也寫個大概,很多人也只是知道和大概,就本文而來讀起來非常的輕鬆,不過算是一些小技巧;但是我們的程式中相信用得最多的就是char數組和byte[]數組,而String就是由char[]數組組成的,一般情況下我們就可以認為String用得是最多的對象之一。

有關Sring的空間利用率方面,這裡不想多說,只能說很低很低,尤其是你定義的String長度很短的時候,簡直利用率不好說;在前序的一篇文章中說明了關於java的對象空間申請方法以及對象在JVM內部如何做對其的過程,就能較為明確的知道一個String是多麼的浪費空間;本文就不在多提及這方面的問題了。

再談及到String與StringBuffer和StringBuilder的區別時,前面一篇文章中將他們迴圈做了一系列的效能對比,發現StringBuilder效能最高,大家都知道用StringBuilder來用了,但是要明白細節才是最好的;簡單來講String是不可變的字串,而StringBuffer和StringBuilder是可變的字串對象,而StringBuffer是在進行內容修改時(即char數組修改)會進行線程同步操作,在同步過程中存在徵用加鎖和訪問對象的過程,開銷較大,在方法內定義的局部變數中沒有必要同步,因為就是當前線程使用,所以StringBuilder為一個非同步的可變字串對象。


OK,我們介紹了基本的概念,可以回到正題了;那麼String到底是一個神馬東西,通過前面的對象結構來看,首先根據String內部的定義,應該有以下內容:一個char數組指標指向一個數組對象(數組對象也是一個對象,和普通對象最大的區別需要一個位置來記錄數組的長度)、offset、count、hash、serialVersionUID(這個不用計算在對象的大小中,因為在JVM啟動時就會被裝入到方法區中)。其次,還有對象對其的過程,而String的內容為char數組引用,指向的數組對象的內部的內容,也就是一個String相當於就包含了兩個對象,兩個對象都有頭部,以及對其方式,數組頭部會多一個儲存數組長度的地區,頭部還會儲存物件加鎖狀態、唯一標識、方法區指標、GC中的Mark標誌等等相應的內容,如果頭部儲存空間不夠就會在外部開闢一個空間來儲存,內部用一個指標指向那塊空間;另外對象會按照8byte對其方法進行對其,即對象大小不是8byte的倍數,將會填充,方便定址。

String經常說是不可變的字串,但是我個人並不習慣將他說成是常量,而很多人也對String字串不可變以及StringBuilder可變有著很多疑惑之處,String可以做+,為什麼說它不可變呢?String的+到底做了什嗎?有人說String還有一些內容可能會放在常量池,這是什麼東西?常量池和常量池的字串拼接結果是什麼(我曾在網上看到有人寫常量池中字串和常量池中字串拼接結果還在常量池,其實未必,後面我們用事實來說話)?

當你對上述問題了如指掌,String你基本瞭解得有點通透了;OK,在解釋這個問題之前,我們先說明一個在Hotspot自從分代JVM產生後到目前為止(G1還沒有正式出來之前)不變的道理就是,當你在程式中只要使用了new關鍵字或者通過任何反射機制執行個體化的任何對象都將首先放在堆當中,當然一般情況下首先是放在Eden空間中(在一些細節的版本中會有一些區別,如啟動了TABL、或對象超過指定大小直接進入Old或對象連Eden也放不下也會直接進入Old);這是不用說的事實,總之目前我們只要知道它肯定是在堆當中的就可以了。

我們先來看一段非常非常簡單的代碼如下所示:

public class StringTest {    public static void main(String[] args) {        String a = "abc";        String b = "def";                String c = a + b;        String d = "abc" + "def";                String e = new String("abc");                System.out.println(a == e);        System.out.println(a.equals(e));        System.out.println(a == "abc");        System.out.println(a == e.intern());        System.out.println(c == "abcdef");        System.out.println(d == "abcdef");    }}

請在沒有在java上運行前猜猜結果是多少,然後再看結果。



結果如下:

false
true
true
true
false
true


如果你的結果不是猜得,而是直接自己通過理解得到的,後面的文章你就不用看了,對你來說應該沒有多大意義,如果你某一個結果說得不對,或者是自己瞎猜出來的,OK,後文可能會對你的理解造成一些影響。


我們首先解釋前面4個結果,再解釋最後2個結果;前4個其實在前面的文章中已經說過他們的區別,不過為了方便文本繼續向下說明,這裡再說明一次,首先String a = "abc"這樣的申請,會將對象放入常量池中,也就是放在Perm Geration中的,而String e = new String("abc")這個對象是放在Eden空間的,所以當使用a
== e發生地址對比,兩者肯定結果是不一樣的;而當發生a == "abc"兩個地址是一樣的,都是指向常量池的對應對象的首地址;而equals是對比值不用多說,肯定是一樣的;a == e.intern()為什麼也是true呢,就是當intern()這個方法發生時,它會在常量池中尋找和e這個字串等值的字串(匹配的方法為equals),如果沒有發現則在常量池申請一個一樣的字串對象,並將對象首位址範圍,如果發現了則直接範圍首地址;而a是常量池中的對象,所以e在常量池中就能找到的地址就是a的首地址;關於這個問題就不多闡述了,也有相關的很多說明,下面說下後面兩個結果;算是較為神奇的結果,也是另很多人納悶的結果,不過不用著急,說完後就很簡單了。

後面兩個結果一個是a指向常量池的“abc”,b指向常量池中的“def”,c是通過a和b相加,兩個都是常量池對象;而d是直接等價於“abc”+“def”按照道理說,兩個也是常量池對象,為什麼兩個對象和常量池的“abcdef”比較的結果不一樣呢?(關於他們為什麼是在常量池就不多說了,上面那一段已經有結果了);我們不管怎麼樣,首先秒殺掉一句話就是:常量池的String+常量池String結果還在常量池,這句話是不正確的,或者你的測試案例正好是後者,那麼你中招了,很多事情只是通過測試也未必能得出非常有效結果,但是較為全面的測試會讓我們得出更多的結論,看看我們兩種幾乎一摸一樣的測試,但是結果竟然是不一樣的;簡單說結果是前者的對象結果不是在常量池中(記住,常量池中同一個字串肯定是唯一的),後者的結果肯定在常量池;為什麼,不是我說的,是Hotspot
VM告訴我的,我們做一個簡單的小實驗,就知道是為什麼了,首先將代碼修改成這樣:

public class StringTest {    public static void main(String[] args) {        String a = "abc";        String b = "def";                String c = a + b;    }}

我們看看編譯完成後它是個什麼樣子:

C:\>javac StringTest.java

C:\>javap -verbose StringTest

Compiled from "StringTest.java"public class StringTest extends java.lang.Object  SourceFile: "StringTest.java"  minor version: 0  major version: 50  Constant pool:const #1 = Method       #9.#18; //  java/lang/Object."<init>":()Vconst #2 = String       #19;    //  abcconst #3 = String       #20;    //  defconst #4 = class        #21;    //  java/lang/StringBuilderconst #5 = Method       #4.#18; //  java/lang/StringBuilder."<init>":()Vconst #6 = Method       #4.#22; //  java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;const #7 = Method       #4.#23; //  java/lang/StringBuilder.toString:()Ljava/lang/String;const #8 = class        #24;    //  StringTestconst #9 = class        #25;    //  java/lang/Objectconst #10 = Asciz       <init>;const #11 = Asciz       ()V;const #12 = Asciz       Code;const #13 = Asciz       LineNumberTable;const #14 = Asciz       main;const #15 = Asciz       ([Ljava/lang/String;)V;const #16 = Asciz       SourceFile;const #17 = Asciz       StringTest.java;const #18 = NameAndType #10:#11;//  "<init>":()Vconst #19 = Asciz       abc;const #20 = Asciz       def;const #21 = Asciz       java/lang/StringBuilder;const #22 = NameAndType #26:#27;//  append:(Ljava/lang/String;)Ljava/lang/StringBuilder;const #23 = NameAndType #28:#29;//  toString:()Ljava/lang/String;const #24 = Asciz       StringTest;const #25 = Asciz       java/lang/Object;const #26 = Asciz       append;const #27 = Asciz       (Ljava/lang/String;)Ljava/lang/StringBuilder;;const #28 = Asciz       toString;const #29 = Asciz       ()Ljava/lang/String;;{public StringTest();  Code:   Stack=1, Locals=1, Args_size=1   0:   aload_0   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V   4:   return  LineNumberTable:   line 2: 0public static void main(java.lang.String[]);  Code:   Stack=2, Locals=4, Args_size=1   0:   ldc     #2; //String abc   2:   astore_1   3:   ldc     #3; //String def   5:   astore_2   6:   new     #4; //class java/lang/StringBuilder   9:   dup   10:  invokespecial   #5; //Method java/lang/StringBuilder."<init>":()V   13:  aload_1   14:  invokevirtual   #6; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;   17:  aload_2   18:  invokevirtual   #6; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;   21:  invokevirtual   #7; //Method java/lang/StringBuilder.toString:()Ljava/lang/String;   24:  astore_3   25:  return  LineNumberTable:   line 7: 0   line 8: 3   line 10: 6   line 13: 25}

說明(這裡不解釋關於棧的計算指令,只說明大概意思):首先看到使用了一個指標指向一個常量池中的對象內容為“abc”,而另一個指標指向“def”,此時通過new申請了一個StringBuilder(jdk 1.5以前是StringBuffer),然後調用這個StringBuilder的初始化方法;然後分別做了兩次append操作,然後最後做一個toString()操作;可見String的+在編譯後會被編譯為StringBuilder來運行(關於為什麼效能還是比StringBuilder慢那麼多,文章後面來說明),我們知道這裡做了一個new
StringBuilder的操作,並且做了一個toString的操作,前面我們已經明確說明,凡是new出來的對象絕對不會放在常量池中;toString會發生一次內容拷貝,但是也不會在常量池中,所以在這裡常量池String+常量池String放在了堆中;而下面這個後面那種情況呢,我們也用同樣的方式來看看結果是什麼,代碼更簡單了:

public class StringTest {    public static void main(String[] args) {        String d = "abc" + "def";    }}

看下結果:

C:\>javac StringTest.java

C:\>javap -verbose StringTest
Compiled from "StringTest.java"
public class StringTest extends java.lang.Object
  SourceFile: "StringTest.java"
  minor version: 0
  major version: 50
  Constant pool:
const #1 = Method       #4.#13; //  java/lang/Object."<init>":()V
const #2 = String       #14;    //  abcdef
const #3 = class        #15;    //  StringTest
const #4 = class        #16;    //  java/lang/Object
const #5 = Asciz        <init>;
const #6 = Asciz        ()V;
const #7 = Asciz        Code;
const #8 = Asciz        LineNumberTable;
const #9 = Asciz        main;
const #10 = Asciz       ([Ljava/lang/String;)V;
const #11 = Asciz       SourceFile;
const #12 = Asciz       StringTest.java;
const #13 = NameAndType #5:#6;//  "<init>":()V
const #14 = Asciz       abcdef;
const #15 = Asciz       StringTest;
const #16 = Asciz       java/lang/Object;

{
public StringTest();
  Code:
   Stack=1, Locals=1, Args_size=1
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return
  LineNumberTable:
   line 2: 0

public static void main(java.lang.String[]);
  Code:
   Stack=1, Locals=2, Args_size=1
   0:   ldc     #2; //String abcdef
   2:   astore_1
   3:   return

  LineNumberTable:
   line 11: 0
   line 13: 3

}

這下看下可能有人一下通透了,可能有人覺得更加模糊了,怎麼編譯完後比前面那個少那麼多,是的,就是少那麼多,因為當發生“abc” + “def”在同一行發生時,JVM在編譯時間就認為這個加號是沒有用處的,編譯的時候就直接變成成

String d = "abcdef";

同理如果出現:String a = "a" + 1,編譯時間候就會變成:String a = "a1";

再例如:

final String a = "a";final String b = "ab";String c = a + b;

在編譯時間候,c部分會被編譯為:String c = "aab";但是如果a或b有任意一個不是final的,都會new一個新的對象出來;其次再補充下,如果a和b,是某個方法返回回來的,不論方法中是final類型的還是常量什麼的,都不會被在編譯時間將資料編譯到常量池,因為編譯器並不會跟蹤到方法體裡面去看你做了什麼,其次只要是變數就是可變的,即使你認為你看到的代碼是不可變的,但是運行時是可以被切入的。

就是這麼簡單,運行時自然直接就在常量池中是一個對象了,而不需要每次訪問到這裡做一個加法操作,有引用的時候,JVM不確定你要拿引用去做什麼,所以它並不會直接將你的字串進行編譯時間的合并(其實在某些情況下JVM可以適當考慮合并,但是JVM可能是考慮到編譯時間最佳化的演算法複雜性,所以這些最佳化可能會放在運行時的JIT來完成,但JIT最佳化這部分java代碼是有一些前提條件的)

所以並不是常量池String+常量池String結果還在常量池,而是編譯時間JVM就認為他們沒有必要做,直接合并了,就像JVM做if(true)和if(false)的最佳化一樣的道理,而前者如果是引用給出來的常量池對象,JVM在拼接過程中是通過申請StringBuilder來完成的,也就是它的結果就像普通對象一樣放在堆當中的。

好了,反過來一切都很明了了,String為什麼不可變,因為+操作是新申請了對象;+到底做了什麼,是申請了一個StringBuilder來做append操作,然後再toString成一個新的對象;如果不是new出來的字串或者是通過.intern()得到的字串,則是常量池中的對象;常量池中的字串和常量池中的字串拼接,他們的結果不一定還在常量池,如果還在常量池只有一種可能性就是編譯時間就合并了,因為運行時new出來的StringBuilder是不可能放在常量池中的,我們絕大部分字串拼接都是有引用的,而不是直接兩個常量串來做的。

下面回顧最後一個問題就是,既然String拼接是通過StringBuilder來完成的,那麼為什麼String的+和StringBuilder會有那麼大的差距呢?這是一個值得考慮的問題,如果String的+操作和StringBuilder是一樣的操作,那麼我們的StringBuilder就沒有多大存在的必要了,因為apend太多字串是一件非常噁心的事情。

首先你會發現,如果在同一條代碼中(不一定是同一行代碼,因為java代碼可以相互封裝嵌套,指對於成來講基本的一條代碼),

如String a = a + b + c;這條代碼算是同一行,而System.out.println(a + b + c + String.format(d , "[%s]"));對於d就會單獨處理後,再和a + b+ c處理,然後再調用System中的靜態成員out對象中的println方法;

回到正題,對於同一條代碼中,如果發生這種加法操作(不是編譯時間合并的),那麼你在通過javap命令分析時會發現,他們的結果回將其申請一個StringBuilder然後進行append,不論多少個字串都會append,然後最後toString()操作,這就納悶了,為什麼效能差距會那麼大(在迴圈次數越多的時候差距會越來越大),最終沒辦法,我們用多行和迴圈測試,又看了下兩者之間的區別,在使用String做+操作時,如果是多條代碼或者在迴圈中做的話,每條代碼都會做一個新的new
StringBuilder,然後最後會toString一下,也就是當兩個字串相加時,會“最少”多申請一個StringBuilder然後再轉換為一個String(雖然是將StringBuilder中內容拷貝到一個新的String中,但是空間是兩塊),所以浪費空間比較快,而且如果字串越長,迴圈的過程中就會逐步進入old,而且old中的東西也會越來越多,導致了瘋狂的GC,最後會瘋狂的Full GC,再多的記憶體也會很快達到Full GC,只要你做迴圈;其實在常規應用中,一般你只需要做幾行的字串疊加也無所謂,如果能寫成一行就寫成一行,如果非要寫成多行還想要效能的話,就用StringBuilder吧;其實快並不是在多少申請了對象,因為java申請對象的速度非常快速,不存在說因為多申請了兩個對象就會導致什麼大的問題,大的問題是因為這些臨時空間所產生的垃圾,最終導致了瘋狂的GC,上述兩種情況在做多次迴圈的過程中本地使用代碼:-XX:+PrintGCDetails來運行,你會發現,使用String做加法,剛開始會瘋狂的YGC,過一段後會瘋狂的FullGC,最後記憶體溢出,而使用StringBuilder幾乎不會做GC,要做應該是做YGC,如果發生FGC一般說明這個字串已經快把OLD地區撐滿了,也就說馬上要記憶體溢出了,而前者臨時對象也應該去掉的,但是它會比StringBuilder疊加次數更少的時候,發生記憶體溢出,那是因為對象比較大的時候,臨時對象已經在old地區,而前一個臨時對象正好是要作為後一個對象的拷貝,所以在後面那個對象還沒有拷貝成功前,前面那個對象的空間還不能被釋放,那麼很明顯,old地區的利用率一般到一半的時候就溢出了。


最後補充一個話題,其實StringBuilder也有一些問題,就是在動態擴容的過程中,每次增加2倍的空間,並不是在原有空間上做類似的C語言的realloc操作,而是新申請一個2倍大小的空間,將這些內容再拷貝過去;StringBuilder之所以可以動態增加是因為一個預先分配的char長度,如果沒有滿可以繼續在後面新增內容,如果滿了就申請一個2倍的空間,然後將前面的拷貝過去;不難說出兩個問題,所謂的動態擴容只是邏輯上的實現,而並非真正的動態擴容,這也有它的記憶體安全性考慮,而String是多長,數組的長度就多長(注意:這個長度和前面說的對象大小關係並不大,對象大小前面有一定的介紹);另一個可以看出的問題就是動態擴容的過程中同樣會產生各種各樣的垃圾對象,其實在迴圈的過程中,看得往往還沒有那麼明顯,在多線程訪問多個隨機方法,每個隨機方法內部都會去做一些apend,而且都大於10的時候,臨時對象就多了;不過還好,它的臨時對象只是char數組,而不是String對象,前面說了,String對象相當於兩個對象,前面那個對象的大小也是很大的;但是如果你需要考慮這樣的細節,那麼請在編寫StringBuilder的時候,預先寫好你認為它可能的最大長度,尤其是被反覆調用的代碼,如StringBuilder
builder = new StringBuilder(2048);一般的小對象沒有必要這樣做,而且一次申請對象如果過大可能很容易進入old地區,甚至於直接進入old地區,這是我們不想看到的;但是這種方法就要求每一位程式員都要有非常高的素質和修養,但是大多數的程式員你可能叫他寫StringBuilder就夠意思了,呵呵,更加不要說叫他去些意思了,那麼這個辦法並不能讓所有的程式員所接受,目前的Hotspot還未解決這個問題,但是JRockit已經有一種解決方案了,它的解決方案很好的一種方法,就是在編譯時間它就能決定在這個局部方法內部你會發生多少次的append操作,那麼它的StringBuilder內部做的就不是char數組,而是一個String[],預先分配數組的長度就是和append次數一樣大小的數組,每做一次append就像數組下標增加1,並且放在對應的數組位置,並記錄下總體的長度,待這個對象發生toString操作時,此時再申請一個這個長度一樣大小的char[]空間,將資料拷貝進去,就解決了所有的臨時對象的問題,對於在增加了一次間接訪問和toString時候發生的逐個拷貝這些開銷都是可以接受的(只要append的次數不是特別的多,一般append的次數也不可能特別多,所以利用迴圈測試出來的效能區別這個時候也是不靠譜的);

最後,所謂的String拼接和StringBuilder下的使用,只要不是太大的字串或者太多次數的拼接或者高並發訪問的程式碼片段做了2行代碼以上的拼接,String做加法幾乎和StringBuilder區別不大;太大的字串產生的太大的臨時空間,太多的拼接次數是產生太多的臨時空間,同一條代碼中作String的拼接(不論拼接次數)和使用StringBuilder做append效果一致,只是每次append結果在這行發生完成後會發生toString操作,而預設申請的StringBuilder大小預設為10,如果超過限制則翻倍,這也算是一個限制。


其餘的就沒什麼了,此文閑扯,做做實驗便知道,使用命令分析更加深入,關於動態擴充,在集合類裡面也有類似的情況,需要注意。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.