前言
前面介紹過
JVM被分為三個(gè)主要的子系統(tǒng):
- 類加載器子系統(tǒng)
運(yùn)行時(shí)數(shù)據(jù)區(qū)(也就是內(nèi)存相關(guān))
- 執(zhí)行引擎
前面我們介紹了JVM的類加載機(jī)制, 今天我們則重點(diǎn)聊聊JVM的運(yùn)行時(shí)數(shù)據(jù)區(qū), 即JVM內(nèi)存相關(guān)知識
關(guān)于JVM內(nèi)存, 有兩個(gè)比較重要的概念,
這兩個(gè)概念, 經(jīng)常會有人搞混, 所以, 順帶來做個(gè)梳理.
內(nèi)存模型
內(nèi)存結(jié)構(gòu)
什么是內(nèi)存模型(JMM)?
Java Memory Model, 就是我們常說的JMM;
JMM和JVM內(nèi)存結(jié)構(gòu)不同, 它只是一個(gè)抽象的概念, 描述了一組規(guī)則或規(guī)范, 這個(gè)規(guī)范定義了一個(gè)線程對共享變量的寫入時(shí)對另一個(gè)線程是可見的。
我們知道, Java的多線程之間是通過共享內(nèi)存進(jìn)行通信的淆院,而由于采用共享內(nèi)存進(jìn)行通信浓利,在通信過程中會存在一系列如可見性、原子性啡专、順序性等問題唤衫,而JMM就是圍繞著多線程通信以及與其相關(guān)的一系列特性而建立的模型.
JMM定義了一些語法集, 這些語法集映射到Java語言中就是volatile、synchronized等關(guān)鍵字.
簡而言之, JMM就是為了解決Java多線程對共享數(shù)據(jù)的讀寫一致性問題而產(chǎn)生的一種模型!
PS: 關(guān)于Java多線程的讀寫一致性問題的前世今生可以閱讀我的另一篇文章你不得不知道的線程安全問題
JMM內(nèi)存模型可以歸納為下圖
什么是JVM內(nèi)存結(jié)構(gòu)?
JVM的內(nèi)存結(jié)構(gòu)也叫運(yùn)行時(shí)數(shù)據(jù)區(qū);
JVM中內(nèi)存通常劃分為兩個(gè)部分, 分別為堆內(nèi)存與棧內(nèi)存叠纹,棧內(nèi)存主要用運(yùn)行線程方法存放本地暫時(shí)變量與線程中方法運(yùn)行時(shí)候須要的引用對象地址;
堆內(nèi)存則存放全部的對象信息.
相比棧內(nèi)存, 堆內(nèi)存能夠所大的多, 所以JVM一直通過對堆內(nèi)存劃分不同的功能區(qū)塊, 實(shí)現(xiàn)對堆內(nèi)存中對象管理.
堆內(nèi)存不夠最常見的錯(cuò)誤就是OOM(OutOfMemoryError)
棧內(nèi)存溢出最常見的錯(cuò)誤就是StackOverflowError
此外, 也有較為細(xì)致的劃分;
根據(jù)JVM 規(guī)范, 定義了五種運(yùn)行時(shí)數(shù)據(jù)區(qū), 分別是:
- 程序計(jì)數(shù)器
- Java虛擬機(jī)棧
- 本地方法棧
- Java堆
- 方法區(qū)
這里注意, JVM規(guī)范只是一種規(guī)范, 而不是具體的實(shí)現(xiàn)!
可以這么理解: JVM規(guī)范和JVM的關(guān)系就是接口和實(shí)現(xiàn)類的關(guān)系!
JVM規(guī)范只是規(guī)定了這五種數(shù)據(jù)區(qū)的作用, 并沒有規(guī)定如何去實(shí)現(xiàn)它,所以在不同的JVM中對這五種數(shù)據(jù)區(qū)實(shí)現(xiàn)是不同的;
舉個(gè)例子:
我們常用的HotSpot虛擬機(jī), 在JDK1.8之前對方法區(qū)的實(shí)現(xiàn)就是永久代!
JDK1.8后又取消了永久代,轉(zhuǎn)而用元空間實(shí)現(xiàn)了方法區(qū)!
接下來,我們就以HotSpot虛擬機(jī)為例來理清JVM的內(nèi)存結(jié)構(gòu)
程序計(jì)數(shù)器(線程私有)
程序計(jì)數(shù)器可以看作是JVM對CPU程序計(jì)數(shù)器的一種模擬;
它是一塊較小的內(nèi)存空間, 用來存儲當(dāng)前線程的所執(zhí)行的字節(jié)碼的行號;
我們知道, Java的多線程是通過線程輪流切換、分配處理器時(shí)間片的方式來實(shí)現(xiàn)的,
所以在任何一個(gè)時(shí)刻, 一個(gè)CPU的內(nèi)核只會執(zhí)行一個(gè)線程中的命令;
一旦當(dāng)前線程的時(shí)間片結(jié)束然后被掛起, 當(dāng)又輪到這個(gè)被掛起的線程執(zhí)行的時(shí)候, 如何去恢復(fù)被掛起前的狀態(tài)敞葛?
這個(gè)就是依靠程序計(jì)數(shù)器吊洼,保存當(dāng)前執(zhí)行的字節(jié)碼的位置.
簡而言之, 就是個(gè)“書簽”的功能.
注意以下幾點(diǎn):
- 程序計(jì)數(shù)器是線程私有的, 每個(gè)線程都有一個(gè)自己的程序計(jì)數(shù)器.
- 如果當(dāng)前線程執(zhí)行的是native方法, 則其值為null
- 在這塊內(nèi)存空間中不存在任何OutOfMemoryError情況
Java虛擬機(jī)棧(線程私有)
Java虛擬機(jī)棧描述 java 方法執(zhí)行的內(nèi)存模型,每個(gè)方法在執(zhí)行的同時(shí)都會創(chuàng)建一個(gè)棧幀(Stack Frame) 用于存儲局部變量表、操作數(shù)棧制肮、動態(tài)鏈接冒窍、方法出口等信息.
每一個(gè)方法從調(diào)用直至執(zhí)行完成 的過程,就對應(yīng)著一個(gè)棧幀在虛擬機(jī)棧中入棧到出棧的過程.
棧幀( Frame)是用來存儲數(shù)據(jù)和部分過程結(jié)果的數(shù)據(jù)結(jié)構(gòu),同時(shí)也被用來處理動態(tài)鏈接 (Dynamic Linking)、 方法返回值和異常分派( Dispatch Exception).
棧幀隨著方法調(diào)用而創(chuàng)建,隨著方法結(jié)束而銷毀, 無論方法是正常完成還是異常完成(拋出了在方法內(nèi)未被捕獲的異常)都算作方法結(jié)束豺鼻。
Java虛擬機(jī)棧特點(diǎn)如下:
- Java虛擬機(jī)棧是線程私有的,它的生命周期與線程相同(隨線程而生,隨線程而滅)
- 棧幀包括局部變量表综液、操作數(shù)棧、動態(tài)鏈接儒飒、方法返回地址和一些附加信息
- 每一個(gè)方法被調(diào)用直至執(zhí)行完畢的過程, 就對應(yīng)這一個(gè)棧幀在虛擬機(jī)棧中從入棧到出棧的過程
棧幀結(jié)構(gòu)如下圖
- 局部變量表
局部變量表也被稱之為局部變量數(shù)組或本地變量表,是一組變量值的存儲空間;
主要用于存儲方法參數(shù)和定義在方法體內(nèi)的局部變量這些數(shù)據(jù)類型.
包括各類基本數(shù)據(jù)類型谬莹、對象引用(reference), 以及returnAddressleixing局部變量表所需的容量大小是在編譯期確定下來的,并保存在方法的Code屬性的maximum local variables數(shù)據(jù)項(xiàng)中, 在方法運(yùn)行期間是不會改變局部變量表的大小的.
方法嵌套調(diào)用的次數(shù)由棧的大小決定, 一般來說, 棧越大, 方法嵌套調(diào)用次數(shù)越多.
對一個(gè)函數(shù)而言, 他的參數(shù)和局部變量越多,使得局部變量表膨脹,它的棧幀就越大,以滿足方法調(diào)用所需傳遞的信息增大的需求。進(jìn)而函數(shù)調(diào)用就會占用更多的棧空間.局部變量表中的變量只在當(dāng)前方法調(diào)用中有效, 在方法執(zhí)行時(shí), 虛擬機(jī)通過使用局部變量表完成參數(shù)值到參數(shù)變量列表的傳遞過程; 當(dāng)方法調(diào)用結(jié)束后, 隨著方法棧幀的銷毀,局部變量表也會隨之銷毀.
- 操作數(shù)棧
操作數(shù)棧, 是一個(gè)后入先出棧, 主要用于保存計(jì)算過程的中間結(jié)果, 同時(shí)作為計(jì)算過程中變量臨時(shí)的存儲空間;
當(dāng)一個(gè)方法開始執(zhí)行的時(shí)候附帽,一個(gè)新的棧幀也會隨之被創(chuàng)建出來埠戳,這個(gè)方法的操作數(shù)棧默認(rèn)是空的, 在方法的執(zhí)行過程中, 根據(jù)字節(jié)碼指令,往棧中寫入數(shù)據(jù)或提取數(shù)據(jù);
操作數(shù)棧并非采用訪問索引的方式來進(jìn)行數(shù)據(jù)訪問的, 而是只能通過標(biāo)準(zhǔn)的入棧push和出棧pop操作來完成一次數(shù)據(jù)訪問
如果被調(diào)用的方法帶有返回值的話,其返回值將會被壓入當(dāng)前棧幀的操作數(shù)棧中蕉扮,并更新PC寄存器中下一條需要執(zhí)行的字節(jié)碼指令
每一個(gè)操作數(shù)棧都會擁有一個(gè)明確的棧深度用于存儲數(shù)值, 其所需的最大深度在編譯器就定義好了,保存在方法的code屬性中,為max_stack的值.
- 動態(tài)鏈接(或運(yùn)行時(shí)常量池的方法引用)
每一個(gè)棧幀內(nèi)部都包含一個(gè)指向運(yùn)行時(shí)常量池中該棧幀所屬方法的引用;
包含這個(gè)引用的目的就是為了支持當(dāng)前方法的代碼能夠?qū)崿F(xiàn)動態(tài)鏈接(Dynamic Linking);
比如: invokedynamic指令
在Java源文件被編譯到字節(jié)碼文件時(shí), 所有的變量和方法引用都作為符號引用(Symbilic Reference)保存在class文件的常量池里.比如:描述一個(gè)方法調(diào)用了另外的其他方法時(shí)整胃,就是通過常量池中指向方法的符號引用來表示的,動態(tài)鏈接的作用就是為了將這些符號引用轉(zhuǎn)換為調(diào)用方法的直接引用.
PS: 幾個(gè)概念
在JVM中喳钟,將符號引用轉(zhuǎn)換為調(diào)用方法的直接引用與方法的綁定機(jī)制相關(guān)
- 靜態(tài)鏈接和動態(tài)鏈接
靜態(tài)鏈接
當(dāng)一個(gè)字節(jié)碼文件被裝載進(jìn)JVM內(nèi)部時(shí)屁使,如果被調(diào)用的目標(biāo)方法在編譯期可知,且運(yùn)行期保持不變奔则。
將調(diào)用方法的符號引用轉(zhuǎn)換為直接引用的過程稱為靜態(tài)鏈接蛮寂。動態(tài)鏈接
被調(diào)用的目標(biāo)方法在編譯期無法被確定下來,只能夠在程序運(yùn)行期將方法的符號引用轉(zhuǎn)換為直接引用易茬,這種引用轉(zhuǎn)換的過程具備動態(tài)性酬蹋,稱為動態(tài)鏈接。
- 早期綁定和晚期綁定
綁定是一個(gè)字段抽莱、方法或者類在符號引用被替換為直接引用的過程范抓,這僅僅發(fā)生一次。早期綁定
被調(diào)用的目標(biāo)方法在編譯期可知岸蜗,且運(yùn)行保持不變尉咕。晚期綁定
被調(diào)用方法在編譯期無法被確定下來叠蝇,只能夠在程序運(yùn)行期根據(jù)實(shí)際類型綁定相關(guān)的方法璃岳。
- 虛方法和非虛方法
非虛方法
如果方法在編譯期就確定了具體的調(diào)用版本,這個(gè)版本在運(yùn)行時(shí)是不可變的悔捶,這樣的方法稱為非虛方法铃慷;
靜態(tài)方法、私有方法蜕该、final方法犁柜、實(shí)例構(gòu)造器、父類方法都是非虛方法堂淡;虛方法
不是非虛方法的方法馋缅,都是虛方法;
- 方法返回地址
存放調(diào)用該方法的PC寄存器的值
一個(gè)方法的結(jié)束, 有兩種方式:
- 正常執(zhí)行完成;
- 出現(xiàn)未處理的異常, 非正常退出;
無論通過哪種方式退出, 在方法退出后都返回該方法被調(diào)用的位置;
方法正常退出時(shí), 調(diào)用pc計(jì)數(shù)器的值作為返回地址, 即調(diào)用該方法的指令的下一條指令的地址;
如果異常退出, 返回地址是通過異常表來確定, 棧幀中一般不會保存這部分信息.
正常完成出口和異常完成出口的區(qū)別在于:
通過異常完成出口退出的不會給他上一層調(diào)用者產(chǎn)生任何返回值绢淀。
- 附加信息
虛擬機(jī)規(guī)范允許具體的虛擬機(jī)實(shí)現(xiàn)增加一些規(guī)范里沒有描述的信息到棧幀中.
例如與調(diào)試相關(guān)的信息, 這部分信息完全取決于具體的虛擬機(jī)實(shí)現(xiàn).
實(shí)際開發(fā)中, 一般會把動態(tài)連接萤悴、方法返回地址與其他附加信息全部歸為一類,成為棧幀信息.
本地方法棧(線程私有)
本地方法棧與虛擬機(jī)棧的區(qū)別是:
虛擬機(jī)棧執(zhí)行的是 Java 方法, 本地方法棧執(zhí)行的是本地方法(Native Method),其他基本上一致;
在 HotSpot 中直接把本地方法棧和虛擬機(jī)棧合二為一, 這里暫時(shí)不做過多敘述.
堆(線程共有)
堆內(nèi)存和元數(shù)據(jù)區(qū)都被所有線程共享, 在虛擬機(jī)啟動時(shí)創(chuàng)建.
Java 堆是內(nèi)存空間占據(jù)的最大一塊區(qū)域了, 用來存放對象實(shí)例及數(shù)組,也就是說我們 new 出來的對象都存放在這里皆的。
這里也是垃圾回收器的主要活動營地了, 于是它就有了一個(gè)別名叫做 GC 堆, 并且單個(gè) JVM 進(jìn)程有且僅有一個(gè) Java 堆.
現(xiàn)代JVM 采用分代收集算法, 因此Java堆從GC的角度還可以細(xì)分為: 新生代(Eden 區(qū)和From Survivor 區(qū)和 To Survivor 區(qū))和老年代.
新生代(占1/3的堆空間,通常使用MinorGC)
新生代幾乎是所有 Java 對象出生的地方, 用來存放新生的對象.
由于頻繁創(chuàng)建對象, 所以會頻繁觸發(fā) MinorGC 進(jìn)行垃圾回收.
新生代又細(xì)分為三個(gè)區(qū)
- Eden
- From Servivor (也叫S0)
- To Servivor (也叫S1)
Eden : S0 : S1 占比為 : 8:1:1 (可以通過參數(shù) –XX:SurvivorRatio 來設(shè)定)
JVM 每次只會使用 Eden 和其中的一塊 Survivor 區(qū)域來為對象服務(wù), 所以無論什么時(shí)候,總是有一塊 Survivor 區(qū)域是空閑著的, 誰空閑誰就是To區(qū),To區(qū)不參與垃圾回收;
因此,新生代實(shí)際可用的內(nèi)存空間為 9/10 ( 即90% )的新生代空間.
MinorGC 的過程(復(fù)制->清空->互換)
一般情況下, 新對象都會在新生代 ( Eden 和 一個(gè) Survivor 區(qū)域, 假設(shè)是 From 區(qū)域 ) 出生;
對于大對象 ( 即: 需要分配一塊較大的連續(xù)內(nèi)存空間 ) , 新生代放不下時(shí), 則直接進(jìn)入到老年代;
一次完整的MinorGC 的過程如下:
復(fù)制: Eden覆履、From復(fù)制到 To,年齡+1
在初始階段, 新創(chuàng)建的對象被分配到Eden區(qū), 此時(shí)From 和 To 都為空;
當(dāng)Eden滿的時(shí)候會觸發(fā)第一次GC, 此時(shí)會把還活著的對象復(fù)制到 From;
當(dāng)Eden再次觸發(fā)GC的時(shí)候會掃描Eden和From, 對這兩個(gè)區(qū)域進(jìn)行垃圾回收;
經(jīng)過這次回收后, 還存活的對象會復(fù)制到 To清空: 清空Eden、From
上述操作完成后, 清空 Eden 和 From 中的對象互換: To 和 From 互換(誰空誰是To區(qū))
最后,To 和 From 互換,原 To 成為下一次 GC 時(shí)的 From區(qū);
PS: 對象會在From和To區(qū)域中復(fù)制來復(fù)制去, 如此交換15次(JVM默認(rèn)為15硝全,最大也是15,因?yàn)橹涣袅?個(gè)字節(jié); 可以通過參數(shù) -XX:MaxTenuringThreshold 來設(shè)定),
最終如果對象還是存活, 就存入老年代.
老年代(占2/3的堆空間,通常使用MajorGC)
老年代主要存放應(yīng)用程序中生命周期長的內(nèi)存對象, 這些對象比較穩(wěn)定, 所以 MajorGC 不會頻繁執(zhí)行.
在進(jìn)行 MajorGC 前一般都先進(jìn)行 了一次 MinorGC, 使得有新生代的對象晉身入老年代, 導(dǎo)致老年代空間不夠用時(shí)才觸發(fā).
當(dāng)無法找到足夠大的連續(xù)空間分配給新創(chuàng)建的較大對象時(shí)也會提前觸發(fā)一次 MajorGC 進(jìn)行垃圾回收騰出空間.
MajorGC(標(biāo)記->清除)
MajorGC 采用標(biāo)記清除算法: 首先掃描一次所有老年代,標(biāo)記出存活的對象,然后回收沒 有標(biāo)記的對象.
MajorGC 的耗時(shí)比較長,因?yàn)橐獟呙柙倩厥?
MajorGC 會產(chǎn)生內(nèi)存碎片,為了減 少內(nèi)存損耗,我們一般需要進(jìn)行合并或者標(biāo)記出來方便下次直接分配.
當(dāng)老年代也滿了裝不下的時(shí)候,就會拋出 OOM(Out of Memory)異常
方法區(qū)(線程共有)
方法區(qū)是線程共享的, 主要存儲類信息栖雾、常量池、靜態(tài)變量伟众、JIT編譯后的代碼等數(shù)據(jù), 理論上來說方法區(qū)是堆的邏輯組成部分析藕;
前面簡單提過, 方法區(qū)只是JVM規(guī)范定義的一個(gè)數(shù)據(jù)區(qū), 不同的JVM對其實(shí)現(xiàn)方式不同, 而我們常用的HotSpot對方法區(qū)的實(shí)現(xiàn), 隨著JDK版本的升級, 也是經(jīng)歷了多次調(diào)整.
JDK1.6及之前(永久代): 方法區(qū)存放類信息、字符串常量池赂鲤、靜態(tài)變量噪径、即時(shí)編譯器編譯后的代碼等數(shù)據(jù)
JDK1.7及以后(永久代): 將靜態(tài)變量、字符串常量池從方法區(qū)中移了出來数初,放在了JVM堆中
JDK1.8及以后(元空間): 類的元信息(類信息找爱、字段、方法泡孩、常量等)被存儲在元空間中; 常量池和靜態(tài)變量被放在了JVM堆中
為什么要用元空間來替代永久代呢?
簡介: 永久代主要存放 Class 和 Meta(元數(shù)據(jù))的信息, Class 在被加載的時(shí)候被放入永久區(qū)域车摄,它和和存放實(shí)例的區(qū)域不同,GC 不會在主程序運(yùn)行期對永久區(qū)域進(jìn)行清理;
所以這 也導(dǎo)致了永久代的區(qū)域會隨著加載的 Class 的增多而脹滿,最終拋出 OOM 異常.
為永久代設(shè)置空間大小很難確定
一個(gè)應(yīng)用動態(tài)加載的類的大小是很難確定的,如果永久代設(shè)置的過小,會頻繁觸發(fā)FullGC,并且可能會出現(xiàn)OOM.
而元空間并不在虛擬機(jī)中,而是使用本地內(nèi)存, 因此,理論上系統(tǒng)可以使用的內(nèi)存有多大,元空間就有多大, 所以出現(xiàn)永久代的OOM的概率很小對永久代進(jìn)行調(diào)優(yōu)十分困難
永久代的調(diào)優(yōu)是很困難的, 雖然可以設(shè)置永久代的大小,但是很難確定一個(gè)合適的大小, 因?yàn)槠渲械挠绊懸蛩睾芏? 比如類數(shù)量的多少仑鸥、常量數(shù)量的多少等;
將元數(shù)據(jù)從永久代剝離出來, 不僅實(shí)現(xiàn)了對元空間的無縫管理, 還可以簡化Full GC以及對以后的并發(fā)隔離類元數(shù)據(jù)等方面進(jìn)行優(yōu)化