Java物件重用如何降低延遲並提高效能 - Minborg

banq 發表於 2022-01-15
Java

通過閱讀本文熟悉物件重用的藝術,並瞭解多執行緒 Java 應用程式中不同重用策略的優缺點。這允許您以更少的延遲編寫更高效能的程式碼。

雖然在 Java 等物件導向的語言中使用物件提供了一種很好的抽象複雜性的方法,但頻繁的物件建立可能會帶來記憶體壓力和垃圾收集方面的不利影響,這將對應用程式的延遲和效能產生不利影響. 

仔細重用物件提供了一種在保持大部分預期抽象級別的同時保持效能的方法。本文探討了幾種重用物件的方法。

預設情況下,JVM 會在堆上分配新物件。這意味著這些新物件將在堆上累積,並且一旦物件超出範圍(即不再被引用),佔用的空間最終必須在稱為“垃圾收集”或簡稱 GC 的過程中回收。隨著建立和刪除物件的幾個週期的過去,記憶體通常會變得越來越碎片化。

這成為對效能敏感的應用程式的一個重要瓶頸。更糟糕的是,這些問題通常在具有許多 CPU 核心和跨 NUMA 區域的伺服器環境中加劇。

近年來,GC 演算法有了顯著的改進,可以緩解上述一些問題。

然而,在建立大量新物件時,基本的記憶體訪問頻寬限制和 CPU 快取耗盡問題仍然是一個因素。

 

重用物件並不容易

不可變的物件總是可以線上程之間重用和傳遞,這是因為它的欄位是最終的並且由建構函式設定,從而確保完全可見性。因此,重用不可變物件很簡單,幾乎總是可取的,但不可變模式可以導致高度的物件建立。

一旦構造了可變例項,Java 的記憶體模型要求在讀取和寫入普通例項欄位(即非volatile欄位)時應用正常的讀寫語義。因此,這些更改僅保證對寫入欄位的同一執行緒可見。 

因此,與許多看法相反,建立 POJO、在一個執行緒中設定一些值並將該 POJO 交給另一個執行緒根本行不通。接收執行緒可能沒有看到更新,可能會看到部分更新(例如long的低四位已更新但高位未更新)或所有更新。更糟糕的是,這些變化可能會在 100 納秒後、一秒後看到,或者根本看不到。根本沒有辦法知道。 

 

各種解決方案

1. 避免 POJO 問題的一種方法是將原始欄位(例如int和long欄位)宣告為volatile併為引用欄位使用原子變體。

宣告所有欄位volatile並使用併發包裝類可能會導致一些效能損失。

2. 重用物件的另一種方法是使用ThreadLocal變數,該變數將為每個執行緒提供不同且時間不變的例項。這意味著可以使用正常的高效能記憶體語義。此外,由於執行緒只按順序執行程式碼,因此也可以在不相關的方法中重用相同的物件。

不幸的是,獲取 ThreadLocal 的內部例項的機制會產生一些開銷。還有許多其他與使用程式碼共享的ThreadLocal變數相關的罪魁禍首:

  • 使用後很難清理。
  • 容易發生記憶體洩漏。
  • 可能無法擴充套件。尤其是因為 Java 即將推出的虛擬執行緒特性促進了建立大量執行緒。
  • 有效地為執行緒構成了一個全域性變數。

 

3. 執行緒程上下文可用於儲存可重用的物件和資源。這通常意味著執行緒上下文將以某種方式暴露在 API 中,但結果是它提供了對執行緒重用物件的快速訪問。因為物件可以線上程上下文中直接訪問,所以它提供了一種更直接和確定性的釋放資源的方式:例如,當執行緒上下文關閉時。 

最後,可以混合使用ThreadLocal和執行緒上下文的概念,從而提供無汙染的 API,同時提供簡化的資源清理,從而避免記憶體洩漏。

  

4. 另一種方法是使用開源Chronicle Queue,它提供了一種高效、執行緒安全、無需物件建立的方式來線上程之間交換訊息。

 

Chronicle Queue重用物件

簡單資料物件:

public class MarketData extends SelfDescribingMarshallable {
    int securityId;
    long time;
    float last;
    float high;
    float low;
    // Getters and setters not shown for brevity
}

建立一個頂級物件,在將大量訊息附加到佇列時實現重用:

public static void main(String[] args) {
    final MarketData marketData = new MarketData();
    final ChronicleQueue q = ChronicleQueue
            .single("market-data");
    final ExcerptAppender appender = q.acquireAppender();
    for (long i = 0; i < 1e9; i++) {
        try (final DocumentContext document =
                     appender.acquireWritingDocument(false)) {
             document
                    .wire()
                    .bytes()
                    .writeObject(MarketData.class, 
                            MarketDataUtil.recycle(marketData));
        }
    }
}

由於 Chronicle Queue 將物件序列化為記憶體對映檔案,它不會建立其他不必要的物件,這一點很重要。

  

測試驗證

使用 VM 選項“ -verbose:gc”啟動,以便通過觀察標準輸出清楚地檢測到任何潛在的 GC。

該應用啟動後,在幾秒鐘後附加了大約 1 億條額外訊息後,進行了新的轉儲(但是JVM 沒有報告 GC

分配的物件數量(大約 1500 個物件)僅略有增加,這表明每個傳送的訊息都沒有進行物件分配,JVM 沒有報告 GC,因此在取樣間隔期間沒有收集任何物件。

執行期間呼叫的分析方法顯示 Chronicle Queue 正在使用ThreadLocal變數:

它花費大約 7% 的時間在ThreadLocal$ThreadLocalMap.getEntry(ThreadLocal)方法上,但與動態建立物件相比,這非常值得。