JVM finalize實現原理與由此引發的血案
JVM finalize實現原理與由此引發的血案 還是不要重寫finalize的好,沒有析構函數那樣明確的語義,調用時間由JVM確定,一個對象的生命周期中只會調用一次,拉長了對象生命周期,拖慢GC速度,增加了OOM風險
本 文由一樁因為使用了JAVA finalize()而引發的血案入手,講解了JVM中finalize()的實現原理和它的陷阱所在,希望能夠對廣大JAVA開發者起到一點警示作用。 除此之外,本文從實際問題出發,描述了解決問題的過程和方法。如寫模擬程序來重現問題,使用jmap工具進行分析等,希望對大家提供借鑒。
本文分三個章節,先介紹實際項目中遇到的問題,隨后介紹了問題重現和分析方法,最后對問題的元兇,override finalize()的實現原理和陷阱進行了講解和介紹。篇幅較長,可以分開獨立閱讀。
閱讀本文前請確保自己的JVM的GC原理有足夠理解,否則看起來會非常艱難。看過本文后若對finalize()仍有疑惑,或有不同意見,歡迎提出和指正。
DDB Proxy的一樁血案
入 職沒多久,接手一個分布式數據庫(分庫分表MySQL)的SQL Proxy(以下簡稱Proxy),在對它進行測試的過程張發現一些帶limit的語句跑著跑著會引發整個代理服務器的TPS驟降。當時遇到這個問題毫無 頭緒,跟著一些人的建議檢查了GC日志,結果發現TPS驟降的時候,Proxy進程正好開始頻繁的full gc,full gc將近每3秒一次,一次維持2秒左右,TPS從2000直接降到100,考慮到GC基本把JVM進程的資源吃光了,這種現象也可以理解(Proxy的 GC參數見下面的血案再現,除了Proxy新生代為1G,老生代也為1G外,其他都一樣)。
之后開始追查頻繁full gc的原因,經過團隊長時間奮戰,最后把問題定位在Proxy Server對大數據結果集的處理對策上。
為了幫助大家理解,舉個例子,有SQL如下:
select titile, content from blog where user_id = 1001 limit 10;
顯示用戶1001第一頁博客列表
因 為Proxy Server后端是一個分庫分表的MySQL集群,它在接到這個SQL請求時,會先把這個SQL下發給所有數據節點,假如集群中有10個MySQL數據節 點,那么會從所有數據節點中返回最多100行數據,然而應用SQL需要的僅僅是10行數據,因此100行數據中90行數據是要丟棄的。雖然在這條SQL 中,90行數據看起來沒什么,但如果集群中有100個節點,limit改為100呢?就需要丟棄9900條數據,而且在常規做法中,要先將10000行數 據載入內存里。倘若limit 100后面再加個offset 1000,如:
select sender_id, msg from message where user_id = 1001 limit 100 offset 1000
顯示用戶1001第10頁消息列表,每頁100條消息
對Proxy Server的內存來說將是一場災難(對分布式執行計劃,offet的數值是要累加到limit中下發給數據節點的)。
為 了避免OOM,Proxy采用了MySQL提供的流結果集機制(詳情谷歌MySQL stream resultset),在這種機制下,MySQL結果集是在調用ResultSet.next()方法是一行行(流水一樣)載入內存的,一般情況下在后面 幾行被載入時前面的數據行就可以被GC了,由此避免了OOM。但是這種機制下還存在另一個問題:拿之前的SQL來說,在得到最終的10行數據 后,Proxy需要丟棄多余的90行數據,而這個丟棄的前提是先把它們讀進內存,因為流數據沒讀完的連接是不可用的(MySQL實現流結果集的機制是一邊 獲得結果集一邊向Client傳輸,因此在流數據沒有讀完前,MySQL對應的連接線程可能還處于忙碌狀態),也就是說,如果不把剩余的90行數據讀進內 存,而直接把連接放回物理連接池,當這些連接被再利用時會向Proxy拋出“stream resultset is still alive”的異常。但是從設計層面講,“讀完”連接中多余的數據是毫無意義的,如果多余的數據有上百萬行,那將是件極其痛苦的事情。為此,Proxy的 設計先驅們想了一個辦法:當一個到數據節點的物理連接中含有多余流數據時,直接關掉。下個SQL請求向連接池申請連接時可以通過創建新連接來彌補不足。
這個方案看起來極其美好,既不會內存溢出,也不會因為讀多余的流數據而影響QPS。
然后我就在性能測試中發現了這個嚴重的full gc問題。
這個問題我是從結論開始講的,認真看下來的朋友應該已經猜到了各種緣由,沒錯,正是因為Proxy采用了當物理連接中含有多余流數據時選擇關連接,而放棄重用,導致了內存資源被快速耗盡,并引發了頻繁full gc。
雖 然現在可以很輕松的說出這個結論,但當時往這個方向想卻費了我很大的周折,試想測試中Proxy的QPS也就2000不到,測試的客戶端并發線程不過10 個,JVM的GC時間和效率取決于GC那STW的一會內存中垃圾所占比重,從原理上講,10個客戶端線程頂多也就10個Connection對象是活躍 的,其他Connection對象都可以被回收,而且每秒2000個對象也不能稱之為多,所以GC時首先觸發的minor gc效率應該很高,因為它僅僅是將活躍的對象拷貝出去,把剩余的整塊內存重利用而已。然而測試中我們發現minor gc時所拷貝的活躍對象遠遠超出了預期:1G的新生代,Survivor區域設置為100m,因此每次最多往Survivor拷貝100m活躍對象,多余 的活躍對象會直接晉升老年代。在我們的測試中,每次minor gc除了拷貝100m活躍對象外,還會有幾十m的對象往老年代晉升,這樣每次minor gc都要花秒級時間,而且過不了多久就會因為老年代撐滿觸發full gc,而full gc時能夠回收的對象又很少,以至于進入一個惡性循環。
現有原理上說不通的事情,最好的辦法就是先用小程序模擬場景,再做細致分析。于是我用一個簡單的JDBC小程序模擬了不斷關連接,申請新連接的操作,結果真的復現了頻繁full gc問題。
無 論如何還是要先解決問題,在把“當物理連接中含有多余流數據時選擇關連接,而放棄重用”的機制改為“讀完多余流數據后,放回連接池重用”,Proxy的 QPS終于穩定下來,查看GC日志,每次minor gc僅拷貝7-10m數據,耗時個位數ms,連續跑2天沒有發生full gc。
于 是最后解決方案就是“讀完多余流數據后,放回連接池重用”,當然讀完流數據是有開銷的,在測試程序中都是limit 10到100的SELECT用例,沒有offset,所以影響甚微。我們也在Proxy的開發者白皮書中建議用戶不要寫過大的limit,盡量不要使用 offset。
到此雖然問題解決,但是究竟什么原因導致了頻繁full gc和gc時間過長,還一頭霧水,接下來我們通過一個小JDBC小程序來再現,并分析一下這個問題場景。
血案再現與分析
寫了個不能再簡單的程序來復現上述問題,代碼如下:
public static void main(String[] args)
throws ClassNotFoundException, InterruptedException {
Class.forName("com.mysql.jdbc.Driver");
final String url = "jdbc:mysql://127.0.0.1:3306";
final String user = "majin";
final String pwd = "123456";
for (int i = 0; i < 5; i++) {
new Thread() {
public void run() {
java.sql.Connection con = null;
while (true) {
try {
con = DriverManager.getConnection(url, user, pwd);
con.createStatement().executeQuery("select 1");
Thread.sleep(200);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (con != null)
try {
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}.start();
}
} 程 序中有10個線程,每個線程循環進行建立連接,執行select 1,釋放連接的操作,為了防止socket被快速耗盡,在釋放連接后sleep 200ms。GC算法與Proxy保持一致采用CMS,設置新生代100m,老生代100m,survivor大小為默認的新生代1/8。另外JDBC Connector/J采用了5.0.8版本(因為之前Proxy使用的是這個老版本,用的還是JDK1.5):
-Xmn100m -Xmx200m -Xms200m -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=85 -XX:+CMSParallelRemarkEnabled -XX:+UseConcMarkSweepGC
從gc日志看出,full gc平均30s一次,截取minor gc日志如下:
|
1 2 3 |
81.049: [GC 81.049: [ParNew: 92159K->10239K(92160K), 0.0878585 secs] 113662K->48935K(194560K), 0.0879499 secs] 89.204: [GC 89.204: [ParNew: 92159K->10239K(92160K), 0.0963608 secs] 130855K->66922K(194560K), 0.0964569 secs] 97.368: [GC 97.368: [ParNew: 92159K->10240K(92160K), 0.0977226 secs] 148842K->85149K(194560K), 0.0978146 secs] |
可 以看出minor gc時間幾乎在百ms級別(如果是1G新生代可能就是秒級別了),很不理想,30s一次full gc也無法令人接受。問題既然已經復現,現在就要尋找分析問題的手段,首先想到的是用jmap命令打印出程序中大概的對象分布。但是發現jmap不支持 JDK1.5。于是將程序的依賴包改為Connector/j 5.1.27和JDK1.6。再測發現full gc的平均間隔從30s延長到了50s,另外minor gc的時間也降到50ms左右,看來1.6的JVM在GC算法上有非常明顯的進步。
用jmap -histo pid得到內存活躍對象列表后,有兩類對象引起了我的注意:
1695 1979760 com.mysql.jdbc.JDBC4Connection
3963 158520 java.lang.ref.Finalizer
第 一個JDBC4Connection是Connection/J中的Connection實例,在jmap的那一瞬間,內存中有1695個 Connection,做個簡答的計算:minor gc的間隔平均8s,8s內10個線程最多產生的Connection數目為1000*8*10/200(假設建立連接,select 1,釋放連接的時間為0,僅除以200ms的間隔),400個。而jmap的結果卻有1695個,而且這個還不是峰值。這個現象足以說明一部分Connection對象在被清除引用后,沒有在第一次minor gc被回收。
第 二個Finalizer對象讓我想到了JAVA中的finalize()方法,我知道override finalize()的對象在被回收以前一定會被調用finalize()以做一些清理工作,但這個實現機制不了解。于是做了一些調研,然后就有了這篇博 客。沒錯,override finalize()就是罪魁禍首,讓我們看看JDBC Connector/J中這個萬惡的存在:
|
1 2 3 |
protected void finalize() throws Throwable { cleanup(null); } |
可 以看到finalize()中僅僅調用了cleanup(null),而cleanup()也是close()方法中的主要邏輯,也就是說 finalize()這里做的工作僅僅是確保Connection對象在被回收前釋放它占有的資源,如果程序中已經調用了 Connection.close(),這個確保可謂是沒有意義的。
嘗試把Connection/J中的finalize()源碼注釋掉,再運行測試程序,結果出乎意料地好,full gc消失了(在有限的測試時間內),截取部分minor gc日志如下:
|
1 2 3 |
74.150: [GC 74.150: [ParNew: 83047K->1223K(92160K), 0.0017912 secs] 83805K->1981K(194560K), 0.0018810 secs] 82.372: [GC 82.372: [ParNew: 83143K->1072K(92160K), 0.0038011 secs] 83901K->1830K(194560K), 0.0038968 secs] 90.455: [GC 90.455: [ParNew: 82992K->1097K(92160K), 0.0024273 secs] 83750K->1855K(194560K), 0.0025451 secs] |
可 以看到minor gc的代價有了質的下降,修改源碼前每次minor gc需要拷貝20m的數據,其中10m是直接晉升老年代的。而去掉finalize()方法后,每次minor gc僅拷貝1m數據,且gc時間從百ms級別降到了5ms以下。可見finalize()的影響之大。
接下來我們看看override finalize()到底是怎樣把GC搞的一塌糊涂的。
Finalize實現原理與代價
相 信有很大一部分JAVA程序員是從C/C++開始的(在我印象里,本科必修課程沒有JAVA),而JAVA在基本語義與C++保持一致的基礎上,其更加面 向對象,類型安全,RTTI等特性使大部分用慣了CC++的程序員對JAVA愛不釋手,然而習慣于C++的程序員不可避免地會在JAVA中尋找C++的影 子,其中最典型的就是析構函數問題。
我 們說JAVA在基本語義與C++保持一致,并不是說C++的所有特性JAVA都會具有,相反,對于一些繁瑣的、有風險的動作,JAVA會把他們隱藏在 JVM的實現細節中,指針的事情大家都是知道的,OK,這里我們就談談C++的析構函數與JAVA的finalize()。
首 先在JAVA看來,析構函數本身是不應該存在的,或者說其存在本身就帶來了一定的風險,因為機器永遠比程序員清楚一個對象什么時候該析構,為什么這么說 呢?假設在程序員A的代碼中構造了個對象O1,程序員A往往無法保證這個對象O1會在自己的代碼片段中析構,那么他能做的就是寫各種各樣的manual或 者與接口開發者溝通,告訴他們哪些對象必須及時析構才不會造成內存泄露,即便程序員A的代碼能夠覆蓋對象O1的所有生命周期,也不能保證他不會在各種各樣 的析構場景下犯錯誤,那我們換個角度考慮,對象O1什么時候需要被析構?當前僅當O1不被任何其他對象需要的情況下,也就是不被任何其他對象引用的時候, 而對象之間的引用關系,程序本身是再清楚不過的了。
基 于上述的考慮,JAVA不為開發者提供析構函數,對象的析構由JVM中的GC線程根據對象間的引用關系決定,但是聰明人會發現,剛才我們僅僅討論的是析構 的時機問題,對于一些對象,在業務層面存在析構的需求,如一些文件描述符,數據庫連接資源,需要在對象被回收之前被釋放,C++的話會把這些邏輯果斷放入 析構函數中,但是JAVA是沒有析構函數的,那我們要怎樣確保對象回收前一些業務邏輯一定執行呢?這就是JAVA finalize()方法能夠解決的問題了。
對finalize()的一句話概括:JVM能夠保證一個對象在回收以前一定會調用一次它的finalize()方法。這句話中兩個陷阱:回收以前一定和一次,這里先請大家記住這句話,后面會結合JVM的實現來解釋。
OK,相信了解過finalize()的人或多或少有個印象:finalize()就是JAVA中的析構函數,或者說finalize()是對析構函數的一種妥協。這其實是個危險的誤會, 因為析構函數是構造函數的逆向過程,當程序員調用析構函數時,析構過程是同步透明的,然而對finalize(),你永遠不知道它什么時候被調用甚至會不 會調用(因為有些對象是永遠不會被回收的,或者被回收以前程序就結束了),其次,finalize()是非必要的,看完這篇文章,你甚至會發現它是不被建 議的,而對需要析構函數的語言,程序沒了它寸步難行。
所以如果一定要給finalize()一個定位,應該說它是JAVA給懶惰的開發者的一個小福利 :)。而且請大家牢牢記住一點,JAVA中的福利往往伴隨著風險和性能開銷,finalize()尤其如此。
廢話說了這么多,現在來看看SUN JVM是怎么實現finalize()機制的。在看以下內容前,請確保自己對JVM GC機制足夠了解。
先看沒有自定義finalize()的對象是怎么被GC回收的:
沒有自定義finalize()的對象的minor gc
如 上圖所示:對象在新生代eden區域創建,在eden滿了之后會發生一次minor gc,minor gc會將新生代中所有活躍對象(被其他對象引用)從eden+s0/s1區域拷貝到s1/s0,這里我們不考慮GC線程是怎樣遍歷heap數據以將新生代 中活躍的數據找出來的(實際上就是root tracing,通過card table加速),因為這樣講起來會成為另外一個故事,我們這里需要知道的就是minor gc非常快,因為它只會把新生代中非常少量的數據(一般<1%)拷貝到另外一個地方罷了。
我們現在來看一下自定義了(override)finalize()的對象(或是某個父類override finalize())是怎樣被GC回收的,首先需要注意的是,含有override finalize()的對象A創建要經歷以下3個步驟:
-
創建對象A實例
-
創建java.lang.ref.Finalizer對象實例F1,F1指向A和一個reference queue
(引用關系,F1—>A,F1—>ReferenceQueue,ReferenceQueue的作用先賣個關子) -
使java.lang.ref.Finalizer的類對象引用F1
(這樣可以保持F1永遠不會被回收,除非解除Finalizer的類對象對F1的引用)
經過上述三個步驟,我們建立了這樣的一個引用關系:
java.lang.ref.Finalizer–>F1–>A,F1–>ReferenceQueue。GC過程如下所示:
有override finalize()對象的minor gc
如 上圖所示,在發生minor gc時,即便一個對象A不被任何其他對象引用,只要它含有override finalize(),就會最終被java.lang.ref.Finalizer類的一個對象F1引用,等等,如果新生代的對象都含有override finalize(),那豈不是無法GC?沒錯,這就是finalize()的第一個風險所在,對于剛才說的情況,minor gc會把所有活躍對象以及被java.lang.ref.Finalizer類對象引用的(實際)垃圾對象拷貝到下一個survivor區域,如果拷貝溢 出,就將溢出的數據晉升到老年代,極端情況下,老年代的容量會被迅速填滿,于是讓人頭痛的full gc就離我們不遠了。
那 么含有override finalize()的對象什么時候被GC呢?例如對象A,當第一次minor gc中發現一個對象只被java.lang.ref.Finalizer類對象引用時,GC線程會把指向對象A的Finalizer對象F1塞入F1所引 用的ReferenceQueue中,java.lang.ref.Finalizer類對象中包含了一個運行級別很低的deamon線程 finalizer來異步地調用這些對象的finalize()方法,調用完之后,java.lang.ref.Finalizer類對象會清除自己對 F1的引用。這樣GC線程就可以在下一次minor gc時將對象A回收掉。
也就是說一次minor gc中實際至少包含兩個操作:
-
將活躍對象拷貝到survivor區域中
-
以Finalizer類對象為根,遍歷所有Finalizer對象,將只被Finalizer對象引用的對象(對應的Finalizer對象)塞入Finalizer的ReferenceQueue中
可見Finalizer對象的多少也會直接影響minor gc的快慢。
包含有自定義finalizer方法的對象回收過程總結下來,有以下三個風險:
-
如果隨便一個finalize()拋出一個異常,finallize線程會終止,很快地會由于f queue的不斷增長導致OOM
-
finalizer線程運行級別很低,有可能出現finalize速度跟不上對象創建速度,最終可能還是會OOM,實際應用中一般會有富裕的CPU時間,所以這種OOM情況可能不太常出現
-
含有override finalize()的對象至少要經歷兩次GC才能被回收,嚴重拖慢GC速度,運氣不好的話直接晉升到老年代,可能會造成頻繁的full gc,進而影響這個系統的性能和吞吐率。
以上的三點還沒有考慮minor gc時為了分辨哪些對象只被java.lang.ref.Finalizer類對象引用的開銷,講完了finalize()原理,我們回頭看看最初的那句話:JVM能夠保證一個對象在回收以前一定會調用一次它的finalize()方法。
含 有override finalize()的對象在會收前必然會進入F QUEUE,但是JVM本身無法保證一個對象什么時候被回收,因為GC的觸發條件是需要GC,所以JVM方法不保證finalize()的調用點,如果對 象一直不被回收,就一直不調用,而調用了finalize(),也不代表對象就被回收了,只有到了下一次GC時該對象才能真正被回收。另外一個關鍵點是一 次,在調用過一次對象A的finalize()之后,就解除了Finalizer類對象和對象F1之間的引用關系,如果在finalize()中又將對象 本身重新賦給另外一個引用(對象拯救),那這個對象在真正被GC前是不會再次調用finalize()的。
總結一下finalize()的兩個個問題:
-
沒有析構函數那樣明確的語義,調用時間由JVM確定,一個對象的生命周期中只會調用一次
-
拉長了對象生命周期,拖慢GC速度,增加了OOM風險
回 到最初的問題,對于那些需要釋放資源的操作,我們應該怎么辦?effective java告訴我們,最好的做法是提供close()方法,并且告知上層應用在不需要該對象時一掉要調用這類接口,可以簡單的理解這類接口充當了析構函數。 當然,在某些特定場景下,finalize()還是非常有用的,例如實現一個native對象的伙伴對象,這種伙伴對象提供一個類似close()接口可 能不太方便,或者語義上不夠友好,可以在finalize()中去做native對象的析構。不過還是那句話,fianlize()永遠不是必須的,千萬 不要把它當做析構函數,對于一個對性能有相當要求的應用或服務,從一開始就杜絕使用finalize()是最好的選擇。
總結
override finalize()的主要風險在于Finalizer的Deamon線程運行的是否夠快,它本身是個級別較低的線程,若應用程序中CPU資源吃緊,很可 能出現Finalizer線程速度趕不上新對象產生的速度,如果出現這種情況,那程序很快會朝著“GC搞死你”的方向發展。當然,如果能確保CPU的性能 足夠好,以及應用程序的邏輯足夠簡單,是不用擔心這個問題的。例如那個再現問題的小程序,在我自己i7的筆記本上跑,就沒有任何GC問題,CPU占用率從 未超過25%(硬件上的東西不太懂,為什么差距會這么多?),出現問題的是在我的辦公機上,CPU使用率維持在90%左右。
當 然,互聯網應用,誰能保障自己的服務器在高峰期不會資源吃緊?無論如何,我們都需要慎重使用override finalize()。至于JDBC Connector/J中應不應該override finalize(),出于保險考慮,我認為是應該的,但若是公司內部服務,例如網易DDB實現的JDBC DBI(分布式JDBC),Connection完全沒必要做這層考慮,如果應用程序忘了調close(),測試環境會很快發現問題,及時更改即可。

