Java記憶體佈局學習筆記【轉載】
1. 總述
我們知道,執行緒是作業系統排程的基本單元。所有執行緒共享父程序的堆空間,而每個執行緒都有自己的棧空間和程式計數器。所以,Java虛擬機器也看以看作是一個獨立的程序,裡面的記憶體空間分為執行緒共享空間和執行緒獨有空間。Java虛擬機器記憶體佈局如下:
2. 所有執行緒共享的記憶體空間
(1)堆空間:JVM規範中規定,所有物件例項以及陣列都要在堆上進行分配。一般來說,堆空間都有一個預設大小,取決於JVM實現,而且可以根據需要動態擴充套件。當建立物件需要在堆上分配空間,而且堆本身的空間不夠也無法申請額外的記憶體空間,則會丟擲OutOfMemoryError異常。
(2)方法區
3. 每個執行緒獨有的記憶體空間
(1)程式計數器:雖然很多程式都是多執行緒的,但是由於一般只有一個處理器,所以當前時刻只可能執行一個執行緒。而經過不停的執行緒切換,則達到一種多執行緒併發執行的假象。如果執行緒A執行到某一條指令時被掛起,切換到執行緒B。而執行緒B執行完後,需要執行執行緒A,這時處理器必須要知道執行緒A上次執行到了哪條指令,才能從中斷處進行恢復。所以,每個執行緒都一個程式計數器,用來表示執行緒當前需要執行的Java指令地址(這裡指的是JVM記憶體空間地址)。
(2)虛擬機器棧空間:JVM在執行一個執行緒的方法時,會為這個執行緒方法建立一個棧幀(可以理解為JVM棧空間中的一段儲存區域)。這個棧幀用於儲存區域性變量表、運算元棧、動態連結和方法入口資訊。
經常有人把Java記憶體區分為堆記憶體(Heap)和棧記憶體(Stack),這種分法比較粗糙,Java記憶體區域的劃分實際上遠比這複雜。這種劃分
方式的流行只能說明大多數程式設計師最關注的、與物件記憶體分配關係最密切的記憶體區域是這兩塊。其中所指的“堆”在後面會專門講述,而所指
的“棧”就是現在講的虛擬機器棧,或者說是虛擬機器棧中的區域性變量表部分。
區域性變量表存放了編譯期可知的各種基本資料型別(boolean、byte、char、short、int、float、long、double)的變數、物件引用
(reference型別)。物件引用不等同於物件本身,根據不同的虛擬機器實現,它可能是一個指向物件起始地址的引用指標,也可能指向一個代表物件的
控制代碼或者其他與此物件相關的位置)和returnAddress型別(指向了一條位元組碼指令的地址)。
(3)本地方法棧空間:跟虛擬機器棧空間類似,只是用來儲存本地方法呼叫的相關資訊。
本地方法棧(Native Method Stacks)與虛擬機器棧所發揮的作用是非常相似的,其區別不過是虛擬機器棧為虛擬機器執行Java方法(也就是字
節碼)服務,而本地方法棧則是為虛擬機器使用到的Native方法服務。虛擬機器規範中對本地方法棧中的方法使用的語言、使用方式與資料結構並沒
有強制規定,因此具體的虛擬機器可以自由實現它。甚至有的虛擬機器(譬如Sun HotSpot虛擬機器)直接就把本地方法棧和虛擬機器棧合二為一。
總之,初學階段可以把虛擬機器棧空間和本地方法棧空間就統一理解為“棧”(和堆對應)。
二、詳解Java中各種資料存放區域
1.暫存器:最快的儲存區, 由編譯器根據需求進行分配,我們在程式中無法控制。【由於無法控制,所以不作了解】
2. 棧:存放基本型別的變數資料和物件的引用,但物件本身不存放在棧中,而是存放在堆(new 出來的物件)或者常量池中(字串常量物件存放在常量池中。)
3. 堆:存放所有new出來的物件。
4. 靜態域:存放靜態成員(static定義的) 【屬於共享空間的方法區】
5. 常量池:存放字串常量和基本型別常量(public static final)。 【屬於共享空間的方法區】
6. 非RAM儲存:硬碟等永久儲存空間【不作了解】
總之,就區分棧、堆、靜態域和常量池四種區域就足夠了。常常也把靜態域和常量池看成一個區,不加區分。
這裡我們主要關心棧,堆和常量池,對於棧和常量池中的物件可以共享,對於堆中的物件不可以共享。棧中的資料大小和生命週期是可以確定的,當沒有引用指向資料時,這個資料就會消失。堆中的物件的由垃圾回收器負責回收,因此大小和生命週期不需要確定,具有很大的靈活性。
對於字串:其物件的引用都是儲存在棧中的,如果是編譯期已經建立好(直接用雙引號定義的)的就儲存在常量池中,如果是執行期(new出來的)才能確定的就儲存在堆中。對於equals相等的字串,在常量池中永遠只有一份,在堆中有多份。
如以下程式碼:
Java程式碼
1.String s1 = "china"; //String預設就是常量型別,可以認為預設省略了final,因為String內容是不可改變的
2.String s2 = "china";
3.String s3 = "china";
4.String ss1 = new String("china");
5.String ss2 = new String("china");
6.String ss3 = new String("china");
對於通過new產生一個字串(假設為”china”)時,會先去常量池中查詢是否已經有了”china”物件,如果沒有則在常量池中建立一個此字串物件,然後堆中再建立一個常量池中此”china”物件的拷貝物件。這也就是有道面試題:String s = new String(“xyz”);產生幾個物件?一個或兩個,如果常量池中原來沒有”xyz”,就是兩個。
對於基礎型別的變數和常量:變數和引用儲存在棧中,常量儲存在常量池中。
如以下程式碼:
Java程式碼
1.int i1 = 9;
2.int i2 = 9;
3.int i3 = 9;
4.public static final int INT1 = 9;
5.public static final int INT2 = 9;
6.public static final int INT3 = 9;
對於成員變數和區域性變數:成員變數就是方法外部,類的內部定義的變數;區域性變數就是方法或語句塊內部定義的變數。區域性變數必須初始化。
形式引數是區域性變數,區域性變數的資料存在於棧記憶體中。棧記憶體中的區域性變數隨著方法的消失而消失。
成員變數儲存在堆中的物件裡面,由垃圾回收器負責回收。
如以下程式碼:
Java程式碼
1.class BirthDate {
2. private int day;
3. private int month;
4. private int year;
5. public BirthDate(int d, int m, int y) {
6. day = d;
7. month = m;
8. year = y;
9. }
10. 省略get,set方法………
11.}
12.
13.public class Test{
14. public static void main(String args[]){
15.int date = 9;
16. Test test = new Test();
17. test.change(date);
18. BirthDate d1= new BirthDate(7,7,1970);
19. }
20.
21. public void change1(int i){
22. i = 1234;
23. }
對於以上這段程式碼,date為區域性變數,i,d,m,y都是形參為區域性變數,day,month,year為成員變數。下面分析一下程式碼執行時候的變化:
1. main方法開始執行:int date = 9;
date區域性變數,基礎型別,引用和值都存在棧中。
2. Test test = new Test();
test為物件引用,存在棧中,物件(new Test())存在堆中。
3. test.change(date);
i為區域性變數,引用和值存在棧中。當方法change執行完成後,i就會從棧中消失。
4. BirthDate d1= new BirthDate(7,7,1970);
d1 為物件引用,存在棧中,物件(new BirthDate())存在堆中,其中d,m,y為區域性變數儲存在棧中,且它們的型別為基礎型別,因此它們的資料也儲存在棧中。 day,month,year為成員變數,它們儲存在堆中(new BirthDate()裡面)。當BirthDate構造方法執行完之後,d,m,y將從棧中消失。
5.main方法執行完之後,date變數,test,d1引用將從棧中消失,new Test(),new BirthDate()將等待垃圾回收。
三、補充一下棧和堆的區別:
Java的堆是一個執行時資料區,類的(物件從中分配空間。這些物件通過new、newarray、 anewarray和multianewarray等指令建立,它們不需要程式程式碼來顯式的釋放。堆是由垃圾回收來負責的,堆的優勢是可以動態地分配記憶體 大小,生存期也不必事先告訴編譯器,因為它是在執行時動態分配記憶體的,Java的垃圾收集器會自動收走這些不再使用的資料。但缺點是,由於要在執行時動態 分配記憶體,存取速度較慢。
棧的優勢是,存取速度比堆要快,僅次於暫存器,棧資料可以共享。但缺點是,存在棧中的資料大小與生存期必須是 確定的,缺乏靈活性。棧中主要存放一些基本型別的變數資料(int, short, long, byte, float, double, boolean, char)和物件控制代碼(引用)。
棧有一個很重要的特殊性,就是存在棧中的資料可以共享。假設我們同時定義:
Java程式碼
int a = 3;
int b = 3; //【這兩句本質就是隻在棧分配了一塊記憶體空間,存放了3,只不過這塊記憶體空間有兩個別名,分別是a和b。】
編譯器先處理int a = 3;首先它會在棧中建立一個變數為a的引用,然後查詢棧中是否有3這個值,如果沒找到,就將3存放進來,然後將a指向3。接著處理int b = 3;在建立完b的引用變數後,因為在棧中已經有3這個值,便將b直接指向3。這樣,就出現了a與b同時均指向3的情況。
這時,如果再令 a=4;那麼編譯器會重新搜尋棧中是否有4值,如果沒有,則將4存放進來,並令a指向4;如果已經有了,則直接將a指向這個地址。因此a值的改變不會影響 到b的值。
要注意這種資料的共享與兩個物件的引用同時指向一個物件的這種共享是不同的,因為這種情況a的修改並不會影響到b, 它是由編譯器完成的,它有利於節省空間。而一個物件引用變數修改了這個物件的內部狀態,會影響到另一個物件引用變數。
四、String常量池問題的幾個例子
示例1:
Java程式碼
String s0="kvill";
String s1="kvill";
String s2="kv" + "ill";
System.out.println( s0==s1 );
System.out.println( s0==s2 );
結果為:
true
true
分析:首先,我們要知結果為道Java 會確保一個字串常量只有一個拷貝。
因為例子中的 s0和s1中的”kvill”都是字串常量,它們在編譯期就被確定了,所以s0==s1為true;而”kv”和”ill”也都是字串常量,當一個字 符串由多個字串常量連線而成時,它自己肯定也是字串常量,所以s2也同樣在編譯期就被解析為一個字串常量,所以s2也是常量池中” kvill”的一個引用。所以我們得出s0==s1==s2;
示例2:
示例:
Java程式碼
String s0="kvill";
String s1=new String("kvill");
String s2="kv" + new String("ill");
System.out.println( s0==s1 );
System.out.println( s0==s2 );
System.out.println( s1==s2 );
結果為:
false
false
false
分析:用new String() 建立的字串不是常量,不能在編譯期就確定,所以new String() 建立的字串不放入常量池中,它們有自己的地址空間。
s0還是常量池 中"kvill”的應用,s1因為無法在編譯期確定,所以是執行時建立的新物件”kvill”的引用,s2因為有後半部分 new String(”ill”)所以也無法在編譯期確定,所以也是一個新建立物件”kvill”的應用;明白了這些也就知道為何得出此結果了。
示例3:
Java程式碼
String a = "a1";
String b = "a" + 1;
System.out.println((a == b)); //result = true
String a = "atrue";
String b = "a" + "true";
System.out.println((a == b)); //result = true
String a = "a3.4";
String b = "a" + 3.4;
System.out.println((a == b)); //result = true
分析:JVM對於字串常量的"+"號連線,將程式編譯期,JVM就將常量字串的"+"連線優化為連線後的值,拿"a" + 1來說,經編譯器優化後在class中就已經是a1。在編譯期其字串常量的值就確定下來,故上面程式最終的結果都為true。
示例4:
Java程式碼
String a = "ab";
String bb = "b";
String b = "a" + bb;
System.out.println((a == b)); //result = false
分析:JVM對於字串引用,由於在字串的"+"連線中,有字串引用存在,而引用的值在程式編譯期是無法確定的,即"a" + bb無法被編譯器優化,只有在程式執行期來動態分配並將連線後的新地址賦給b。所以上面程式的結果也就為false。
示例5:
Java程式碼
String a = "ab";
final String bb = "b";
String b = "a" + bb;
System.out.println((a == b)); //result = true
分析:和[4]中唯一不同的是bb字串加了final修飾,對於final修飾的變數,它在編譯時被解析為常量值的一個本地拷貝儲存到自己的常量 池中或嵌入到它的位元組碼流中。所以此時的"a" + bb和"a" + "b"效果是一樣的。故上面程式的結果為true。
示例6:
Java程式碼
String a = "ab";
final String bb = getBB();
String b = "a" + bb;
System.out.println((a == b)); //result = false
private static String getBB() { return "b"; }
分析:JVM對於字串引用bb,它的值在編譯期無法確定,只有在程式執行期呼叫方法後,將方法的返回值和"a"來動態連線並分配地址為b,故上面 程式的結果為false。
關於String是不可變的
通過上面例子可以得出得知:
String s = "a" + "b" + "c";
就等價於String s = "abc";
String a = "a";
String b = "b";
String c = "c";
String s = a + b + c;
這個就不一樣了,最終結果等於:
Java程式碼
StringBuffer temp = new StringBuffer();
temp.append(a).append(b).append(c);
String s = temp.toString();
由上面的分析結果,可就不難推斷出String 採用連線運算子(+)效率低下原因分析,形如這樣的程式碼:
Java程式碼
public class Test {
public static void main(String args[]) {
String s = null;
for(int i = 0; i < 100; i++) {
s += "a";
}
}
}
每做一次 + 就產生個StringBuilder物件,然後append後就扔掉。下次迴圈再到達時重新產生個StringBuilder物件,然後 append 字串,如此迴圈直至結束。如果我們直接採用 StringBuilder 物件進行 append 的話,我們可以節省 N - 1 次建立和銷燬物件的時間。所以對於在迴圈中要進行字串連線的應用,一般都是用StringBuffer或StringBulider物件來進行 append操作。
由於String類的immutable性質,這一說又要說很多,大家只 要知道String的例項一旦生成就不會再改變了,比如說:String str=”kv”+”ill”+” “+”ans”; 就是有4個字串常量,首先”kv”和”ill”生成了”kvill”存在記憶體中,然後”kvill”又和” ” 生成 “kvill “存在記憶體中,最後又和生成了”kvill ans”;並把這個字串的地址賦給了str,就是因為String的”不可變”產生了很多臨時變數,這也就是為什麼建議用StringBuffer的原 因了,因為StringBuffer是可改變的。
String中的final用法和理解
Java程式碼
final StringBuffer a = new StringBuffer("111");
final StringBuffer b = new StringBuffer("222");
a=b;//此句編譯不通過
final StringBuffer a = new StringBuffer("111");
a.append("222");// 編譯通過
可見,final只對引用的"值"(即記憶體地址)有效,它迫使引用只能指向初始指向的那個物件,改變它的指向會導致編譯期錯誤。至於它所指向的物件 的變化,final是不負責的。
總結
棧中用來存放一些原始資料型別的區域性變數資料和物件的引用(String,陣列.物件等等)但不存放物件內容
堆中存放使用new關鍵字建立的物件.
字串是一個特殊包裝類,其引用是存放在棧裡的,而物件內容必須根據建立方式不同定(常量池和堆).有的是編譯期就已經建立好,存放在字串常 量池中,而有的是執行時才被建立.使用new關鍵字,存放在堆中。