記憶體?java模型?jvm結構?一篇文章讓你全部看懂!

tengshe789發表於2019-03-01

又是一年秋招季,哎呀媽呀我被虐的慘來~這不,前幾陣失蹤沒更新部落格,其實是我偷偷把時間用在複習課本了(霧

堅持在社群分享部落格也很久了,由於過去的文章有很多疏漏之處,很多大佬都在評論指出我的過錯,我很開心也很失望,開心的是有大家幫我指出錯誤,失望的鄙人學識淺薄總沒法做到完美。總之,歡迎評論區各種pr~

好,回到正題。複習的時候,無意間看到java虛擬機器的有關知識點,我產生了非常濃厚的興趣,今天我來結合計算機記憶體模型的相關知識,與Java記憶體模型Java物件模型JVM記憶體結構等相關的知識串聯起來,本篇文章共1.5W字,分享給大家,感謝閱讀。

想要解鎖更多新姿勢?請訪問我的個人部落格https://blog.tengshe789.tech/(?

計算機記憶體

相信每個人都有一臺電腦,也有diy電腦的經歷。現在一臺功能強大的diy電腦大概3k就能組裝起來,一個i5-8400 的cpu 869元,DDR4 記憶體 1200塊錢,b360主機板300元 散熱器50元 機械硬碟200元 350w電源300元 機箱100元 ,沒錯,只要3k就能拿到一個效能強大的6C6T電腦。

要說一臺PC中最重要的部件是什麼?大家看價格也會看明白,是cpu和記憶體,下面我來介紹一下cpu和記憶體之間的關係。

cpu與記憶體快取的千絲萬縷

cpu相關術語

首先說明一下相關的cpu術語:

  • socket:cpu插在主機板上那個槽與cpu稱作一個socket。
  • Die:核心(Die)又稱為核心,是cpu的物理組成部分之一。cpu也會分為多die cpu與單die cpu,譬如我們現在強大的AMD TR-2990WX就是4die cpu,每個die裡面有8個核心(core)
  • core:也就是物理核心了。core這個詞是英特爾起的,起初是為了與競爭對手AMD區別開,後面用的多了也淡了。
  • thread:就是硬體執行緒數。一個程式執行可能需要多個執行緒一起進行~而現在也就比較強大的超執行緒技術,過去的cpu往往一個cpu核心只支援一個執行緒,現在一些強大的cpu中,就譬如IBM 的POWER 9 ,支援8核心32個執行緒(平均一個核心4個執行緒),理論效能非常強大。

總結一下,以明星cpu AMD TR-2990WX作為栗子,這個cpu使用一個socket,一個socket裡面有4個die,總共32個物理核心64個執行緒

cpu快取

我們都知道,cpu將要處理的資料會放到記憶體中儲存,可是,為什麼會這樣,將記憶體快取硬碟行不行呢?

答案當然是不行的。cpu的處理速度很強大,記憶體的速度雖然非常快速但是根本跟不上cpu的步伐,所以,就出現的快取。與來自DRAM家族的記憶體不同,快取SRAM與記憶體最大的特點是,特別快,容量小,結構複雜,成本也高。

造成記憶體和快取效能差異,主要有以下原因:

  1. DRAM儲存一位資料只需要一個電容加上一個電晶體,而SRAM需要6個電晶體。由於DRAM儲存資料其實是在電容裡面的,電容需要充放電才能進行讀寫操作,這就導致其讀寫資料就有比較大的延遲問題。
  2. 儲存可以看錯一個二維陣列,每個儲存單元都有其行地址列地址。SRAM的容量很小,其儲存單元比較短(行列短),可以一次性傳輸到SRAM中;而DRAM,需要分別傳送行列地址。
  3. SRAM的頻率和cpu頻率比較接近;而DRAM的頻率和cpu差距比較大。

近代的快取通常被整合到cpu當中,為了適應效能與成本的需要,現實中的快取往往使用金字塔型多級快取架構。也就是當CPU要讀取一個資料時,首先從一級快取中查詢,如果沒有找到再從二級快取中查詢,如果還是沒有就從三級快取或記憶體中查詢。

下面是英特爾最近以來用的初代skylake架構

skl架構

可以看到,每個個核心有專屬的L1,L2快取,他們共享一個L3快取。如果cpu如果要訪問記憶體中的資料,必須要經過L1,L2,L3,LLC(或者L4)四層快取。

快取一致性問題

最開始的cpu,其實只是一個核心一個執行緒的,當時根本不需要考慮快取一致性問題,單執行緒,也就是cpu核心的快取只被一個執行緒訪問。快取獨佔,不會出現訪問衝突等問題。

後來超執行緒技術來到我們視野,''單核CPU多執行緒'',也就是程式中的多個執行緒會同時訪問程式中的共享資料,CPU將某塊記憶體載入到快取後,不同執行緒在訪問相同的實體地址的時候,都會對映到相同的快取位置,這樣即使發生執行緒的切換,快取仍然不會失效。但由於任何時刻只能有一個執行緒在執行,因此不會出現快取訪問衝突。

時代不斷髮展,**“多核CPU多執行緒”**來了,即多個執行緒訪問程式中的某個共享記憶體,且這多個執行緒分別在不同的核心上執行,則每個核心都會在各自的caehe中保留一份共享記憶體的緩衝。由於多核是可以並行的,可能會出現多個執行緒同時寫各自的快取的情況,而各自的cache之間的資料就有可能不同。

這就是我們說的快取一致性問題。

目前公認最好的解決方案是英特爾的MESI協議,下面我們著重介紹。

MESI協議

首先說說I/O操作的單位問題,大部分人都知道,在記憶體中操作I/O不是以位元組為單位,而是以“塊”為單位,這是為什麼呢?

其實這是因為I/O操作的資料訪問有空間連續性特徵,即需要訪問記憶體空間很多資料,但是I/O操作比較慢,讀一個位元組和讀N個位元組的時間基本相同。

機智的intel就規定了,cpu快取中最小的儲存單元是快取行cache line,在x86的cpu中,一個cache line儲存64位元組,每一級的快取都會被劃分成許多組cache line

快取工作原理請看?維基百科

接下來我們看看MESI規範,這其實是用四種快取行狀態命名的,我們定義了CPU中每個快取行使用4種狀態進行標記(使用額外的兩位(bit)表示),分別是:

  • M: 被修改(Modified)

    該快取行只被快取在該CPU的快取中,並且是被修改過的(dirty),即與主存中的資料不一致,該快取行中的記憶體需要在未來的某個時間點(允許其它CPU讀取請主存中相應記憶體之前)寫回(write back)主存。當被寫回主存之後,該快取行的狀態會變成獨享(exclusive)狀態。

  • E: 獨享的(Exclusive)

    該快取行只被快取在該CPU的快取中,它是未被修改過的(clean),與主存中資料一致。該狀態可以在任何時刻當有其它CPU讀取該記憶體時變成共享狀態(shared)。同樣地,當CPU修改該快取行中內容時,該狀態可以變成Modified狀態。

  • S: 共享的(Shared)

    該狀態意味著該快取行可能被多個CPU快取,並且各個快取中的資料與主存資料一致(clean),當有一個CPU修改該快取行中,其它CPU中該快取行可以被作廢(變成無效狀態(Invalid))。

  • I: 無效的(Invalid)

    該快取是無效的(可能有其它CPU修改了該快取行)。

mesi

然而,只是有這四種狀態也會帶來一定的問題。下面引用一下oracle的文件

同時更新來自不同處理器的相同快取程式碼行中的單個元素會使整個快取程式碼行無效,即使這些更新在邏輯上是彼此獨立的。每次對快取程式碼行的單個元素進行更新時,都會將此程式碼行標記為無效。其他訪問同一程式碼行中不同元素的處理器將看到該程式碼行已標記為無效。即使所訪問的元素未被修改,也會強制它們從記憶體或其他位置獲取該程式碼行的較新副本。這是因為基於快取程式碼行保持快取一致性,而不是針對單個元素的。因此,互連通訊和開銷方面都將有所增長。並且,正在進行快取程式碼行更新的時候,禁止訪問該程式碼行中的元素。

MESI協議,可以保證快取的一致性,但是無法保證實時性。這種情況稱為偽共享

偽共享問題

偽共享問題其實在Java中是真實存在的一個問題。假設有如下所示的java class

class MyObiect{
    long a;
    long b;
    long c;
}
複製程式碼

按照java規範,MyObiect物件是在堆空間中分配的,a、b、c這三個變數在記憶體空間中是近鄰,分別佔8位元組,長度之和為24位元組。而我們的x86的快取行是64位元組,這三個變數完全有可能會在一個快取行中,並且被兩個不同的cpu核心共享!

根據MESI協議,如果不同物理核心cpu中的執行緒1和執行緒2要互斥的對這幾個變數進行操作,很有可能要互相搶佔資源,導致原來的並行變成序列,大大降低了系統的併發性,這就是快取的偽共享。

解決偽共享

其實解決偽共享很簡單,只需要將這幾個變數分別放到不同的快取行即可。在java8中,就已經提供了普適性的解決方案,即採用@Contended註解來保證物件中的變數或者屬性不在一個快取行中~

@Contended
class VolatileObiect{
    volatile long a = 1L;
    volatile long b = 2L;
    volatile long c = 3L;
}
複製程式碼

記憶體不一致性問題

上面我說了MESI協議在多核心cpu中解決快取一致性的問題,下面我們說說cpu的記憶體不一致性問題。

三種cpu架構

首先,要了解三個名詞:

  • SMP(Symmetric Multi-Processor)

SMP ,對稱多處理系統內有許多緊耦合多處理器,在這樣的系統中,所有的CPU共享全部資源,如匯流排,記憶體和I/O系統等,作業系統或管理資料庫的複本只有一個,這種系統有一個最大的特點就是共享所有資源。多個CPU之間沒有區別,平等地訪問記憶體、外設、一個作業系統。作業系統管理著一個佇列,每個處理器依次處理佇列中的程式。如果兩個處理器同時請求訪問一個資源(例如同一段記憶體地址),由硬體、軟體的鎖機制去解決資源爭用問題。

[

clip_image001

所謂對稱多處理器結構,是指伺服器中多個 CPU 對稱工作,無主次或從屬關係。各 CPU 共享相同的實體記憶體,每個 CPU 訪問記憶體中的任何地址所需時間是相同的,因此 SMP 也被稱為一致儲存器訪問結構 (UMA : Uniform Memory Access) 。對 SMP 伺服器進行擴充套件的方式包括增加記憶體、使用更快的 CPU 、增加 CPU 、擴充 I/O( 槽口數與匯流排數 ) 以及新增更多的外部裝置 ( 通常是磁碟儲存 ) 。

SMP 伺服器的主要特徵是共享,系統中所有資源 (CPU 、記憶體、 I/O 等 ) 都是共享的。也正是由於這種特徵,導致了 SMP 伺服器的主要問題,那就是它的擴充套件能力非常有限。對於 SMP 伺服器而言,每一個共享的環節都可能造成 SMP 伺服器擴充套件時的瓶頸,而最受限制的則是記憶體。由於每個 CPU 必須通過相同的記憶體匯流排訪問相同的記憶體資源,因此隨著 CPU 數量的增加,記憶體訪問衝突將迅速增加,最終會造成 CPU 資源的浪費,使 CPU 效能的有效性大大降低。實驗證明, SMP 伺服器 CPU 利用率最好的情況是 2 至 4 個 CPU 。

[

clip_image002

  • NUMA(Non-Uniform Memory Access)

  由於 SMP 在擴充套件能力上的限制,人們開始探究如何進行有效地擴充套件從而構建大型系統的技術, NUMA 就是這種努力下的結果之一。利用 NUMA 技術,可以把幾十個 CPU( 甚至上百個 CPU) 組合在一個伺服器內。其NUMA 伺服器 CPU 模組結構如圖所示:

clip_image003

NUMA 伺服器的基本特徵是具有多個 CPU 模組,每個 CPU 模組由多個 CPU( 如 4 個 ) 組成,並且具有獨立的本地記憶體、 I/O 槽口等。由於其節點之間可以通過互聯模組 ( 如稱為 Crossbar Switch) 進行連線和資訊互動,因此每個 CPU 可以訪問整個系統的記憶體 ( 這是 NUMA 系統與 MPP 系統的重要差別 ) 。顯然,訪問本地記憶體的速度將遠遠高於訪問遠地記憶體 ( 系統內其它節點的記憶體 ) 的速度,這也是非一致儲存訪問 NUMA 的由來。由於這個特點,為了更好地發揮系統效能,開發應用程式時需要儘量減少不同 CPU 模組之間的資訊互動。

利用 NUMA 技術,可以較好地解決原來 SMP 系統的擴充套件問題,在一個物理伺服器內可以支援上百個 CPU 。比較典型的 NUMA 伺服器的例子包括 HP 的 Superdome 、 SUN15K 、 IBMp690 等。

  但 NUMA 技術同樣有一定缺陷,由於訪問遠地記憶體的延時遠遠超過本地記憶體,因此當 CPU 數量增加時,系統效能無法線性增加。如 HP 公司釋出 Superdome 伺服器時,曾公佈了它與 HP 其它 UNIX 伺服器的相對效能值,結果發現, 64 路 CPU 的 Superdome (NUMA 結構 ) 的相對效能值是 20 ,而 8 路 N4000( 共享的 SMP 結構 ) 的相對效能值是 6.3 。從這個結果可以看到, 8 倍數量的 CPU 換來的只是 3 倍效能的提升。

  • MPP(Massive Parallel Processing)

  和 NUMA 不同, MPP 提供了另外一種進行系統擴充套件的方式,它由多個 SMP 伺服器通過一定的節點網際網路絡進行連線,協同工作,完成相同的任務,從使用者的角度來看是一個伺服器系統。其基本特徵是由多個 SMP 伺服器 ( 每個 SMP 伺服器稱節點 ) 通過節點網際網路絡連線而成,每個節點只訪問自己的本地資源 ( 記憶體、儲存等 ) ,是一種完全無共享 (Share Nothing) 結構,因而擴充套件能力最好,理論上其擴充套件無限制,目前的技術可實現 512 個節點互聯,數千個 CPU 。目前業界對節點網際網路絡暫無標準,如 NCR 的 Bynet , IBM 的 SPSwitch ,它們都採用了不同的內部實現機制。但節點網際網路僅供 MPP 伺服器內部使用,對使用者而言是透明的。

  在 MPP 系統中,每個 SMP 節點也可以執行自己的作業系統、資料庫等。但和 NUMA 不同的是,它不存在異地記憶體訪問的問題。換言之,每個節點內的 CPU 不能訪問另一個節點的記憶體。節點之間的資訊互動是通過節點網際網路絡實現的,這個過程一般稱為資料重分配 (Data Redistribution) 。

但是 MPP 伺服器需要一種複雜的機制來排程和平衡各個節點的負載和並行處理過程。目前一些基於 MPP 技術的伺服器往往通過系統級軟體 ( 如資料庫 ) 來遮蔽這種複雜性。舉例來說, NCR 的 Teradata 就是基於 MPP 技術的一個關聯式資料庫軟體,基於此資料庫來開發應用時,不管後臺伺服器由多少個節點組成,開發人員所面對的都是同一個資料庫系統,而不需要考慮如何排程其中某幾個節點的負載。

MPP (Massively Parallel Processing),大規模並行處理系統,這樣的系統是由許多鬆耦合的處理單元組成的,要注意的是這裡指的是處理單元而不是處理器。每個單元內的CPU都有自己私有的資源,如匯流排,記憶體,硬碟等。在每個單元內都有作業系統和管理資料庫的例項複本。這種結構最大的特點在於不共享資源。

NUMA結構下的快取一致性

要知道,MESI協議解決的是傳統SMP結構下快取的一致性,為了在NUMA架構也實現快取一致性,intel引入了MESI的一個擴充協議--MESIF,但是目前並沒有什麼資料,也沒法研究,更多訊息請查閱intel的wiki。

Java記憶體模型

起因

我們寫程式,為什麼要考慮記憶體模型呢,我們前面說了,快取一致性問題、記憶體一致問題是硬體的不斷升級導致的。解決問題,最簡單直接的做法就是廢除CPU快取,讓CPU直接和主存互動。但是,這麼做雖然可以保證多執行緒下的併發問題。但是,這就有點時代倒退了。

所以,為了保證併發程式設計中可以滿足原子性、可見性及有序性。有一個重要的概念,那就是——記憶體模型。

即為了保證共享記憶體的正確性(可見性、有序性、原子性),需要記憶體模型來定義了共享記憶體系統中多執行緒程式讀寫操作行為的相應規範~

JMM

Java記憶體模型是根據英文Java Memory Model(JMM)翻譯過來的。其實JMM並不像JVM記憶體結構一樣是真實存在的。它是一種符合記憶體模型規範的,遮蔽了各種硬體和作業系統的訪問差異的,保證了Java程式在各種平臺下對記憶體的訪問都能保證效果一致的機制及規範。就像JSR-133: Java Memory Model and Thread Specification 中描述了,JMM是和多執行緒相關的,他描述了一組規則或規範,這個規範定義了一個執行緒對共享變數的寫入時對另一個執行緒是可見的。

那麼,簡單總結下,Java的多執行緒之間是通過共享記憶體進行通訊的,而由於採用共享記憶體進行通訊,在通訊過程中會存在一系列如可見性、原子性、順序性等問題,而JMM就是圍繞著多執行緒通訊以及與其相關的一系列特性而建立的模型。JMM定義了一些語法集,這些語法集對映到Java語言中就是volatilesynchronized等關鍵字。

在JMM中,我們把多個執行緒間通訊的共享記憶體稱之為主記憶體,而在併發程式設計中多個執行緒都維護了一個自己的本地記憶體(這是個抽象概念),其中儲存的資料是主記憶體中的資料拷貝。而JMM主要是控制本地記憶體和主記憶體之間的資料互動的

JMM

在Java中,JMM是一個非常重要的概念,正是由於有了JMM,Java的併發程式設計才能避免很多問題。

JMM應用

瞭解Java多執行緒的朋友都知道,在Java中提供了一系列和併發處理相關的關鍵字,比如volatilesynchronizedfinalconcurrent包等。其實這些就是Java記憶體模型封裝了底層的實現後提供給我們使用的一些關鍵字。

在開發多執行緒的程式碼的時候,我們可以直接使用synchronized等關鍵字來控制併發,從來就不需要關心底層的編譯器優化、快取一致性等問題。所以,Java記憶體模型,除了定義了一套規範,還提供了一系列原語,封裝了底層實現後,供開發者直接使用。

併發程式設計要解決原子性、有序性和可見性的問題,我們就再來看下,在Java中,分別使用什麼方式來保證。

原子性

原子性是指在一個操作中就是cpu不可以在中途暫停然後再排程,既不被中斷操作,要不執行完成,要不就不執行。

JMM提供保證了訪問基本資料型別的原子性(其實在寫一個工作記憶體變數到主記憶體是分主要兩步:store、write),但是實際業務處理場景往往是需要更大的範圍的原子性保證。

在Java中,為了保證原子性,提供了兩個高階的位元組碼指令monitorentermonitorexit,而這兩個位元組碼,在Java中對應的關鍵字就是synchronized

因此,在Java中可以使用synchronized來保證方法和程式碼塊內的操作是原子性的。這裡推薦一篇文章深入理解Java併發之synchronized實現原理

可見性

可見性是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。

Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值的這種依賴主記憶體作為傳遞媒介的方式來實現的。

Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變數在被修改後可以立即同步到主記憶體,被其修飾的變數在每次是用之前都從主記憶體重新整理。因此,可以使用volatile來保證多執行緒操作時變數的可見性。

除了volatile,Java中的synchronizedfinalstatic三個關鍵字也可以實現可見性。下面分享一下我的讀書筆記:

1538374970743

有序性

有序性即程式執行的順序按照程式碼的先後順序執行。

在Java中,可以使用synchronizedvolatile來保證多執行緒之間操作的有序性。實現方式有所區別:

volatile關鍵字會禁止指令重排。synchronized關鍵字保證同一時刻只允許一條執行緒操作。

好了,這裡簡單的介紹完了Java併發程式設計中解決原子性、可見性以及有序性可以使用的關鍵字。讀者可能發現了,好像synchronized關鍵字是萬能的,他可以同時滿足以上三種特性,這其實也是很多人濫用synchronized的原因。

但是synchronized是比較影響效能的,雖然編譯器提供了很多鎖優化技術,但是也不建議過度使用。

JVM

我們都知道,Java程式碼是要執行在虛擬機器上的,而虛擬機器在執行Java程式的過程中會把所管理的記憶體劃分為若干個不同的資料區域,這些區域都有各自的用途。下面我們來說說JVM執行時記憶體區域結構

JVM執行時記憶體區域結構

在《Java虛擬機器規範(Java SE 8)》中描述了JVM執行時記憶體區域結構如下:

JVM

1.程式計數器

  程式計數器(Program Counter Register),也有稱作為PC暫存器。想必學過組合語言的朋友對程式計數器這個概念並不陌生,在組合語言中,程式計數器是指CPU中的暫存器,它儲存的是程式當前執行的指令的地址(也可以說儲存下一條指令的所在儲存單元的地址),當CPU需要執行指令時,需要從程式計數器中得到當前需要執行的指令所在儲存單元的地址,然後根據得到的地址獲取到指令,在得到指令之後,程式計數器便自動加1或者根據轉移指標得到下一條指令的地址,如此迴圈,直至執行完所有的指令。

  雖然JVM中的程式計數器並不像組合語言中的程式計數器一樣是物理概念上的CPU暫存器,但是JVM中的程式計數器的功能跟組合語言中的程式計數器的功能在邏輯上是等同的,也就是說是用來指示 執行哪條指令的。

  由於在JVM中,多執行緒是通過執行緒輪流切換來獲得CPU執行時間的,因此,在任一具體時刻,一個CPU的核心只會執行一條執行緒中的指令,因此,為了能夠使得每個執行緒都線上程切換後能夠恢復在切換之前的程式執行位置,每個執行緒都需要有自己獨立的程式計數器,並且不能互相被干擾,否則就會影響到程式的正常執行次序。因此,可以這麼說,程式計數器是每個執行緒所私有的。

  在JVM規範中規定,如果執行緒執行的是非native方法,則程式計數器中儲存的是當前需要執行的指令的地址;如果執行緒執行的是native方法,則程式計數器中的值是undefined。

  由於程式計數器中儲存的資料所佔空間的大小不會隨程式的執行而發生改變,因此,對於程式計數器是不會發生記憶體溢位現象(OutOfMemory)的。

2.Java棧

  Java棧也稱作虛擬機器棧(Java Vitual Machine Stack),也就是我們常常所說的棧,跟C語言的資料段中的棧類似。事實上,Java棧是Java方法執行的記憶體模型。為什麼這麼說呢?下面就來解釋一下其中的原因。

  Java棧中存放的是一個個的棧幀,每個棧幀對應一個被呼叫的方法,在棧幀中包括區域性變數表(Local Variables)、運算元棧(Operand Stack)、指向當前方法所屬的類的執行時常量池(執行時常量池的概念在方法區部分會談到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些額外的附加資訊。當執行緒執行一個方法時,就會隨之建立一個對應的棧幀,並將建立的棧幀壓棧。當方法執行完畢之後,便會將棧幀出棧。因此可知,執行緒當前執行的方法所對應的棧幀必定位於Java棧的頂部。講到這裡,大家就應該會明白為什麼 在 使用 遞迴方法的時候容易導致棧記憶體溢位的現象了以及為什麼棧區的空間不用程式設計師去管理了(當然在Java中,程式設計師基本不用關係到記憶體分配和釋放的事情,因為Java有自己的垃圾回收機制),這部分空間的分配和釋放都是由系統自動實施的。對於所有的程式設計語言來說,棧這部分空間對程式設計師來說是不透明的。下圖表示了一個Java棧的模型:

img

  區域性變數表,顧名思義,想必不用解釋大家應該明白它的作用了吧。就是用來儲存方法中的區域性變數(包括在方法中宣告的非靜態變數以及函式形參)。對於基本資料型別的變數,則直接儲存它的值,對於引用型別的變數,則存的是指向物件的引用。區域性變數表的大小在編譯器就可以確定其大小了,因此在程式執行期間區域性變數表的大小是不會改變的。

  運算元棧,想必學過資料結構中的棧的朋友想必對錶達式求值問題不會陌生,棧最典型的一個應用就是用來對錶達式求值。想想一個執行緒執行方法的過程中,實際上就是不斷執行語句的過程,而歸根到底就是進行計算的過程。因此可以這麼說,程式中的所有計算過程都是在藉助於運算元棧來完成的。

  指向執行時常量池的引用,因為在方法執行的過程中有可能需要用到類中的常量,所以必須要有一個引用指向執行時常量。

  方法返回地址,當一個方法執行完畢之後,要返回之前呼叫它的地方,因此在棧幀中必須儲存一個方法返回地址。

  由於每個執行緒正在執行的方法可能不同,因此每個執行緒都會有一個自己的Java棧,互不干擾。

3.本地方法棧

  本地方法棧與Java棧的作用和原理非常相似。區別只不過是Java棧是為執行Java方法服務的,而本地方法棧則是為執行本地方法(Native Method)服務的。在JVM規範中,並沒有對本地方發展的具體實現方法以及資料結構作強制規定,虛擬機器可以自由實現它。在HotSopt虛擬機器中直接就把本地方法棧和Java棧合二為一。

4.

  在C語言中,堆這部分空間是唯一一個程式設計師可以管理的記憶體區域。程式設計師可以通過malloc函式和free函式在堆上申請和釋放空間。那麼在Java中是怎麼樣的呢?

  Java中的堆是用來儲存物件本身的以及陣列(當然,陣列引用是存放在Java棧中的)。只不過和C語言中的不同,在Java中,程式設計師基本不用去關心空間釋放的問題,Java的垃圾回收機制會自動進行處理。因此這部分空間也是Java垃圾收集器管理的主要區域。另外,堆是被所有執行緒共享的,在JVM中只有一個堆。

5.方法區

  方法區在JVM中也是一個非常重要的區域,它與堆一樣,是被執行緒共享的區域。在方法區中,儲存了每個類的資訊(包括類的名稱、方法資訊、欄位資訊)、靜態變數、常量以及編譯器編譯後的程式碼等。

  在Class檔案中除了類的欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用來儲存編譯期間生成的字面量和符號引用。

  在方法區中有一個非常重要的部分就是執行時常量池,它是每一個類或介面的常量池的執行時表示形式,在類和介面被載入到JVM後,對應的執行時常量池就被建立出來。當然並非Class檔案常量池中的內容才能進入執行時常量池,在執行期間也可將新的常量放入執行時常量池中,比如String的intern方法。

  在JVM規範中,沒有強制要求方法區必須實現垃圾回收。很多人習慣將方法區稱為“永久代”,是因為HotSpot虛擬機器以永久代來實現方法區,從而JVM的垃圾收集器可以像管理堆區一樣管理這部分割槽域,從而不需要專門為這部分設計垃圾回收機制。不過自從JDK7之後,Hotspot虛擬機器便將執行時常量池從永久代移除了。

Java物件模型的記憶體佈局

java是一種物件導向的語言,而Java物件在JVM中的儲存也是有一定的結構的。而這個關於Java物件自身的儲存模型稱之為Java物件模型。

HotSpot虛擬機器中,設計了一個OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通物件指標,而Klass用來描述物件例項的具體型別。

每一個Java類,在被JVM載入的時候,JVM會給這個類建立一個instanceKlass,儲存在方法區,用來在JVM層表示該Java類。當我們在Java程式碼中,使用new建立一個物件的時候,JVM會建立一個instanceOopDesc物件,物件在記憶體中儲存的佈局可以分為3塊區域:物件頭(Header)、 例項資料(Instance Data)和對齊填充(Padding)。

  1. 物件頭:標記字(32位虛擬機器4B,64位虛擬機器8B) + 型別指標(32位虛擬機器4B,64位虛擬機器8B)+ [陣列長(對於陣列物件才需要此部分資訊)]
  2. 例項資料:儲存的是真正有效資料,如各種欄位內容,各欄位的分配策略為longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同寬度的欄位總是被分配到一起,便於之後取資料。父類定義的變數會出現在子類定義的變數的前面。
  3. 對齊填充:對於64位虛擬機器來說,物件大小必須是8B的整數倍,不夠的話需要佔位填充

JVM記憶體垃圾收集器

為了理解現有收集器,我們需要先了解一些術語。最基本的垃圾收集涉及識別不再使用的記憶體並使其可重用。現代收集器在幾個階段進行這一過程,對於這些階段我們往往有如下描述:

  • 並行- 在JVM執行時,同時存在應用程式執行緒和垃圾收集器執行緒。 並行階段是由多個gc執行緒執行,即gc工作在它們之間分配。 不涉及GC執行緒是否需要暫停應用程式執行緒。
  • 序列- 序列階段僅在單個gc執行緒上執行。與之前一樣,它也沒有說明GC執行緒是否需要暫停應用程式執行緒。
  • STW - STW階段,應用程式執行緒被暫停,以便gc執行其工作。 當應用程式因為GC暫停時,這通常是由於Stop The World階段。
  • 併發 -如果一個階段是併發的,那麼GC執行緒可以和應用程式執行緒同時進行。 併發階段很複雜,因為它們需要在階段完成之前處理可能使工作無效(譯者注:因為是併發進行的,GC執行緒在完成一階段的同時,應用執行緒也在工作產生操作記憶體,所以需要額外處理)的應用程式執行緒。
  • 增量 -如果一個階段是增量的,那麼它可以執行一段時間之後由於某些條件提前終止,例如需要執行更高優先順序的gc階段,同時仍然完成生產性工作。 增量階段與需要完全完成的階段形成鮮明對比。

Serial收集器

Serial收集器是最基本的收集器,這是一個單執行緒收集器,它仍然是JVM在Client模式下的預設新生代收集器。它有著優於其他收集器的地方:簡單而高效(與其他收集器的單執行緒比較),Serial收集器由於沒有執行緒互動的開銷,專心只做垃圾收集自然也獲得最高的效率。在使用者桌面場景下,分配給JVM的記憶體不會太多,停頓時間完全可以在幾十到一百多毫秒之間,只要收集不頻繁,這是完全可以接受的。

ParNew收集器

ParNew是Serial的多執行緒版本,在回收演算法、物件分配原則上都是一致的。ParNew收集器是許多執行在Server模式下的預設新生代垃圾收集器,其主要在於除了Serial收集器,目前只有ParNew收集器能夠與CMS收集器配合工作。

Parallel Scavenge收集器

Parallel Scavenge收集器是一個新生代垃圾收集器,其使用的演算法是複製演算法,也是並行的多執行緒收集器。

Parallel Scavenge 收集器更關注可控制的吞吐量,吞吐量等於執行使用者程式碼的時間/(執行使用者程式碼的時間+垃圾收集時間)。直觀上,只要最大的垃圾收集停頓時間越小,吞吐量是越高的,但是GC停頓時間的縮短是以犧牲吞吐量和新生代空間作為代價的。比如原來10秒收集一次,每次停頓100毫秒,現在變成5秒收集一次,每次停頓70毫秒。停頓時間下降的同時,吞吐量也下降了。

停頓時間越短就越適合需要與使用者互動的程式;而高吞吐量則可以最高效的利用CPU的時間,儘快的完成計算任務,主要適用於後臺運算。

Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,也是一個單執行緒收集器,採用“標記-整理演算法”進行回收。其執行過程與Serial收集器一樣。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多執行緒和標記-整理演算法進行垃圾回收。其通常與Parallel Scavenge收集器配合使用,“吞吐量優先”收集器是這個組合的特點,在注重吞吐量和CPU資源敏感的場合,都可以使用這個組合。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短停頓時間為目標的收集器,CMS收集器採用標記--清除演算法,執行在老年代。主要包含以下幾個步驟:

  • 初始標記
  • 併發標記
  • 重新標記
  • 併發清除

其中初始標記和重新標記仍然需要“Stop the world”。初始標記僅僅標記GC Root能直接關聯的物件,併發標記就是進行GC Root Tracing過程,而重新標記則是為了修正併發標記期間,因使用者程式繼續執行而導致標記變動的那部分物件的標記記錄。

由於整個過程中最耗時的併發標記和併發清除,收集執行緒和使用者執行緒一起工作,所以總體上來說,CMS收集器回收過程是與使用者執行緒併發執行的。雖然CMS優點是併發收集、低停頓,很大程度上已經是一個不錯的垃圾收集器,但是還是有三個顯著的缺點:

  1. CMS收集器對CPU資源很敏感。在併發階段,雖然它不會導致使用者執行緒停頓,但是會因為佔用一部分執行緒(CPU資源)而導致應用程式變慢。
  2. CMS收集器不能處理浮動垃圾。所謂的“浮動垃圾”,就是在併發標記階段,由於使用者程式在執行,那麼自然就會有新的垃圾產生,這部分垃圾被標記過後,CMS無法在當次集中處理它們,只好在下一次GC的時候處理,這部分未處理的垃圾就稱為“浮動垃圾”。也是由於在垃圾收集階段程式還需要執行,即還需要預留足夠的記憶體空間供使用者使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎填滿才進行收集,需要預留一部分空間提供併發收集時程式運作使用。要是CMS預留的記憶體空間不能滿足程式的要求,這是JVM就會啟動預備方案:臨時啟動Serial Old收集器來收集老年代,這樣停頓的時間就會很長。
  3. 由於CMS使用標記--清除演算法,所以在收集之後會產生大量記憶體碎片。當記憶體碎片過多時,將會給分配大物件帶來困難,這是就會進行Full GC。

G1收集器

G1收集器與CMS相比有很大的改進:

· G1收集器採用標記--整理演算法實現。

· 可以非常精確地控制停頓。

​ G1收集器可以實現在基本不犧牲吞吐量的情況下完成低停頓的記憶體回收,這是由於它極力的避免全區域的回收,G1收集器將Java堆(包括新生代和老年代)劃分為多個區域(Region),並在後臺維護一個優先列表,每次根據允許的時間,優先回收垃圾最多的區域 。

ZGC收集器

Java 11 新加入的ZGC垃圾收集器號稱可以達到10ms 以下的 GC 停頓,ZGC給Hotspot Garbage Collectors增加了兩種新技術:著色指標和讀屏障。下面引用國外文章說的內容:

著色指標

著色指標是一種將資訊儲存在指標(或使用Java術語引用)中的技術。因為在64位平臺上(ZGC僅支援64位平臺),指標可以處理更多的記憶體,因此可以使用一些位來儲存狀態。 ZGC將限制最大支援4Tb堆(42-bits),那麼會剩下22位可用,它目前使用了4位: finalizableremapmark0mark1。 我們稍後解釋它們的用途。

著色指標的一個問題是,當您需要取消著色時,它需要額外的工作(因為需要遮蔽資訊位)。 像SPARC這樣的平臺有內建硬體支援指標遮蔽所以不是問題,而對於x86平臺來說,ZGC團隊使用了簡潔的多重對映技巧。

多重對映

要了解多重對映的工作原理,我們需要簡要解釋虛擬記憶體和實體記憶體之間的區別。 實體記憶體是系統可用的實際記憶體,通常是安裝的DRAM晶片的容量。 虛擬記憶體是抽象的,這意味著應用程式對(通常是隔離的)實體記憶體有自己的檢視。 作業系統負責維護虛擬記憶體和實體記憶體範圍之間的對映,它通過使用頁表和處理器的記憶體管理單元(MMU)和轉換查詢緩衝器(TLB)來實現這一點,後者轉換應用程式請求的地址。

多重對映涉及將不同範圍的虛擬記憶體對映到同一實體記憶體。 由於設計中只有一個remapmark0mark1在任何時間點都可以為1,因此可以使用三個對映來完成此操作。 ZGC原始碼中有一個很好的圖表可以說明這一點。

讀屏障

讀屏障是每當應用程式執行緒從堆載入引用時執行的程式碼片段(即訪問物件上的非原生欄位non-primitive field):

void printName( Person person ) {
    String name = person.name;  // 這裡觸發讀屏障
                                // 因為需要從heap讀取引用 
                                // 
    System.out.println(name);   // 這裡沒有直接觸發讀屏障
}
複製程式碼

在上面的程式碼中,String name = person.name 訪問了堆上的person引用,然後將引用載入到本地的name變數。此時觸發讀屏障。 Systemt.out那行不會直接觸發讀屏障,因為沒有來自堆的引用載入(name是區域性變數,因此沒有從堆載入引用)。 但是System和out,或者println內部可能會觸發其他讀屏障。

這與其他GC使用的寫屏障形成對比,例如G1。讀屏障的工作是檢查引用的狀態,並在將引用(或者甚至是不同的引用)返回給應用程式之前執行一些工作。 在ZGC中,它通過測試載入的引用來執行此任務,以檢視是否設定了某些位。 如果通過了測試,則不執行任何其他工作,如果失敗,則在將引用返回給應用程式之前執行某些特定於階段的任務。

標記

現在我們瞭解了這兩種新技術是什麼,讓我們來看看ZG的GC迴圈。

GC迴圈的第一部分是標記。標記包括查詢和標記執行中的應用程式可以訪問的所有堆物件,換句話說,查詢不是垃圾的物件。

ZGC的標記分為三個階段。 第一階段是STW,其中GC roots被標記為活物件。 GC roots類似於區域性變數,通過它可以訪問堆上其他物件。 如果一個物件不能通過遍歷從roots開始的物件圖來訪問,那麼應用程式也就無法訪問它,則該物件被認為是垃圾。從roots訪問的物件集合稱為Live集。GC roots標記步驟非常短,因為roots的總數通常比較小。

該階段完成後,應用程式恢復執行,ZGC開始下一階段,該階段同時遍歷物件圖並標記所有可訪問的物件。 在此階段期間,讀屏障針使用掩碼測試所有已載入的引用,該掩碼確定它們是否已標記或尚未標記,如果尚未標記引用,則將其新增到佇列以進行標記。

在遍歷完成之後,有一個最終的,時間很短的的Stop The World階段,這個階段處理一些邊緣情況(我們現在將它忽略),該階段完成之後標記階段就完成了。

重定位

GC迴圈的下一個主要部分是重定位。重定位涉及移動活動物件以釋放部分堆記憶體。 為什麼要移動物件而不是填補空隙? 有些GC實際是這樣做的,但是它導致了一個不幸的後果,即分配記憶體變得更加昂貴,因為當需要分配記憶體時,記憶體分配器需要找到可以放置物件的空閒空間。 相比之下,如果可以釋放大塊記憶體,那麼分配記憶體就很簡單,只需要將指標遞增新物件所需的記憶體大小即可。

ZGC將堆分成許多頁面,在此階段開始時,它同時選擇一組需要重定位活動物件的頁面。選擇重定位集後,會出現一個Stop The World暫停,其中ZGC重定位該集合中root物件,並將他們的引用對映到新位置。與之前的Stop The World步驟一樣,此處涉及的暫停時間僅取決於root的數量以及重定位集的大小與物件的總活動集的比率,這通常相當小。所以不像很多收集器那樣,暫停時間隨堆增加而增加。

移動root後,下一階段是併發重定位。 在此階段,GC執行緒遍歷重定位集並重新定位其包含的頁中所有物件。 如果應用程式執行緒試圖在GC重新定位物件之前載入它們,那麼應用程式執行緒也可以重定位該物件,這可以通過讀屏障(在從堆載入引用時觸發)

這可確保應用程式看到的所有引用都已更新,並且應用程式不可能同時對重定位的物件進行操作。

GC執行緒最終將對重定位集中的所有物件重定位,然而可能仍有引用指向這些物件的舊位置。 GC可以遍歷物件圖並重新對映這些引用到新位置,但是這一步代價很高昂。 因此這一步與下一個標記階段合併在一起。在下一個GC週期的標記階段遍歷物件物件圖的時候,如果發現未重對映的引用,則將其重新對映,然後標記為活動狀態。

JVM記憶體優化

在《深入理解Java虛擬機器》一書中講了很多jvm優化思路,下面我來簡單說說。

java記憶體抖動

堆記憶體都有一定的大小,能容納的資料是有限制的,當Java堆的大小太大時,垃圾收集會啟動停止堆中不再應用的物件,來釋放記憶體。現在,記憶體抖動這個術語可用於描述在極短時間內分配給物件的過程。 具體如何優化請谷歌查詢~

jvm大頁記憶體

什麼是記憶體分頁?

CPU是通過定址來訪問記憶體的。32位CPU的定址寬度是 0~0xFFFFFFFF,即4G,也就是說可支援的實體記憶體最大是4G。但在實踐過程中,程式需要使用4G記憶體,而可用實體記憶體小於4G,導致程式不得不降低記憶體佔用。為了解決此類問題,現代CPU引入了MMU(Memory Management Unit,記憶體管理單元)。

MMU 的核心思想是利用虛擬地址替代實體地址,即CPU定址時使用虛址,由MMU負責將虛址對映為實體地址。MMU的引入,解決了對實體記憶體的限制,對程式來說,就像自己在使用4G記憶體一樣。

記憶體分頁(Paging)是在使用MMU的基礎上,提出的一種記憶體管理機制。它將虛擬地址和實體地址按固定大小(4K)分割成頁(page)和頁幀(page frame),並保證頁與頁幀的大小相同。這種機制,從資料結構上,保證了訪問記憶體的高效,並使OS能支援非連續性的記憶體分配。在程式記憶體不夠用時,還可以將不常用的實體記憶體頁轉移到其他儲存裝置上,比如磁碟,這就是虛擬記憶體。

要知道,虛擬地址與實體地址需要通過對映,才能使CPU正常工作。而對映就需要儲存對映表。在現代CPU架構中,對映關係通常被儲存在實體記憶體上一個被稱之為頁表(page table)的地方。 頁表是被儲存在記憶體中的,CPU通過匯流排訪問記憶體,肯定慢於直接訪問暫存器的。為了進一步優化效能,現代CPU架構引入了TLB(Translation lookaside buffer,頁表暫存器緩衝),用來快取一部分經常訪問的頁表內容 。

為什麼要支援大記憶體分頁?

TLB是有限的,這點毫無疑問。當超出TLB的儲存極限時,就會發生 TLB miss,於是OS就會命令CPU去訪問記憶體上的頁表。如果頻繁的出現TLB miss,程式的效能會下降地很快。

為了讓TLB可以儲存更多的頁地址對映關係,我們的做法是調大記憶體分頁大小。

如果一個頁4M,對比一個頁4K,前者可以讓TLB多儲存1000個頁地址對映關係,效能的提升是比較可觀的。

開啟JVM大頁記憶體

JVM啟用時加引數 -XX:LargePageSizeInBytes=10m 如果JDK是在1.5 update5以前的,還需要加 -XX:+UseLargePages,作用是啟用大記憶體頁支援。

通過軟引用和弱引用提升JVM記憶體使用效能

強軟弱虛
  1. 強引用:

只要引用存在,垃圾回收器永遠不會回收

Object obj = new Object();

//可直接通過obj取得對應的物件 如obj.equels(new Object());

而這樣 obj物件對後面new Object的一個強引用,只有當obj這個引用被釋放之後,物件才會被釋放掉,這也是我們經常所用到的編碼形式。

  1. 軟引用(可以實現快取):

非必須引用,記憶體溢位之前進行回收,可以通過以下程式碼實現

Object obj = new Object();

SoftReference<Object> sf = new SoftReference<Object>(obj);

obj = null;

sf.get();//有時候會返回null
複製程式碼

這時候sf是對obj的一個軟引用,通過sf.get()方法可以取到這個物件,當然,當這個物件被標記為需要回收的物件時,則返回null;軟引用主要使用者實現類似快取的功能,在記憶體足夠的情況下直接通過軟引用取值,無需從繁忙的真實來源查詢資料,提升速度;當記憶體不足時,自動刪除這部分快取資料,從真正的來源查詢這些資料。

  1. 弱引用(用來在回撥函式中防止記憶體洩露):

第二次垃圾回收時回收,可以通過如下程式碼實現

Object obj = new Object();

WeakReference<Object> wf = new WeakReference<Object>(obj);

obj = null;

wf.get();//有時候會返回null

wf.isEnQueued();//返回是否被垃圾回收器標記為即將回收的垃圾
複製程式碼

弱引用是在第二次垃圾回收時回收,短時間內通過弱引用取對應的資料,可以取到,當執行過第二次垃圾回收時,將返回null。弱引用主要用於監控物件是否已經被垃圾回收器標記為即將回收的垃圾,可以通過弱引用的isEnQueued方法返回物件是否被垃圾回收器標記。

  1. 虛引用:

垃圾回收時回收,無法通過引用取到物件值,可以通過如下程式碼實現

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj=null;
pf.get();//永遠返回null
pf.isEnQueued();//返回是否從記憶體中已經刪除
複製程式碼

虛引用是每次垃圾回收的時候都會被回收,通過虛引用的get方法永遠獲取到的資料為null,因此也被成為幽靈引用。虛引用主要用於檢測物件是否已經從記憶體中刪除。

優化

簡單來說,可以使用軟引用還引用數量巨大的物件,詳情請參考http://www.cnblogs.com/JavaArchitect/p/8685993.html

總結

此篇文章總共1.5W字,我從計算機實體記憶體體系講到了java記憶體模型,在通過java記憶體模型引出了JVM記憶體的相關知識點。覺得寫的好的請給個贊。本篇文章我會率先發布在我的個人部落格,隨後會在掘金等平臺相繼發出。最後,非常感謝你的閱讀~

參考資料

文中的各種超連結

《深入理解Java虛擬機器》

《Java併發程式設計的藝術》

《架構解密從分散式到微服務》

SMP、NUMA、MPP體系結構介紹

ZGC原理(請用正確的姿勢魔法上網觀看)

Stefan Karlsson和PerLiden Jfokus的演講(請用正確的姿勢魔法上網)

宣告

【版權申明】此片為原創內容,使用MIT授權條款,請遵守對應的義務,即被授權人有義務在所有副本中都必須包含版權宣告。謝謝合作~

想要解鎖更多新姿勢?請訪問我的個人部落格https://blog.tengshe789.tech/(? github社群地址https://github.com/tengshe789/,歡迎互fo

相關文章