想知道Java與內存的關系?這篇文章全部告訴你

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

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

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

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

計算機內存

相信每個人都有一臺電腦,也有diy電腦的經歷?,F在一臺功能強大的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語言中就是volatile、synchronized等關鍵字。

在JMM中,我們把多個線程間通信的共享內存稱之為主內存,而在并發編程中多個線程都維護了一個自己的本地內存(這是個抽象概念),其中保存的數據是主內存中的數據拷貝。而JMM主要是控制本地內存和主內存之間的數據交互的

JMM

在Java中,JMM是一個非常重要的概念,正是由于有了JMM,Java的并發編程才能避免很多問題。

JMM應用

了解Java多線程的朋友都知道,在Java中提供了一系列和并發處理相關的關鍵字,比如volatile、synchronized、final、concurrent包等。其實這些就是Java內存模型封裝了底層的實現后提供給我們使用的一些關鍵字。

在開發多線程的代碼的時候,我們可以直接使用synchronized等關鍵字來控制并發,從來就不需要關心底層的編譯器優化、緩存一致性等問題。所以,Java內存模型,除了定義了一套規范,還提供了一系列原語,封裝了底層實現后,供開發者直接使用。

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

原子性

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

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

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

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

可見性

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

Java內存模型是通過在變量修改后將新值同步回主內存,在變量讀取前從主內存刷新變量值的這種依賴主內存作為傳遞媒介的方式來實現的。

Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變量在被修改后可以立即同步到主內存,被其修飾的變量在每次是用之前都從主內存刷新。因此,可以使用volatile來保證多線程操作時變量的可見性。

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

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內存垃圾收集器

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

  • 并行- 在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位: finalizableremap, mark0mark1。 我們稍后解釋它們的用途。

著色指針的一個問題是,當您需要取消著色時,它需要額外的工作(因為需要屏蔽信息位)。 像SPARC這樣的平臺有內置硬件支持指針屏蔽所以不是問題,而對于x86平臺來說,ZGC團隊使用了簡潔的多重映射技巧。

多重映射

要了解多重映射的工作原理,我們需要簡要解釋虛擬內存和物理內存之間的區別。 物理內存是系統可用的實際內存,通常是安裝的DRAM芯片的容量。 虛擬內存是抽象的,這意味著應用程序對(通常是隔離的)物理內存有自己的視圖。 操作系統負責維護虛擬內存和物理內存范圍之間的映射,它通過使用頁表和處理器的內存管理單元(MMU)和轉換查找緩沖器(TLB)來實現這一點,后者轉換應用程序請求的地址。

多重映射涉及將不同范圍的虛擬內存映射到同一物理內存。 由于設計中只有一個remap,mark0mark1在任何時間點都可以為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堆的大小太大時,垃圾收集會啟動停止堆中不再應用的對象,來釋放內存?,F在,內存抖動這個術語可用于描述在極短時間內分配給對象的過程。 具體如何優化請谷歌查詢~

jvm大頁內存

什么是內存分頁?

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

MMU 的核心思想是利用虛擬地址替代物理地址,即CPU尋址時使用虛址,由MMU負責將虛址映射為物理地址。MMU的引入,解決了對物理內存的限制,對程序來說,就像自己在使用4G內存一樣。

內存分頁(Paging)是在使用MMU的基礎上,提出的一種內存管理機制。它將虛擬地址和物理地址按固定大?。?K)分割成頁(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

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374

推薦閱讀更多精彩內容