我方有一應用,偶爾會出現GC時間過長(間隔約4小時),導致性能波動的問題(介面最長需要耗時3秒以上)。經排查為G1垃圾回收器參數配置不當 疊加 MySQL 鏈接超過閑置時間回收,產生大量的虛引用,導致G1在執行老年代混合GC,標記階段耗時過長導致。以下為對此問題的分析及問題總結。 ...
1. 背景
我方有一應用,偶爾會出現GC時間過長(間隔約4小時),導致性能波動的問題(介面最長需要耗時3秒以上)。經排查為G1垃圾回收器參數配置不當 疊加 MySQL 鏈接超過閑置時間回收,產生大量的虛引用,導致G1在執行老年代混合GC,標記階段耗時過長導致。以下為對此問題的分析及問題總結。
此外,此應用因為使用redis緩存為資料庫緩存一段時間的熱點數據,導致業務起量創建資料庫鏈接後,會很容易被閑置,容易被資料庫連接池判定為閑置過長而清理。
應用背景
JDK1.8 , mysql-connector-java-5.1.30, commons-dbcp-1.4, spring-4.3.20.RELEASE
硬體:8核16GB;
JVM啟動參數概要:
-Xms9984m -Xmx9984m -XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=512m -XX:MaxDirectMemorySize=512m -XX:ParallelGCThreads=8 -XX:CICompilerCount=4 -XX:ConcGCThreads=4 -server -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1HeapRegionSize=32M -XX:SurvivorRatio=10 -XX:MaxTenuringThreshold=5 -XX:InitiatingHeapOccupancyPercent=45 -XX:G1ReservePercent=20 -XX:G1MixedGCLiveThresholdPercent=80 -XX:MaxGCPauseMillis=100 -XX:+ExplicitGCInvokesConcurrent -XX:+PrintGCDetails -XX:+PrintReferenceGC -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:/export/Logs/app/tomcat7-gc.log
dbcp配置:
maxActive=20
initialSize=10
maxIdle=10
minIdle=5
minEvictableIdleTimeMillis=180000
timeBetweenEvictionRunsMillis=20000
validationQuery=select 1
配置說明:
maxActive=20
: 連接池中最多能夠同時存在的連接數,配置為20。- `initialSize=10: 數據源初始化時創建的連接數,配置為10。
maxIdle=10
: 連接池中最大空閑連接數,也就是說當連接池中的連接數超過10個時,多餘的連接將會被釋放,配置為10。minIdle=5
: 連接池中最小空閑連接數,也就是說當連接池中的連接數少於5個時,會自動添加新的連接到連接池中,配置為5。- `minEvictableIdleTimeMillis=180000: 連接在連接池中最小空閑時間,這裡配置為3分鐘,表示連接池中的連接在空閑3分鐘之後將會被移除,避免長時間占用資源。
timeBetweenEvictionRunsMillis=20000
: 連接池中維護線程的運行時間間隔,單位毫秒。這裡配置為20秒,表示連接池中會每隔20秒檢查連接池中的連接是否空閑過長時間並且需要關閉。validationQuery=select 1
: 驗證連接是否有效的SQL語句,這裡使用了一個簡單的SELECT 1
查詢。
關鍵詞
java G1GC , G1參數調優,G1 STW 耗時過長,com.mysql.jdbc.NonRegisteringDriver,ConnectionPhantomReference,PhantomReference, GC ref-proc spent too much time ,GC remark,Finalize Marking
2. 問題排查
可用率報警
日誌查詢
查詢本地日誌,找到下游timeout的介面日誌, 並找到相關IP為:11.#.#.201
查看jvm監控
這裡有個問題,因為系統工具ump對jvm 的監控,只關註young gc & full gc, 遺漏了G1的混合GC, 導致直接通過系統監控未及時發現GC過長的問題。到後面下載了gc日誌,最終分析得到了 GC時間超過3.7秒的監控結果,這也是問題的根源。
3. 問題分析
GC日誌分析
2023-06-03T14:40:31.391+0800: 184748.113: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.1017154 secs]
[Parallel Time: 70.3 ms, GC Workers: 6]
[GC Worker Start (ms): Min: 184748113.5, Avg: 184748113.6, Max: 184748113.6, Diff: 0.1]
[Ext Root Scanning (ms): Min: 1.9, Avg: 2.1, Max: 2.2, Diff: 0.3, Sum: 12.3]
[Update RS (ms): Min: 9.7, Avg: 9.9, Max: 10.4, Diff: 0.7, Sum: 59.6]
[Processed Buffers: Min: 12, Avg: 39.5, Max: 84, Diff: 72, Sum: 237]
[Scan RS (ms): Min: 0.1, Avg: 0.7, Max: 1.2, Diff: 1.1, Sum: 4.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 56.9, Avg: 57.5, Max: 57.7, Diff: 0.8, Sum: 344.8]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 6]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 70.1, Avg: 70.1, Max: 70.2, Diff: 0.1, Sum: 420.9]
[GC Worker End (ms): Min: 184748183.7, Avg: 184748183.7, Max: 184748183.7, Diff: 0.0]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.4 ms]
[Other: 31.0 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 30.1 ms]
[Ref Enq: 0.1 ms]
[Redirty Cards: 0.2 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.1 ms]
[Eden: 1760.0M(1760.0M)->0.0B(2080.0M) Survivors: 192.0M->224.0M Heap: 6521.7M(9984.0M)->4912.0M(9984.0M)]
Heap after GC invocations=408 (full 0):
garbage-first heap total 10223616K, used 5029872K [0x0000000570000000, 0x00000005720009c0, 0x00000007e0000000)
region size 32768K, 7 young (229376K), 7 survivors (229376K)
Metaspace used 112870K, capacity 115300K, committed 115968K, reserved 1150976K
class space used 12713K, capacity 13380K, committed 13568K, reserved 1048576K
}
[Times: user=0.45 sys=0.01, real=0.10 secs]
2023-06-03T14:40:31.493+0800: 184748.215: [GC concurrent-root-region-scan-start]
2023-06-03T14:40:31.528+0800: 184748.251: [GC concurrent-root-region-scan-end, 0.0359052 secs]
2023-06-03T14:40:31.528+0800: 184748.251: [GC concurrent-mark-start]
2023-06-03T14:40:31.623+0800: 184748.345: [GC concurrent-mark-end, 0.0942951 secs]
2023-06-03T14:40:31.624+0800: 184748.347: [GC remark 2023-06-03T14:40:31.624+0800: 184748.347: [Finalize Marking, 0.0003013 secs] 2023-06-03T14:40:31.625+0800: 184748.347: [GC ref-proc, 3.7471488 secs] 2023-06-03T14:40:35.372+0800: 184752.094: [Unloading, 0.0254883 secs], 3.7778434 secs]
[Times: user=3.88 sys=0.05, real=3.77 secs]
2023-06-03T14:40:35.404+0800: 184752.127: [GC cleanup 4943M->4879M(9984M), 0.0025357 secs]
[Times: user=0.01 sys=0.00, real=0.00 secs]
2023-06-03T14:40:35.407+0800: 184752.129: [GC concurrent-cleanup-start]
2023-06-03T14:40:35.407+0800: 184752.130: [GC concurrent-cleanup-end, 0.0000777 secs]
{Heap before GC invocations=409 (full 0):
garbage-first heap total 10223616K, used 6930416K [0x0000000570000000, 0x00000005720009c0, 0x00000007e0000000)
region size 32768K, 67 young (2195456K), 7 survivors (229376K)
Metaspace used 112870K, capacity 115300K, committed 115968K, reserved 1150976K
class space used 12713K, capacity 13380K, committed 13568K, reserved 1048576K
根據日誌分析,該G1垃圾回收器共進行了一次youngGC 和 一次併發標記。
young GC在14:40:31.391開始,暫停時間為0.1017154秒,是 young collection 和 initial-mark 階段。並且6個 GC 工作線程並行執行了70.3毫秒。GC 過程中,有多個階段的時間記錄和詳細信息。堆大小從6521M減少到4912M。
併發標記在14:40:31.493開始,並涉及併發掃描、標記和重標記等階段。在清理階段結束後,堆大小從4943M減少到4879M。
因此,該 G1 垃圾回收器在這段時間STW(Stop-The-World)的總時間為 0.10 + 3.77 = 3.87 秒。
Gceasy.io 分析的結果:
疑問1:youngGC後堆記憶體回收不明顯?
後面會追溯到MySQL的鏈接池虛引用,本次GC只能先將鏈接實例GC掉, 而用於追蹤connection實例回收的虛引用,會在下次GC才能被回收。
疑問2:G1的MaxGCPauseMillis是否嚴格遵守,為什麼我配置了-XX:MaxGCPauseMillis=100 本次gc時間還是達到了3秒以上?
G1 的 -XX:MaxGCPauseMillis
參數表示 G1 GC 的最大 STW 時間,即如果單次 GC 暫停時間超過該值,則 G1 會儘可能地調整其行為以達到該目標。但是請註意,該參數是一個指導性的參數,不能保證絕對精度。
在實際應用中,由於應用程式和系統的負載情況、堆記憶體大小等因素的影響,GC 的發生和 STW 時間都可能會出現一定程度的波動和不確定性。因此,單次 GC 暫停時間超過 -XX:MaxGCPauseMillis
配置值是有可能發生的,而且這種情況在高負載或堆記憶體使用較高時更容易出現。
疑問3:監控中為啥出現了多次mixed GC, 而且間隔還如此的短
-XX:G1MixedGCCountTarget,預設為8,這個參數標識最後的混合回收階段會執行8次,一次只回收掉一部分的Region,然後系統繼續運行,過一小段時間之後,又再次進行混合回收,重覆8次。執行這種間斷的混合回收,就可以把每次的混合回收時間控制在我們需要的停頓時間之內了,同時達到垃圾清理的效果。清理了 7 次就已經滿足了回收效果,所以沒有繼續 mixed GC。
以上是為了:MaxGCPauseMillis 停頓時間控制在期望範圍內,所以會出現多次間隔很短連續的mixed GC.
dump分析
通過 MAT 工具分析發現,第一個可能的問題就是:com.mysql.jdbc.NonRegisteringDriver 占用比較大的記憶體,如下圖:
The class com.mysql.jdbc.NonRegisteringDriver, loaded by org.apache.catalina.loader.ParallelWebappClassLoader @ 0x73e8b9a00, occupies 857,532,208 (88.67%) bytes. The memory is accumulated in one instance of java.util.concurrent.ConcurrentHashMap$Node[], loaded by , which occupies 857,529,112 (88.67%) bytes.
Keywords
com.mysql.jdbc.NonRegisteringDriver
org.apache.catalina.loader.ParallelWebappClassLoader @ 0x73e8b9a00
java.util.concurrent.ConcurrentHashMap$Node[]
·然後看大對象列表,NonRegisteringDriver 對象確實占記憶體比較多,其中成員變數connectionPhantomRefs 占記憶體最多,裡面存的是資料庫連接的虛引用,其類型是 ConcurrentHashMap<ConnectionPhantomReference, ConnectionPhantomReference>,占比居然有88%之多。
增加JVM參數,列印詳細對象回收掃描日誌
增加JVM參數: -XX:+PrintReferenceGC, 列印各種引用對象的詳細回收時間:
PrintReferenceGC是JVM提供的一個參數,用於在進行垃圾回收時列印引用處理相關信息。當啟用該參數後,JVM會在每次進行垃圾回收時將一些有關引用處理(Reference Processing)的信息輸出到標準輸出或指定的日誌文件中。
具體來說,PrintReferenceGC參數可幫助開發人員和系統管理員更好地瞭解應用程式中各種類型引用被處理的情況,包括Soft Reference、Weak Reference、Phantom Reference等。這些信息對於診斷記憶體泄漏和調整垃圾回收器性能非常有用。
列印的詳細的gc日誌如下:
2023-06-04T10:28:52.886+0800: 24397.548: [GC concurrent-root-region-scan-start]
2023-06-04T10:28:52.941+0800: 24397.602: [GC concurrent-root-region-scan-end, 0.0545027 secs]
2023-06-04T10:28:52.941+0800: 24397.602: [GC concurrent-mark-start]
2023-06-04T10:28:53.198+0800: 24397.859: [GC concurrent-mark-end, 0.2565503 secs]
2023-06-04T10:28:53.199+0800: 24397.860: [GC remark 2023-06-04T10:28:53.199+0800: 24397.860: [Finalize Marking, 0.0004169 secs] 2023-06-04T10:28:53.199+0800: 24397.861: [GC ref-proc2023-06-04T10:28:53.199+0800: 24397.861: [SoftReference, 9247 refs, 0.0035753 secs]2023-06-04T10:28:53.203+0800: 24397.864: [WeakReference, 963 refs, 0.0003121 secs]2023-06-04T10:28:53.203+0800: 24397.865: [FinalReference, 60971 refs, 0.0693649 secs]2023-06-04T10:28:53.273+0800: 24397.934: [PhantomReference, 49828 refs, 20 refs, 4.5339260 secs]2023-06-04T10:28:57.807+0800: 24402.468: [JNI Weak Reference, 0.0000755 secs], 4.6213645 secs] 2023-06-04T10:28:57.821+0800: 24402.482: [Unloading, 0.0332897 secs], 4.6620392 secs]
[Times: user=4.60 sys=0.31, real=4.67 secs]
2023-06-04T10:28:57.863+0800: 24402.524: [GC cleanup 4850M->4850M(9984M), 0.0031413 secs]
[Times: user=0.01 sys=0.01, real=0.00 secs]
{Heap before GC invocations=68 (full 0):
garbage-first heap total 10223616K, used 7883923K [0x0000000570000000, 0x00000005720009c0, 0x00000007e0000000)
region size 32768K, 98 young (3211264K), 8 survivors (262144K)
Metaspace used 111742K, capacity 114304K, committed 114944K, reserved 1150976K
class space used 12694K, capacity 13362K, committed 13568K, reserved 1048576K
翻譯日誌內容如下:
2023-06-04T10:28:52.886+0800: 24397.548: [GC concurrent-root-region-scan-start]:開始掃描併發根區域。
2023-06-04T10:28:52.941+0800: 24397.602: [GC concurrent-root-region-scan-end, 0.0545027 secs]:併發根區域掃描結束,持續時間為0.0545027秒。
2023-06-04T10:28:52.941+0800: 24397.602: [GC concurrent-mark-start]:開始併發標記過程。
2023-06-04T10:28:53.198+0800: 24397.859: [GC concurrent-mark-end, 0.2565503 secs]:併發標記過程結束,持續時間為0.2565503秒。
2023-06-04T10:28:53.199+0800: 24397.860: [GC remark]: G1執行remark階段。
2023-06-04T10:28:53.199+0800: 24397.860: [Finalize Marking, 0.0004169 secs]:標記finalize隊列中待處理對象,持續時間為0.0004169秒。
2023-06-04T10:28:53.199+0800: 24397.861: [GC ref-proc]: 進行引用處理。
2023-06-04T10:28:53.199+0800: 24397.861: [SoftReference, 9247 refs, 0.0035753 secs]:處理軟引用,持續時間為0.0035753秒。
2023-06-04T10:28:53.203+0800: 24397.864: [WeakReference, 963 refs, 0.0003121 secs]:處理弱引用,持續時間為0.0003121秒。
2023-06-04T10:28:53.203+0800: 24397.865: [FinalReference, 60971 refs, 0.0693649 secs]:處理虛引用,持續時間為0.0693649秒。
2023-06-04T10:28:53.273+0800: 24397.934: [PhantomReference, 49828 refs, 20 refs, 4.5339260 secs]:處理final reference中的phantom引用,持續時間為4.5339260秒。
2023-06-04T10:28:57.807+0800: 24402.468: [JNI Weak Reference, 0.0000755 secs]:處理JNI weak引用,持續時間為0.0000755秒。
2023-06-04T10:28:57.821+0800: 24402.482: [Unloading, 0.0332897 secs]:卸載無用的類,持續時間為0.0332897秒。
[Times: user=4.60 sys=0.31, real=4.67 secs]:垃圾回收的時間信息,user表示用戶態CPU時間、sys表示內核態CPU時間、real表示實際運行時間。
2023-06-04T10:28:57.863+0800: 24402.524: [GC cleanup 4850M->4850M(9984M), 0.0031413 secs]:執行cleanup操作,將堆大小從4850M調整為4850M,持續時間為0.0031413秒。
其中PhantomReference耗時最長:
2023-06-04T10:28:53.273+0800: 24397.934: [PhantomReference, 49828 refs, 20 refs, 4.5339260 secs]:處理final reference中的phantom引用,持續時間為4.5339260秒。
源碼追蹤
MYSQL driver相關
註意,本源碼基於當前依賴的mysql-connector-java-5.1.30.jar, 各版本的代碼有一定差異性質;
涉及MySQL創建連接池的地方為:com.mysql.jdbc.NonRegisteringDriver#connect方法:
public Connection connect(String url, Properties info) throws SQLException {
//...省略部分代碼
Properties props = null;
if ((props = this.parseURL(url, info)) == null) {
return null;
} else if (!"1".equals(props.getProperty("NUM_HOSTS"))) {
return this.connectFailover(url, info);
} else {
try {
// 獲取連接主要在這裡
com.mysql.jdbc.Connection newConn = ConnectionImpl.getInstance(this.host(props), this.port(props), props, this.database(props), url);
return newConn;
} catch (SQLException var6) {
throw var6;
} catch (Exception var7) {
SQLException sqlEx = SQLError.createSQLException(Messages.getString("NonRegisteringDriver.17") + var7.toString() + Messages.getString("NonRegisteringDriver.18"), "08001", (ExceptionInterceptor)null);
sqlEx.initCause(var7);
throw sqlEx;
}
}
}
com.mysql.jdbc.ConnectionImpl
構造器比較長,在完成參數構建後,在其結尾處,發現它調用了NonRegisteringDriver.trackConnection(this)
。
public ConnectionImpl(String hostToConnectTo, int portToConnectTo, Properties info, String databaseToConnectTo, String url) throws SQLException {
......
NonRegisteringDriver.trackConnection(this);
}
其中追蹤任務的註冊如下:這段MySQL driver源碼的作用是實現對MySQL資料庫連接的跟蹤。
public class NonRegisteringDriver {
//省略部分代碼...
// 連接虛引用指向map的容器聲明
protected static final ConcurrentHashMap<ConnectionPhantomReference, ConnectionPhantomReference> connectionPhantomRefs = new ConcurrentHashMap();
// 將連接放入追蹤容器中
protected static void trackConnection(com.mysql.jdbc.Connection newConn) {
ConnectionPhantomReference phantomRef = new ConnectionPhantomReference((ConnectionImpl)newConn, refQueue);
connectionPhantomRefs.put(phantomRef, phantomRef);
}
//省略部分代碼...
}
第一行代碼聲明瞭一個名為connectionPhantomRefs
的ConcurrentHashMap
容器,該容器用於存儲ConnectionPhantomReference
實例。
第二個方法trackConnection
的作用是將新連接添加到connectionPhantomRefs
映射中。它接受一個com.mysql.jdbc.Connection
對象作為參數,創建一個新的ConnectionPhantomReference
實例,並使用它和引用隊列(refQueue
)將其添加到connectionPhantomRefs
映射中。
總的來說,這兩個代碼片段旨在通過使用虛引用來實現跟蹤連接到MySQL資料庫的機制。虛引用用於跟蹤已被JVM垃圾回收的對象,允許程式在對象從記憶體中刪除後執行特定任務。
連接虛引用為靜態內部類
這段代碼是 MySQL 驅動程式的一部分,用於清理底層網路資源並確保在 MySQL 連接對象被垃圾回收時釋放這些資源。
static class ConnectionPhantomReference extends PhantomReference<ConnectionImpl> {
private NetworkResources io;
ConnectionPhantomReference(ConnectionImpl connectionImpl, ReferenceQueue<ConnectionImpl> q) {
super(connectionImpl, q);
try {
this.io = connectionImpl.getIO().getNetworkResources();
} catch (SQLException var4) {
}
}
void cleanup() {
if (this.io != null) {
try {
this.io.forceClose();
} finally {
this.io = null;
}
}
}
}
這段 MySQL driver 源碼中的 ConnectionPhantomReference
類是一個繼承自 PhantomReference<ConnectionImpl>
的靜態內部類,其中 ConnectionImpl 是 MySQL 連接的實現類。該類的作用是在連接對象被垃圾回收時清理底層網路資源。
ConnectionPhantomReference
構造函數接受 ConnectionImpl
對象和一個引用隊列作為參數,並調用父類 PhantomReference
的構造函數來創建虛引用。它還通過調用 ConnectionImpl
的 getIO().getNetworkResources()
方法獲取與連接關聯的網路資源。如果獲取失敗,則不會拋出異常。
cleanup()
方法用於在連接對象被垃圾回收後清理網路資源。它檢查 io
屬性是否為空,如果不為空,則調用 forceClose()
方法來強制關閉底層網路資源,最終將 io
屬性設置為 null。整個過程確保連接對象被垃圾回收時,底層網路資源也被正確地釋放。
MySQL為什麼要使用虛引用來解決IO資源回收問題?
MySQL 使用虛引用來解決 IO 資源回收問題,主要是因為 JDBC 連接對象在關閉連接時無法保證其底層網路資源會被立即釋放。這可能會導致底層網路資源長時間占用,最終導致應用程式出現性能下降或者資源耗盡的情況。
使用虛引用的好處在於,它允許程式在對象從記憶體中刪除後執行特定任務。 MySQL 驅動程式利用 Java 提供的引用隊列機制,將 JDBC 連接對象的虛引用加入到隊列中。一旦連接對象被垃圾回收,JVM 會將它加入到引用隊列中等待進一步處理。此時,MySQL 驅動程式通過監視引用隊列並清理底層網路資源,確保這些資源在連接對象被垃圾回收時被正確地釋放,從而避免了底層網路資源長時間占用的問題。
使用虛引用的優點在於可以實現更精細的記憶體管理和資源回收,提高應用程式的可靠性和性能。
那MySQL是怎樣執行最終的IO資源回收的呢,是使用了定時任務還是非同步守護線程?
MySQL 在執行最終的 IO 資源回收時,使用了非同步守護線程來清理底層網路資源。
MySQL 驅動程式將 JDBC 連接對象的虛引用加入到引用隊列中後,會創建一個專門的守護線程來監視該引用隊列。當連接對象被垃圾回收並加入到引用隊列中後,守護線程會從引用隊列中獲取該連接對象的虛引用,併在後臺線程中執行與連接相關的底層網路資源清理工作。
具體來說,守護線程在每次迭代中從引用隊列中獲取一組虛引用,並調用虛引用的 cleanup()
方法來清理底層網路資源。如果成功清理,則將其從映射表中刪除;否則,保留虛引用以便下一次迭代。
由於清理操作是在後臺線程中進行的,因此不會阻塞應用程式的主線程。同時,使用非同步守護線程還可以避免定時任務可能帶來的性能開銷和不必要的 CPU 使用率。
MySQL 使用非同步守護線程來執行 IO 資源回收工作,這種方式可以確保底層網路資源的及時釋放,並且不會對應用程式的性能產生負面影響。
以下是 MySQL 驅動程式中執行 IO 資源回收的守護線程的相關代碼:
public class NonRegisteringDriver implements java.sql.Driver {
//省略代碼...
//在MySQL driver中守護線程創建及啟動...
static {
AbandonedConnectionCleanupThread referenceThread = new AbandonedConnectionCleanupThread();
referenceThread.setDaemon(true);
referenceThread.start();
}
//省略代碼...
}
public class AbandonedConnectionCleanupThread extends Thread {
private static boolean running = true;
private static Thread threadRef = null;
public AbandonedConnectionCleanupThread() {
super("Abandoned connection cleanup thread");
}
public void run() {
threadRef = this;
while (running) {
try {
Reference<? extends ConnectionImpl> ref = NonRegisteringDriver.refQueue.remove(100);
if (ref != null) {
try {
((ConnectionPhantomReference) ref).cleanup();
} finally {
NonRegisteringDriver.connectionPhantomRefs.remove(ref);
}
}
} catch (Exception ex) {
// no where to really log this if we're static
}
}
}
public static void shutdown() throws InterruptedException {
running = false;
if (threadRef != null) {
threadRef.interrupt();
threadRef.join();
threadRef = null;
}
}
}
這段代碼是 MySQL 驅動程式中的一個類 AbandonedConnectionCleanupThread
,該類繼承自 Java 的 Thread
類。其作用是定期清理連接對象的底層網路資源,防止出現資源泄露和記憶體溢出等問題。在該類的 run()
方法中,迴圈檢查是否有虛引用需要清理。如果存在,則調用 cleanup()
方法來清理虛引用所關聯的底層網路資源,並從 NonRegisteringDriver.connectionPhantomRefs
映射中刪除該虛引用。
在 MySQL 驅動程式中,connectionPhantomRefs
映射表的鍵和值都是 ConnectionPhantomReference
類型的對象,即使用同一個對象作為鍵和值。
這樣做的主要原因是,在虛引用被加入到引用隊列中時,需要從映射表中刪除對應的虛引用。如果將虛引用對象作為鍵,那麼在刪除虛引用時,需要遍歷整個映射表才能找到該虛引用對象並將其刪除,這會造成不必要的性能開銷。
相反,如果將虛引用對象作為值,那麼只需要使用該虛引用對象自身來刪除映射表中的條目,就可以提高刪除效率。在 trackConnection()
方法中,創建一個新的 ConnectionPhantomReference
對象,並將其作為鍵和值存儲到 connectionPhantomRefs
映射表中。這樣,在虛引用被加入到引用隊列中時,只需從映射表中刪除該虛引用對象自身即可,無需遍歷整個映射表。
總之,使用同一個對象作為映射表的鍵和值,可以更有效地刪除映射表中的條目,提高性能和效率。
PS: java.lang.ref.ReferenceQueue#remove(long) 介紹
java.lang.ref.ReferenceQueue#remove(long)
方法的作用是從隊列中移除已經被回收的 Reference
對象,若隊列中沒有任何元素,則該方法會一直等待直到有元素加入或者經過指定時間後返回 null。
該方法的參數為超時時間,單位為毫秒。如果在指定的超時時間內沒有元素加入隊列,則該方法返回 null。如果超時時間為 0,則該方法立即返回,並且不會等待任何元素加入隊列。
通常情況下,程式不需要調用這個方法。Java 虛擬機會自動調用該方法來處理已經被回收的對象。只有在特定的情況下,如手動管理記憶體時才需要手動調用該方法。
這裡使用java.lang.ref.ReferenceQueue主要作用是在Connection被垃圾回收器回收後, 能通過remove(timeout) 獲取已經被回收的引用隊列,然後進行後置處理。
推斷
因為目前在NonRegisteringDriver的靜態成員變數:connectionPhantomRefs, 有幾萬個對象, 證明在這段時間積累了大量的資料庫鏈接connection實例進入以下生命周期:
創建 --> 閑置 ---> 回收;
疑點1:
因為前面連接閑置時間為db.minEvictableIdleTimeMillis
=180000(3分鐘), 可能是造成問題的關鍵。正常的MySQL鏈接閑置時間預設為8小時。
疑點2:
此外,因為該應用有多個數據源,且部分數據進行了分庫,導致和資料庫的連接數較多,而且訪問請求也不均勻,這就導致了高峰期創建大量資料庫連接池,低峰期又被回收。使com.mysql.jdbc.NonRegisteringDriver#connectionPhantomRefs
中的對象越來越多,而且大部分進入老年代,需要fullGC或者G1 的mixedGC才能進行回收。
4. 解決方案
方案1:調整JVM參數
雖然問題的根源是MySQL的鏈接失效,造成虛引用在gc的remark階段耗時較長。但是我們的G1參數配置也存在問題:其中參數1的調整是本次減少GC時間的關鍵。
參數1:ParallelRefProcEnabled (並行引用處理開啟)
經過核查應用確認為G1GC 參數配置不當,未開啟ParallelRefProcEnabled(並行引用處理)JDK8需要手動開啟,JDK9+已預設開啟。 導致G1 Final Remark階段使用單線程標記(G1混合GC的時候,STW約為2~3秒);
解決方案:
開啟ParallelRefProcEnabled(並行引用處理)功能後,ParallelGCThreads=8 啟用8線程標記;
參數2:ParallelGCThreads (並行 GC 線程數)
ParallelGCThreads
參數是一個用於指定G1垃圾回收器並行垃圾收集線程數的參數。與其他Java垃圾回收器不同,G1垃圾回收器可以同時執行多個垃圾回收線程來加速垃圾回收速度。
在Java 8中,預設情況下,G1垃圾回收器的ParallelGCThreads
參數設置為與CPU核心數量相同的值,最大為8。如果您的機器有更多的CPU核心,則可以通過手動調整該參數來提高並行垃圾回收的效率。例如,如果您的機器有8個核心,則可以將ParallelGCThreads
設置為8或更高的值。
需要註意的是,過高的ParallelGCThreads
值可能會導致處理器爭用和上下文切換,從而影響應用程式的性能。因此,在進行調整時,應根據機器規格和實際負載進行測試和優化,以找到最佳參數配置。建議保持預設值,併在實測結果反饋之後再進行適當的調整。
另外,還可以使用-XX:+UseDynamicNumberOfGCThreads
參數讓G1自動適配並行線程數。這樣,線程數量會根據系統負載自動增減,從而獲得更好的性能
解決方案:
機器為8核,最開始只配置了6C,故而將並行GC線程數量增加到8,或者不不配置,預設為核心數8;
參數3:G1MixedGCLiveThresholdPercent (G1混合垃圾回收器存活閾值百分比)
G1MixedGCLiveThresholdPercent
是一個用於控制G1垃圾回收器混合垃圾回收的參數,它決定混合垃圾回收器在何時執行。這個值指某一個標記為old region的區域中存活對象百分達到此閾值後,會被G1 mixed GC回收處理;
如果將該值設置得太低,則可能導致頻繁的混合垃圾回收,從而影響應用程式的性能;如果將其設置得太高,則可能會導致老年代中的垃圾增加,造成堆記憶體占用過高,單次STW延長。
預設值: 85%;
解決方案:
因為前期設置為80, 本次調整為60 , 雖然增加混合GC觸發的頻率,但是因為此時對象更少,需要消耗的時間也變短;
參數4: MaxTenuringThreshold (對象晉升到老年代的最大年齡)
MaxTenuringThreshold
是一個用於控制對象在Survivor區域中存活的時間閾值的參數,它指定了對象在經過多少次Minor GC後可以晉升到老年代。具體來說,如果某個對象在Survivor區域中存活了足夠長的時間(即經過足夠多次的Minor GC),則會被移動到老年代中。
在Java 8中,預設情況下,MaxTenuringThreshold
參數設置為15,即對象必須在Survivor區域中存活15次Minor GC才能晉升到老年代。如果將其設置為0,則表示所有的對象都可以在第一次Minor GC時晉升到老年代;如果將其設置為較大的值,則表示只有存活時間比較長的對象才能晉升到老年代。
需要註意的是,如果將MaxTenuringThreshold
參數設置得過小,則可能導致頻繁的晉升和回收操作,從而影響應用程式的性能;如果將其設置得過大,則可能會導致Survivor區域中的空間不足,從而強制進行Full GC操作,同樣也會影響應用程式的性能。
建議在實際應用程式場景中進行測試和優化,以找到最佳的MaxTenuringThreshold
值。通常,該值應該設置在8-15之間,並且應根據應用程式的記憶體使用情況和GC日誌進行調整。
解決方案:
因為前期設置為5, 本次調整為15 ,或者不需要配置,預設為15;
調整後的JVM參數
-Xms9984m -Xmx9984m -XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=512m -XX:MaxDirectMemorySize=512m -XX:ParallelGCThreads=8 -XX:CICompilerCount=4 -XX:ConcGCThreads=4 -server -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:SurvivorRatio=10 -XX:InitiatingHeapOccupancyPercent=45 -XX:G1ReservePercent=20 -XX:G1MixedGCLiveThresholdPercent=60 -XX:MaxGCPauseMillis=100 -XX:+ExplicitGCInvokesConcurrent -XX:+ParallelRefProcEnabled -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintReferenceGC -XX:+PrintHeapAtGC -Xloggc:/export/Logs/app/tomcat7-gc.log
方案2: 優化MySQL的虛引用問題
調整連接池參數
在諸多連接池中,發現一個參數配置錯誤,有一個資料庫的db.minEvictableIdleTimeMillis
=180000(3分鐘),適當調大連接的閑置時間到30Min;
將DBCP連接池參數minIdle調小,減少閑置線程的數量(在整個連接都不活躍的情況下),也可以起到一定作用;
但是這個方式,治標不治本, 因為虛引用還在一直產生。
優化MySQL driver虛引用
A. 反射暴力清理
虛引用往往做為一種兜底策略,避免用戶忘記釋放資源,引發記憶體泄露(在未使用連接池的如java初學者手擼的資料庫鏈接管理代碼往往是有用的)。我們使用DBCP或其它池化技術已經會嚴謹處理資源的釋放,可以不採用兜底策略,直接刪除中 connectionPhantomRefs 中的虛引用,使對象不可達,在 GC 時直接回收,從而減少 PhantomReference 的處理時間。
// 每兩小時清理 connectionPhantomRefs,減少對 mixed GC 的影響
SCHEDULED_EXECUTOR.scheduleAtFixedRate(() -> {
try {
Field connectionPhantomRefs = NonRegisteringDriver.class.getDeclaredField("connectionPhantomRefs");
connectionPhantomRefs.setAccessible(true);
Map map = (Map) connectionPhantomRefs.get(NonRegisteringDriver.class);
if (map.size() > 50) {
map.clear();
}
} catch (Exception e) {
log.error("connectionPhantomRefs clear error!", e);
}
}, 2, 2, TimeUnit.HOURS);
以上代碼通過反射定時清理掉NonRegisteringDriver靜態變數connectionPhantomRefs,即信任DBCP在鏈接回收的時候,會自動嚴謹處理資源釋放。MySQL driver自帶的安全防護全部定時清空,相當於禁用此功能。
特別說明: A方案無安全性問題,可放心採納;
B. 升級MySQL jdbc driver到8.0.22+,開啟disableAbandonedConnectionCleanup
Oracle應該是接收了大量開發人員的反饋,意識到了此功能可能造成GC嚴重過長的問題,在高版本中已經可以通過配置選擇性關閉此非必要拖尾功能。 mysql-connector-java 版本(8.0.22+)的代碼對資料庫連接的虛引用有新的處理方式,其增加了開關,可以手動關閉此功能。
其版本8.0.22介紹增加此參數即是為瞭解決JVM 虛引用相關問題,但是預設是未啟用,需要手動開啟:
https://dev.mysql.com/doc/relnotes/connector-j/8.0/en/news-8-0-22.html
When using Connector/J, the AbandonedConnectionCleanupThread thread can now be disabled completely by setting the new system property com.mysql.cj.disableAbandonedConnectionCleanup to true when configuring the JVM. The feature is for well-behaving applications that always close all connections they create. Thanks to Andrey Turbanov for contributing to the new feature. (Bug #30304764, Bug #96870)
有了這個配置,就可以在啟動參數上設置屬性:
java -jar app.jar -Dcom.mysql.cj.disableAbandonedConnectionCleanup=true
或者在代碼里設置屬性:
System.setProperty(PropertyDefinitions.SYSP_disableAbandonedConnectionCleanup,"true");
當 com.mysql.cj.disableAbandonedConnectionCleanup=true 時,生成資料庫連接時就不會生成虛引用,對 GC 就沒有任何影響了。
是否升級MySQL的驅動版本,根據實際情況取捨,以上A/B方案都能解決該問題。
總結
G1參數調優總結
- ParallelGCThreads 主要為併發標記相關線程,其本身處理時候也會STW, 最好全部占用CPU內核,如機器為8核,則該值最好設置為8;
- ConcGCThreads 為併發標記線程數量,其標記的時候,業務線程可以不STW, 可以使用部分核心,剩餘部分核心用於跑業務線程;我們這裡配置為CPU核心數量的一半4;
- MaxGCPauseMillis 只是一個期望值(GC會儘可能保證),並不能嚴格遵守,尤其是在標記階段,會受到堆記憶體對象數量的影響,故而不能太過依賴此值覺得STW的實際時間;如我方配置的100ms, 實際出現3~4秒的機會也比較多;
- ParallelRefProcEnabled 如果你搜索的各種資料提醒你這個開關是預設開啟的,那你要特別註意了; JDK8版本此功能是預設關閉的,在JDK9+之後才是預設開啟。故而JDK8需要手動開啟,保險起見,無論什麼版本都配置該值;
- G1垃圾回收器基本上不會出現fullGC, 在youngGC 和 mixedGC(mixedGC頻次也不高)就完成了垃圾回收。如果出現了fullGC, 反而應該排查是否有異常。
- 本文未對G1垃圾回收器之過程及原理進行詳細分析,請參考其它文章進行理解;
MySQL虛引用問題總結
- MySQL 鏈接驅動如果在8.0.22版本以下,只能通過反射主動清理此非必要拖尾功能(一般成熟的資料庫連接池功能都能安全釋放鏈接相關資源)。
- MySQL驅動8.0.22+版本, 可以通過配置手動關閉此功能;
結果說明
經過以上調整後(MySQL的虛引用問題一併解決後),gc時間能較好的控制在100ms以內,平均耗時26ms。 mixed gc頻率大幅度減少, 主要為youngGC。
參考資料
線上問題分析系列:資料庫連接池記憶體泄漏問題的分析和解決方案
com.mysql.jdbc.NonRegisteringDriver 記憶體泄漏
infoq-Tips for Tuning the Garbage First Garbage Collector
Java Garbage Collection handbook
PPPHUANG- MySQL 驅動中虛引用 GC 耗時優化與源碼分析
作者:京東零售 張世彬
來源:京東雲開發者社區