《資料結構》之棧和堆結構及JVM簡析

2023-06-04 21:00:21

導言:

在資料結構中,我們第一瞭解到了棧或堆疊,它的結構特點是什麼呢?先進後出,它的特點有什麼用呢?我們在哪裡可以使用到棧結構,棧結構那麼簡單,使用這麼久了為什麼不用其它結構替代?

一.程式在記憶體中的分佈

作為一個程式猿,我們應該會常常跟程式碼打交道,那麼我們所編寫的程式或程式碼,是怎麼跑起來的,作業系統怎麼呼叫的,怎麼劃分的我們可以使用一個簡單的圖來了解一下:

在圖片中,把我們的記憶體一共分為了兩個部分,一個是作業系統的核心區,另外一個就是使用者區

對於作業系統: 作業系統(英語:Operating System,縮寫:OS)是管理計算機硬體與軟體資源的系統軟體,同時也是計算機系統的核心與基石。作業系統需要處理如管理與設定記憶體、決定系統資源供需的優先次序、控制輸入與輸出裝置、操作網路與管理檔案系統等基本事務。作業系統也提供一個讓使用者與系統互動的操作介面。

作業系統核心要負責:程式呼叫,驅動呼叫,記憶體管理和分配,有興趣可以學學作業系統,我們這裡主要是看看使用者記憶體區

使用者記憶體區主要是使用者對計算機發出的指令,計算機要做出相應的操作,使用者區也是我們最大化操作計算機的地方,啟動一個APP,開啟網站,滑鼠單擊,Linux中的一次 dir指令等等,都是我們人,使用者在進行操作和管理

我們寫的程式或程式碼也是執行在使用者空間上的

程式在使用者空間上載入出來六個部分:

  • 程式碼段
  • 唯讀資料段
  • 初始化資料段
  • 未初始化資料段
  • heap堆
  • stack棧

程式碼段:就是我們寫的源生的一些語言指令,在作業系統也可以叫做臨界區,他在執行是不可改變的,又是純的

唯讀資料段:就是我們在程式中寫的一些常數,比如c或Java中使用const修飾的變數,它們是全域性唯一且不可變的,包括語言本省有一些自定義的宏都是不可變的

初始化和未初始化的資料段:都是一些申請的全域性變數,有些是未初始化的,有些是初始化的,在程式執行的時候就會被呼叫,都是資料段範圍

heap堆:這就是我們的重點了,它的作用是動態申請空間的程式存放的地方,比如Java中我們new了一個物件,物件都存放在堆中的,還有c語言中malloc函數,它們所申請的東西都是存放在堆中的

stack棧:這是程式執行時被呼叫最頻繁的了,它的結構很適合儲存不長時間存在的變數,以及斷點記錄,每當一個函數執行完,它就會釋放此次函數所產生的空間

補充:

我們仔細去看看圖片就會發現,堆空間是向上擴張的,棧空間是向下擴張的,除了這兩個空間,其它的空間都是靜態資源,即在編譯時分配的空間在執行的時候所需的空間還是那麼大

棧空間是緊貼核心區的,因為棧在程式執行時本身就需要大量的操作,即使在記憶體中,把資料送到cpu或核心都是要開銷的,所以為了使計算機可以快起來,棧作為操作多的結構就被選擇緊貼核心

堆中儲存的資料結構普遍都很大,包括物件這些操作集合,它們兩者都向中間靠,會不會相遇呢?其實中間的資料區空間是很大的,絕大多數情況不會

棧中儲存的都是值型別,指標也是值型別

二.JVM中的記憶體模型

記憶體模型分析

 在JVM中,

棧空間,負責的就是存放著函數呼叫產生的區域性變數,每當一個函數被呼叫它所產生的記憶體開銷都會在棧中,隨著函數的結束會被銷燬,但是我們知道,在函數執行的時候會有物件的建立,物件大多數情況都是存在堆空間的,

那麼函數怎麼執行物件呢?其實它會在棧中存放一個指標,用於指向某個物件,在棧中存在的是一個連結地址

堆空間是共有的,也就是一個執行緒建立出來的物件,另外一個物件可以繼續呼叫,對空間的執行結構就比棧更加複雜,它伴隨著記憶體清理,堆空間的物件一旦不使用了就會被jvm的GC機制給清理掉,它和C語言的free()釋放空間一樣,都是對堆空間進行操作,由於棧空間會被系統接管,所以很少有對棧空間的操作

本地方法棧,其實和Java語言沒什麼關係,它是用於其它語言和Java相容的,我們匯入了其它語言的方法,就會被存在這裡,包括C++等,jvm就是c++寫出來的,它交由jvm執行而專門設定的一個棧

程式計數器,就和它的名字一樣,它是用來記錄程式執行到那個位置的,它的所佔空間需求很小,它會對棧空間的函數開始點和結束點進行記錄

方法區,它是具體的實現,也是我們自定義的一些靜態方法儲存的地方,與之對應還有一個元空間的東西,它和方法區的區別就是,方法區注重編寫介面,而元空間注重實現這些介面,在jdk1.8以前元空間是在堆空間的一塊區域,1.8以後就把它從堆空間中獨立出來了

棧空間(執行緒私有)

 我們可以看到一次函數執行中,從括號開始,就會為傳進來的引數建立空間,如果函數內部有單獨宣告的變數,在這時候也會去申請空間

函數的計算階段,沒有額外的記憶體開銷,如果對已有的變數進行賦值,也是在開始建立的空間上完成覆蓋,這裡a = 11進行賦值,其實這個a去覆蓋了剛開始傳進來空間開闢的a =10,所以沒有額外的空間開銷

當遇到呼叫函數的結束大括號 } 時,就會結束函數,相應的在棧空間內就會刪除相應的在函數執行時開闢的空間,所以一次呼叫完成以後,就像最左邊一樣,棧內的空間就僅有主函數了

我們可以看看函數執行的結果

 我們在fun函數不是把值已經改變成11了嘛,為什麼主函數列印最後執行還是輸出自己定義的a = 10呢?

這就是棧空間的特點,基於這個特點,我們可以很容易就實現了區域性變數和全域性變數,我們來細細的品一下這段程式碼記憶體的分佈:

 從這個範例中我們可換一種思維來理解區域性變數,或者說全域性變數和區域性變數本意上都是一樣的,在棧中也具有同等地位,不同的是區域性變數的特點是朝生夕死的,而全域性變數在棧中活到了最後

這也是為什麼靜態變數,常數一般都是全域性作用域,它們在程式開始的時候就已經被壓到棧內了,棧的特點是先進後出,也就是說要把常數和靜態資源拿出來需要把絕大部分的變數方法和引數都要拿出來,符合這種情況的就只有程式快結束的時候,主函數也到了大括號 } 

每執行完成一個函數就刪除它所開闢的空間,對記憶體利用的效率也是很高的

堆空間(程式公共存取)

不同於棧空間的執行緒私有,所在堆中的物件都可以被任何棧中指標存取,棧結構總歸是有序的,但是堆空間是無序儲存的,不像棧空間有先進後出這一特點

物件在堆空間中如果內部屬性為基本變數,就會是自己就是一個實體,但是如果內部屬性涵蓋了其它物件,它會以一個指標的形式去指向那個物件,如果物件存在,否則再去自己造一個物件指向它,很顯然是為了減少物件頻繁構建的大量開銷

 我們可以看到圖中,id=1是直接賦值到自己的person物件內部的,但是String型別的卻是用指標指向,因為String也是一個物件,不是基本型別,物件都會以指標的形式去指向

且堆空間是公用的,也就是我們person物件執行完成以後,其它物件需要用到String物件的時候,會直接鏈向這兩個String,而不是自己去建立

可能這些連結來自於不同的私有棧,但是都是可以的,對於堆的清理,我們就要知道jvm的GC機制了,它是專門負責清理堆中不會再使用的物件的,會使用到的物件還是一樣的會繼續存放再堆空間中

棧和堆與物件的關係

既然物件和變數是分別存在堆和棧中的,我們在函數執行的時候主要還是對棧操作,那麼當棧中需要使用到物件的時候怎麼辦呢?

 如圖,因為棧中只儲存基本型別,所以使用棧要使用物件的時候,它會用一個指標去指向堆中的物件,一般物件的大小是不確定的,每個物件有它自己的大小,所以棧中儲存指標是基本型別它和int大小是一樣的,對棧的管理也更進一步

值得注意的是,當棧中開闢的函數空間被清理了以後,堆中的空間不會立馬被清理,可能其它的棧正在呼叫,或者還沒有觸發jvm的GC機制

這樣的設定又變相的解決了堆中物件的共用問題

省流:棧空間中儲存的都是值型別,指標也是值型別,而堆中儲存的都是物件

三.JVM的GC機制(堆特別篇)

我們從jvm的記憶體模型中分析了,堆的中產生的物件在一個棧使用完了以後不會立即清理,而是要等到GC機制來清理,我們就來看看GC機制是怎麼清理的

  • 首先我們得知道什麼樣的情況可以清理
  1. 正在使用的不能清理
  2. 間接被其它物件呼叫的不能清理
  3. 本地方法棧和方法區(元空間)的物件不能清理

除了這些情況其它都是可以清理的,這樣GC機制就會啟動去清理這些不在此範圍內的物件

清理方案(三種)

1.標記清理

標記清理指的是GC對堆中的物件先進行掃描,那些物件沒有用了可以清理的就打上標記,然後掃描完了統一清理有標記的

 這種方式執行起來簡單,實現邏輯清楚,但是有個很明顯的缺點,在刪除完成後,會有內碎片,相信學過作業系統的堆內碎片一定很熟悉,尤其是程式的物件不一,會導致內碎片越來越多

2.標記整理

標記整理是基於標記清理實現的,它在清理完成以後會把物件的位置向前移動,使得後面可以空出來大片區域

 標記整理的方式,的確非常符合我們對堆空間的清理,但是它的開銷很大,每次清理都伴隨著大量的移動

3.分割區複製

 分割區指的是把堆空間分為兩個部分,在進行清理標記的時候,把沒有標記的分到另外一個半區,表示物件還在使用,然後把此半區的全部清理掉,以供它們交替工作

 分割區複製的缺點也很明顯,它需要兩倍的記憶體,開銷也是非常大的

JVM中的GC機制

那麼在jvm中,它的物件清理機制是如何實現的呢?肯定比上面三種更合理

 

 jvm中的堆空間大致被分為了兩個比較大的部分,一個是老年代,即old,一個是年輕代,即young

年輕代中裝的是E區和S區,E區即Eden,想必大家都很熟悉,就是伊甸園的意思,是亞當和夏娃偷食禁果產生新生命的地方,在jvm中也是一樣的每個新new出來的物件都是出生在E區的,S區對應的就是Survivor,即倖存的意思

老年代中裝的是在S區存活6次都沒被清理掉的物件,因為開發者認為6次都沒被清理掉,說明這個物件可能會存活更久或者存活到到最後

young區工作過程

 我們知道了E區是裝載新物件的地方,當Eden區快要裝滿時,觸發GC的時候,就會有一次掃描標記,然後將沒有標記的複製到S區中區,表示存活的物件,然後清理全部的E區和另外的一個S區,我們有兩個S區其實是交替工作的,這次複製到S0區,下次必複製到S1區,

E區要滿的時候就會觸發youngGC因為是在young區觸發 的也很好理解,每一次複製都會有一個年齡標誌,當一個物件達到6歲的時候就會被複制到old區中區,表示它會存活更長或者存活到最後

我們可以看到S區比E區要小,其實在JVM的設定中S0:S1:E 是 1:1:8,也就是E區要比S區大很多,這是因為物件也滿足朝生夕死的特點,每次觸發GC的時候大部分的物件都會被標記清理

補充:

還有一個FullGC,指的是old區要滿的時候觸發的,old區要是滿了,會造成程式異常終止,JVM會專門來處理FullGC,同時會順帶的觸發youngGC,所以每次FullGC必youngGC,但是一般不會FullGC,因為old區很大,

FullGC的清理方式是標記整理,也就是那三種清理方法的前兩種,而youngGC是複製也就是第三種清理方法,youngGC是專門為物件的朝生夕死而設計的

JVM中的垃圾收集器(瞭解)

由於我們的堆是有兩部分構成的,老年代和年輕代,所以垃圾收集器也是分別有兩種:

解釋: 

Serial是單執行緒的移動複製用於young區,與之對應的就是Serial  Old 是標記整理用於old區,會發生STM

PawNew是多執行緒的移動複製和Serial其它都一樣,而CMS則是伺服器段old區使用最多的垃圾回收器他不會造成STM

parallel Scavenge 則是更注重吞吐量,在使用者端使用最多的就是它,單次執行很滿,但整體的效率很高,與之對應就是old區的Parallel  Old

 注:STM指的是stop-the-world,簡稱 STW,指的是 GC 事件發生過程中,會產生應用程式的停頓。停頓產生時整個應用程式執行緒都會被暫停,沒有任何響應,有點像卡死的感覺,這個停頓稱為 STW。

由於young區的空間小,所以STW時間短,但是old區要是發生了GC,STW停頓時間很長,這也是伺服器端為什麼要使用CMS的原因,它不會發生STM現象,因為它採用的是標記清理,但是缺點我們也知道,會有內碎片

young區的演演算法都是移動複製,就不用說了,並且會發生短暫的STM,我們可以著重看看CMS,這個伺服器端常用的垃圾收集器

CMS:

  1. 初次標記,GCRoot物件,會發生STM
  2. 並行標記,所有old區的物件
  3. 從新標記,修正第二步(有可能在標記的時候又產生了廢棄物件)會發生STM
  4. 並行清理,標記清理

此外,G1垃圾收集器是從jdk1.9開始沿用的,它對堆的劃分就不像這幾種那麼規律了,它提出了一個堆空間的新型劃分概念,有興趣的朋友可以去了解一下

四.拓展

Java逃逸分析機制:

逃逸分析在jdk6以後是預設開啟的

逃逸分析指的是它會分析物件如果是在當前函數使用完了不存,因而改為在棧上申請空間,而棧執行完就清理,所以不需要等GC,大大緩解了GC的壓力

如果不是當前函數範圍則不會在棧上,而是在堆上申請

物件的大小計算:

 非陣列物件,頭部大小是固定的12位元組,陣列物件則多了4位元組為16位元組

內容會根據具體的基本型別和其它物件大小而定

如果我此物件中有兩個int變數,那麼物件大小就是 12+8=20,但結果會是24,因為Java為了使物件在空間上對齊,會對不滿足8倍數的大小物件強制擴大,24是8的倍數所以可以,

這裡就會有個小tips,那你會發現一個物件中兩個int型和三個int型的物件大小是一樣的

如果此物件的一個屬性是物件的的話,會取決於被參照物件的基本型別和它包含的其它物件

分析:

地址:是此物件在堆中的實際位置

標記:記錄了hash值,是否有鎖,年齡(是否進入old區使用的)

陣列長度:記錄了陣列的下標,這也是為什麼陣列物件長度為一個int的大小,它們是等位元組的