【Java雜貨鋪】JVM#Java高牆以內存模型

Java與C++之間有一堵由內存動態分配和垃圾回收技術所圍成的「高牆」,牆外的人想進去,牆外的人想出來。——《深刻理解Java虛擬機》

圖片描述

前言

《深刻理解Java虛擬機》,學習JVM的經典著做,幾乎學習JAVA的小夥伴人手一本。當初買了,翻看了一部分,到了字節碼那邊完全讀不下去了,遂棄之。最近打算看Spring源碼,反射、動態代理、設計模式等基礎工具的確可讓我更加容易理解源碼內容。然而,看着看着才發現,這個日常咱們幾乎用不到的東西(除了面試),才應該是理解java生態的出發站。因此,停下手來,從新看下這本書,再全面的瞭解下虛擬機,此次不管多麼困難,也要把書讀完,同時記好內容筆記和思考補充。做爲Java圍城之一的內存模型,比當時第一個要看的內容。java

出發,看看JVM大工廠

剛開始學Java的時候,被貫徹最多的兩句話就是「一次編譯,處處運行」和「Java不須要手動釋放內存」。能作到這兩點都是因爲Jvm的存在。記得大學第一個啓蒙語言c,電腦安裝了一個cfree(一個體積超小的ide)就能夠直接寫了。而Java還須要下載一個叫JDK的東西,來開發。JDK包含一個叫JRE的東西,是Java的運行環境,之因此能夠運行,是jre下擁有着JVM虛擬機。JVM做爲一個程序,必定會佔用電腦內存,而它所管轄內存間數據的互動,驅動着Java的工做。程序員

線程的指揮官:程序計數器

做爲面嚮對象語言,Java每一個類都有本身的屬性和使命,而且暴露方法出來供其餘成員調用。一個業務邏輯,不一樣對象之間調用方法、返回調用者,一個方法內部分支、循環等基礎功能,都須要一個指揮官來完成,指揮官告訴這個線程內的對象執行的前後順序。這個指揮官就叫作程序計數器。<mark>程序計數器是一塊較小的內存空間,它能夠看做是當前線程所執行的字節碼的行號指示器。</mark>由於一個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類全部的方法。ide

GC最喜歡的地方:Java堆

咱們常說的堆棧,說的就是這個堆。能夠說Java堆是虛擬機所管轄最大的一塊內存空間,而且此空間是全部線程共享的。<mark>幾乎全部的對象實例都分配在這裏</mark>,全部的對象實例和數組都要在堆上索取空間。Java堆也是垃圾收集器管理的主要區域,這個之後會細講。
Java堆能夠處於物理上不連續的空間中,只要邏輯上是連續的便可。若是堆中沒有內存完成實例分配,而且對也沒法再拓展時,將會拋出OutOfMemoryError異常。

永久代的假裝:方法區

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

方法區也屬於線程共享區間,它儲存着<mark>類信息、常量、靜態變量即時編譯後的代碼等數據</mark>

相對而言,垃圾收集行爲在這個區域是比較少出現的,但並不是數據進入了方法區就如永久代的名字同樣「永久」存在了。這羣有一樣的內存回收目標主要是針對常量池的回收和堆類型的卸載,可是回收條件至關苛刻。同堆同樣,可能會致使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堆的共享強制編程線程私有。

對象設置

接下來,虛擬機要對對象頭進行必要的設置,例如這個對象是哪一個類的實例、如何才能找到<mark>類的元數據信息、對象的哈希碼、對象的GC分代年齡等信息。這些信息都存放在對象的對象頭之中。</mark>完成上述操做,一個對象在虛擬機的層面已經完成了,可是在代碼層面還須要設置初始值,按照程序員的意願選擇不一樣的構造函數,傳入不一樣的參數進行初始化。

對象的內存分佈

在HotSpot的虛擬機中,對象在內存中儲存的佈局能夠分爲3塊區域:<mark>對象頭、實例數據、對齊填充。</mark>

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的小夥伴仍是買來看一看吧。我一直以爲,從長遠來看,比起看博客看視頻,看書是效益最高的方式,畢竟伴隨者大量的思考。