來說說垃圾回收怎么樣~
本文轉載自微信公眾號「Java極客技術」,作者鴨血粉絲 。轉載本文請聯系Java極客技術公眾號。
JVM 的自動內存管理,讓原本應該是開發人員去做的事情,變成了垃圾回收器來做的事情
既然是別人幫忙做的事情,那么可能就不是自己想要的,所以就需要我們了解一下垃圾回收相關的內容
引用計數法與可達性分析
垃圾回收,垃圾回收,那就是有的內存分配給了一些對象,但是這些對象已經用完了,那么它所占用的內存也就應該該釋放掉了,卻還沒有釋放
那么,這里就有個問題:該如何確定一個對象用完了呢?
其中一種方法就是引用計數法
引用計數法就是給每個對象添加一個引用計數器,來統計指向該對象的引用個數
比如:如果有一個引用,被賦值為某一個對象,那么這個對象的引用計數器就 +1 ,如果一個指向這個對象的引用,被賦值為了其他的值,那么這個對象的引用計數器就 -1 ,這樣如果這個對象的引用計數器為 0 ,我們就可以認為這個對象已經使用完畢,它所占用的內存空間可以回收掉了
這種方案聽上去無懈可擊,但是有一個致命的漏洞,就是沒辦法處理循環引用的問題
比如說: A 和 B 互相引用,除此之外也沒有其他的引用指向 A 或者 B ,在這種情況下,其實 A 和 B 所占用的內存就可以釋放掉了,但是因為它們互相都有引用,所以此時的引用計數器并不為 0 ,在這種情況下,就不能對它們進行回收
現在只是兩個對象,如果再來兩個,再來兩個,這樣循環引用的對象多了之后,就會造成內存泄露
基于引用計數法的弊端,當前 JVM 主流的垃圾回收器采取的是可達性分析算法
這個算法本質就是將一系列的 GC Roots 作為初始的存活對象合集( live set ),然后從這個合集出發,探索所有能夠被該集合引用到的對象,并把這些對象加入到集合中來,這個過程就叫做標記( mark ),遍歷到最后,沒有被探索到的對象就是可以回收的對象
那么什么是 GC Roots 嘞?一般包括(但不限于)以下幾種:
- Java 方法棧楨中的局部變量
- 已加載類的靜態變量
- JNI handles
- 已啟動并且沒有停止的 Java 線程
剛才說因為引用計數法存在循環引用的問題,所以目前主流垃圾回收器選用的都是可達性分析法,也就是說,它解決了循環引用問題,其實這一點也比較好理解,雖然 A 和 B 相互引用,但是這個時候從 GC Roots 開始出發,是沒有辦法到達 A 和 B 的,那么就不會把它們放到存活對象合集之中,自然也就會被回收掉
但是在實際中還是會有問題的,比如:在多線程環境下,就會有其他線程更新已經訪問過的對象中的引用,但是是多線程并行的嘛,這個時候可達性分析法已經把這個引用設置成了 null ,或者這個對象還在使用,但可達性分析法把它標記為了沒有被訪問過的對象,被回收掉了,這種情況可能直接導致 JVM 崩潰掉
Stop-the-world & safepoint
既然可達性分析法也有自己的一些缺陷,總得有解決方案吧?比較暴力的一種方法就是 Stop-the-world ,估計聽名字也能知道,就是讓全世界都停下來,也就是說,在進行垃圾回收的時候,其他所有非垃圾回收線程的工作都需要停下來,先讓垃圾回收器工作完畢再說。這就是所謂的暫停時間( GC pause )
Stop-the-world 是通過安全點( safepoint )機制來實現的。啥意思嘞?咱先想個場景,現在你敲代碼敲的特別開心,又有思路,狀態又好,美滋滋的正在工作,突然毫無緣由的就讓你現在不準敲代碼,你會不會不開心?好不容易思路來了對吧,就一點兒理由都不給的就讓我停下,不合理吧?
同樣的場景,一個線程現在跑的特別 happy ,而且再有一秒鐘就完成了任務,這個時候 JVM 收到了 Stop-the-world 請求,二話不說就把所有的線程給停掉,不太好吧?那么這個時候安全點( safepoint )機制就登場了。有了安全點機制,當 JVM 收到 Stop-the-world 請求的時候,它就會等待所有的線程都達到安全點,才允許請求 Stop-the-world 的線程進行獨占的工作
那么,什么時候是安全點呢?舉個例子來說:當 Java 程序通過 JNI 執行本地代碼時,如果這段代碼不訪問 Java 對象,不調用 Java 方法,不返回到原 Java 方法,那么 Java 虛擬機的堆棧就不會發生改變,那這段本地代碼就可以作為一個安全點。只要不離開這個安全點, JVM 就可以在垃圾回收的同時,繼續運行這段本地代碼
因為本地代碼需要通過 JNI 的 API 來完成上述三個操作,因此 JVM 只需要在 API 的入口處進行安全點檢測( safepoint poll ),看看有沒有其他線程請求停留在安全點這里,就可以在必要的時候掛起當前線程
垃圾回收的三種方式
當標記好存活的對象之后,就可以進行垃圾回收了
主流的垃圾回收方式,可以分為三種:清除( sweep ),壓縮( compact ),復制( copy )
清除,就是把死亡對象所占據的內存標記成空閑內存,并把它記錄在一個空閑列表( free list )中,當需要新建對象的時候,就直接在空閑列表中尋找空閑內存,劃分給新建的對象就完了
但是這里會產生一個問題,因為死亡的對象所占據的內存可能是隨機的,回收完畢之后,內存就是碎片化的,如果此時有對象申請一塊連續的內存空間,盡管碎片化的內存空間是夠用的,也沒辦法進行分配
壓縮,就是把存活的對象聚集到內存區域的起始位置,這樣就可以留下一段連續的內存空間。這樣去做的話,可以解決內存碎片化的問題,代價就是壓縮算法帶來的性能開銷
復制,就是把內存區域分成兩等分,分別用兩個指針 from 和 to 來維護,并且只是用 from 指針指向的內存區域來分配內存。當進行垃圾回收時,就把存活的對象復制到 to 指針指向的內存區域中,并且交換 from 指針和 to 指針的內容。
復制這種方式也可以解決內存碎片化的問題,但是它的缺點也是比較明顯的,因為把內存區域分成了兩等分嘛,那利用率就比較低咯,最高也是 50% 了,不能再高了
垃圾回收在 JVM 中的應用
上面說的三種垃圾回收方式是理論上的,那么在 JVM 中是如何應用的呢?
這就先要來了解下 JVM 的堆劃分,大概就是這樣子:
JVM 將堆劃分為新生代和老年代,在新生代中又劃分為 Eden 區,還有兩個大小相同的 Survivor 區
當程序調用 new 指令時,會在 Eden 區中劃出一塊作為存儲對象的內存,但是因為堆空間是線程共享的,所以在這里面劃分空間的話就需要同步,要不然出現了兩個對象共用一段內存,那不就該打架了嘛
JVM 為了避免兩個對象打架的事情發生,就讓每個線程向 JVM 申請一段連續的內存,來作為線程私有的 TLAB ( Thread Local Allocation Buffer ,對應虛擬機參數 -XX:+UseTLAB ,默認開啟的)
Eden 區一直進行分配,總有空間分配完畢的時候,該怎么辦?此時 JVM 就會觸發一次 Minor GC ,來收集新生代的垃圾,存活下來的對象就會被送到 Survivor 區
在圖中可以看到, Survivor 區有兩個,一個是 from ,一個是 to ,其中 to 指向的 Survivor 區是空的
當發生 Minor GC 時, Eden 區和 from 指向的 Survivor 區中的存活對象會被復制到 to 指向的 Survivor 區,然后交換 from 和 to 指針,這樣就保證了下一次 Minor GC 時, to 指向的 Survivor 區還是空的
同時 JVM 會記錄 Survivor 區的對象一共被來回復制了幾次,如果一個對象被復制的次數為 15 (對應虛擬機參數 -XX:+MaxTenuringThreshold ),這個對象就會被晉升( promote )到老年代
那么在發生 Minor GC 時,采用哪種垃圾回收方式會比較好一些呢?采用復制方式,也就是 標記-復制 算法會好一些。為什么呢?因為在新生代中,大部分的 Java 對象只存活一小段時間,那么我們就可以采用耗時比較短的垃圾回收算法,讓大部分的垃圾都能在新生代被回收掉。使用 標記-復制 算法的話,理想情況下就是 Eden 區中的對象基本都死亡了,那么需要復制的數據非常少,此時這種算法的優勢就被極大的體現了出來