1. 程式人生 > >Java記憶體區域(執行時資料區域)和記憶體模型(JMM)

Java記憶體區域(執行時資料區域)和記憶體模型(JMM)

Java 記憶體區域和記憶體模型是不一樣的東西,記憶體區域是指 Jvm 執行時將資料分割槽域儲存,強調對記憶體空間的劃分。

而記憶體模型(Java Memory Model,簡稱 JMM )是定義了執行緒和主記憶體之間的抽象關係,即 JMM 定義了 JVM 在計算機記憶體(RAM)中的工作方式,如果我們要想深入瞭解Java併發程式設計,就要先理解好Java記憶體模型。

Java執行時資料區域

眾所周知,Java 虛擬機器有自動記憶體管理機制,如果出現記憶體洩漏和溢位方面的問題,排查錯誤就必須要了解虛擬機器是怎樣使用記憶體的。

下圖是 JDK8 之後的 JVM 記憶體佈局。

這裡再放一張 JDK8 之前得記憶體區域圖。

程式計數器

程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。

由於 Java 虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器核心都只會執行一條執行緒中的指令。

因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。

如果執行緒正在執行的是一個 Java 方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是 Native 方法,這個計數器值則為空(Undefined)。此記憶體區域是唯一一個在 Java 虛擬機器規範中沒有規定任何 OutOfMemoryError 情況的區域。

Java虛擬機器棧

與程式計數器一樣,Java 虛擬機器棧(Java Virtual Machine Stacks)也是執行緒私有的,它的生命週期與執行緒相同。

虛擬機器棧描述的是 Java 方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame,是方法執行時的基礎資料結構)用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。

在活動執行緒中,只有位千棧頂的幀才是有效的,稱為當前棧幀。正在執行的方法稱為當前方法,棧幀是方法執行的基本結構。在執行引擎執行時,所有指令都只能針對當前棧幀進行操作。

1. 區域性變量表

區域性變量表是存放方法引數和區域性變數的區域。 區域性變數沒有準備階段, 必須顯式初始化。如果是非靜態方法,則在 index[0] 位置上儲存的是方法所屬物件的例項引用,一個引用變數佔 4 個位元組,隨後儲存的是引數和區域性變數。位元組碼指令中的 STORE 指令就是將操作棧中計算完成的區域性變呈寫回區域性變量表的儲存空間內。

虛擬機器棧規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲 StackOverflowError 異常;如果虛擬機器棧可以動態擴充套件(當前大部分的 Java 虛擬機器都可動態擴充套件),如果擴充套件時無法申請到足夠的記憶體,就會丟擲 OutOfMemoryError 異常。

2. 操作棧

操作棧是個初始狀態為空的桶式結構棧。在方法執行過程中, 會有各種指令往
棧中寫入和提取資訊。JVM 的執行引擎是基於棧的執行引擎, 其中的棧指的就是操
作棧。位元組碼指令集的定義都是基於棧型別的,棧的深度在方法元資訊的 stack 屬性中。

i++ 和 ++i 的區別:

  1. i++:從區域性變量表取出 i 並壓入操作棧,然後對區域性變量表中的 i 自增 1,將操作棧棧頂值取出使用,最後,使用棧頂值更新區域性變量表,如此執行緒從操作棧讀到的是自增之前的值。
  2. ++i:先對區域性變量表的 i 自增 1,然後取出並壓入操作棧,再將操作棧棧頂值取出使用,最後,使用棧頂值更新區域性變量表,執行緒從操作棧讀到的是自增之後的值。

之前之所以說 i++ 不是原子操作,即使使用 volatile 修飾也不是執行緒安全,就是因為,可能 i 被從區域性變量表(記憶體)取出,壓入操作棧(暫存器),操作棧中自增,使用棧頂值更新區域性變量表(暫存器更新寫入記憶體),其中分為 3 步,volatile 保證可見性,保證每次從區域性變量表讀取的都是最新的值,但可能這 3 步可能被另一個執行緒的 3 步打斷,產生資料互相覆蓋問題,從而導致 i 的值比預期的小。

3. 動態連結

每個棧幀中包含一個在常量池中對當前方法的引用, 目的是支援方法呼叫過程的動態連線。

4.方法返回地址

方法執行時有兩種退出情況:

  1. 正常退出,即正常執行到任何方法的返回位元組碼指令,如 RETURN、IRETURN、ARETURN 等;
  2. 異常退出。

無論何種退出情況,都將返回至方法當前被呼叫的位置。方法退出的過程相當於彈出當前棧幀,退出可能有三種方式:

  1. 返回值壓入上層呼叫棧幀。
  2. 異常資訊拋給能夠處理的棧幀。
  3. PC計數器指向方法呼叫後的下一條指令。

本地方法棧

本地方法棧(Native Method Stack)與虛擬機器棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機器棧為虛擬機器執行 Java 方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的 Native 方法服務。Sun HotSpot 虛擬機器直接就把本地方法棧和虛擬機器棧合二為一。與虛擬機器棧一樣,本地方法棧區域也會丟擲 StackOverflowError 和 OutOfMemoryError 異常。

執行緒開始呼叫本地方法時,會進入 個不再受 JVM 約束的世界。本地方法可以通過 JNI(Java Native Interface)來訪問虛擬機器執行時的資料區,甚至可以呼叫暫存器,具有和 JVM 相同的能力和許可權。 當大量本地方法出現時,勢必會削弱 JVM 對系統的控制力,因為它的出錯資訊都比較黑盒。對記憶體不足的情況,本地方法棧還是會丟擲 nativeheapOutOfMemory。

JNI 類本地方法最著名的應該是 System.currentTimeMillis() ,JNI使 Java 深度使用作業系統的特性功能,複用非 Java 程式碼。 但是在專案過程中, 如果大量使用其他語言來實現 JNI , 就會喪失跨平臺特性。

Java堆

對於大多數應用來說,Java 堆(Java Heap)是 Java 虛擬機器所管理的記憶體中最大的一塊。Java 堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。

堆是垃圾收集器管理的主要區域,因此很多時候也被稱做“GC堆”(Garbage Collected Heap)。從記憶體回收的角度來看,由於現在收集器基本都採用分代收集演算法,所以 Java 堆中還可以細分為:新生代和老年代;再細緻一點的有 Eden 空間、From Survivor 空間、To Survivor 空間等。從記憶體分配的角度來看,執行緒共享的 Java 堆中可能劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。

Java 堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,當前主流的虛擬機器都是按照可擴充套件來實現的(通過 -Xmx 和 -Xms 控制)。如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲 OutOfMemoryError 異常。

方法區

方法區(Method Area)與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然
Java 虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

Java 虛擬機器規範對方法區的限制非常寬鬆,除了和 Java 堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。垃圾收集行為在這個區域是比較少出現的,其記憶體回收目標主要是針對常量池的回收和對型別的解除安裝。當方法區無法滿足記憶體分配需求時,將丟擲 OutOfMemoryError 異常。

JDK8 之前,Hotspot 中方法區的實現是永久代(Perm),JDK8 開始使用元空間(Metaspace),以前永久代所有內容的字串常量移至堆記憶體,其他內容移至元空間,元空間直接在本地記憶體分配。

為什麼要使用元空間取代永久代的實現?

  1. 字串存在永久代中,容易出現效能問題和記憶體溢位。
  2. 類及方法的資訊等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢位,太大則容易導致老年代溢位。
  3. 永久代會為 GC 帶來不必要的複雜度,並且回收效率偏低。
  4. 將 HotSpot 與 JRockit 合二為一。
執行時常量池

執行時常量池(Runtime Constant Pool)是方法區的一部分。Class 檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。

一般來說,除了儲存 Class 檔案中描述的符號引用外,還會把翻譯出來的直接引用也儲存在執行時常量池中。

執行時常量池相對於 Class 檔案常量池的另外一個重要特徵是具備動態性,Java 語言並不要求常量一定只有編譯期才能產生,也就是並非預置入 Class 檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是 String 類的 intern() 方法。

既然執行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會丟擲 OutOfMemoryError 異常。

直接記憶體

直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是 Java 虛擬機器規範中定義的記憶體區域。

在 JDK 1.4 中新加入了 NIO,引入了一種基於通道(Channel)與緩衝區(Buffer)的 I/O 方式,它可以使用 Native 函式庫直接分配堆外記憶體,然後通過一個儲存在 Java 堆中的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在 Java 堆和 Native 堆中來回複製資料。

顯然,本機直接記憶體的分配不會受到 Java 堆大小的限制,但是,既然是記憶體,肯定還是會受到本機總記憶體(包括 RAM 以及 SWAP 區或者分頁檔案)大小以及處理器定址空間的限制。伺服器管理員在配置虛擬機器引數時,會根據實際記憶體設定 -Xmx 等引數資訊,但經常忽略直接記憶體,使得各個記憶體區域總和大於實體記憶體限制(包括物理的和作業系統級的限制),從而導致動態擴充套件時出現 OutOfMemoryError 異常。

Java記憶體模型

Java記憶體模型是共享記憶體的併發模型,執行緒之間主要通過讀-寫共享變數(堆記憶體中的例項域,靜態域和陣列元素)來完成隱式通訊。

Java 記憶體模型(JMM)控制 Java 執行緒之間的通訊,決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。

計算機快取記憶體和快取一致性

計算機在高速的 CPU 和相對低速的儲存裝置之間使用快取記憶體,作為記憶體和處理器之間的緩衝。將運算需要使用到的資料複製到快取中,讓運算能快速執行,當運算結束後再從快取同步回記憶體之中。

在多處理器的系統中(或者單處理器多核的系統),每個處理器核心都有自己的快取記憶體,它們有共享同一主記憶體(Main Memory)。

當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致。

為此,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議進行操作,來維護快取的一致性。

JVM主記憶體與工作記憶體

Java 記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數(執行緒共享的變數)儲存到記憶體和從記憶體中取出變數這樣底層細節。

Java記憶體模型中規定了所有的變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。

這裡的工作記憶體是 JMM 的一個抽象概念,也叫本地記憶體,其儲存了該執行緒以讀 / 寫共享變數的副本。

就像每個處理器核心擁有私有的快取記憶體,JMM 中每個執行緒擁有私有的本地記憶體。

不同執行緒之間無法直接訪問對方工作記憶體中的變數,執行緒間的通訊一般有兩種方式進行,一是通過訊息傳遞,二是共享記憶體。Java 執行緒間的通訊採用的是共享記憶體方式,執行緒、主記憶體和工作記憶體的互動關係如下圖所示:

這裡所講的主記憶體、工作記憶體與 Java 記憶體區域中的 Java 堆、棧、方法區等並不是同一個層次的記憶體劃分,這兩者基本上是沒有關係的,如果兩者一定要勉強對應起來,那從變數、主記憶體、工作記憶體的定義來看,主記憶體主要對應於Java堆中的物件例項資料部分,而工作記憶體則對應於虛擬機器棧中的部分割槽域。

重排序和happens-before規則

在執行程式時為了提高效能,編譯器和處理器常常會對指令做重排序。重排序分三種類型:

  1. 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
  2. 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
  3. 記憶體系統的重排序。由於處理器使用快取和讀 / 寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

從 java 原始碼到最終實際執行的指令序列,會分別經歷下面三種重排序:

JMM 屬於語言級的記憶體模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定型別的編譯器重排序和處理器重排序,為程式設計師提供一致的記憶體可見性保證。

java 編譯器禁止處理器重排序是通過在生成指令序列的適當位置會插入記憶體屏障(重排序時不能把後面的指令重排序到記憶體屏障之前的位置)指令來實現的。

happens-before

從 JDK5 開始,java 記憶體模型提出了 happens-before 的概念,通過這個概念來闡述操作之間的記憶體可見性。

如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在 happens-before 關係。這裡提到的兩個操作既可以是在一個執行緒之內,也可以是在不同執行緒之間。

這裡的“可見性”是指當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的。

如果 A happens-before B,那麼 Java 記憶體模型將向程式設計師保證—— A 操作的結果將對 B 可見,且 A 的執行順序排在 B 之前。

重要的 happens-before 規則如下:

  1. 程式順序規則:一個執行緒中的每個操作,happens- before 於該執行緒中的任意後續操作。
  2. 監視器鎖規則:對一個監視器鎖的解鎖,happens- before 於隨後對這個監視器鎖的加鎖。
  3. volatile 變數規則:對一個 volatile 域的寫,happens- before 於任意後續對這個 volatile 域的讀。
  4. 傳遞性:如果 A happens- before B,且 B happens- before C,那麼 A happens- before C。

下圖是 happens-before 與 JMM 的關係

volatile關鍵字

volatile 可以說是 JVM 提供的最輕量級的同步機制,當一個變數定義為volatile之後,它將具備兩種特性:

  1. 保證此變數對所有執行緒的可見性。而普通變數不能做到這一點,普通變數的值線上程間傳遞均需要通過主記憶體來完成。

注意,volatile 雖然保證了可見性,但是 Java 裡面的運算並非原子操作,導致 volatile 變數的運算在併發下一樣是不安全的。而 synchronized 關鍵字則是由“一個變數在同一個時刻只允許一條執行緒對其進行 lock 操作”這條規則獲得執行緒安全的。

  1. 禁止指令重排序優化。普通的變數僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致。

最後,推薦與感謝:
深入理解Java虛擬機器(第2版)
碼出高效:Java開發手冊
Java記憶體模型原理,你真的理解嗎?)
深入理解 Java 記憶體模