JVM垃圾收集器基準報告 – Ionuț Baloșin 如何選擇適合你的垃圾回收器

目錄

JVM垃圾收集器基準報告 – Ionuț Baloșin

各項基準:

BurstHeapMemoryAllocator基準

ConstantHeapMemoryOccupancyBenchmark

HeapMemoryBandwidthAllocatorBenchmark

ReadWriteBarriers基準

WriteBarriersLoopingOverArrayBenchmark

ReadBarriersLoopingOverArrayBenchmark

ReadBarriersChainOfClassesBenchmark

NMTMeasurementsMain

結論


 

JVM垃圾收集器基準報告 – Ionuț Baloșin

本文是我根據  google 翻譯自垃圾收集器基準報告,翻譯的不清晰請勿怪。

希望讀者甚於借鑑。

 

本文使用一組不同的模式描述了一系列Java虛擬機(JVM)垃圾收集器(GC)微基準及其結果。對於當前問題,我包括了AdoptOpenJDK 64位服務器VM版本13(內部版本13 + 33)中的所有垃圾收集器:

 

  • 串行GC
  • Parallel / ParallelOld GC (啓動Java 7u4 ParallelGC和ParallelOld GC基本上是同一收集器)
  • 併發標記掃描CMS GC (目前不建議使用,它將根據JEP 363在Java 14版本中刪除)
  • 垃圾優先G1 GC
  • Shenandoah GC
  • ZGC (目前處於實驗階段)
  • Epsilon GC  (目前處於實驗狀態)

我故意選擇了AdoptOpenJDK,因爲並非所有的OpenJDK構建都包括Shenandoah GC。

當前所有GC基準測試都集中在以下指標上:

  1. (a)在相對較小和較大對象的不同分配率下,GC對象回收的效率;(b)具有或不具有恆定的堆預分配部分。
  2. 遍歷和/或更新堆數據結構時嘗試讀寫屏障的影響,並嘗試避免在基準方法中進行任何顯式分配,除非它是由基礎生態系統引起的。
  3. 內部GC本機結構的佔用空間。

配置

  • 所有基準都是用Java編寫的,並使用JMH v1.22
  • 基準測試源代碼不是公開的,但我詳細介紹了它們所依賴的優化模式。
  • 每個基準測試使用5x10s的熱身迭代,5x10s的測量迭代,3個JVM分支,並且是單線程的。
  • 所有針對高對象分配率的基準測試,都將初始堆大小設置爲與最大堆大小相同的值,而且還會預先觸摸頁面以避免調整大小和內存提交打commit(例如-Xms4g -Xmx4g -XX:+ AlwaysPreTouch)。
  • 所有測試均在具有以下配置的計算機上啓動:

     

    • CPU:Intel i7-8550U Kaby Lake R
    • 內存:32GB DDR4 2400 MHz
    • 作業系統:Ubuntu 19.04 / 5.0.0-37-generic
  • 爲了消除動態頻率縮放的影響,我禁用了intel_pstate驅動程序,並將CPU調節器設置爲performance。
  • 請記住,當前的基準測試可能會受到其他因素的影響,例如即時編譯器優化,底層庫(例如JMH),CPU緩存和分支預測效果,內存分配器子系統等。
  • 所有基準測試結果(包括吞吐量,gc.alloc.rate.norm,gc.count,gc.time等)都合併在我的GitHub帳戶的專用HTML報告中。爲了獲得更好的圖表質量,我建議您打開HTML報告,因爲當前帖子僅包含打印屏幕(通常用於吞吐量測量)。您還可以在GitHub的同一存儲庫下找到原始基準測試結果(即JSON測試結果)。

一點理論

在進一步介紹之前,我想簡要地介紹一些理論,以更好地瞭解即將到來的基準測試。

什麼是垃圾回收機制(GC)? 它是一種自動內存管理形式。垃圾收集器嘗試回收由程序不再使用的對象所佔用的垃圾或內存。值得一提的是,垃圾回收器除了回收(對於不再可訪問的對象)外,還進行對象的分配。

 

分代 GC意味着將數據劃分爲多個分配區域,這些區域根據對象的使用期限(即,倖存的GC迭代次數)保持分開。雖然有些收集器是單代的,但其他收集器則使用兩個堆代:

(1)年輕代(劃分爲Eden 代和兩個Survivor代)

(2)老生代。

 

單代GC:

  • Shenandoah  GC
  • ZGC

兩代GC:

  • 串行GC
  • Parallel/ParallelOld GC
  • CMS GC
  • G1 GC

 

讀/寫屏障(Read/write barriers)是一種在對對象進行讀/寫時執行一些額外的內存管理代碼的機制。即使沒有真正的GC發生,這些障礙通常也會影響應用程序的性能(只是讀/寫)。讓我們考慮以下僞代碼:

 

object.field = some_other_object // write
 
object = some_other_object.field // read

 

使用讀/寫障礙的概念,從概念上講,它可能類似於:

write_barrier(object).field = some_other_object
 
object = read_barrier(some_other_object).field

關於上述AdoptOpenJDK收集器,使用的讀/寫障礙如下:

  • 寫屏障
    • 一個寫屏障(用於跟蹤從老生代到年輕代的引用,例如,卡片表),用於:
      • 串行GC
      • Parallel/ParallelOld GC
      • CMS GC
    • 一個寫屏障(在程序運行時併發標記的情況下,例如,Snapshot-At-The-Beginning (SATB)用於:
      • Shenandoah GC
    • 兩個寫屏障:(a)首先,在併發標記(例如SATB)的情況下是PreWrite屏障,(b)其次,再是PostWrite屏障,不僅要跟蹤從老生代到年輕代的引用,還要跟蹤任何跨區域引用(例如,記憶集):
      • G1 GC
  • 讀屏障
    • Shenandoah  GC
      • 在OpenJDK版本<= 12的情況下通過引用訪問對象的字段(即,每次訪問都引用Brooks指針)時
      • 如果OpenJDK版本> = 13,則從堆中加載引用(即,加載引用屏障(LRB))時
    • ZGC,當從堆中加載引用時
  • 沒有障礙
    • Epsilon GC完全不使用任何屏障,並且可以用作所有其他收集器的基準

超出範圍

  • 有關每個垃圾收集器如何工作的更多詳細信息。互聯網上有很多這樣的材料(例如演示,書籍,博客等),其呈現方式比我可能寫的要好。
  • 任何其他JVM實現和GC類型(至少目前是這樣)。
  • 除4GB以外的任何其他JVM Heap大小。
  • 除了報告的JHM時序外,還有任何關於爲何基準X優於或劣於基準Y的詳細說明。但是,我可以將資源提供給可能對重現該場景並進行進一步分析感興趣的任何HotSpot工程師。
  • 真實應用程序上的任何端到端宏基準測試。這可能是最有代表性的,但是,當前的重點是微基準測試。

  •  

各項基準:

 

BurstHeapMemoryAllocator基準

該基準測試 創建了許多臨時對象,在ArrayList中保持對它們的強引用,直到它填充了一定比例的Heap佔用率,然後釋放了它們(即調用blackhole.consume()),因此它們都突然有資格使用垃圾收集器。

void allocate(int sizeInBytes, int numberOfObjects) {
    for (int iter = 0; iter < 4; iter++) {
        List<byte[]> junk = new ArrayList<>(numberOfObjects);
        for (int j = 0; j < numberOfObjects; j++) {
            junk.add(new byte[sizeInBytes]);
        }
        blackhole.consume(junk);
    }
}

 

sizeInBytes爲:

  • _4_KB
  • _4_MB

自動計算numberOfObjects以僅消耗60%的可用堆內存

結論

  • ZGC和Shenandoah GC的性能明顯優於其他所有收集器。
  • G1 GC的吞吐量比ZGC和Shenandoah GC差,但是在_4_MB對象(即,根據G1術語爲巨大對象)的情況下,其性能明顯優於CMS GC,ParallelOld GC和串行GC。

(banq注:適合突然而來的尖鋒訪問)

 

 

ConstantHeapMemoryOccupancyBenchmark

此基準最初(在設置過程中)分配了許多對象,作爲堆的預分配部分,並對其保持強烈引用,直到它填滿一定百分比的堆佔用率(例如70%)。預先分配的對象由大量的複合類組成(例如,類C1->類C2->…->類C32)。這可能會影響GC根遍歷(例如,在“並行”標記階段),因爲遍歷對象圖時指針間接定向(即參考處理)的程度不可忽略。

然後,在基準測試方法中,分配了臨時對象(大小爲8 MB)並立即釋放,因此它們很快就可以使用垃圾收集器。由於這些對象被認爲是大對象,因此它們通常遵循緩慢的路徑分配,直接駐留在“老生代”中(對於代收集者而言),從而增加了使用完整GC (FullGC) 的可能性。

void test(Blackhole blackhole) {
    blackhole.consume(new byte[_8_MB]);
}

結論

  • CMS GC和G1 GC的性能明顯優於其他所有GC。
  • 也許令人驚訝的是,ZGC和Shenandoah GC的吞吐量最差。

(banq注:程序有緩存機制,啓動時預先warm了內存,加載了一些熱點數據在內存中,或者使用類似Redis原理的常駐內存機制)

 

對於ZGC和Shenandoah GC,這可以通過在每個週期標記整個堆以回收垃圾的成本來解釋。在單代GC的情況下,收集(簡單的)垃圾仍然需要標記整個可到達的預分配對象。通過使用-XX:ConcGCThreads = <n>增加併發GC線程數,可以部分緩解此問題。否則,世代GC的工作就是在收集(簡單的)垃圾時跳過其餘的堆。

ParallelOld GC和Serial GC陷入了由大量分配(例如8 MB大小的字節數組)引起的過早的完全GC綜合症。但是,過早的完整GC也可能是由非大量分配引起的。

在G1 GC的情況下,巨大的分配具有特定的處理方式(即,它們被分配到專用區域中),並且在撤離暫停期間(在每個Young GC上)收集起始Java 8u40。

 

HeapMemoryBandwidthAllocatorBenchmark

此基準測試分配大小不同塊的分配率。與以前的基準測試(例如,ConstantHeapMemoryOccupancyBenchmark)相比,它只是分配臨時對象並立即釋放它們,而沒有保留任何預分配的對象。

byte[] allocate() {
    return new byte[sizeInBytes];
}

sizeInBytes爲:

  • _4_KB
  • _4_MB

結論

  • 對於較大的對象(例如_4_MB),G1 GC的響應時間最差(比所有其他Collector慢5倍),而ParallelOld GC似乎是最高效的。
  • 對於相對較小的對象(例如_4_KB),結果幾乎相同,但對Shenandoah GC的支持略好一些,但沒有相關的區別。

對讀寫屏障測試點擊標題見原文

  • 每個GC都有不同的內存佔用空間來保存其內部GC本機結構。儘管這可能會受到堆大小的影響(即,增加/減小堆大小也可能會增加/減少GC本機結構的佔用空間),但很顯然,所有GC都需要額外的本機內存來進行堆管理。
  • 除Epsilon GC外,最小的內存屬於串行GC,其次是CMS GC,ZGC,Shenandoah GC,ParallelOld GC和G1 GC。

 

ReadWriteBarriers基準

測試讀/寫屏障的開銷,同時遍歷整數數組並在它們之間的每個值之間交換值(即array [i] <-> array [j])。整數數組在設置過程中初始化,因此在基準測試方法中,幾乎不存在分配數。

void test() {
    int lSize = size;
    int mask = lSize - 1;
 
    for (int i = 0; i < lSize; i++) {
        Integer aux = array[i];
        array[i] = array[(i + index) & mask];
        array[(i + index) & mask] = aux;
    }
 
    index++;
}

結論

  • Epsilon GC由於不使用任何讀/寫屏障,因此可提供最佳吞吐量。包括它只是爲了爲其他收集器提供基準。
  • Shenandoah GC似乎比任何其他收集器都要好(當然,除了Epsilon GC)。
  • G1 GC提供最差的吞吐量,比其他吞吐量慢約10倍至14倍。在這種情況下,PostWrite障礙(即,跨G1區域的“記憶集”管理)可能是其背後的原因。

 

WriteBarriersLoopingOverArrayBenchmark

在遍歷整數數組的元素並更新其中的每個元素時,測試寫屏障的開銷。基準測試方法中的分配數量保持爲零。

void test(Integer lRefInteger) {
    int lSize = size;
 
    for (int i = 0; i < lSize; i++) {
        array[i] = lRefInteger;
    }
}

 

結論

  • Epsilon GC由於不使用任何讀/寫屏障,因此可提供最佳吞吐量。包括它只是爲了爲其他收集器提供基準。
  • ZGC的性能似乎比任何其他收集器都要好(當然,除了Epsilon GC外)。
  • G1 GC提供了最差的吞吐量,比其餘的要慢大約10到20倍。它最有可能與先前的基準測試具有相同的根本原因(即PostWrite障礙開銷)。

    由於缺少壓縮的OOP,因此在ZGC的支持下產生了細微的差別。對於其他收集器(包括Shenandoah GC),訪問壓縮OOP需支付少量費用。由於ZGC不支持壓縮OOP,因此在這裏具有優勢。

 

ReadBarriersLoopingOverArrayBenchmark

在遍歷整數數組的元素並讀取其中的每個元素時,測試讀取障礙的開銷。取消裝箱效果(即int <-Integer之間的轉換)也是此基準的副作用。

注意:遍歷數組有利於可以提升屏障而不需要真正考慮屏障本身成本的算法。

int test() {
    int lSize = size;
 
    int sum = 0;
    for (int i = 0; i < lSize; i++) {
        sum += array[i];
    }
 
    return sum;
}

 

結論

  • ZGC提供最低的吞吐量。顯然,可以用兩件事來解釋:(1)無法提升讀取屏障檢查;(2)缺少壓縮的OOP,這現在反過來了(與以前的基準相比;例如WriteBarriersLoopingOverArrayBenchmark)。由於整數數組足夠大,因此適合放入CPU緩存的機會較少。
  • 在所有其他情況下,結果都非常相似,這從本質上強調了 Shenandoah GC通過將LRB提升到循環之外,在優化LRB方面做得更好(當然,壓縮OOP的存在很重要)。提醒一下,所有其他收集器(例如串行GC,ParallelOld GC,CMS GC,G1 GC,Epsilon GC)均不使用任何讀取屏障。

儘管在以後的版本中可能會修復讀取屏障提升,但是在ZGC的情況下,壓縮OOP的缺失是設計的約束。

 

ReadBarriersChainOfClassesBenchmark

在遍歷一大堆預分配的複合類實例(例如,類H1->類H2->…->類H32)進行迭代時,測試讀取屏障的開銷,並返回由其最內部保留的字段屬性。

int test() {
    return  h1.h2.h3.h4.h5.h6.h7.h8
           .h9.h10.h11.h12.h13.h14.h15.h16
           .h17.h18.h19.h20.h21.h22.h23.h24
           .h25.h26.h27.h28.h29.h30.h31.h32.aValue;
}
 
// where:
class H1 {
    H2 h2;
 
    H1(H2 h2) {
        this.h2 = h2;
    }
}
 
// ...
 
class H32 {
    int aValue;
 
    public H32(int aValue) {
        this.aValue = aValue;
    }
}

 

結論

  • Epsilon GC由於不使用任何讀/寫屏障,因此可提供最佳吞吐量。包括它只是爲了爲其他收集器提供基準。
  • 在所有其他情況下,吞吐量是相同的,沒有明顯的差異。

 

 

NMTMeasurementsMain

此方案運行一個簡單的“ Hello World” Java程序,該程序在JVM生命週期結束時打印堆管理所需的內部GC本機結構的大小。這樣的結構可能是:

  • 標記位圖–標記可訪問的Java對象所需
  • 標記堆棧– GC算法本身遍歷對象圖所需的標記
  • 記憶集–跟蹤跨區域引用所必需(對於G1 GC)

該測試依賴於本機內存跟蹤(NMT),該程序跟蹤(即檢測)內部JVM分配並報告GC本機結構的佔用空間。

public class NMTMeasurementsMain {
    public static void main(String []args) {
        System.out.println("Hello World");
    }
 
}

使用以下JVM參數模式,每次通過指定不同的GC類型多次啓動“ Hello World” Java程序:

-XX:+ UnlockDiagnosticVMOptions -XX:+使用<Type> GC -XX:+ PrintNMTStatistics -XX:NativeMemoryTracking = summary -Xms4g -Xmx4g -XX:+ AlwaysPreTouch
  • 類型  爲{Serial,ParallelOld,ConcMarkSweep,G1,Shenandoah,Z,Epsilon}
// Serial GC
(reserved=13_694KB, committed=13_694KB)
(malloc=34 KB)
(mmap: reserved=13_660KB, committed=13_660KB)
 
// ParallelOld GC
(reserved=182_105KB, committed=182_105KB)
(malloc=28_861KB)
(mmap: reserved=153_244KB, committed=153_244KB)
 
// CMS GC
(reserved=34_574KB, committed=34_574KB)
(malloc=19_514KB)
(mmap: reserved=15_060KB, committed=15_060KB)
 
// G1 GC
(reserved=216_659KB, committed=216_659KB)
(malloc=27_711KB)
(mmap: reserved=188_948KB, committed=188_948KB)
 
// Shenandoah GC
(reserved=136_082KB, committed=70_538KB)
(malloc=4_994KB)
(mmap: reserved=131_088KB, committed=65_544KB)
 
// ZGC
(reserved=8_421_751KB, committed=65_911KB)
(malloc=375KB)
(mmap: reserved=8_421_376KB, committed=65_536KB)
 
// Epsilon GC
(reserved=29KB, committed=29KB)
(malloc=29KB)

說明:

  • 已提交:已用除PROT_NONE以外的內容映射的地址範圍
  • reserved:已爲特定內存池預映射的總地址範圍

結論

  • 每個GC都有不同的內存佔用空間來保存其內部GC本機結構。儘管這可能會受到堆大小的影響(即,增加/減小堆大小也可能會增加/減少GC本機結構的佔用空間),但很顯然,所有GC都需要額外的本機內存來進行堆管理。
  • 除Epsilon GC外,最小的佔用空間屬於串行GC,其次是CMS GC,ZGC,Shenandoah GC,ParallelOld GC和G1 GC。

 

 

 

 

最終結論

請不要過於虔誠地接受此報告,因爲它涵蓋了所有可能的用例。此外,某些基準可能存在缺陷,而另一些基準可能需要付出更多的努力才能深入研究並試圖理解這些數字背後的真正原因(超出範圍)。即使這樣,我認爲它仍可以爲您提供更廣泛的瞭解,並證明沒有一個垃圾收集器適合所有情況。雙方各有利弊,各有千秋。

根據當前的基準設置和此特定設置很難提供一般性結論。不過,我將其總結爲:

 

  • 對於G1 GC,“記憶集”的管理有很大的開銷。
  • 當大量已分配實例(佔堆大小的60%)有資格進行回收時,ZGC和Shenandoah GC似乎非常有效。
  • 當堆Heap不包含許多其他可以在GC迭代之間存活的強可訪問實例時,ParallelOld GC可以很好地回收短期分配的對象。
  • CMS GC和G1 GC似乎提供了更好的吞吐量,同時當大量Heap堆(大約70%)不斷被佔用時,回收臨時分配的大對象(例如8_MB),因此在GC迭代之間可以生存的實例非常強大。

即使有一些通用的GC特性,您也可以大致瞭解哪種特性更適合您的應用程序:

 

  • 串行GC佔用空間最小,並且可能是參考實現(即最簡單的GC算法)。
  • ParallelOld GC嘗試針對高吞吐量進行優化。
  • G1 GC努力在吞吐量和暫停時間之間取得平衡。
  • ZGC努力爭取儘可能短的暫停時間(例如,最大10毫秒),並且旨在從較小的堆大小到較大的堆大小(即,從數百MB到很多TB)更好地擴展。
  • Shenandoah GC的目標是低暫停時間,不再與堆大小成正比。

(banq注:吞吐量與暫停是一對矛盾,ParallelOld GC注重高吞吐量,而Shenandoah GC是注重低暫停,其他是在這兩個極端之間平滑)

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章