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

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

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

總結

此篇文章總共1.5W字,我從計算機物理內存體系講到了java內存模型,在通過java內存模型引出了JVM內存的相關知識點。覺得寫的好的請給個贊。本篇文章我會率先發布在我的個人博客,隨後會在掘金等平臺相繼發出。最後,非常感謝你的閱讀~
https://blog.tengshe789.tech/...

參考資料

文中的各種超鏈接

《深入理解Java虛擬機》

《Java併發編程的藝術》

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

SMP、NUMA、MPP體系結構介紹

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

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

聲明

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章