前言
相對於C、C++這些高效能語言,Java有著讓此類程式設計師羨慕的功能:記憶體自動管理。似乎這樣,Java程式設計師不用再關心記憶體,也不用去了解相關知識。但結果真的是這樣嗎?特別對於我們這種Android程式設計師來說,對記憶體可是吃得死死的,一旦出現較為複雜的記憶體洩露和溢位方面的問題,簡直就是噩夢。因此,對Java記憶體管理有個大體的瞭解似乎已經成為一個合格的Android程式設計師必備的技能,就算是新進的Kotlin同樣是基於JVM的。不如趁此機會,大家一起來揭開它的面紗。
物件
Java是一門物件導向的程式語言,江湖一直流傳著這麼一句話:萬物皆物件。因此,Java的記憶體管理也可以理解成為物件的建立與釋放。那麼,物件到底是什麼?男朋友?女朋友?還是?物件和記憶體到底是什麼關係?這裡的問題太多,我們一步一步來。
Tips1:全文以常用的虛擬機器HotSpot、常用的記憶體區域Java堆和普通Java物件為例。
Tips2:如果深讀過《深入理解Java虛擬機器》的同學可以不用看了,請右上角,如果忘了,請繼續!
複製程式碼
概念
男朋友或者說女朋友你都可以理解成物件,物件是實實在在存在的,比如老爸,老媽,同時伴隨著一個抽象的概念,類:它是對物件的抽象,不管是男朋友和女朋友都是人,屬於人類。概念差不多就介紹到這,感覺自己在大學上課一樣。。。我的天(捂臉)。
物件與記憶體
建立
程式設計師沒媳婦怎麼辦?new一個。老簡單了,高的,矮的,瘦的,胖的,想要啥就有啥,此生最不後悔的就是當程式設計師了,雖然頭有點冷。
new一個就是一個物件的建立,那麼究竟是怎樣的一個過程呢?JVM遇到一條new指令的時候,首先將去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過。如果沒有,那必須先執行相應的類載入過程,類載入檢查通過後,可以說一個物件的模型已經出來了,但Java畢竟只是程式語言,還是得分配記憶體不是?不然怎麼操作?
分配
物件記憶體的分配和現實很多場景都是一樣的,比如停車,有些地方可能只有100個車位,先到的停在最前的空位上,就這樣按順序一輛一輛的停下來。這樣的分配稱為“指標碰撞”。還有一種你想停哪就停哪,只要你插得進去。這樣的分配稱為“空閒列表”。不管是前者還是後者,停車我們是靠眼睛看的,哪裡有空位才停,那麼JVM如何“看”的呢?前者是靠一個指標作為指示器,分配多大記憶體的物件就往後移多大距離,後者會維護一個列表來記錄可用記憶體(可插車位)。
對於併發敏感的同學肯定會提出疑問,在併發的時候如何能正確分配到相應位置? 一般也有兩種解決方案,一種是一輛一輛停,保證前一輛停完,下一輛才開始停;另一種是大家說好要停哪一片區域,比如A,B,C停在A區域,那麼A,B,C每次去停A區域就行了,跟其它區域沒關係(區域指的是執行緒),如果他們邀了朋友D,那對不起,只能等其他區域人停完,你再停。因此,物件的建立並不是原子操作,切記,切記。
佈局
車停哪裡,我們已經知道了,那麼怎麼停?有人喜歡正著停,有人喜歡橫著停,有人喜歡倒著停。同樣的,物件在記憶體中是怎麼擺放的呢?大體分為3個部分:物件頭(Header)、例項資料(Instance Data)和對齊填充(Padding)。
簡單地來介紹這3位,畢竟這概念性太強。
物件頭包括兩部分資訊,第一部分用於儲存物件自身的執行時資料、如雜湊碼、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等;另一部分是型別指標,即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的示例。對上面部分名詞不理解的,我在後續文章可能會解釋,畢竟自己也在學習當中,如果想急於知道的同學可以查閱相關資料,姑且當它是概念記住即可。
例項資料就比較好理解了,它是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。
對齊填充並不是必然存在的,由於記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說,就是物件的大小必須是8位元組的整數倍。物件頭大小是8位元組的整數倍,所以例項資料大小不是8位元組的整數倍時,就需要對齊填充來補齊。
訪問
你停完車,幹完事,總得開車回家吧,那總得找到自己的車吧?怎麼找?自己停在哪個車位總記得吧?自己的牌照總記得吧?那麼我們如何在記憶體中訪問我們的物件呢?大家來看一組圖:
前者稱為控制程式碼訪問,優點很明顯,物件移動了只要修改控制程式碼中的指標就行了,不會牽涉到reference;後者稱為直接指標訪問,優點也很明顯,就是快,直接少了控制程式碼這一層。而本文中討論的HotSpot採用後者。
回收
車炸了怎麼辦?當然是買輛新的(手動壞笑)。那麼我們如何判定一個物件屎沒屎呢?在此之前介紹兩種引用演算法:第一種是引用計數演算法,很好理解,給物件一個計數器,初始值為0,有地方引用就加1,失效就減1,計數器為0的說明都是屎了的;第二種是可達性分析演算法,也很好理解,從GC Roots開始,向下引用物件,如果一個物件存在一條從GC Roots到本身的路徑,那麼說明這個物件還活著,否則就屎了。如下圖object567就是屎的:
那麼哪些可以作為GC Roots呢?
- 虛擬機器棧(棧幀中的本地變數表)中引用的物件
- 方法區中類靜態屬性引用的物件
- 方法區中常量引用的物件
- 本地方法棧中JNI(即一般說的Native方法)引用的物件
我們的HotSpot是採用後者,那麼為啥沒采用前者呢?因為它很難解決物件之間相互迴圈引用的問題。例如:
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
複製程式碼
那麼問題來了,不可達的物件真的屎了嗎?當然不會,至少經過2次標記才會宣告一個物件的屎亡。第一次標記是發現物件不可達,同時篩選出沒有覆蓋finalize()方法或者finalize()方法已經被虛擬機器呼叫過,那麼這些可以認為屎了,可以回收(那麼這時候不是隻標記一次嗎?有沒有大佬解答);剩下的物件會被放置F-Quenue的佇列中並且GC會對這些物件進行第二次標記,在執行finalize()方法的時候也是拯救自己的時候(只要在方法中合重新建立與引用鏈上其它物件的關聯即可)。大家最好忘記這個方法的存在。它的執行代價高昂,不確定性大,無法保證各個物件的呼叫順序等。《Effective Java》中也有提到避免此方法。
物件的簡單分析差不多就到這裡結束了,你以為到這裡全部結束了?太天真。
像上面碰到的名詞,諸如虛擬機器棧、方法區、Java堆等到底是什麼玩意?
執行時資料區
國際慣例,No picture,say a J8!
看到這張圖,大家肯定知道我要幹什麼了。。。我也不願意啊,寫到這感覺是篇說明文了,我的天,賊尷尬。
程式計數器
程式計數器是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指標。例如平時的分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能要依賴這個計數器完成。從圖上我們可知,它是執行緒私有的,也就說每個執行緒都會有一個獨立的程式計數器且互不影響。而且它是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況的區域。
Java虛擬機器棧
虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法在執行的同時會建立一個棧幀用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。細心的朋友,會發現區域性變數表在物件的訪問章節圖中出現過,重要的是當進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,換句話說,區域性變數表所需的記憶體空間是在編譯期間就完成分配的。
在Java虛擬機器規範中,對這個區域規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;如果虛擬機器棧可以動態擴充套件(當前大部分的Java虛擬機器都可動態擴充套件,只不過Java虛擬機器規範中也允許固定長度的虛擬機器棧),如果無法擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常。
本地方法棧
本地方法棧與虛擬機器棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機器棧為虛擬機器執行Java(也就說位元組碼)服務,而本地方法棧則為虛擬機器使用到的Native方法服務。因此與Java虛擬機器棧丟擲的異常狀況也是一樣的。
Java堆
你可以認為幾乎所有的物件例項都在堆上分配的。難道不是所有的?這是一個優化技術,試想一下,如果一個物件無法被別的方法或者執行緒通過任何途徑訪問到,為何不直接分配在棧上呢?
根據Java虛擬機器規範的規定,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上連續即可,這也意味著,如果邏輯上沒有足夠的記憶體完成分配且堆也無法擴充套件,那麼將會丟擲OutOfMemoryError異常。
方法區
方法區與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。但它除了和Java堆一樣不需要連續的記憶體和無法選擇固定大小或者可擴充套件記憶體將丟擲OutOfMemoryError異常外,還可以選擇不實現垃圾收集。
執行時資料區介紹的差不多了,這裡補充一個概念叫直接記憶體,在jdk1.4加入的NIO有用到,感興趣的可以看看。大家肯定注意到每個區域(除程式計數器)都有拋記憶體溢位的狀況,以後有人問到, 何時會產生OOM,就不要再說記憶體不夠的時候了,很傷感情。
垃圾收集演算法
上面提到的Java堆可以說是虛擬機器管理的記憶體中最大的一塊了,是GC光顧的常客,因此也叫“GC堆”。GC顧名思義就是垃圾回收,這也是Java一大優勢,不用的記憶體可以自動回收。既然是垃圾回收可以有垃圾回收裝置啊,掃地的還用掃帚呢。
圖中是我們HotSpot的垃圾收集器,上邊是新生代,下邊是老年代,具體的垃圾收集器來歷作用我就是不介紹,沒有必要,本文希望讀者有個大概的瞭解。那麼,有垃圾器,總得有方法吧,喝飲料還用吸管呢,吸管什麼原理大家沒點13數嗎?那麼在這裡大體介紹幾種演算法的思想。
標記-清除演算法
見名知義啊,先標記需要回收的物件,然後一次性清除標記的物件。它可以說是最基礎的收集演算法,就算是後面介紹的演算法都是在它基礎上加以改進的。既然改進,那麼肯定有無法忍受的缺點,它除了效率不高外,還有個嚴重的問題,就算會產生大量不連續的記憶體碎片,從剛剛我們提到的Java對OOM的原因可知,非常容易無法分配而第二次執行垃圾回收,或者直接OOM。執行過程如圖所示:
複製演算法
這個演算法很好理解,將可用記憶體化為兩塊,每次只用其中一塊,當要回收的時候,把可用的物件複製到另外一塊,然後把原先那塊一次性清理掉,可用說在效率上大大的提高,但有個致命的弱點就是記憶體減半。
複製演算法執行過程如圖所示:
標記-整理演算法
複製演算法理論上效率很高,但是你想想如果存在100個物件,其中98個都可用的,那麼你得複製98個物件,極端情況100個都存活,你還得複製全部一遍,這是無法接受的。該演算法針對標記-清楚演算法產生大量記憶體碎片做了改進,先把可用物件移到一端,然後直接清理掉端邊界以外的記憶體。執行過程如圖所示:
分代收集演算法
從我們剛剛分析來看,複製演算法貌似更適合朝生夕屎的物件,而剩餘的兩個演算法更適合“百歲”物件。前者那些物件所在區域我們就叫做新生代,後者物件所在區域就叫做老年代。我們的分代演算法就是根據新生代和老年代採用不同的演算法而已。
那麼,這裡有個問題,老年代的物件到底怎麼來?換句話問,怎樣才能進入老年代?首先,分析一個特例:大物件直接進入老年代;然後是正常步驟:物件A在分配的時候優先分配在新生代的Eden空間,當Eden空間不夠分配記憶體的時候,將進行一次Minor GC,此後物件A仍然存活且能被Survivor空間容納,那麼將移至Survivor空間,並將其年齡計數器置為1,此後,物件A每度過一次Minor GC且存活,年齡就加1,當達到最大年齡(MaxTenuringThreshold)時,將被榮升到老年代(鼓掌鼓掌)。當然這也不是絕對的,如果Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接晉級。
關於本文主要內容差不多就到這了,最後留下一個很關鍵的問題,垃圾回收器到底什麼時候進行垃圾回收,又是如何進行的?這裡有個很牛逼的名詞叫“Stop The World”。
雜談
首先,我想說深入理解Java虛擬機器(第2版)真的是一本不錯的書,我這種小菜雞根本沒機會認識這種大神,也談不上打廣告,看過的同學應該都知道。其次,本文所有的內容均來自於該書,甚至有一字不差的一段話。本文可以說是我讀完該書第二部分:自動記憶體管理機制的筆記。本文很多都屬於概念性知識,就比如地球為什麼叫地球?這種屬於約定俗成的東西,但對於我們Android程式設計師來說,最好是能夠對其有個大概的瞭解,但不是所有同學都看過該書(買了,也不一定看),因此我分享了該文章,其中有部分是自己的理解,如果有問題我及時改正,最好大家還是買原著仔細閱讀,我這裡拋磚引玉一下- -!
每天都學習一點點也是極好的。既然是學習,物件肯定是有前輩已經總結了的,你應該做的是將其理解,並轉為自己的東西(用自己的思想把它翻譯出來,本質不變),不然就叫做探索。還有一句話就是好記性不如爛筆頭,老師肯定說過這句話,當時一句都沒進我法耳。
最後,感謝一直支援我的人!
在這裡,提前祝大家新年快樂!
傳送門
Github:github.com/crazysunj/
部落格:crazysunj.com/