原創內容,轉載請註明出處java
本文由一樁由於使用了JAVA finalize()而引起的血案入手,講解了JVM中finalize()的實現原理和它的陷阱所在,但願可以對廣大JAVA開發者起到一點警示做用。除此以外,本文從實際問題出發,描述瞭解決問題的過程和方法。如寫模擬程序來重現問題,使用jmap工具進行分析等,但願對你們提供借鑑。mysql
本文分三個章節,先介紹實際項目中遇到的問題,隨後介紹了問題重現和分析方法,最後對問題的元兇,override finalize()的實現原理和陷阱進行了講解和介紹。篇幅較長,能夠分開獨立閱讀。程序員
閱讀本文前請確保本身的JVM的GC原理有足夠理解,不然看起來會很是艱難。看過本文後若對finalize()仍有疑惑,或有不一樣意見,歡迎提出和指正。web
入職沒多久,接手一個分佈式數據庫(分庫分表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
爲了幫助你們理解,舉個例子,有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小程序來再現,並分析一下這個問題場景。
寫了個不能再簡單的程序來複現上述問題,代碼以下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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搞的一塌糊塗的。
相信有很大一部分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(),測試環境會很快發現問題,及時更改便可。