Java記憶體分配和String型別的深度解析

oschina發表於2015-04-05

一、引題

在java語言的所有資料型別中,String型別是比較特殊的一種型別,同時也是面試的時候經常被問到的一個知識點,本文結合java記憶體分配深度分析關於String的許多令人迷惑的問題。下面是本文將要涉及到的一些問題,如果讀者對這些問題都瞭如指掌,則可忽略此文。

1、java記憶體具體指哪塊記憶體?這塊記憶體區域為什麼要進行劃分?是如何劃分的?劃分之後每塊區域的作用是什麼?如何設定各個區域的大小?

2、String型別在執行連線操作時,效率為什麼會比StringBuffer或者StringBuilder低?StringBuffer和StringBuilder有什麼聯絡和區別?

3、java中常量是指什麼?String s = “s” 和 String s = new String(“s”) 有什麼不一樣?

本文經多方資料的收集整理和歸納,最終撰寫成文,如果有錯誤之處,請多多指教!

二、java記憶體分配

1、JVM簡介

Java虛擬機器(Java Virtual Machine 簡稱JVM)是執行所有Java程式的抽象計算機,是Java語言的執行環境,它是Java 最具吸引力的特性之一。Java虛擬機器有自己完善的硬體架構,如處理器、堆疊、暫存器等,還具有相應的指令系統。JVM遮蔽了與具體作業系統平臺相關的資訊,使得Java程式只需生成在Java虛擬機器上執行的目的碼(位元組碼),就可以在多種平臺上不加修改地執行。

一個執行時的Java虛擬機器例項的天職是:負責執行一個java程式。當啟動一個Java程式時,一個虛擬機器例項也就誕生了。當該程式關閉退出,這個虛擬機器例項也就隨之消亡。如果同一臺計算機上同時執行三個Java程式,將得到三個Java虛擬機器例項。每個Java程式都執行於它自己的Java虛擬機器例項中。

如下圖所示,JVM的體系結構包含幾個主要的子系統和記憶體區:

垃圾回收器(Garbage Collection):負責回收堆記憶體(Heap)中沒有被使用的物件,即這些物件已經沒有被引用了。

類裝載子系統(Classloader Sub-System):除了要定位和匯入二進位制class檔案外,還必須負責驗證被匯入類的正確性,為類變數分配並初始化記憶體,以及幫助解析符號引用。

執行引擎(Execution Engine):負責執行那些包含在被裝載類的方法中的指令。

執行時資料區(Java Memory Allocation Area):又叫虛擬機器記憶體或者Java記憶體,虛擬機器執行時需要從整個計算機記憶體劃分一塊記憶體區域儲存許多東西。例如:位元組碼、從已裝載的class檔案中得到的其他資訊、程式建立的物件、傳遞給方法的引數,返回值、區域性變數等等。

java記憶體分配和String型別的深度解析

2、java記憶體分割槽

從上節知道,執行時資料區即是java記憶體,而且資料區要儲存的東西比較多,如果不對這塊記憶體區域進行劃分管理,會顯得比較雜亂無章。程式喜歡有規律的東西,最討厭雜亂無章的東西。 根據儲存資料的不同,java記憶體通常被劃分為5個區域:程式計數器(Program Count Register)、本地方法棧(Native Stack)、方法區(Methon Area)、棧(Stack)、堆(Heap)。

程式計數器(Program Count Register):又叫程式暫存器。JVM支援多個執行緒同時執行,當每一個新執行緒被建立時,它都將得到它自己的PC暫存器(程式計數器)。如果執行緒正在執行的是一個Java方法(非native),那麼PC暫存器的值將總是指向下一條將被執行的指令,如果方法是 native的,程式計數器暫存器的值不會被定義。 JVM的程式計數器暫存器的寬度足夠保證可以持有一個返回地址或者native的指標。

棧(Stack):又叫堆疊。JVM為每個新建立的執行緒都分配一個棧。也就是說,對於一個Java程式來說,它的執行就是通過對棧的操作來完成的。棧以幀為單位儲存執行緒的狀態。JVM對棧只進行兩種操作:以幀為單位的壓棧和出棧操作。我們知道,某個執行緒正在執行的方法稱為此執行緒的當前方法。我們可能不知道,當前方法使用的幀稱為當前幀。當執行緒啟用一個Java方法,JVM就會線上程的 Java堆疊裡新壓入一個幀,這個幀自然成為了當前幀。在此方法執行期間,這個幀將用來儲存引數、區域性變數、中間計算過程和其他資料。從Java的這種分配機制來看,堆疊又可以這樣理解:棧(Stack)是作業系統在建立某個程式時或者執行緒(在支援多執行緒的作業系統中是執行緒)為這個執行緒建立的儲存區域,該區域具有先進後出的特性。其相關設定引數:

  • -Xss –設定方法棧的最大值

本地方法棧(Native Stack):儲存本地方方法的呼叫狀態。

java記憶體分配和String型別的深度解析

方法區(Method Area):當虛擬機器裝載一個class檔案時,它會從這個class檔案包含的二進位制資料中解析型別資訊,然後把這些型別資訊(包括類資訊、常量、靜態變數等)放到方法區中,該記憶體區域被所有執行緒共享,如下圖所示。本地方法區存在一塊特殊的記憶體區域,叫常量池(Constant Pool),這塊記憶體將與String型別的分析密切相關。

java記憶體分配和String型別的深度解析

堆(Heap):Java堆(Java Heap)是Java虛擬機器所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域。在此區域的唯一目的就是存放物件例項,幾乎所有的物件例項都是在這裡分配記憶體,但是這個物件的引用卻是在棧(Stack)中分配。因此,執行String s = new String(“s”)時,需要從兩個地方分配記憶體:在堆中為String物件分配記憶體,在棧中為引用(這個堆物件的記憶體地址,即指標)分配記憶體,如下圖所示。

java記憶體分配和String型別的深度解析

JAVA虛擬機器有一條在堆中分配新物件的指令,卻沒有釋放記憶體的指令,正如你無法用Java程式碼區明確釋放一個物件一樣。虛擬機器自己負責決定如何以及何時釋放不再被執行的程式引用的物件所佔據的記憶體,通常,虛擬機器把這個任務交給垃圾收集器(Garbage Collection)。其相關設定引數:

  • -Xms — 設定堆記憶體初始大小
  • -Xmx — 設定堆記憶體最大值
  • -XX:MaxTenuringThreshold — 設定物件在新生代中存活的次數
  • -XX:PretenureSizeThreshold — 設定超過指定大小的大物件直接分配在舊生代中

Java堆是垃圾收集器管理的主要區域,因此又稱為“GC 堆”(Garbage Collectioned Heap)。現在的垃圾收集器基本都是採用的分代收集演算法,所以Java堆還可以細分為:新生代(Young Generation)和老年代(Old Generation),如下圖所示。分代收集演算法的思想:第一種說法,用較高的頻率對年輕的物件(young generation)進行掃描和回收,這種叫做minor collection,而對老物件(old generation)的檢查回收頻率要低很多,稱為major collection。這樣就不需要每次GC都將記憶體中所有物件都檢查一遍,以便讓出更多的系統資源供應用系統使用;另一種說法,在分配物件遇到記憶體不足時,先對新生代進行GC(Young GC);當新生代GC之後仍無法滿足記憶體空間分配需求時, 才會對整個堆空間以及方法區進行GC(Full GC)。

java記憶體分配和String型別的深度解析

在這裡可能會有讀者表示疑問:記得還有一個什麼永久代(Permanent Generation)的啊,難道它不屬於Java堆?親,你答對了!其實傳說中的永久代就是上面所說的方法區,存放的都是jvm初始化時載入器載入的一些型別資訊(包括類資訊、常量、靜態變數等),這些資訊的生存週期比較長,GC不會在主程式執行期對PermGen Space進行清理,所以如果你的應用中有很多CLASS的話,就很可能出現PermGen Space錯誤。其相關設定引數:

  • -XX:PermSize –設定Perm區的初始大小
  • -XX:MaxPermSize –設定Perm區的最大值

新生代(Young Generation又分為:Eden區和Survivor區,Survivor區有分為From Space和To Space。Eden區是物件最初分配到的地方;預設情況下,From Space和To Space的區域大小相等。JVM進行Minor GC時,將Eden中還存活的物件拷貝到Survivor區中,還會將Survivor區中還存活的物件拷貝到Tenured區中。在這種GC模式下,JVM為了提升GC效率, 將Survivor區分為From Space和To Space,這樣就可以將物件回收和物件晉升分離開來。新生代的大小設定有2個相關引數:

  • -Xmn — 設定新生代記憶體大小。
  • -XX:SurvivorRatio — 設定Eden與Survivor空間的大小比例

老年代(Old Generation): 當 OLD 區空間不夠時, JVM 會在 OLD 區進行 major collection;完全垃圾收集後,若Survivor及OLD區仍然無法存放從Eden複製過來的部分物件,導致JVM無法在Eden區為新物件建立記憶體區域,則出現”Out of memory錯誤”  。

三、String型別的深度解析

讓我們從Java資料型別開始說起吧!Java資料型別通常(分類方法多種多樣)從整體上可以分為兩大類:基礎型別和引用型別,基礎型別的變數持有原始值,引用型別的變數通常表示的是對實際物件的引用,其值通常為物件的記憶體地址。對於基礎型別和引用型別的細分,直接上圖吧,大家看了一目瞭然。當然,下圖也僅僅只是其中的一種分類方式。

 (原文圖丟失)

針對上面的圖,有3點需要說明:

  •     char型別可以單獨出來形成一類,很多基本型別的分類為:數值型別、字元型(char)和bool型。
  •     returnAddress型別是一個Java虛擬機器在內部使用的型別,被用來實現Java程式中的finally語句。
  •     String型別在上圖的什麼位置?yes,屬於引用型別下面的類型別。下面開始對String型別的挖掘!

1、String的本質

開啟String的原始碼,類註釋中有這麼一段話“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的一個最重要的特點:String是值不可變(immutable)的常量,是執行緒安全的(can be shared)。

接下來,String類使用了final修飾符,表明了String類的第二個特點:String類是不可繼承的。

下面是String類的成員變數定義,從類的實現上闡明瞭String值是不可變的(immutable)。

private final char value[];
private final int count; 

因此,我們看String類的concat方法。實現該方法第一步要做的肯定是擴大成員變數value的容量,擴容的方法重新定義一個大容量的字元陣列buf。第二步就是把原來value中的字元copy到buf中來,再把需要concat的字串值也copy到buf中來,這樣子,buf中就包含了concat之後的字串值。下面就是問題的關鍵了,如果value不是final的,直接讓value指向buf,然後返回this,則大功告成,沒有必要返回一個新的String物件。但是。。。可惜。。。由於value是final型的,所以無法指向新定義的大容量陣列buf,那怎麼辦呢?“return new String(0, count + otherLen, buf);”,這是String類concat實現方法的最後一條語句,重新new一個String物件返回。這下真相大白了吧!

總結:String實質是字元陣列,兩個特點:1、該類不可被繼承;2、不可變性(immutable)

java記憶體分配和String型別的深度解析

2、String的定義方法

在討論String的定義方法之前,先了解一下常量池的概念,前面在介紹方法區的時候已經提到過了。下面稍微正式的給一個定義吧。

常量池(constant pool)指的是在編譯期被確定,並被儲存在已編譯的.class檔案中的一些資料。它包括了關於類、方法、介面等中的常量,也包括字串常量。常量池還具備動態性,執行期間可以將新的常量放入池中,String類的intern()方法是這一特性的典型應用。不懂嗎?後面會介紹intern方法的。虛擬機器為每個被裝載的型別維護一個常量池,池中為該型別所用常量的一個有序集合,包括直接常量(string、integer和float常量)和對其他型別、欄位和方法的符號引用(與物件引用的區別?讀者可以自己去了解)。

String的定義方法歸納起來總共為三種方式:

  • 使用關鍵字new,如:String s1 = new String(“myString”);
  • 直接定義,如:String s1 = “myString”;
  • 串聯生成,如:String s1 = “my” + “String”;這種方式比較複雜,這裡就不贅述了,請參見java–String常量池問題的幾個例子

第一種方式通過關鍵字new定義過程:在程式編譯期,編譯程式先去字串常量池檢查,是否存在“myString”,如果不存在,則在常量池中開闢一個記憶體空間存放“myString”;如果存在的話,則不用重新開闢空間,保證常量池中只有一個“myString”常量,節省記憶體空間。然後在記憶體堆中開闢一塊空間存放new出來的String例項,在棧中開闢一塊空間,命名為“s1”,存放的值為堆中String例項的記憶體地址,這個過程就是將引用s1指向new出來的String例項。各位,最模糊的地方到了!堆中new出來的例項和常量池中的“myString”是什麼關係呢?等我們分析完了第二種定義方式之後再回頭分析這個問題。

第二種方式直接定義過程:在程式編譯期,編譯程式先去字串常量池檢查,是否存在“myString”,如果不存在,則在常量池中開闢一個記憶體空間存放“myString”;如果存在的話,則不用重新開闢空間。然後在棧中開闢一塊空間,命名為“s1”,存放的值為常量池中“myString”的記憶體地址。常量池中的字串常量與堆中的String物件有什麼區別呢?為什麼直接定義的字串同樣可以呼叫String物件的各種方法呢?

帶著諸多疑問,我和大家一起探討一下堆中String物件和常量池中String常量的關係,請大家記住,僅僅是探討,因為本人對這塊也比較模糊。

第一種猜想:因為直接定義的字串也可以呼叫String物件的各種方法,那麼可以認為其實在常量池中建立的也是一個String例項(物件)。String s1 = new String(“myString”);先在編譯期的時候在常量池建立了一個String例項,然後clone了一個String例項儲存在堆中,引用s1指向堆中的這個例項。此時,池中的例項沒有被引用。當接著執行String s1 = “myString”;時,因為池中已經存在“myString”的例項物件,則s1直接指向池中的例項物件;否則,在池中先建立一個例項物件,s1再指向它。如下圖所示:

這種猜想認為:常量池中的字串常量實質上是一個String例項,與堆中的String例項是克隆關係。

第二種猜想也是目前網上闡述的最多的,但是思路都不清晰,有些問題解釋不通。下面引用《JAVA String物件和字串常量的關係解析》一段內容。

在解析階段,虛擬機器發現字串常量”myString”,它會在一個內部字串常量列表中查詢,如果沒有找到,那麼會在堆裡面建立一個包含字元序列[myString]的String物件s1,然後把這個字元序列和對應的String物件作為名值對( [myString], s1 )儲存到內部字串常量列表中。如下圖所示:

java記憶體分配和String型別的深度解析

如果虛擬機器後面又發現了一個相同的字串常量myString,它會在這個內部字串常量列表內找到相同的字元序列,然後返回對應的String物件的引用。維護這個內部列表的關鍵是任何特定的字元序列在這個列表上只出現一次。

例如,String s2 = “myString”,執行時s2會從內部字串常量列表內得到s1的返回值,所以s2和s1都指向同一個String物件。

這個猜想有一個比較明顯的問題,紅色字型標示的地方就是問題的所在。證明方式很簡單,下面這段程式碼的執行結果,javaer都應該知道。

String s1 = new String(“myString”);
String s2 = “myString”;

System.out.println(s1 == s2);  //按照上面的推測邏輯,那麼列印的結果為true;而實際上真實的結果是false,因為s1指向的是堆中String物件,而s2指向的是常量池中的String常量。

java記憶體分配和String型別的深度解析

雖然這段內容不那麼有說服力,但是文章提到了一個東西——字串常量列表,它可能是解釋這個問題的關鍵。

文中提到的三個問題,本文僅僅給出了猜想,請知道真正內幕的高手幫忙分析分析,謝謝!

  •  堆中new出來的例項和常量池中的“myString”是什麼關係呢?
  • 常量池中的字串常量與堆中的String物件有什麼區別呢?
  • 為什麼直接定義的字串同樣可以呼叫String物件的各種方法呢?

3、String、StringBuffer、StringBuilder的聯絡與區別

上面已經分析了String的本質了,下面簡單說說StringBuffer和StringBuilder。

StringBuffer和StringBuilder都繼承了抽象類AbstractStringBuilder,這個抽象類和String一樣也定義了char[] value和int count,但是與String類不同的是,它們沒有final修飾符。因此得出結論:String、StringBuffer和StringBuilder在本質上都是字元陣列,不同的是,在進行連線操作時,String每次返回一個新的String例項,而StringBuffer和StringBuilder的append方法直接返回this,所以這就是為什麼在進行大量字串連線運算時,不推薦使用String,而推薦StringBuffer和StringBuilder。那麼,哪種情況使用StringBuffe?哪種情況使用StringBuilder呢?

關於StringBuffer和StringBuilder的區別,翻開它們的原始碼,下面貼出append()方法的實現。

java記憶體分配和String型別的深度解析

java記憶體分配和String型別的深度解析

面第一張圖是StringBuffer中append()方法的實現,第二張圖為StringBuilder對append()的實現。區別應該一目瞭然,StringBuffer在方法前加了一個synchronized修飾,起到同步的作用,可以在多執行緒環境使用。為此付出的代價就是降低了執行效率。因此,如果在多執行緒環境可以使用StringBuffer進行字串連線操作,單執行緒環境使用StringBuilder,它的效率更高。

相關文章