MySQL 驅動中虛引用 GC 耗時最佳化與原始碼分析

PPPHUANG發表於2023-05-15
本文要點:
  • 一種優雅解決 MySQL 驅動中虛引用導致 GC 耗時較長問題的解決方法
  • 虛引用的作用與使用場景
  • MySQL 驅動原始碼中的虛引用分析

背景

​ 在之前文章中寫過 MySQL JDBC 驅動中的虛引用導致 JVM GC 耗時較長的問題(可以看這裡),在驅動程式碼(mysql-connector-java 5.1.38版本)中 NonRegisteringDriver 類有個虛引用集合 connectionPhantomRefs 用於儲存所有的資料庫連線,NonRegisteringDriver.trackConnection 方法負責把新建立的連線放入集合,虛引用隨著時間積累越來越多,導致 GC 時處理虛引用的耗時較長,影響了服務的吞吐量:

public ConnectionImpl(String hostToConnectTo, int portToConnectTo, Properties info, String databaseToConnectTo, String url) throws SQLException {
    ...
    NonRegisteringDriver.trackConnection(this);
  ...
}
public class NonRegisteringDriver implements Driver {
  ...
  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);
    }
  ...
}

​ 嘗試減少資料庫連線的生成速度,來降低虛引用的數量,但是效果並不理想。最終的解決方案是透過反射獲取虛引用集合,利用定時任務來定期清理集合,避免 GC 處理虛引用耗時較長。

// 每兩小時清理 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);

​ 利用定時任務清理虛引用效果立竿見影,每日幾億請求的服務 mixed GC 耗時只有 10 - 30 毫秒左右,系統也很穩定,線上執行將近一年沒有任何問題。

最佳化——由暴力破解到優雅配置

​ 最近又有同事遇到相同的問題,使用的 mysql-connector-java 版本與我們使用的版本一致,檢視最新版本(8.0.32)的程式碼發現對資料庫連線的虛引用有新的處理方式,不像老版本(5.1.38)中每一個連線都會生成虛引用,而是可以透過引數來控制是否需要生成。類 AbandonedConnectionCleanupThread 的相關程式碼如下:

//靜態變數透過 System.getProperty 獲取配置
private static boolean abandonedConnectionCleanupDisabled = Boolean.getBoolean("com.mysql.cj.disableAbandonedConnectionCleanup");

public static boolean getBoolean(String name) {
      return parseBoolean(System.getProperty(name));
}

protected static void trackConnection(MysqlConnection conn, NetworkResources io) {
          //判斷配置的屬性值來決定是否需要生成虛引用
      if (!abandonedConnectionCleanupDisabled) {
         ···
          ConnectionFinalizerPhantomReference reference = new ConnectionFinalizerPhantomReference(conn, io, referenceQueue);
          connectionFinalizerPhantomRefs.add(reference);
         ··· 
      }
  }

​ mysql-connector-java 的維護者應該是注意到了虛引用對 GC 的影響,所以最佳化了程式碼,讓使用者可以自定義虛引用的生成。

​ 有了這個配置,就可以在啟動引數上設定屬性:

java -jar app.jar -Dcom.mysql.cj.disableAbandonedConnectionCleanup=true

​ 或者在程式碼裡設定屬性:

System.setProperty(PropertyDefinitions.SYSP_disableAbandonedConnectionCleanup,"true");

​ 當 com.mysql.cj.disableAbandonedConnectionCleanup=true 時,生成資料庫連線時就不會生成虛引用,對 GC 就沒有任何影響了。

​ 建議還是使用第一種方式,透過啟動引數配置更靈活一點。

什麼是虛引用

​ 有些讀者看到這裡知道 mysql-connector-java 生成的虛引用對 GC 有一些副作用,但是還不太瞭解虛引用到底是什麼,有什麼作用,這裡我們在虛引用上做一點點擴充。

​ Java 虛引用(Phantom Reference)是Java中一種特殊的引用型別,它是最弱的一種引用。與其他引用不同,虛引用並不會影響物件的生命週期,也不會影響物件的垃圾回收。虛引用主要用於在物件被回收時收到系統通知,以便在回收時執行一些必要的清理工作。

​ 上述虛引用的定義還是比較難理解,我們用程式碼來輔助理解:

​ 先來生成一個虛引用:

//虛引用佇列
ReferenceQueue<Object> queue = new ReferenceQueue<>();
//關聯物件
Object o = new Object();
//呼叫構造方法生成一個虛引用 第一個引數就是關聯物件 第二個引數是關聯佇列
PhantomReference<Object> phantomReference = new PhantomReference<>(o, queue);
//執行垃圾回收
System.gc();
//延時確保回收完畢
Thread.sleep(300L);
//當 Object o 被回收時可以從虛引用佇列裡獲取到與之關聯的虛引用 這裡就是 phantomReference 這個物件
Reference<?> poll = queue.poll();

​ 虛引用的構造方法需要兩個入參,第一個就是關聯的物件、第二個是虛引用佇列 ReferenceQueue。虛引用需要和 ReferenceQueue 配合使用,當物件 Object o 被垃圾回收時,與 Object o 關聯的虛引用就會被放入到 ReferenceQueue 中。透過從 ReferenceQueue 中是否存在虛引用來判斷物件是否被回收。

​ 我們再來理解上面對虛引用的定義,虛引用不會影響物件的生命週期,也不會影響物件的垃圾回收。如果上述程式碼裡的phantomReference 是一個普通的物件,那麼在執行 System.gc() 時 Object o 一定不會被回收掉,因為普通物件持有 Object o 的強引用,還不會被作為垃圾。這裡的 phantomReference 是一個虛引用的話 Object o 就會被直接回收掉。然後會將關聯的虛引用放到佇列裡,這就是虛引用關聯物件被回收時會收到系統通知的機制。

​ 一些實踐能力很強的讀者會複製上述程式碼去執行,發現垃圾回收之後佇列裡並沒有虛引用。這是因為 Object o 還在棧裡,屬於是 GC Root 的一種,不會被垃圾回收。我們可以這樣改寫:

static ReferenceQueue<Object> queue = new ReferenceQueue<>();

public static void main(String[] args) throws InterruptedException {
    PhantomReference<Object> phantomReference = buildReference();
    System.gc();Thread.sleep(100);
    System.out.println(queue.poll());
}

public static PhantomReference<Object> buildReference() {
    Object o = new Object();
    return new PhantomReference<>(o, queue);
}

​ 不在 main 方法裡例項化關聯物件 Object o,而是利用一個 buildReference 方法來例項化,這樣在執行垃圾回收的時候,Object o 已經出棧了,不再是 GC Root,會被當做垃圾來回收。這樣就能從虛引用佇列裡取出關聯的虛引用進行後續處理。

關聯物件真的被回收了嗎

​ 執行完垃圾回收之後,我們確實能從虛引用佇列裡獲取到虛引用了,我們可以思考一下,與該虛引用關聯的物件真的已經被回收了嗎?

​ 使用一個小實驗來探索答案:

public static void main(String[] args) {
      ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
      PhantomReference<byte[]> phantomReference = new PhantomReference<>(
              new byte[1024 * 1024 * 2], queue);
      System.gc();Thread.sleep(100L);
      System.out.println(queue.poll());
      byte[] bytes = new byte[1024 * 1024 * 4];
  }

​ 程式碼裡生成一個虛引用,關聯物件是一個大小為 2M 的陣列,執行垃圾回收之後嘗試再例項化一個大小為 4M 的陣列。如果我們從虛引用佇列裡獲取到虛引用的時候關聯物件已經被回收,那麼就能正常申請到 4M 的陣列。(設定堆記憶體大小為 5M -Xmx5m -Xms5m)

​ 執行程式碼輸出如下:

java.lang.ref.PhantomReference@533ddba
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at com.ppphuang.demo.phantomReference.PhantomReferenceDemo.main(PhantomReferenceDemo.java:15)

​ 從輸出可以看到,申請 4M 記憶體的時候記憶體溢位,那麼問題的答案就很明顯了,關聯物件並沒有被真正的回收,記憶體也沒有被釋放。

​ 再做一點小小的改造,例項化新陣列的之前將虛引用直接置為 null,這樣關聯物件就能被真正的回收掉,也能申請足夠的記憶體:

public static void main(String[] args) {
      ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
      PhantomReference<byte[]> phantomReference = new PhantomReference<>(
              new byte[1024 * 1024 * 2], queue);
      System.gc();Thread.sleep(100L);
      System.out.println(queue.poll());
          //虛引用直接置為 null
          phantomReference = null;
      byte[] bytes = new byte[1024 * 1024 * 4];
  }

如果我們使用了虛引用,但是沒有及時清理虛引用的話可能會導致記憶體洩露

虛引用的使用場景——mysql-connector-java 虛引用原始碼分析

​ 讀到這裡相信你已經瞭解了虛引用的一些基本情況,那麼它的使用場景在哪裡呢?

​ 最典型的場景就是最開始寫到的 mysql-connector-java 裡處理 MySQL 連線的兜底邏輯。用虛引用來包裝 MySQL 連線,如果一個連線物件被回收的時候,會從虛引用佇列裡收到通知,如果有些連線沒有被正確關閉的話,就會在回收之前進行連線關閉的操作。

​ 從 mysql-connector-java 的 AbandonedConnectionCleanupThread 類程式碼中可以發現並沒有使用原生的 PhantomReference 物件,而是使用的是包裝過的 ConnectionFinalizerPhantomReference,增加了一個屬性 NetworkResources,這是為了方便從虛引用佇列中的虛引用上獲取到需要處理的資源。包裝類中還有一個 finalizeResources 方法,用來關閉網路連線:

private static class ConnectionFinalizerPhantomReference extends PhantomReference<MysqlConnection> {
      //放置需要GC後後置處理的網路資源
      private NetworkResources networkResources;
      ConnectionFinalizerPhantomReference(MysqlConnection conn, NetworkResources networkResources, ReferenceQueue<? super MysqlConnection> refQueue) {
          super(conn, refQueue);
          this.networkResources = networkResources;
      }
      void finalizeResources() {
          if (this.networkResources != null) {
              try {
                  this.networkResources.forceClose();
              } finally {
                  this.networkResources = null;
              }
          }
      }
  }

​ AbandonedConnectionCleanupThread 實現了 Runnable 介面,在 run 方法裡迴圈讀取虛引用佇列 referenceQueue 裡的虛引用,然後呼叫 finalizeResource 方法來進行後置的處理,避免連線洩露:

public void run() {
    while(true) {
        try {
              ...
            Reference<? extends MysqlConnection> reference = referenceQueue.remove(5000L);
            if (reference != null) {
                  //強轉為 ConnectionFinalizerPhantomReference
                finalizeResource((ConnectionFinalizerPhantomReference)reference);
            }
              ...
        }
    }
}

private static void finalizeResource(ConnectionFinalizerPhantomReference reference) {
    try {
          //兜底處理網路資源
        reference.finalizeResources();
        reference.clear();
    } finally {
          //移除虛引用 避免可能造成的記憶體溢位
        connectionFinalizerPhantomRefs.remove(reference);
    }
}

​ 如果你希望在某些物件被回收的時候做一些後置工作,可以參考 mysql-connector-java 中的一些實現邏輯。

總結

​ 本文簡述了一種優雅解決 MySQL 驅動中虛引用導致 GC 耗時較長問題的解決方法、也根據自己的理解講述了虛引用的作用、結合 MySQL 驅動的原始碼描述了虛引用的使用場景,希望對你能有所幫助。

公眾號:DailyHappy

一位後端寫碼師,一位黑暗料理製造者。

相關文章