1. 程式人生 > 程式設計 >【Java雜貨鋪】JVM#Java高牆之記憶體模型

【Java雜貨鋪】JVM#Java高牆之記憶體模型

Java與C++之間有一堵由記憶體動態分配和垃圾回收技術所圍成的“高牆”,牆外的人想進去,牆外的人想出來。——《深入理解Java虛擬機器器》

前言

《深入理解Java虛擬機器器》,學習JVM的經典著作,幾乎學習JAVA的小夥伴人手一本。當初買了,翻看了一部分,到了位元組碼那邊徹底讀不下去了,遂棄之。最近打算看Spring原始碼,反射、動態代理、設計模式等基礎工具的確可以讓我更加容易理解原始碼內容。然而,看著看著才發現,這個平常我們幾乎用不到的東西(除了面試),才應該是理解java生態的出發站。所以,停下手來,重新看下這本書,再全面的瞭解下虛擬機器器,這次無論多麼困難,也要把書讀完,同時記好內容筆記和思考補充。作為Java圍城之一的記憶體模型,比當時第一個要看的內容。

出發,看看JVM大工廠

剛開始學Java的時候,被貫徹最多的兩句話就是“一次編譯,到處執行”和“Java不需要手動釋放記憶體”。能做到這兩點都是由於Jvm的存在。記得大學第一個啟蒙語言c,電腦安裝了一個cfree(一個體積超小的ide)就可以直接寫了。而Java還需要下載一個叫JDK的東西,來開發。JDK包含一個叫JRE的東西,是Java的執行環境,之所以可以執行,是jre下擁有著JVM虛擬機器器。JVM作為一個程式,一定會佔用電腦記憶體,而它所管轄記憶體間資料的互動,驅動著Java的工作。

執行緒的指揮官:程式計數器

作為面嚮物件語言,Java每個類都有自己的屬性和使命,並且暴露方法出來供其他成員呼叫。一個業務邏輯,不同物件之間呼叫方法、返回呼叫者,一個方法內部分支、迴圈等基礎功能,都需要一個指揮官來完成,指揮官告訴這個執行緒內的物件執行的先後順序。這個指揮官就叫做程式計數器

程式計數器是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。因為一個CPU同一時間只能操作一個執行緒中的指令,所以每個執行緒需要私有一個指揮官,所以程式計數器這類記憶體也叫做執行緒私有記憶體。

如果一個執行緒正在執行的是Java方法,這個計數器記錄的是正在執行的虛擬機器器位元組碼指令地址;如果是正在執行的Native方法,這個計數器值則為空(Undefined)。Native方法就是Java調取本地其他語言的方法,此方法實現不受JVM管控,所以無法感知到地址,計數器值自然為空。

另外,程式計數器區域是唯一一個Java虛擬機器器規範中沒有規定任何OutOfMemoryError情況的記憶體區域。

引用的地盤: Java虛擬機器器棧

我們使用Java新建一個物件,首先需要宣告型別,此時就出現了一個引用,引用指向創建出的物件。我們都知道引用在棧中,物件在堆中,此時說的棧就特指Java虛擬機器器棧。Java虛擬機器器棧同樣屬於執行緒私有的,所以生命週期和執行緒相同。每個方法在建立的同時,都會建立一個棧幀用於儲存區域性變量表、運算元棧、動態連結、方法出入口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器器棧中入棧到出棧的過程。

區域性變量表存放了編譯時剋制的基本資料型別(boolean、byte、char、short、int、float、long、double)、物件引用(reference)。物件引用直接或者間接指向堆中物件的地址。由於此過程是在編譯時期完成的,所以區域性變數記憶體分配大小是固定的,不會在執行時改變大小。其中64位長度的long和double型別的資料都佔用了2個區域性變數空間(Slot),其他資料型別只佔1位。

在這個區域可能會出現兩種異常:如果執行緒請求的棧深度過大,也就是說虛擬機器器棧在自己管轄的記憶體造成的原因,會丟擲StackOverflowError異常,這個一般比較深的遞迴可能會造成。如果虛擬機器器棧發現自己記憶體不夠,動態擴充套件,並且無法申請到足夠的空間時,就會丟擲OutMemoryError異常。

虛擬機器器棧的孿生兄弟:本地方法棧

本地方法棧幾乎與虛擬機器器棧發揮的作用基本相似,畢竟孿生兄弟嘛。區別是Java虛擬機器器棧是為位元組碼服務的,也就是Java方法本身。而本地方法棧是為了Native方法服務的,這個涉及調取本地的語言,例如C。

這裡插個小曲,native對於咱們Java程式設計者來說很少直接操作,但是這東西無處不在,比如說Object類,你看原始碼,很多方法都有native關鍵字。這些方法具體實現在java程式碼裡面無論如何都找不到的,因為具體實現就是調取的本地,並且調取本地的程式碼不受JVM控制!在編譯的過程中,如果發現一個類沒有顯示繼承,那麼就會被隱式繼承Object類,也就有了Object類所有的方法。

GC最喜歡的地方:Java堆

我們常說的堆疊,說的就是這個堆。可以說Java堆是虛擬機器器所管轄最大的一塊記憶體空間,並且此空間是所有執行緒共享的。幾乎所有的物件例項都分配在這裡,所有的物件例項和陣列都要在堆上索取空間。Java堆也是垃圾收集器管理的主要區域,這個以後會細講。 Java堆可以處於物理上不連續的空間中,只要邏輯上是連續的即可。如果堆中沒有記憶體完成例項分配,並且對也無法再拓展時,將會丟擲OutOfMemoryError異常。

永久代的偽裝:方法區

大佬書中講這部分內容的時候還是以JDK1.6為範本,但是直接被堆記憶體所託管了。JDK1.8這部分已經變成元空間了,並且成為了堆外記憶體,不受JVM直接管轄。但是為了更好的理解JVM記憶體模型的設計理念還是看下這部分內容。

方法區也屬於執行緒共享區間,它儲存著類資訊、常量、靜態變數即時編譯後的程式碼等資料

相對而言,垃圾收集行為在這個區域是比較少出現的,但並非資料進入了方法區就如永久代的名字一樣“永久”存在了。這群有同樣的記憶體回收目標主要是針對常量池的回收和堆型別的解除安裝,但是回收條件相當苛刻。同堆一樣,可能會導致OutOfMemeoryError異常。

執行可變區域:執行時常量池

既然有執行時常量池,就會有普通的常量池(簡稱常量池)。常量池用於存放編譯期生成的各種字面量和符號引用,字面量相當於Java語言層面常量的概念,如文字字串,宣告為final的常量值等,符號引用則屬於編譯原理方面的概念,包括瞭如下三種型別的常量:類和介面的全限定名、欄位名稱和描述符、方法名稱和描述符。

執行時常量池相對於普通的常量池(又稱Class檔案常量池)有一個重要特徵動態性。Java語言並不要求常量只能在比那一起才能產生,執行期間也可以加入常量到常量池(執行時常量池)中,比如String的intern()方法。

執行時常量池屬於方法區的一部分,自然受到方法去記憶體的限制,也會丟擲OutOfMemoryError異常。

JVM外的世界:直接記憶體

直接記憶體並不是虛擬機器器執行時資料區的一部分,也不是Java虛擬機器器規範定義的記憶體區域。還記著前面說的有native關鍵字的方法嗎?包括netty模組的一些Native函式庫都是直接分配堆外記憶體的,然後通過一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的引用來操作。這樣做,就是以為需要操作的資料在Native堆(你電腦上不被JVM管轄的記憶體空間)上,避免了將Java堆資料和Native堆資料來回複製。當然這塊記憶體也不能無限放大,比如超過你電腦的記憶體,所以也可能出現OutOfMemoryError異常。

讓資料動起來

記憶體空間不在於劃分,在於使用。大佬在書中繼續以HotStop虛擬機器器堆記憶體為例,講解了資料的建立、分佈、與訪問。

一個物件的誕生

記憶體分配

虛擬機器器遇到一條new指令時,首先將去檢查這個指令的引數是否能夠在常量池中定位到一個類的符號引用,並檢查這個符號引用代表的類是否已被載入、解析和初始化過。接下來,虛擬機器器會為這個新生兒分配記憶體(載入完成後的記憶體是完全確定大小的)。和計算機管理記憶體的方式一樣,Java堆維護記憶體,有一張空閒列表,用於記錄堆內哪些空間沒有被使用過。由於堆在物理上是不連續的,所以就需要有個地方記錄哪些空間是被使用的,哪些是空閒的。還有一種記錄方式叫指標碰撞,假定Java堆中的記憶體是絕對規整的連續的(這顯然很難做到,需要GC做壓縮整理)。在這條十分規整的,十分長的堆記憶體空間上,有一個指標,左右兩側分別是空閒區間和已使用空間,如果有空間需要被申請或者釋放,指標就左右移動。就好像溫度計,水銀好似已使用空間,上方空閒部分就是空閒空間,當溫度達到100度,到了溫度計的量程,就會炸了(出現OutOfMemoryError異常)。

原子操作

為了保證記憶體在使用的時候是執行緒安全的,需要採用一些機制。第一種就是CAS機制,這是一種樂觀鎖機制,再加上失敗重試,可以保證操作的原子性。還有一種就是本地執行緒分配緩衝,把記憶體的動作按照執行緒劃分在不同的空間上進行,即每個執行緒在Java堆中預想分配一小塊記憶體供自己使用,讓Java堆的共享強製程式設計執行緒私有。

物件設定

接下來,虛擬機器器要對物件頭進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的元資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊都存放在物件的物件頭之中。完成上述操作,一個物件在虛擬機器器的層面已經完成了,但是在程式碼層面還需要設定初始值,按照程式設計師的意願選擇不同的建構函式,傳入不同的引數進行初始化。

物件的記憶體分佈

在HotSpot的虛擬機器器中,物件在記憶體中儲存的佈局可以分為3塊區域:物件頭、例項資料、對齊填充。

HotStop虛擬的物件頭包含兩部分資訊,第一部分用於儲存物件自身的執行時資料,如雜湊碼、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒II、偏向時間戳。官方叫這部分是Mark Word,這部分雖然在對空間上,但是這部分會根據物件的狀態服用自己的儲存空間。除了儲存自身狀態外,還有一部分內容叫型別指標,即指向它的類元陣列的指標,虛擬機器器通過這個指標來確定這個給物件是哪個類的例項。另外,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄組長度的資料。

接下了就是例項資料部分,即真實儲存的有效資訊,也就是程式程式碼中所定義的各種型別的欄位內容。包含從弗雷繼承的,和子類定義的。HotSpot虛擬機器器預設的分配策略為longs/doubles、ints、shorts/chars、bytes/booleans、oops,從分配策略中可以看出,相同寬度的欄位總是被安排在一起。在滿足這個前提條年間的情況下,在父類中定義的變數會出現在子類之前

第三部分就是對齊填充,沒有什麼特別的意義,就是個佔位符。由於物件的大小必須是8位元組的整數倍,由於物件頭部分正好是8位元組的倍數,例項資料不一定是,所以就需要填充一下。

物件的訪問定位

我們都知道真正的物件實在堆上,但是我們操作物件使用的是引用,在虛擬機器器棧上的引用是如何訪問對上的資料呢?主流的有兩種方式。

控制程式碼

Java堆中將會劃分出一塊記憶體來作為控制程式碼池,reference中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與型別資料各自的具體地址資訊。

大佬的圖片拿來一用

直接指標

Java堆物件的佈局中就必須考慮如何防止訪問型別資料的相關資訊,而reference中儲存的直接就是物件地址。

大佬的圖片再用一下

這兩種方式的優缺點就好像陣列和連結串列一樣,一個訪問速度快,一個操作快。畢竟世界是公平的,省功不省力,省力不省功。控制程式碼訪問的最大優點就是reference中儲存的是穩定的控制程式碼地址,在物件被移動時指揮改變控制程式碼中的例項資料指標,而reference本身不需要修改。所以修改資料特別快。

相應的直接指標訪問最大的優勢就是訪問物件本身更快,畢竟少了一次指標的地址定位。HotShot最主要就是採用這種方式訪問物件。

一些補充

大佬在本章還進行了拋OutOfMemoryError異常的實戰,內容較長,還是看書講的更清楚些。更主要的是,我覺得實戰這種東西不能只看,具體問題還得具體分析,等遇到的多了,自然解決起來就會得心應手。不過這部分內容有一些值得記錄的知識點。

  1. 一般來說,棧深度(比如遞迴)達到1000~2000是沒有問題的,所以我們寫程式碼的時候一定要注意棧的深度,不要過深,但也要充分使用遞迴這種用空間省時間的方式。
  2. JDK1.6~JDK1.8常量池的位置變動,導致一些方法展現出來的現象不同。例如String.intern()方法,在1.6時代,intern()方法會將首次遇到的字串例項複製到永久代中,返回永久代中這個字串例項的引用。而1.7的intern()方法不會複製例項,只是在常量池中記錄首次出現的例項引用。
  3. 動態代理(例如CGLib)是對類的一種增強,增強的類越多,就需要更大的記憶體來儲存這些資料。
  4. 還有種動態生成就是JSP(雖然現在大多數都是前後端分離,不用這個了),JSP第一次執行需要編譯成Servlet,也需要產生大量的空間。值得一提的是,原來我在上家公司,有個系統是JDK1.7,當時JSP編譯出來的東西還存放在方法堆中,當時可能設定的堆記憶體不大,本地跑一天,每次開啟JSP頁面,電腦都會卡頓一下(當然機子差也是原因之一),普通的Java檔案就沒事,我想是不是也是這個原因呢。另外對於同一個檔案,不同的載入器載入也會視為不同的類。

結束

感覺每次看JVM這塊內容都會有新的體會。JVM作為Java執行的基石,是每一個Javaer都需要了解的。和很多面試JVM總結內容相比,看本文確實是浪費時間,但我還是想記錄下看書的感受,為了將來回憶起看書時靈光一現的小想法留個筆記吧。這本書真的不錯,如果想了解JVM的小夥伴還是買來看一看吧。我一直覺得,從長遠來看,比起看部落格看視訊,看書是效益最高的方式,畢竟伴隨者大量的思考。