深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

穿格子衣上班發表於2019-05-01

1:什麼是JVM

大家可以想想,JVM 是什麼?JVM是用來幹什麼的?在這裡我列出了三個概念,第一個是JVM,第二個是JDK,第三個是JRE。相信大家對這三個不會很陌生,相信你們都用過,但是,你們對這三個概念有清晰的知道麼?我不知道你們會不會,知不知道。接下來你們看看我對JVM的理解。

(1):JVM

JVM是Java Virtual Machine(Java虛擬機器)的縮寫,JVM是一種用於計算裝置的規範,它是一個虛構出來的計算機,是通過在實際的計算機上模擬模擬各種計算機功能來實現的。 引入Java語言虛擬機器後,Java語言在不同平臺上執行時不需要重新編譯。 Java語言使用Java虛擬機器遮蔽了與具體平臺相關的資訊, 使得Java語言編譯程式只需生成在Java虛擬機器上執行的目的碼(位元組碼), 就可以在多種平臺上不加修改地執行。 Java虛擬機器在執行位元組碼時,把位元組碼解釋成具體平臺上的機器指令執行。 這就是Java的能夠“一次編譯,到處執行”的原因。

(2):JDK

JDK(Java Development Kit) 是 Java 語言的軟體開發工具包(SDK)。 JDK包含的基本元件包括: javac – 編譯器,將源程式轉成位元組碼 jar – 打包工具,將相關的類檔案打包成一個檔案 javadoc – 文件生成器,從原始碼註釋中提取文件 jdb – debugger,查錯工具 java – 執行編譯後的java程式(.class字尾的) appletviewer:小程式瀏覽器,一種執行HTML檔案上的Java小程式的Java瀏覽器。 Javah:產生可以呼叫Java過程的C過程,或建立能被Java程式呼叫的C過程的標頭檔案。 Javap:Java反彙編器,顯示編譯類檔案中的可訪問功能和資料,同時顯示位元組程式碼含義。 Jconsole: Java進行系統除錯和監控的工具

(3):JRE

JRE(Java Runtime Environment,Java執行環境),執行JAVA程式所必須的環境的集合,包含JVM標準實現及Java核心類庫。

包括兩部分:

Java Runtime Environment:

是可以在其上執行、測試和傳輸應用程式的Java平臺。

它包括Java虛擬機器(jvm)、Java核心類庫和支援檔案。

它不包含開發工具(JDK)--編譯器、偵錯程式和其它工具。

JRE需要輔助軟體--Java Plug-in--以便在瀏覽器中執行applet。

Java Plug-in。

允許Java Applet和JavaBean元件在使用Sun的Java Runtime Environment(JRE)的瀏覽器中執行,

而不是在使用預設的Java執行環境的瀏覽器中執行。

Java Plug-in可用於Netscape Navigator和Microsoft Internet Explorer。

2:JVM執行時資料區

Java虛擬機器在執行Java程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域。這些區域都有各自的用途,已經建立和銷燬時間,有的區域隨著虛擬機器程式的啟動而建立,有些區域則依賴使用者執行緒的啟動和結束而建立和銷燬。根據《Java虛擬機器規範(Java SE 7)》的規定,Java虛擬機器所管理的記憶體將會包括以下幾個執行時資料區域,如下圖所示:

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

2.1、程式計數器

程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看做是當前執行緒所執行的位元組碼的行號指示器。在虛擬機器的概念模型裡(僅是概念模型,各種虛擬機器可能會通過一些更高效的方式去實現),位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令、分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的。在任何一個確定的時刻,一個處理器都只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各個執行緒之間計數器互不影響,獨立儲存。

如果執行緒正在執行的是一個Java方法,那這個計數器記錄的是正在執行的位元組碼指令的地址;如果正在執行的是Native方法,這個計數器值則為空(undefined)。

此記憶體區域是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況的區域。 程式計數器是執行緒私有的,它的生命週期與執行緒相同(隨執行緒而生,隨執行緒而滅)。

2.2、Java虛擬機器棧

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

在Java虛擬機器規範中,對這個區域規定了兩種異常情況:

  • 如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;

  • 如果虛擬機器棧可以動態擴充套件(當前大部分的Java虛擬機器都可以擴充套件),如果擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常。

與程式暫存器一樣,java虛擬機器棧也是執行緒私有的,它的生命週期與執行緒相同。

2.3、本地方法棧

本地方法棧(Native Method Stack)與虛擬機器棧所發揮的作用是非常類似,它們之間的區別在於虛擬機器棧為虛擬機器執行Java方法服務,而本地方法棧則是為虛擬機器使用到的Native方法服務。在虛擬機器規範中對本地方法棧中方法使用的語言、使用方式與資料結構並沒有強制規定,因此具體的虛擬機器可以自由的實現它。

與虛擬機器棧一樣,本地方法棧區域也會丟擲StackOverflowError和OutOfMemoryError異常。 與虛擬機器棧一樣,本地方法棧也是執行緒私有的。

2.4、Java 堆(Java Heap)

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

Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱為“GC堆”(Garbage Collected Heap)。從記憶體回收的角度來看,由於現在收集器基本都採用分代收集演算法,所以Java堆還可以細分為:新生代和老年代;新生代又可以分為:Eden 空間、From Survivor空間、To Survivor空間。

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

2.5、方法區(Method Area)

方法區(Method Area)和Java堆一樣,是各個執行緒共享的記憶體區域,它用於存放已被虛擬機器載入的類資訊、常量、靜態變數、JIT編譯後的程式碼等資料。方法區在虛擬機器啟動的時候建立。

Java虛擬機器規範對方法區的限制非常寬鬆,除了和堆一樣不需要不連續的記憶體空間和可以固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。

根據Java虛擬機器規範的規定,如果方法區的記憶體空間不能滿足記憶體分配需要時,將丟擲OutOfMemoryError異常。

2.6、執行時常量池

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

2.7、直接記憶體

直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。但是這部分記憶體也被頻繁使用,而且也可能導致OutOfMemoryError異常出現。

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

到這裡我們大致知道了Java虛擬機器的執行時區的概況,接下來會繼續介紹更多JVM相關資訊。

3:JVM記憶體模型

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

Java記憶體模型即Java Memory Model,簡稱JMM。JMM定義了Java

虛擬機器(JVM)在計算機記憶體(RAM)中的工作方式。JVM是整個計算機虛擬模型,所以JMM是隸屬於JVM的。

如果我們要想深入瞭解Java併發程式設計,就要先理解好Java記憶體模型。Java記憶體模型定義了多執行緒之間共享變數的可見性以及如何在需要的時候對共享變數進行同步。原始的Java記憶體模型效率並不是很理想,因此Java1.5版本對其進行了重構,現在的Java8仍沿用了Java1.5的版本。

關於併發程式設計

在併發程式設計領域,有兩個關鍵問題:

1.執行緒之間的通訊

執行緒的通訊是指執行緒之間以何種機制來交換資訊。在指令式程式設計中,執行緒之間的通訊機制有兩種共享記憶體和訊息傳遞。

在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊,典型的共享記憶體通訊方式就是通過共享物件進行通訊。

在訊息傳遞的併發模型裡,執行緒之間沒有公共狀態,執行緒之間必須通過明確的傳送訊息來顯式進行通訊,在java中典型的訊息傳遞方式就是wait()和notify()。

關於Java執行緒之間的通訊,可以參考執行緒之間的通訊(thread signal)。

2.執行緒之間的同步

同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。 在共享記憶體併發模型裡,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。

在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的。

Java的併發採用的是共享記憶體模型

Java執行緒之間的通訊總是隱式進行,整個通訊過程對程式設計師完全透明。如果編寫多執行緒程式的Java程式設計師不理解隱式進行的執行緒之間通訊的工作機制,很可能會遇到各種奇怪的記憶體可見性問題。

Java記憶體模型

上面講到了Java執行緒之間的通訊採用的是過共享記憶體模型,這裡提到的共享記憶體模型指的就是Java記憶體模型(簡稱JMM),JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(main memory)中,每個執行緒都有一個私有的本地記憶體(local memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取,寫緩衝區,暫存器以及其他的硬體和編譯器優化。

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

從上圖來看,執行緒A與執行緒B之間如要通訊的話,必須要經歷下面2個步驟:

  • 首先,執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去。
  • 然後,執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數。

下面通過示意圖來說明這兩個步驟:

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

如上圖所示,本地記憶體A和B有主記憶體中共享變數x的副本。假設初始時,這三個記憶體中的x值都為0。執行緒A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地記憶體A中。當執行緒A和執行緒B需要通訊時,執行緒A首先會把自己本地記憶體中修改後的x值重新整理到主記憶體中,此時主記憶體中的x值變為了1。隨後,執行緒B到主記憶體中去讀取執行緒A更新後的x值,此時執行緒B的本地記憶體的x值也變為了1。

從整體來看,這兩個步驟實質上是執行緒A在向執行緒B傳送訊息,而且這個通訊過程必須要經過主記憶體。JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,來為java程式設計師提供記憶體可見性保證。

上面也說到了,Java記憶體模型只是一個抽象概念,那麼它在Java中具體是怎麼工作的呢?為了更好的理解上Java記憶體模型工作方式,下面就JVM對Java記憶體模型的實現、硬體記憶體模型及它們之間的橋接做詳細介紹。

JVM對Java記憶體模型的實現

在JVM內部,Java記憶體模型把記憶體分成了兩部分:執行緒棧區和堆區,下圖展示了Java記憶體模型在JVM中的邏輯檢視:

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

JVM中執行的每個執行緒都擁有自己的執行緒棧,執行緒棧包含了當前執行緒執行的方法呼叫相關資訊,我們也把它稱作呼叫棧。隨著程式碼的不斷執行,呼叫棧會不斷變化。

執行緒棧還包含了當前方法的所有本地變數資訊。一個執行緒只能讀取自己的執行緒棧,也就是說,執行緒中的本地變數對其它執行緒是不可見的。即使兩個執行緒執行的是同一段程式碼,它們也會各自在自己的執行緒棧中建立本地變數,因此,每個執行緒中的本地變數都會有自己的版本。

所有原始型別(boolean,byte,short,char,int,long,float,double)的本地變數都直接儲存線上程棧當中,對於它們的值各個執行緒之間都是獨立的。對於原始型別的本地變數,一個執行緒可以傳遞一個副本給另一個執行緒,當它們之間是無法共享的。

堆區包含了Java應用建立的所有物件資訊,不管物件是哪個執行緒建立的,其中的物件包括原始型別的封裝類(如Byte、Integer、Long等等)。不管物件是屬於一個成員變數還是方法中的本地變數,它都會被儲存在堆區。

下圖展示了呼叫棧和本地變數都儲存在棧區,物件都儲存在堆區:

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

一個本地變數如果是原始型別,那麼它會被完全儲存到棧區。

一個本地變數也有可能是一個物件的引用,這種情況下,這個本地引用會被儲存到棧中,但是物件本身仍然儲存在堆區。

對於一個物件的成員方法,這些方法中包含本地變數,仍需要儲存在棧區,即使它們所屬的物件在堆區。

對於一個物件的成員變數,不管它是原始型別還是包裝型別,都會被儲存到堆區。 Static型別的變數以及類本身相關資訊都會隨著類本身儲存在堆區。

堆中的物件可以被多執行緒共享。如果一個執行緒獲得一個物件的應用,它便可訪問這個物件的成員變數。如果兩個執行緒同時呼叫了同一個物件的同一個方法,那麼這兩個執行緒便可同時訪問這個物件的成員變數,但是對於本地變數,每個執行緒都會拷貝一份到自己的執行緒棧中。

下圖展示了上面描述的過程:

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

硬體記憶體架構

不管是什麼記憶體模型,最終還是執行在計算機硬體上的,所以我們有必要了解計算機硬體記憶體架構 ,下圖就簡單描述了當代計算機硬體記憶體架構:

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

現代計算機一般都有2個以上CPU,而且每個CPU還有可能包含多個核心。因此,如果我們的應用是多執行緒的話,這些執行緒可能會在各個CPU核心中並行執行。

在CPU內部有一組CPU暫存器,也就是CPU的儲存器。CPU操作暫存器的速度要比操作計算機主存快的多。在主存和CPU暫存器之間還存在一個CPU快取,CPU操作CPU快取的速度快於主存但慢於CPU暫存器。某些CPU可能有多個快取層(一級快取和二級快取)。計算機的主存也稱作RAM,所有的CPU都能夠訪問主存,而且主存比上面提到的快取和暫存器大很多。

當一個CPU需要訪問主存時,會先讀取一部分主存資料到CPU快取,進而在讀取CPU快取到暫存器。當CPU需要寫資料到主存時,同樣會先flush暫存器到CPU快取,然後再在某些節點把快取資料flush到主存。

Java記憶體模型和硬體架構之間的橋接

正如上面講到的,Java記憶體模型和硬體記憶體架構並不一致。硬體記憶體架構中並沒有區分棧和堆,從硬體上看,不管是棧還是堆,大部分資料都會存到主存中,當然一部分棧和堆的資料也有可能會存到CPU暫存器中,如下圖所示,Java記憶體模型和計算機硬體記憶體架構是一個交叉關係:

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

當物件和變數儲存到計算機的各個記憶體區域時,必然會面臨一些問題,其中最主要的兩個問題是:

1.共享物件對各個執行緒的可見性2. 共享物件的競爭現象123

共享物件的可見性

當多個執行緒同時操作同一個共享物件時,如果沒有合理的使用volatile和synchronization關鍵字,一個執行緒對共享物件的更新有可能導致其它執行緒不可見。

想象一下我們的共享物件儲存在主存,一個CPU中的執行緒讀取主存資料到CPU快取,然後對共享物件做了更改,但CPU快取中的更改後的物件還沒有flush到主存,此時執行緒對共享物件的更改對其它CPU中的執行緒是不可見的。最終就是每個執行緒最終都會拷貝共享物件,而且拷貝的物件位於不同的CPU快取中。

下圖展示了上面描述的過程。左邊CPU中執行的執行緒從主存中拷貝共享物件obj到它的CPU快取,把物件obj的count變數改為2。但這個變更對執行在右邊CPU中的執行緒不可見,因為這個更改還沒有flush到主存中:

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

要解決共享物件可見性這個問題,我們可以使用java volatile關鍵字。 Java’s volatile keyword. volatile 關鍵字可以保證變數會直接從主存讀取,而對變數的更新也會直接寫到主存。volatile原理是基於CPU記憶體屏障指令實現的,後面會講到。

競爭現象

如果多個執行緒共享一個物件,如果它們同時修改這個共享物件,這就產生了競爭現象。

如下圖所示,執行緒A和執行緒B共享一個物件obj。假設執行緒A從主存讀取Obj.count變數到自己的CPU快取,同時,執行緒B也讀取了Obj.count變數到它的CPU快取,並且這兩個執行緒都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU快取中。

如果這兩個加1操作是序列執行的,那麼Obj.count變數便會在原始值上加2,最終主存中的Obj.count的值會是3。然而下圖中兩個加1操作是並行的,不管是執行緒A還是執行緒B先flush計算結果到主存,最終主存中的Obj.count只會增加1次變成2,儘管一共有兩次加1操作。

深入淺出虛擬機器難(JVM)?現在讓它通俗易懂

要解決上面的問題我們可以使用java synchronized程式碼塊。synchronized程式碼塊可以保證同一個時刻只能有一個執行緒進入程式碼競爭區,synchronized程式碼塊也能保證程式碼塊中所有變數都將會從主存中讀,當執行緒退出程式碼塊時,對所有變數的更新將會flush到主存,不管這些變數是不是volatile型別的。

volatile和 synchronized區別

volatile本質是在告訴jvm當前變數在暫存器(工作記憶體)中的值是不確定的,需要從主存中讀取; synchronized則是鎖定當前變數,只有當前執行緒可以訪問該變數,其他執行緒被阻塞住。

volatile僅能使用在變數級別;synchronized則可以使用在變數、方法、和類級別的 volatile僅能實現變數的修改可見性,不能保證原子性;而synchronized則可以保證變數的修改可見性和原子性

volatile不會造成執行緒的阻塞;synchronized可能會造成執行緒的阻塞。 volatile標記的變數不會被編譯器優化;synchronized標記的變數可以被編譯器優化 支撐Java記憶體模型的基礎原理

指令重排序

在執行程式時,為了提高效能,編譯器和處理器會對指令做重排序。但是,JMM確保在不同的編譯器和不同的處理器平臺之上,通過插入特定型別的Memory Barrier來禁止特定型別的編譯器重排序和處理器重排序,為上層提供一致的記憶體可見性保證。

1.編譯器優化重排序:編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

2.指令級並行的重排序:如果不存l在資料依賴性,處理器可以改變語句對應機器指令的執行順序。

3.記憶體系統的重排序:處理器使用快取和讀寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

資料依賴性

如果兩個操作訪問同一個變數,其中一個為寫操作,此時這兩個操作之間存在資料依賴性。 編譯器和處理器不會改變存在資料依賴性關係的兩個操作的執行順序,即不會重排序。

as-if-serial

不管怎麼重排序,單執行緒下的執行結果不能被改變,編譯器、runtime和處理器都必須遵守as-if-serial語義。

記憶體屏障(Memory Barrier )

上面講到了,通過記憶體屏障可以禁止特定型別處理器的重排序,從而讓程式按我們預想的流程去執行。記憶體屏障,又稱記憶體柵欄,是一個CPU指令,基本上它是一條這樣的指令: 保證特定操作的執行順序。

影響某些資料(或則是某條指令的執行結果)的記憶體可見性。 編譯器和CPU能夠重排序指令,保證最終相同的結果,嘗試優化效能。插入一條Memory Barrier會告訴編譯器和CPU:不管什麼指令都不能和這條Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是強制刷出各種CPU cache,如一個Write-Barrier(寫入屏障)將刷出所有在Barrier之前寫入 cache 的資料,因此,任何CPU上的執行緒都能讀取到這些資料的最新版本。

這和java有什麼關係?上面java記憶體模型中講到的volatile是基於Memory Barrier實現的。

如果一個變數是volatile修飾的,JMM會在寫入這個欄位之後插進一個Write-Barrier指令,並在讀這個欄位之前插入一個Read-Barrier指令。這意味著,如果寫入一個volatile變數,就可以保證: 一個執行緒寫入變數a後,任何執行緒訪問該變數都會拿到最新值。

在寫入變數a之前的寫入操作,其更新的資料對於其他執行緒也是可見的。因為Memory Barrier會刷出cache中的所有先前的寫入。

happens-before

從jdk5開始,java使用新的JSR-133記憶體模型,基於happens-before的概念來闡述操作之間的記憶體可見性。

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

與程式設計師密切相關的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關係,並不意味前一個操作必須要在後一個操作之前執行!僅僅要求前一個操作的執行結果,對於後一個操作是可見的,且前一個操作按順序排在後一個操作之前。

相關文章