一、問題描述
上周三晚上主營出現(xiàn)部分設(shè)備掉線了,查看了日志之后發(fā)現(xiàn)是由于緩存system出現(xiàn)長時間gc導(dǎo)致的。這里的gc日志的特征是:
1.gc時間都在2秒以上,一部分節(jié)點甚至出現(xiàn)13s超長時間gc。
2.同一個節(jié)點距離上次gc時間間隔為普遍為12~15天。
隨后緊急把剩余未gc的一個節(jié)點內(nèi)存dump下來,使用mat工具打開后發(fā)現(xiàn),com.mysql.jdbc.NonRegisteringDriver 對象占了堆內(nèi)存的大部分空間。
經(jīng)過查看對象數(shù)量,發(fā)現(xiàn)com.mysql.jdbc.NonRegisteringDriver$ConnectionPhantomReference這個對象堆積了10140個啦。
經(jīng)過判斷長時間gc的問題是由于 com.mysql.jdbc.NonRegisteringDriver$ConnectionPhantomReference 這個對象大量堆積造成的。
二、問題分析
current正式環(huán)境使用數(shù)據(jù)庫依賴如下面:
依賴版本
mysql5.1.47
hikari2.7.9
Sharding-jdbc3.1.0
根據(jù)以上描述,提出以下問題:
1、com.mysql.jdbc.NonRegisteringDriver$ConnectionPhantomReference 到底是個啥對象呢?
2、這種對象為什么會造成大量堆積,JVM回收不過來了呢?
NonRegisteringDriver$ConnectionPhantomReference 到底是個什么對象呀?
簡單來說,NonRegisteringDriver類有個虛引用集合connectionPhantomRefs用于存儲所有的數(shù)據(jù)庫連接啊,NonRegisteringDriver.trackConnection方法負責(zé)把新創(chuàng)建的連接放入connectionPhantomRefs集合。源碼如下:
1.public class NonRegisteringDriver implements java.sql.Driver {?
2. ? protected static final ConcurrentHashMap<ConnectionPhantomReference, ConnectionPhantomReference> connectionPhantomRefs = new ConcurrentHashMap<ConnectionPhantomReference, ConnectionPhantomReference>();?
3. ? protected static final ReferenceQueue<ConnectionImpl> refQueue = new ReferenceQueue<ConnectionImpl>();
4. ?
5. ? ? ....?
6. ?
7. ? protected static void trackConnection(Connection newConn) {?
8. ?
9. ? ? ? ConnectionPhantomReference phantomRef = new ConnectionPhantomReference((ConnectionImpl) newConn, refQueue);?
10. ? ? ? ? connectionPhantomRefs.put(phantomRef, phantomRef);?
11. ? }?
12. ? ? ....?
13. }?
我們追蹤創(chuàng)建數(shù)據(jù)庫連接的中間源碼,發(fā)現(xiàn)其中會調(diào)到com.mysql.jdbc.ConnectionImpl構(gòu)造函數(shù),該方法會調(diào)用createNewIO方法創(chuàng)建一個新的數(shù)據(jù)庫連接MysqlIO對象之后,然后調(diào)用我們上面提到的NonRegisteringDriver.trackConnection方法,把該對象放入NonRegisteringDriver.connectionPhantomRefs集合中。源碼如下面:
1.public class ConnectionImpl extends ConnectionPropertiesImpl implements MySQLConnection {?
2. ?
3. ? public ConnectionImpl(String hostToConnectTo, int portToConnectTo, Properties info, String databaseToConnectTo, String url) throws SQLException {?
4. ? ? ? ? ...?
5. ? ? ? createNewIO(false);?
6. ? ? ? ? ...?
7. ? ? ? NonRegisteringDriver.trackConnection(this);?
8. ? ? ? ? ...?
9. ? }?
10.}
connectionPhantomRefs 是一個虛引用集合,什么是虛引用?為什么設(shè)計為虛引用隊列呢
虛引用隊列也稱為“幽靈引用”,它是最弱的一種引用關(guān)系。
如果一個對象僅持有虛引用,那么它就和沒有任何引用一樣,在任何時候都可能被垃 圾回收器回收。
為一個對象設(shè)置虛 引用關(guān)聯(lián)的唯一目的只是為了能在這個對象被收集器回收時收到一個系統(tǒng)通知。
當(dāng)垃圾回收器準(zhǔn)備回收一個對象時,如果發(fā)現(xiàn)它還有虛引用,就會在垃圾回收后,將這個虛引用加入引用隊列,在其關(guān)聯(lián)的虛引用出隊前,不會徹底銷毀該對象。所以可以通過檢查引用隊列中是否有相應(yīng)的虛引用來判斷對象是否已經(jīng)被回收了。
connectionPhantomRefs 這種對象為啥會大量堆積,JVM回收不過來了么?
我們先查閱hikaricp數(shù)據(jù)池的官網(wǎng)鏈接地址,看看部分屬性介紹如下所示:
maximumPoolSize
This property controls the maximum size that the pool is allowed to reach, including both idle and in-use connections. Basically this value will determine the maximum number of actual connections to the database backend. A reasonable value for this is best determined by your execution environment. When the pool reaches this size, and no idle connections are available, calls to getConnection() will block for up to connectionTimeout milliseconds before timing out. Please read about pool sizing. Default: 10
maximumPoolSize控制最大連接數(shù),默認為10
minimumIdle
This property controls the minimum number of idle connections that HikariCP tries to maintain in the pool. If the idle connections dip below this value and total connections in the pool are less than maximumPoolSize, HikariCP will make a best effort to add additional connections quickly and efficiently. However, for maximum performance and responsiveness to spike demands, we recommend not setting this value and instead allowing HikariCP to act as a fixed size connection pool. Default: same as maximumPoolSize
minimumIdle控制最小連接數(shù),默認等同于maximumPoolSize,10。
?idleTimeout
This property controls the maximum amount of time that a connection is allowed to sit idle in the pool. This setting only applies when minimumIdle is defined to be less than maximumPoolSize. Idle connections will not be retired once the pool reaches minimumIdle connections. Whether a connection is retired as idle or not is subject to a maximum variation of +30 seconds, and average variation of +15 seconds. A connection will never be retired as idle before this timeout. A value of 0 means that idle connections are never removed from the pool. The minimum allowed value is 10000ms (10 seconds). Default: 600000 (10 minutes)
連接空閑時間超過idleTimeout(默認10分鐘)后,連接會被拋棄
?maxLifetime
This property controls the maximum lifetime of a connection in the pool. An in-use connection will never be retired, only when it is closed will it then be removed. On a connection-by-connection basis, minor negative attenuation is applied to avoid mass-extinction in the pool. We strongly recommend setting this value, and it should be several seconds shorter than any database or infrastructure imposed connection time limit. A value of 0 indicates no maximum lifetime (infinite lifetime), subject of course to the idleTimeout setting. Default: 1800000 (30 minutes)
連接生存時間超過 maxLifetime(默認30分鐘)后,連接會被拋棄.
我們再回頭看看項目的hikari配置:
配置了minimumIdle = 10,maximumPoolSize = 50,沒有配置idleTimeout和maxLifetime。所以這兩項會使用默認值 idleTimeout = 10分鐘,maxLifetime = 30分鐘。
也就是說假如數(shù)據(jù)庫連接池已滿,有50個連接,假如系統(tǒng)空閑,40個連接會在10分鐘后(超過idleTimeout)被廢棄;假如系統(tǒng)一直繁忙,50個連接會在30分鐘后(超過maxLifetime)后被廢棄。
猜測問題產(chǎn)生的根源:
每次新建一個數(shù)據(jù)庫連接,都會把該連接放入connectionPhantomRefs集合中。數(shù)據(jù)連接在空閑時間超過idleTimeout或生存時間超過maxLifetime后會被廢棄,在connectionPhantomRefs集合中等待回收。因為連接資源一般存活時間比較久,經(jīng)過多次Young GC,一般都能存活到老年代。如果這個數(shù)據(jù)庫連接對象本身在老年代,connectionPhantomRefs中的元素就會一直堆積,直到下次 full gc。如果等到full gc 的時候connectionPhantomRefs集合的元素非常多,該次full gc就會非常耗時。
那么怎么解決呢?可以考慮優(yōu)化minimumIdle、maximumPoolSize、idleTimeout、maxLifetime這些參數(shù),下一小節(jié)我們分析一波
三、問題驗證
線上模擬環(huán)境
為了驗證問題,我們需要模擬線上環(huán)境,調(diào)整maxLifetime等參數(shù)~壓測思路如下:
1.緩存系統(tǒng)模擬線上的配置,使用壓測系統(tǒng)一段時間內(nèi)持續(xù)壓緩存系統(tǒng),使緩存系統(tǒng)短時間創(chuàng)建/廢棄大量數(shù)據(jù)庫連接,觀察 NonRegisteringDriver 對象是否如期大量堆積,再手動調(diào)用 System.gc() 觀察 NonRegisteringDriver 對象是否被清理。
2.調(diào)整maxLifetime 參數(shù),觀察相同的壓測時間內(nèi) NonRegisteringDriver 對象是否還發(fā)生堆積。
這里有以下注意點:
1、 要滿足 (gc 間隔時間 * 新生代進入老年代前的存活次數(shù) < maxLifetime)這個條件,NonRegisteringDriver 對象才滿足進入老年代的條件。
2、 minimumIdle = 10,maximumPoolSize = 50(minimumIdle和maximumPoolSize和線上配置一致),idleTimeout設(shè)置10s,maxLifetime設(shè) 100s(gc時間約20s,所以要大于 20 * 3 = 60s)。這樣預(yù)計在持續(xù)壓測下每30s就會產(chǎn)生10個新連接(就算設(shè)置了maximumPoolSize = 50,這種程序的壓測10個連接足以應(yīng)付)
3、 項目內(nèi)存分配小一點,以及把新生代進入老年代前的存活次數(shù)調(diào)小一點,方便新生代的NonRegisteringDriver對象在較短時間能進入老年代,方便在較短時間觀察到明顯的對象增長。
4、 要監(jiān)測緩存系統(tǒng)數(shù)據(jù)連接池的連接存活情況,以及系統(tǒng) gc情況。
最終環(huán)境配置如下:
模擬實驗結(jié)果
啟用jvisualvm工具對緩存系統(tǒng)進行實時觀察
打開hikari相關(guān)debug日志觀察連接池情況
設(shè)置 maxLifetime = 100s,啟動緩存系統(tǒng)
確認hikari和jvm配置生效
觀察jvisualvm,發(fā)現(xiàn)產(chǎn)生20個NonRegisteringDriver 對象
觀察 hikari日志,確認有20個連接對象生成,以及產(chǎn)生總連接10個,空閑連接10個。
?初步判斷一個數(shù)據(jù)庫連接會生成兩個 NonRegisteringDriver 對象。
啟動壓測程序,壓測1000s
期間觀察gc日志,gc時間間隔約20s,100s后發(fā)生5次 gc
觀察 hikari日志,確認有20個連接對象生成
觀察jvisualvm變成 40個 NonRegisteringDriver 對象,符合預(yù)期。
持續(xù)觀察,1000s后理論上會產(chǎn)生220個對象(20 + 20 * 1000s / 100s),查看 jvisualvm 如下
產(chǎn)生了240個對象,基本和預(yù)期符合。
實驗結(jié)果分析
再結(jié)合我們生產(chǎn)的問題,假設(shè)我們每天14個小時高峰期(12:00 ~ 凌晨2:00),期間連接數(shù)20,10個小時低峰期,期間連接數(shù)10,每次 full gc 間隔14天,等到下次 full gc 堆積的 NonRegisteringDriver 對象為 (20 * 14 + 10 * 10) * 2 * 14 = 10640,與問題dump里面NonRegisteringDriver對象的數(shù)量10140 個基本吻合。
至此問題根源已經(jīng)得到完全確認!??!
四、問題解決方案
由上面分析可知,問題產(chǎn)生的廢棄的數(shù)據(jù)庫連接對象堆積,最終導(dǎo)致 full gc 時間過長。所以我們可以從以下方面思考解決方案:
1、減少廢棄的數(shù)據(jù)連接對象的產(chǎn)生和堆積。
2、優(yōu)化full gc時間.
【調(diào)整hikari參數(shù)】
我們可以考慮設(shè)置 maxLifetime 為一個較大的值,用于延長連接的生命周期,減少產(chǎn)生被廢棄的數(shù)據(jù)庫連接的頻率,等到下次 full gc 的時候需要清理的數(shù)據(jù)庫連接對象會大大減少。
Hikari 推薦 maxLifetime 設(shè)置為比數(shù)據(jù)庫的 wait_timeout 時間少 30s 到 1min。如果你使用的是 mysql 數(shù)據(jù)庫,可以使用 show global variables like '%timeout%'; 查看 wait_timeout,默認為 8 小時。
下面開始驗證,設(shè)置maxLifetime = 1小時,其他條件不變。壓測啟動前觀察jvisualvm,NonRegisteringDriver 對象數(shù)量為20
?1000s,觀察 NonRegisteringDriver 對象仍然為20
?NonRegisteringDriver 對象沒有發(fā)生堆積,問題得到解決。
同時另外注意:minimumIdle和maximumPoolSize不要設(shè)置得太大,一般來說配置minimumIdle=10,maximumPoolSize=10~20即可。
【使用G1回收器】
G1回收器是目前java垃圾回收器的最新成果,是一款低延遲高吞吐的優(yōu)秀回收器,用戶可以自定義最大暫停時間目標(biāo),G1會盡可能在達到高吞吐量同時滿足垃圾收集暫停時間目標(biāo)。
下面開始驗證G1回收器的實用性,該驗證過程需要一段較長時間的觀察,同時借助鏈路追蹤工具skywalking。最終觀察了10天,結(jié)果圖如下: 使用G1回收器,部分jvm參數(shù)-Xms3G -Xmx3G -XX:+UseG1GC
使用java 8默認的Parallel GC回收器組合,部分jvm參數(shù)-Xms3G -Xmx3G
以上圖中四個內(nèi)容,從左到右分別為
1、堆內(nèi)存,分為已使用和空閑內(nèi)存。
2、方法區(qū)內(nèi)存,這個不需要關(guān)注
3、young gc和full gc時間
4、程序啟動以后young gc和full gc次數(shù)
我們可以看到使用Parallel GC回收器組合的服務(wù)消耗的內(nèi)存速度較快,發(fā)生了6996次young gc且發(fā)生了一次full gc,full gc時間長達5s。另外一組使用G1回收器的服務(wù)消耗內(nèi)存速度較為平穩(wěn),只發(fā)生3827次young gc且沒有發(fā)生full gc。由此可以看到G1回收器確實可以用來解決我們的數(shù)據(jù)庫連接對象堆積問題。
【建立巡查系統(tǒng)】
這個我們目前還沒有經(jīng)過實踐,但是根據(jù)上面分析結(jié)果判斷,定期觸發(fā)full gc可以達到每次清理少量堆積的數(shù)據(jù)庫連接的作用,避免過多數(shù)據(jù)庫連接一直堆積。采用該方法需要對業(yè)務(wù)的內(nèi)容和高低峰周期非常熟悉。實現(xiàn)思路參考如下:
1、創(chuàng)建java程序,使用定時任務(wù)定期調(diào)用System.gc()。該方法的缺點是即使手動調(diào)用了System.gc(),jvm不一定會立刻開始回收工作,有可能會根據(jù)它本身的算法,自行選擇最優(yōu)時間才開始進行回收工作。
2、創(chuàng)建shell腳本調(diào)用jmap -dump:live,file=dump_001.bin PID,使用linux的crontab任務(wù)保證定時執(zhí)行,執(zhí)行完后再把dump_001.bin刪掉即可。該方法能保證一定發(fā)生full gc,缺點是功能過于單一零散,不好集中管理。
五、總結(jié)
我們這次問題產(chǎn)生的根源是數(shù)據(jù)庫連接對象堆積,導(dǎo)致full gc時間過長。解決思路可以從以下三點入手:
1、調(diào)整hikari配置參數(shù)。例如把maxLifetime設(shè)置為較大的值(比數(shù)據(jù)庫的wait_timeout少30s),minimumIdle和maximumPoolSize值不能設(shè)置太大,或者直接采用默認值即可。
2、采用G1垃圾回收器。
3、建立巡查系統(tǒng),在業(yè)務(wù)低峰期主動觸發(fā)full gc。
個人公眾號