各種程式語言的記憶體模型
①C程式記憶體分配:
一個正在執行著的C編譯程式佔用的記憶體分為程式碼區、初始化資料區、未初始化資料區、堆區和棧區5個部分。
(點選檢視大圖)圖3-1 C程式的記憶體佈局 |
(1)程式碼區(text segment)。程式碼區指令根據程式設計流程依次執行,對於順序指令,則只會執行一次(每個程序),如果反覆,則需要使用跳轉指令,如果進行遞迴,則需要藉助棧來實現。
程式碼區的指令中包括操作碼和要操作的物件(或物件地址引用)。如果是立即數(即具體的數值,如5),將直接包含在程式碼中;如果是區域性資料,將在棧區分配空間,然後引用該資料地址;如果是BSS區和資料區,在程式碼中同樣將引用該資料地址。
(2)全域性初始化資料區/靜態資料區(Data Segment)。只初始化一次。
(3)未初始化資料區(BSS)。在執行時改變其值。
(4)棧區(stack)。由編譯器自動分配釋放,存放函式的引數值、區域性變數的值等。其操作方式類似於資料結構中的棧。每當一個函式被呼叫,該函式返回地址和一些關於呼叫的資訊,比如某些暫存器的內容,被儲存到棧區。然後這個被呼叫的函式再為它的自動變數和臨時變數在棧區上分配空間,這就是C實現函式遞迴呼叫的方法。每執行一次遞迴函式呼叫,一個新的棧框架就會被使用,這樣這個新例項棧裡的變數就不會和該函式的另一個例項棧裡面的變數混淆。
(5)堆區(heap)。用於動態記憶體分配。堆在記憶體中位於bss區和棧區之間。一般由程式設計師分配和釋放,若程式設計師不釋放,程式結束時有可能由OS回收。
②C++程式記憶體分配:
在C++中,記憶體分成5個區,他們分別是堆、棧、自由儲存區、全域性/靜態儲存區和常量儲存區。 棧:就是那些由編譯器在需要的時候分配,在不需要的時候自動清楚的變數的儲存區。裡面的變數通常是區域性變數、函式引數等。堆:就是那些由new分配的記憶體塊,他們的釋放編譯器不去管,由我們的應用程式去控制,一般一個new就要對應一個delete。如果程式設計師沒有釋放掉,那麼在程式結束後,作業系統會自動回收。
自由儲存區:就是那些由malloc等分配的記憶體塊,他和堆是十分相似的,不過它是用free來結束自己的生命的。
全域性/靜態儲存區:全域性變數和靜態變數被分配到同一塊記憶體中,在以前的C語言中,全域性變數又分為初始化的和未初始化的,在C++裡面沒有這個區分了,他們共同佔用同一塊記憶體區。
常量儲存區:這是一塊比較特殊的儲存區,他們裡面存放的是常量,不允許修改
詳細說明:
1、棧區(stack)— 由編譯器自動分配釋放 ,存放函式的引數值,區域性變數的值等。其操作方式類似於資料結構中的棧。
2、堆區(heap) — 一般由程式設計師分配釋放, 若程式設計師不釋放,程式結束時可能由OS回收 。注意它與資料結構中的堆是兩回事,分配方式倒是類似於連結串列,呵呵。
3、全域性區(靜態區)(static)—,全域性變數和靜態變數的儲存是放在一塊的,初始化的全域性變數和靜態變數在一塊區域, 未初始化的全域性變數和未初始化的靜態變數在相鄰的另一塊區域。 - 程式結束後有系統釋放
4、文字常量區—常量字串就是放在這裡的。 程式結束後由系統釋放
5、程式程式碼區—存放函式體的二進位制程式碼。
我們現在來逐個的看下每個到底是做什麼的!
1、程式計數器
程式計數器(Program Counter Register)是一塊較小的記憶體空間,它的作用可以看
做是當前執行緒所執行的位元組碼的行號指示器。在虛擬機器的概念模型裡(僅是概念模型,
各種虛擬機器可能會通過一些更高效的方式去實現),位元組碼直譯器工作時就是通過改變
這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、
執行緒恢復等基礎功能都需要依賴這個計數器來完成。
由於Java 虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現
的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)只會執行
一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要
有一個獨立的程式計數器,各條執行緒之間的計數器互不影響,獨立儲存,我們稱這類內
存區域為“執行緒私有”的記憶體。
如果執行緒正在執行的是一個Java 方法,這個計數器記錄的是正在執行的虛擬機器位元組
碼指令的地址;如果正在執行的是Natvie 方法,這個計數器值則為空(Undefined)。此
記憶體區域是唯一一個在Java 虛擬機器規範中沒有規定任何OutOfMemoryError 情況的區域。
2、Java 虛擬機器棧
與程式計數器一樣,Java 虛擬機器棧(Java Virtual Machine Stacks)也是執行緒私有的,
它的生命週期與執行緒相同。虛擬機器棧描述的是Java 方法執行的記憶體模型:每個方法被執
行的時候都會同時建立一個棧幀(Stack Frame ①)用於儲存區域性變量表、操作棧、動態
連結、方法出口等資訊。每一個方法被呼叫直至執行完成的過程,就對應著一個棧幀在
虛擬機器棧中從入棧到出棧的過程。
經常有人把Java 記憶體區分為堆記憶體(Heap)和棧記憶體(Stack),這種分法比較粗
糙,Java 記憶體區域的劃分實際上遠比這複雜。這種劃分方式的流行只能說明大多數程式
員最關注的、與物件記憶體分配關係最密切的記憶體區域是這兩塊。其中所指的“堆”在後
面會專門講述,而所指的“棧”就是現在講的虛擬機器棧,或者說是虛擬機器棧中的區域性變
量表部分。
區域性變量表存放了編譯期可知的各種基本資料型別(boolean、byte、char、short、int、
float、long、double)、物件引用(reference 型別,它不等同於物件本身,根據不同的虛擬
機實現,它可能是一個指向物件起始地址的引用指標,也可能指向一個代表物件的控制代碼或
者其他與此物件相關的位置)和returnAddress 型別(指向了一條位元組碼指令的地址)。
其中64 位長度的long 和double 型別的資料會佔用2 個區域性變數空間(Slot),其餘
的資料型別只佔用1 個。區域性變量表所需的記憶體空間在編譯期間完成分配,當進入一個
方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間
不會改變區域性變量表的大小。
在Java 虛擬機器規範中,對這個區域規定了兩種異常狀況:如果執行緒請求的棧深度大
於虛擬機器所允許的深度,將丟擲StackOverflowError 異常;如果虛擬機器棧可以動態擴充套件
(當前大部分的Java 虛擬機器都可動態擴充套件,只不過Java 虛擬機器規範中也允許固定長度的
虛擬機器棧),當擴充套件時無法申請到足夠的記憶體時會丟擲OutOfMemoryError 異常。
3、本地方法棧
本地方法棧(Native Method Stacks)與虛擬機器棧所發揮的作用是非常相似的,其
區別不過是虛擬機器棧為虛擬機器執行Java 方法(也就是位元組碼)服務,而本地方法棧則
是為虛擬機器使用到的Native 方法服務。虛擬機器規範中對本地方法棧中的方法使用的語
言、使用方式與資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。甚至
有的虛擬機器(譬如Sun HotSpot 虛擬機器)直接就把本地方法棧和虛擬機器棧合二為一。
與虛擬機器棧一樣,本地方法棧區域也會丟擲StackOverflowError 和OutOfMemoryError
異常。
4、Java 堆
對於大多數應用來說,Java 堆(Java Heap)是Java 虛擬機器所管理的記憶體中最大的
一塊。Java 堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的
唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。這一點在Java 虛
擬機規範中的描述是:所有的物件例項以及陣列都要在堆上分配①,但是隨著JIT 編譯器
的發展與逃逸分析技術的逐漸成熟,棧上分配、標量替換②優化技術將會導致一些微妙
的變化發生,所有的物件都分配在堆上也漸漸變得不是那麼“絕對”了。
Java 堆是垃圾收集器管理的主要區域,因此很多時候也被稱做“GC 堆”(Garbage
Collected Heap,幸好國內沒翻譯成“垃圾堆”)。如果從記憶體回收的角度看,由於現在
收集器基本都是採用的分代收集演算法,所以Java 堆中還可以細分為:新生代和老年代;
再細緻一點的有Eden 空間、From Survivor 空間、To Survivor 空間等。如果從記憶體分配
的角度看,執行緒共享的Java 堆中可能劃分出多個執行緒私有的分配緩衝區(Thread Local
Allocation Buffer,TLAB)。不過,無論如何劃分,都與存放內容無關,無論哪個區域,
儲存的都仍然是物件例項,進一步劃分的目的是為了更好地回收記憶體,或者更快地分配
記憶體。在本章中,我們僅僅針對記憶體區域的作用進行討論,Java 堆中的上述各個區域的
分配和回收等細節將會是下一章的主題。
根據Java 虛擬機器規範的規定,Java 堆可以處於物理上不連續的記憶體空間中,只要
邏輯上是連續的即可,就像我們的磁碟空間一樣。在實現時,既可以實現成固定大小
的,也可以是可擴充套件的,不過當前主流的虛擬機器都是按照可擴充套件來實現的(通過-Xmx
和-Xms 控制)。如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲
OutOfMemoryError 異常。
4、方法區
方法區(Method Area)與Java 堆一樣,是各個執行緒共享的記憶體區域,它用於存
儲已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖
然Java 虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-
Heap(非堆),目的應該是與Java 堆區分開來。
對於習慣在HotSpot 虛擬機器上開發和部署程式的開發者來說,很多人願意把方法區
稱為“永久代”(Permanent Generation),本質上兩者並不等價,僅僅是因為HotSpot 虛
擬機的設計團隊選擇把GC 分代收集擴充套件至方法區,或者說使用永久代來實現方法區而
已。對於其他虛擬機器(如BEA JRockit、IBM J9 等)來說是不存在永久代的概念的。即
使是HotSpot 虛擬機器本身,根據官方釋出的路線圖資訊,現在也有放棄永久代並“搬家”
至Native Memory 來實現方法區的規劃了。
Java 虛擬機器規範對這個區域的限制非常寬鬆,除了和Java 堆一樣不需要連續的內
存和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。相對而言,垃圾
收集行為在這個區域是比較少出現的,但並非資料進入了方法區就如永久代的名字一
樣“永久”存在了。這個區域的記憶體回收目標主要是針對常量池的回收和對型別的卸
載,一般來說這個區域的回收“成績”比較難以令人滿意,尤其是型別的解除安裝,條件
相當苛刻,但是這部分割槽域的回收確實是有必要的。在Sun 公司的BUG 列表中,曾出
現過的若干個嚴重的BUG 就是由於低版本的HotSpot 虛擬機器對此區域未完全回收而導
致記憶體洩漏。
根據Java 虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲
OutOfMemoryError 異常。
5、執行時常量池
執行時常量池(Runtime Constant Pool)是方法區的一部分。Class 檔案中除了有
類的版本、欄位、方法、介面等描述等資訊外,還有一項資訊是常量池(Constant Pool
Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後存放
到方法區的執行時常量池中。
Java 虛擬機器對Class 檔案的每一部分(自然也包括常量池)的格式都有嚴格的規
定,每一個位元組用於儲存哪種資料都必須符合規範上的要求,這樣才會被虛擬機器認可、
裝載和執行。但對於執行時常量池,Java 虛擬機器規範沒有做任何細節的要求,不同的
提供商實現的虛擬機器可以按照自己的需要來實現這個記憶體區域。不過,一般來說,除
了儲存Class 檔案中描述的符號引用外,還會把翻譯出來的直接引用也儲存在執行時常
量池中①。
執行時常量池相對於Class 檔案常量池的另外一個重要特徵是具備動態性,Java 語
言並不要求常量一定只能在編譯期產生,也就是並非預置入Class 檔案中常量池的內容
才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,這種特性被開發
人員利用得比較多的便是String 類的intern() 方法。
既然執行時常量池是方法區的一部分,自然會受到方法區記憶體的限制,當常量池無
法再申請到記憶體時會丟擲OutOfMemoryError 異常
6、直接記憶體
直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java
虛擬機器規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用,而且也可能導致
OutOfMemoryError 異常出現,所以我們放到這裡一起講解。
在JDK 1.4 中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)
與緩衝區(Buffer)的I/O 方式,它可以使用Native 函式庫直接分配堆外記憶體,然
後通過一個儲存在Java 堆裡面的DirectByteBuffer 物件作為這塊記憶體的引用進行
操作。這樣能在一些場景中顯著提高效能,因為避免了在Java 堆和Native 堆中來
回覆制資料。
顯然,本機直接記憶體的分配不會受到Java 堆大小的限制,但是,既然是記憶體,則
肯定還是會受到本機總記憶體(包括RAM 及SWAP 區或者分頁檔案)的大小及處理器
定址空間的限制。伺服器管理員配置虛擬機器引數時,一般會根據實際記憶體設定-Xmx
等引數資訊,但經常會忽略掉直接記憶體,使得各個記憶體區域的總和大於實體記憶體限制
(包括物理上的和作業系統級的限制),從而導致動態擴充套件時出現OutOfMemoryError
異常。
邏輯記憶體模型我們已經看到了,那當我們建立一個物件的時候是怎麼進行訪問的呢?
在Java 語言中,物件訪問是如何進行的?物件訪問在Java 語言中無處不在,是最普通的程式行為,但即使是最簡單的訪問,也會卻涉及Java 棧、Java 堆、方法區這三個最重要記憶體區
域之間的關聯關係,如下面的這句程式碼:
Object obj = new Object();
假設這句程式碼出現在方法體中,那“Object obj”這部分的語義將會反映到Java 棧
的本地變量表中,作為一個reference 型別資料出現。而“new Object()”這部分的語義
將會反映到Java 堆中,形成一塊儲存了Object 型別所有例項資料值(Instance Data,對
象中各個例項欄位的資料)的結構化記憶體,根據具體型別以及虛擬機器實現的物件記憶體布
局(Object Memory Layout)的不同,這塊記憶體的長度是不固定的。另外,在Java 堆中
還必須包含能查詢到此物件型別資料(如物件型別、父類、實現的介面、方法等)的地
址資訊,這些型別資料則儲存在方法區中。
由於reference 型別在Java 虛擬機器規範裡面只規定了一個指向物件的引用,並沒有
定義這個引用應該通過哪種方式去定位,以及訪問到Java 堆中的物件的具體位置,因此
不同虛擬機器實現的物件訪問方式會有所不同,主流的訪問方式有兩種:使用控制代碼和直接
指標。
如果使用控制代碼訪問方式,Java 堆中將會劃分出一塊記憶體來作為控制代碼池,reference
中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料和型別資料各自的
具體地址資訊,如下圖所示。
如果使用直接指標訪問方式,
Java 堆物件的佈局中就必須考慮如何放置訪問型別資料的相關資訊,reference 中直接儲存的就是物件地址,如下圖所示
這兩種物件的訪問方式各有優勢,使用控制代碼訪問方式的最大好處就是
reference 中存儲的是穩定的控制代碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只
會改變控制代碼中的例項資料指標,而reference 本身不需要被修改。
使用直接指標訪問方式的最大好處就是速度更快,它節省了一次指標定位的時間開
銷,由於物件的訪問在Java 中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的
執行成本。就本書討論的主要虛擬機器Sun HotSpot 而言,它是使用第二種方式進行物件訪問的,但從整個軟體開發的範圍來看,各種語言和框架使用控制代碼來訪問的情況也十分常見。