天天看點

JVM源碼分析之FinalReference完全解讀

概述

JAVA對象引用體系除了強引用之外,出于對性能、可擴充性等方面考慮還特地實作了四種其他引用:SoftReference、WeakReference、PhantomReference、FinalReference,本文主要想講的是FinalReference,因為我們在使用記憶體分析工具比如zprofiler、mat等在分析一些oom的heap的時候,經常能看到 java.lang.ref.Finalizer占用的記憶體大小遠遠排在前面,而這個類占用的記憶體大小又和我們這次的主角FinalReference有着密不可分的關系。

對于FinalReference及關聯的内容,我們可能有如下印象:

自己代碼裡從沒有使用過

線程dump之後,我們能看到一個叫做Finalizer的java線程

偶爾能注意到java.lang.ref.Finalizer的存在

我們在類裡可能會寫finalize方法

那FinalReference到底存在的意義是什麼,以怎樣的形式和我們的代碼相關聯呢,這是本文要理清的問題。

JDK中的FinalReference

首先我們看看FinalReference在JDK裡的實作

JVM源碼分析之FinalReference完全解讀

大家應該注意到了類通路權限是package的,這也就意味着我們不能直接去對其進行擴充,但是JDK裡對此類進行了擴充實作java.lang.ref.Finalizer,這個類也是我們在概述裡提到的,而此類的通路權限也是package的,并且是final的,意味着真的不能被擴充了,接下來的重點我們圍繞java.lang.ref.Finalizer展開(PS:後續講Finalizer相關的其實也就是在說FinalReference)

JVM源碼分析之FinalReference完全解讀

Finalizer的構造函數

從構造函數上我們獲得下面的幾個關鍵資訊 private:意味着我們在外面無法自己建構這類對象 finalizee參數:FinalReference指向的對象引用 * 調用add方法:将目前對象插入到Finalizer對象鍊裡,鍊裡的對象和Finalizer類靜态相關聯,言外之意是在這個鍊裡的對象都無法被gc掉,除非将這種引用關系剝離掉(因為Finalizer類無法被unload)。

雖然外面無法建立Finalizer對象,但是注意到有一個register的靜态方法,在方法裡會建立這種對象,同時将這個對象加入到Finalizer對象鍊裡,這個方法是被vm調用的,那麼問題來了,vm在什麼情況下會調用這個方法呢?

Finalizer對象何時被注冊到Finalizer對象鍊裡

類其實有挺多的修飾,比如final,abstract,public等等,如果一個類有final修飾,我們就說這個類是一個final類,上面列的都是文法層面我們可以顯示标記的,在jvm裡其實還給類标記其他一些符号,比如finalizer,表示這個類是一個finalizer類(為了和java.lang.ref.Fianlizer類進行區分,下文要提到的finalizer類的地方都說成f類),gc在處理這種類的對象的時候要做一些特殊的處理,如在這個對象被回收之前會調用一下它的finalize方法。

如何判斷一個類是不是一個f類

在講這個問題之前,我們先來看下java.lang.Object裡的一個方法

JVM源碼分析之FinalReference完全解讀

在Object類裡定義了一個名為finalize的空方法,這意味着Java世界裡的所有類都會繼承這個方法,甚至可以覆寫該方法,并且根據方法覆寫原則,如果子類覆寫此方法,方法通路權限都是至少是protected級别的,這樣其子類就算沒有覆寫此方法也會繼承此方法。

而判斷目前類是否是一個f類的标準并不僅僅是目前類是否含有一個參數為空,傳回值為void的名為finalize的方法,而另外一個要求是finalize方法必須非空,是以我們的Object類雖然含有一個finalize方法,但是并不是一個f類,Object的對象在被gc回收的時候其實并不會去調用它的finalize方法。

需要注意的是我們的類在被加載過程中其實就已經被标記為是否為f類了(周遊所有方法,包括父類的方法,隻要有一個非空的參數為空傳回void的finalize方法就認為是一個f類)。

f類的對象何時傳到Finalizer.register方法

對象的建立其實是被拆分成多個步驟的,比如A a=new A(2)這樣一條語句對應的位元組碼如下:

JVM源碼分析之FinalReference完全解讀

先執行new配置設定好對象空間,然後再執行invokespecial調用構造函數,jvm裡其實可以讓使用者選擇在這兩個時機中的任意一個将目前對象傳遞給Finalizer.register方法來注冊到Finalizer對象鍊裡,這個選擇依賴于RegisterFinalizersAtInit這個vm參數是否被設定,預設值為true,也就是在調用構造函數傳回之前調用Finalizer.register方法,如果通過-XX:-RegisterFinalizersAtInit關閉了該參數,那将在對象空間配置設定好之後就将這個對象注冊進去。

另外需要提一點的是當我們通過clone的方式複制一個對象的時候,如果目前類是一個f類,那麼在clone完成的時候将調用Finalizer.register方法進行注冊。

hotspot如何實作f類對象在構造函數執行完畢後調用Finalizer.register

這個實作比較有意思,在這裡簡單提一下,我們知道一個構造函數執行的時候,會去調用父類的構造函數,主要是為了能對繼承自父類的屬性也能做初始化,那麼任何一個對象的初始化最終都會調用到Object的空構造函數裡(任何空的構造函數其實并不空,會含有三條位元組碼指令,如下代碼所示),為了不對所有的類的構造函數都做埋點調用Finalizer.register方法,hotspot的實作是在Object這個類在做初始化的時候将構造函數裡的return指令替換為_return_register_finalizer指令,該指令并不是标準的位元組碼指令,是hotspot擴充的指令,這樣在處理該指令的時候調用Finalizer.register方法,這樣就在侵入性很小的情況下完美地解決了這個問題。

JVM源碼分析之FinalReference完全解讀

f類對象的GC回收

FinalizerThread線程

在Finalizer類的clinit方法(靜态塊)裡我們看到它會建立了一個FinalizerThread的守護線程,這個線程的優先級并不是最高的,意味着在cpu很緊張的情況下其被排程的優先級可能會受到影響

JVM源碼分析之FinalReference完全解讀

這個線程主要就是從queue裡取Finalizer對象,然後執行該對象的runFinalizer方法,這個方法主要是将Finalizer對象從Finalizer對象鍊裡剝離出來,這樣意味着下次gc發生的時候就可能将其關聯的f對象gc掉了,最後将這個Finalizer對象關聯的f對象傳給了一個native方法invokeFinalizeMethod

JVM源碼分析之FinalReference完全解讀

其實invokeFinalizeMethod方法就是調了這個f對象的finalize方法,看到這裡大家應該恍然大悟了,整個過程都串起來了

JVM源碼分析之FinalReference完全解讀

f對象的finalize方法抛出異常會導緻FinalizeThread退出嗎

不知道大家有沒有想過如果f對象的finalize方法抛了一個沒捕獲的異常,這個FinalizerThread會不會退出呢,細心的讀者看上面的代碼其實就可以找到答案,在runFinalizer方法裡對Throwable的異常都進行了捕獲,是以不可能出現FinalizerThread因異常未捕獲而退出的情況。

f對象的finalize方法會執行多次嗎

如果我們在f對象的finalize方法裡重新将目前對象指派出去,變成可達對象,當這個f對象再次變成不可達的時候還會被執行finalize方法嗎?答案是否定的,因為在執行完第一次finalize方法之後,這個f對象已經和之前的Finalizer對象關系剝離了,也就是下次gc的時候不會再發現Finalizer對象指向該f對象了,自然也就不會調用這個f對象的finalize方法了。

Finalizer對象何時被放到ReferenceQueue裡

除了這裡要說的環節之外,整個過程大家應該都比較清楚了。

當gc發生的時候,gc算法會判斷f類對象是不是隻被Finalizer類引用(f類對象被Finalizer對象引用,然後放到Finalizer對象鍊裡),如果這個類僅僅被Finalizer對象引用的時候,說明這個對象在不久的将來會被回收了現在可以執行它的finalize方法了,于是會将這個Finalizer對象放到Finalizer類的ReferenceQueue裡,但是這個f類對象其實并沒有被回收,因為Finalizer這個類還對他們持有引用,在gc完成之前,jvm會調用ReferenceQueue裡的lock對象的notify方法(當ReferenceQueue為空的時候,FinalizerThread線程會調用ReferenceQueue的lock對象的wait方法直到被jvm喚醒),此時就會執行上面FinalizeThread線程裡看到的其他邏輯了。

Finalizer導緻的記憶體洩露

這裡舉一個簡單的例子,我們使用挺廣的socket通信,SocksSocketImpl的父類其實就實作了finalize方法:

JVM源碼分析之FinalReference完全解讀

其實這麼做的主要目的是萬一使用者忘記關閉socket了,那麼在這個對象被回收的時候能主動關閉socket來釋放一些系統資源,但是如果真的是使用者忘記關閉了,那這些socket對象可能因為FinalizeThread遲遲沒有執行到這些socket對象的finalize方法,而導緻記憶體洩露,這種問題我們碰到過多次,需要特别注意的是對于已經沒有地方引用的這些f對象,并不會在最近的那一次gc裡馬上回收掉,而是會延遲到下一個或者下幾個gc時才被回收,因為執行finalize方法的動作無法在gc過程中執行,萬一finalize方法執行很長呢,是以隻能在這個gc周期裡将這個垃圾對象重新标活,直到執行完finalize方法從queue裡删除,這樣下次gc的時候就真的是漂浮垃圾了會被回收,是以給大家的一個建議是千萬不要在運作期不斷建立f對象,不然會很悲劇。

Finalizer的客觀評價

上面的過程基本對Finalizer的實作細節進行完整剖析了,java裡我們看到有構造函數,但是并沒有看到析構函數一說,Finalizer其實是實作了析構函數的概念,我們在對象被回收前可以執行一些『收拾性』的邏輯,應該說是一個特殊場景的補充,但是這種概念的實作給我們的f對象生命周期以及gc等帶來了一些影響:

f對象因為Finalizer的引用而變成了一個臨時的強引用,即使沒有其他的強引用了,還是無法立即被回收

f對象至少經曆兩次GC才能被回收,因為隻有在FinalizerThread執行完了f對象的finalize方法的情況下才有可能被下次gc回收,而有可能期間已經經曆過多次gc了,但是一直還沒執行f對象的finalize方法

cpu資源比較稀缺的情況下FinalizerThread線程有可能因為優先級比較低而延遲執行f對象的finalize方法

因為f對象的finalize方法遲遲沒有執行,有可能會導緻大部分f對象進入到old分代,此時容易引發old分代的gc,甚至fullgc,gc暫停時間明顯變長

f對象的finalize方法被調用了,但是這個對象其實還并沒有被回收,雖然可能在不久的将來會被回收

轉載自PerfMa社群