在上文中,我們使用C++和Java分別開發了一個隊列,可以作為時鐘發生器。今天我們將其用作度量工具。
今天的問題是:為每個新消息分配新內存,還是使用內存池?我觀察到的網上討論中,老派C程序員通常避免分配內存,而Java程序員則傾向於分配新內存。本文中我們將詳細分析兩種做法。
該問題適用於批處理或者軟實時應用。對批處理程序來說,程序的吞吐量更加重要,對於軟實時程序來說,則存在延遲問題,如果處理某個消息的時間太長,程序會錯過一些傳入的消息。本文將分別研究這兩種情況。事實上也存在第三種情況,網絡服務器,同時有延遲和吞吐量的限制,在本文中暫不討論。
關於實時程序
一些讀者可能想知道為什麼有人甚至嘗試使用Java寫實時程序。 每個人都知道Java不是實時平臺。 實際上普通Windows或Linux都不是實時操作系統。沒有人會用Java編寫真正的實時程序(例如自動駕駛儀)。 在本文中,實時程序是指接近實時的程序(即軟實時程序):那些允許發生少量事件丟失的程序。距離來說比如網絡流量分析器,如果在一百萬個數據包中丟失一兩百個包,通常不是大問題。 這樣的程序幾乎可以用任何語言(包括Java)開發,並可以在常規操作系統上運行。 我們將使用這種分析器的極其簡模型作為示例程序。
GC的影響
為什麼在分配內存和內存池之間進行選擇非常重要?對於Java而言 ,最重要的因素是垃圾收集器( GC ),因為它確實可以暫停整個程序的執行(稱為“停止世界”)。
最簡單的形式的垃圾收集器:
真正的垃圾收集器採用各種技巧來提高性能,消除長時間停頓並降低對活動對象數量的敏感性:
Java
這些改進通常需要生成代碼,例如寫屏障,甚至讀屏障。這些都降低了執行速度,但在許多情況下,仍可通過減少垃圾收集暫停來證明其合理性。
但是,這些改進不會影響兩個基本的GC規則:分配更多內存時,GC調用頻率更高;而當存在更多活動對象時,GC運行時間更長。
總是分配新內存和內存池這兩種方法對GC的影響並不相同。分配新內存策略通常使活動對象的數量保持較小,但會導致GC被頻繁調用。內存池策略減少了內存分配,但所有緩衝區都是活動Java對象,導致GC的調用頻率較低,但運行時間更長。
我們在內存池版本中創建許多緩衝區的原因是,我們希望在短時間內同時使用多個緩衝區的。對於分配新內存的方案,這意味著頻繁且長期運行的GC。
顯然,緩衝區不是程序分配的唯一對象。一些程序保留了許多永久分配的數據結構(映射,緩存,事務日誌),相比之下緩衝區反而變得微不足道了。其他一些分配了如此多的臨時對象,從而使得分配緩衝區變得微不足道了。本文的例子不適用於這些情況。
其他問題
單獨進行內存分配會產生其他成本。通常,獲取新對象的地址很快(特別是對於採用線程本地內存池的Java實現)。然而,也將內存清零並調用構造函數等開銷。
另一方面,池化也涉及一些開銷。必須仔細跟蹤每個緩衝區的使用情況,以便一旦緩衝區變空就可以將其返回到空閒池。在多線程情況下,這可能會變得非常棘手。無法跟蹤緩衝區可能導致緩衝區洩漏,類似於C程序中的經典內存洩漏。
混合版本
一種常用的方法是保留一定容量的池,並在需求超出此容量時分配緩衝區。如果釋放的緩衝區未滿,則僅將其返回池中,否則將被丟棄。這種方法在池化和分配新內存之間提供了很好權衡,因此值得測試。
測試
我們將模擬網絡分析器,該程序從網絡接口捕獲數據包,解碼協議並收集統計信息。我們使用一個非常簡化的模型,該模型包括:
DirectByteBuffer
INTERNAL_QUEUE_SIZE
ArrayDeque
STORED_COUNT
我們暫時先研究單線程的情況:處理程序和數據源將在同一線程中運行。稍後我們將考慮多線程情況。
為了減少代碼量,我們將以與池化方法相同的方式來實現混合解決方案,唯一的區別是池大小: MIX_POOL_SIZE或POOL_SIZE 。
我們將使用兩個數據源:
Java隊列,該線程每隔SOURCE_INTERVAL_NS納秒向隊列寫入時鐘信號(序列號)。接收到信號後,數據源將生成數據包。源隊列總容量將以毫秒為單位定義為MAX_CAPTURING_DELAY_MS 。如果在此時間段內不為隊列提供服務,則源數據包將丟失(此丟失將使用序列號檢測到)。在這裡,我們將關注數據包不會丟失的SOURCE_INTERVAL_NS(兩次包間隔時間)的最小值。我們假設MAX_CAPTURING_DELAY_MS
我們將對三種場景進行測試:
場景A:接收數據包,解析並且丟棄大部分數據包。幾乎沒有保存在內存中。
場景B:使用了大量的數據包,但仍然遠遠少於預先分配的內存;
場景C:幾乎所有預分配的內存都將被使用。
這些情況對應於消息處理器的不同反應:
A :負載異常低, 或者,數據速率可能很高,但是大多數數據包被早期過濾掉,並且不會存儲在任何地方。
B :負載較為實際;在大多數情況下,這種情況是可以預期的;
C ; 負載異常高;我們不希望這種情況持續很長時間,但必須處理。這種情況是預先分配多緩衝區的原因。
這是我們使用的參數:
<table><tbody>源代碼在這裡(https://github.com/pzemtsov/article-allocate-or-pool)。
批量策略
為了衡量測試框架的成本,我們將引入另一種緩衝區分配策略:Dummy策略,其中我們只有一個數據包,其他地方都使用這一個數據包。
我們將在2.40GHz的雙Xeon®CPU E5-2620 v3上運行該程序,使用Linux內核版本為3.17.4和Java 的版本為1.8.142並使用2G堆內存。使用如下JVM參數:
# java -Xloggc:gclog -Xms2g -Xmx2g -server Main X [strategy] batch
測試結果(以納秒為單位):
<table><tbody>到目前為止,分配內存策略是最差的(在C場景下很糟糕),這似乎回答了我們的問題,池化模式更加合適,混合策略在A和B情況下是最好的,而在C場景下則稍差一些,這使其成為批處理的理想策略。
測試代碼跑得飛快(60 ns),內存分配,清零和垃圾回收拖慢了速度。
導致此測試性能下降的三個可能因素是:頻繁內存分配,頻繁垃圾回收和高垃圾回收成本。 分配內存策略在C場景下同時受到這三個方面的影響;難怪它的表現如此悲摧。
在A場景中,我們看到了頻繁但快速的GC與罕見但緩慢的GC之間的競爭(在第一種選擇中增加了分配和清零成本)。罕見但緩慢的GC贏了。
當我們查看垃圾收集統計信息時,總體情況就變得不那麼樂觀了,池化策略的優勢也變得不那麼明顯了。讓我們看一下這些文件。它們都包含大量有關GC暫停的記錄,只是其持續時間,頻率和類型(不同。以下是這些文件的分析結果:
<table><tbody>這裡的“ GC計數”是平均每秒GC調用次數,“ GC百分比”是在執行GC花費的時間的百分比。
根據GC的暫停數據,在進行實時操作時,池化策略實際上是最差的一種。它根本行不通,幾乎沒有任何顯示一秒鐘的行為以視為實時。實際上我們的策略針對場景C都不工作。
C場景下使用分配內存策略非常糟糕:它花費了89%的時間在GC上。在分配內存和清理內存上花費了很多時間。然而池化模式也可能很糟糕(情況A佔67%)。目前尚不清楚為什麼A中的GC負載比B和C中的GC負載重得多。
出於好奇,我測量了活動對象數量和每個對象平均GC時間(最後兩列)。GC時間與活動對象數量並不完全成正比,但總的來活動對象數據量高則回收速度慢。回收每個對象的時間驚人地長。回收每個對象大約100納秒,回收100萬個對象就消耗100毫秒,而一百萬個對象實際上並不多。大多數現實的Java程序更加複雜, 內存中有更多(數億)的對象。 這些程序在使用CMS垃圾收集器的時候無法實時運行。
實時測試
對於分配內存策略和A場景和源間隔為1000 ns的實時測試,這是參數和結果:
<code># java -Djava.library.path=. -Xloggc:gclog -Xms2g -Xmx2g \\/<code><code> -server Main A alloc 1000/<code><code>Test: ALLOC/<code><code>Input queue size: 100000/<code><code>Input queue capacity, ms: 99.99999999999999/<code><code>Internal queue: 1000 = 1 ms/<code><code>Stored: 1000/<code><code> 6.0; 6.0; lost: 0/<code><code> 7.0; 1.0; lost: 0/<code><code> 8.0; 1.0; lost: 5717/<code><code> 9.0; 1.0; lost: 0/<code><code> 10.1; 1.0; lost: 0/<code><code> 11.0; 1.0; lost: 0/<code><code> 12.0; 1.0; lost: 0/<code>
沒有任何數據包丟失,這意味著測試程序可以處理負載(我們可以忍受初始性能不足)。
隨著傳入的數據包速率增加, 結果逐步惡化。在500 ns時,我們在27秒後丟棄了約80K數據包,此後再無丟棄。300 ns的輸出如下所示:
<code>5.5; 5.5; lost: 279184/<code><code> 5.8; 0.3; lost: 113569/<code><code> 6.2; 0.3; lost: 111238/<code><code> 6.5; 0.4; lost: 228014/<code><code> 6.9; 0.3; lost: 143214/<code><code> 7.5; 0.6; lost: 296348/<code><code> 8.1; 0.6; lost: 1334374/<code>
實驗表明,不丟失數據包的最小延遲為400 ns(2.5M數據包/秒),與批處理結果非常匹配。
現在讓我們看一下池化策略:
<code># java -Djava.library.path=. -Xloggc:gclog -Xms2g -Xmx2g \\/<code><code> -server Main A pool 1000/<code><code>Test: POOL, size = 1000000/<code><code>Input queue size: 100000/<code><code>Input queue capacity, ms: 99.99999999999999/<code><code>Internal queue: 1000 = 1 ms/<code><code>Stored: 1000/<code><code> 6.0; 6.0; lost: 0/<code><code> 7.0; 1.0; lost: 0/<code><code> 8.0; 1.0; lost: 0/<code><code> 10.3; 2.3; lost: 1250212/<code><code> 11.3; 1.0; lost: 0/<code><code> 12.3; 1.0; lost: 0/<code><code> 13.3; 1.0; lost: 0/<code><code> 15.0; 1.8; lost: 756910/<code><code> 16.0; 1.0; lost: 0/<code><code> 17.0; 1.0; lost: 0/<code><code> 18.0; 1.0; lost: 0/<code><code> 19.8; 1.8; lost: 768783/<code>
這是我們從批處理測試結果中得出的預測:因為其GC暫停時間長於輸入隊列容量,合併數據包處理器將無法處理負載。快速瀏覽gclog文件會發現暫停與批處理測試中的暫停(大約800毫秒)相同,GC大約每四秒鐘運行一次。
無論我們做什麼,池化策略都無法處理情況A ,更不用說B或C了 。增加堆大小會降低GC的頻率,但不會影響其持續時間。增加 源數據包間隔也無濟於事,例如,即使數據包間隔10,000 ns,每40秒也會丟失約80K數據包。將源隊列的容量增加到GC暫停(一秒或更長時間)以上的某個值才能緩解,但這顯然也是有問題的。
這是所有測試的合併結果。使用以下圖例:
<table><tbody>請注意,內存池用於處理C場景。 相同的池,但針對B場景的大小稱為“mix”,並且效果很好。這意味著,對於我們可以處理的情況,池化策略仍比分配內存策略更好,而在某些情況下無法處理。
增加堆大小可以將損失減少到幾乎可以承受的程度,並“幾乎解決”了該問題。如人們所料,它在池化策略的情況下效果更好。然而這種方法看起來很荒謬:誰想使用10 Gb RAM而不是2 Gb只是為了將丟包率從17%減少到0.5%?
G1垃圾收集器
到目前為止,我們一直在使用CMS垃圾收集器。G1(“垃圾優先”)收集器。,在Java 9中成為事實標準,但在Java 8中也可以使用。該垃圾收集器對實時性要求較高的場景更加友好。例如,可以在命令行中指定允許的最大GC暫停時間。因此讓我們使用G1重複測試。
這是批處理測試的命令行參數:
<code>java -Xloggc:gclog -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=80 \\ -server Main alloc batch/<code>
以下是批處理測試的結果(圖例:G1時間/ CMS時間):
<table><tbody>在大多數情況下,執行速度會變慢,在10%到130%之間,但在情況A和B中,池化策略速度更快。
分析垃圾收集器日誌。現在更加複雜了,因為G1日誌中的每一行並非都表示暫停。有些表示異步操作,實際不會停止程序執行。
<table><tbody>結果看起來比CMS更好,並有望為B場景提供可行的解決方案。讓我們運行實時測試:
<table><tbody>G1收集器的影響參差不齊,然而與傳統CMS相比,這樣做的性能要差得多。G1並不是解決所有問題的銀彈:對於C場景我們仍然沒有解決方案。
池化策略仍然比分配內存策略更好。
ZGC
我們從Java 8直接跳到Java 11 ,它具有一個全新的垃圾收集器ZGC,號稱能夠處理TB級的堆和億萬個對象。
在撰寫本文時,此垃圾收集器僅在Linux上可用,並且僅作為實驗性功能。讓我們吃個螃蟹。
命令行如下所示:
<code>java -Xloggc:gclog -Xms2g -Xmx2g -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -server Main A alloc batch/<code>
以下是批處理測試結果(圖例為ZGC時間/ G1時間):
<table><tbody>在某些情況下,性能會有所下降,而在大部分些情況下,性能會有所提高。ZGC確實比以前的GC更好。
我沒有找到帶有暫停時間的完整ZGC日誌轉儲的JVM命令行參數,因此我暫時跳過這部分。這是ZGC的實時測試結果:
<table><tbody>所有場景的結果都不錯,可以說處理一個數據包需要450 ns太多了(每秒只處理200萬個數據包),然而即使如此我們以前也做不到。其他場景的數字也不錯。池化策略看起來仍然比分配內存策略好。
使用預先分配本機緩衝區的CMS
儘管ZGC似乎可以解決我們的問題,但我們不想就此罷休。畢竟,它仍然是試驗性的。如果我們可以提高傳統垃圾收集器的性能呢?ZGC是否可以進一步提高吞吐量?
對於傳統的收集器,觀察到的GC性能似乎有點低,每個對象的延遲似乎很高。為什麼會這樣?一個想法是我們的內存分配模式與GC所調整的模式不同。Java程序會隨時分配對象,通常它們分配“普通”對象(多個字段的結構),而不是大數組。
我們將這些緩衝區移出堆並使堆變小。我們使用DirectByteBuffer在堆外內存中分配它們。分配DirectByteBuffer的代價也是相當高昂的(除其他事項外,它還會調用System.gc() ),並且釋放內存也不簡單 。這就是為什麼在我們的分配內存版本和池化版本中,我們都將這些緩衝區池化,並且我們將在堆外進行。除此之外,分配內存版本將在每次需要它們時分配數據包對象,而池化版本會將它們保留在集合中。儘管數據包的數量與以前相同,但是對象的總數會減少,因為以前我們有byte buffer和byte array,而現在我們只有byte buffer。
也可以說,“分配內存”策略現在不再是真正的“分配”:我們仍然必須為本機緩衝區實現某種池化方案。但我們仍然會測試其性能。
讓我們從CMS GC(批處理測試)開始。這是命令行:
<code> java -Xloggc:gclog -Xms1g -Xmx1g -XX:MaxDirectMemorySize=2g -server \\ Main A native-alloc batch/<code>
Java堆的大小已減少到1 GB。
這是批處理結果:
<table><tbody>結果(除分配內存策略在C場景情況下 )看起來非常好,並且所有結果都比我們到目前為止所看到的要好得多。這似乎是批處理的理想選擇。
讓我們看一下實時結果:
<table><tbody>注意新的符號:“ 250; 丟失:0.0025%”表示,儘管我們仍然丟失數據包,但損耗很小,足以引發最小適用間隔的問題。簡而言之,這是一個“幾乎可行的”解決方案。
池化策略在C場景的GC日誌如下所示:
<code>60.618: [GC (Allocation Failure) 953302K->700246K(1010688K), 0.0720599 secs]/<code><code>62.457: [GC (Allocation Failure) 973142K->717526K(1010176K), 0.0583657 secs]/<code><code>62.515: [Full GC (Ergonomics) 717526K->192907K(1010176K), 0.4102448 secs]/<code><code>64.652: [GC (Allocation Failure) 465803K->220331K(1011712K), 0.0403231 secs]/<code>
大約每兩秒鐘就會有一次短暫的GC運行,收集大約200MB內存,但每次仍會增加20MB的內存使用量。最終會內存不足,每60秒就會有一個400毫秒的GC,將導致大約35萬個數據包丟棄。
“ B ”場景甚至更好:FULL GC僅每1100秒出現一次,大約相當於丟棄總數據包的0.03%(一百萬箇中的300個)。對於混合方案而言更是如此。這樣甚至可以在生產環境中使用該解決方案。
本地緩衝區,G1
這是批處理結果:
結果比沒有本地緩衝區要好,但比cms批處理結果差。
<table><tbody>實時測試的結果:
<table><tbody>雖然看起來比a場景下cms結果差一點,但是依然有進步。
本地緩衝區,ZGC
現在讓我們在批處理測試中嘗試ZGC(將結果與沒有本地緩衝區的ZGC結果進行比較):
<table><tbody>幾乎所有場景都有明顯的改進,尤其是在分配內存策略測試中。但是G1,尤其是CMS的結果仍然好得多。
最後,這是實時測試結果:
<table><tbody>現在我們為所有策略和所有場景提供了一個可行的解決方案。甚至在C場景分配內存策略的情況下都可以使用。
嘗試C ++
我們已經看到內存管理確實影響Java程序的性能。我們可以嘗試通過使用自己的堆外內存管理器來減少這些開銷(我將在以下文章之一中探討這種技術)。 然而我們也可以嘗試用C ++來寫。
C ++中不存在垃圾回收問題;我們可以根據需要保留儘可能多的活動對象,不會引起任何暫停。它可能會由於緩存性能差而降低性能,但這是另一回事。
這使得分配內存策略和池化策略之間的選擇顯而易見:無論分配內存的成本多麼小,池化的成本均為零。因此,池化必將獲勝。讓我們測試一下。
我們的第一個版本將是Java版本的直接翻譯,具有相同的設計特性。具體來說,我們將在需要時分配ipheader和ipv4address對象。這使得dummy版本洩漏內存,因為同一個緩衝區對象多次重複使用而不返回池中,並且沒有人在過程中刪除這些對象。
這是批處理結果:
<table><tbody>結果看起來不錯,但令人驚訝的是,效果並不理想。在使用Java的本地緩衝區+CMS解決方案中,我們已經得到了更好的結果。其他一些組合,Java版的結果也更好。分配內存策略的結果與Java中的大多數結果一樣糟糕,而且令人驚訝的是,dummy的結果也很糟糕。這表明內存分配在C ++中非常昂貴,即使沒有GC也比Java中昂貴得多。
以下是實時測試的結果:
<table><tbody>結果看起來不錯(至少涵蓋了所有情況),但是使用ZGC和本機緩衝區的Java數字起來更好。使用C++的方法必須儘可能減少內存分配。
C ++:無分配
以前的解決方案是以Java方式實現的:在需要時分配一個對象(例如IPv4A
ddress )。在Java中 我們別無選擇,但是在C ++中,我們可以在緩衝區內為最常用的對象保留內存。這將導致在分組處理期間將內存分配減少到零。我們將其稱為flat C ++版本。這是批處理結果:
<table><tbody>所有這些結果都比對應的Java測試要好得多。從絕對意義上講,mix和池化也非常好。
實時測試結果如下所示:
<table><tbody>某些Java版本為分配內存策略提供了更好的結果。本機ZGC在C場景下甚至表現更好,這可以歸因於C ++內存管理器的緩慢和不可預測的特性。但是,其他版本的性能都很好。池化版本在C場景下每秒可以處理400萬個數據包,在B場景下每秒可以處理500萬個數據包,可以達到我們的期望值。A場景的處理速度絕對是驚人的(兩千萬),但是我們必須記住,在這種情況下,我們會丟棄這些數據包。
由於在池化過程中根本不執行任何內存分配,因此場景A , B和C之間的速度差異只能由已用內存的總容量不同來解釋–所用內存更多和隨機訪問模式會降低緩存效率。
彙總
讓我們將所有結果彙總在一個表中。我們將忽略dummpy的結果以及使用高得離譜的堆內存大小獲得的結果。
讓我們首先看一下批處理測試:
<table><tbody>每列中的絕對最佳結果被標記為綠色,並且所有這三個都恰好來自flat C ++ 。
最佳和次佳Java結果分別標記為黃色和紅色。它們來自“ Native CMS”,這表明CMS垃圾收集器距離退役為時尚早。它仍然可以很好地用於批處理程序。
最後,這是實時測試的主要結果:
<table><tbody>深灰色塊表示缺少解決方案(數據包始終丟失)。否則,配色方案相同。flat C ++版本依然是最好的,而最好的和次之的Java版本則來自多個解決方案,最好的是Native ZGC。
結論
如果要編寫真正的實時系統,請使用C或C ++編寫,並避免分配內存。也可以在Java中實現一些相當不錯的實時近似。在這種情況下,它也有助於減少內存分配。
這回答了我們最初的問題(分配內存或池化):池化。 在我們運行的每個測試中,池化的性能要好於分配內存。此外,在大多數Java測試中,分配內存策略在批處理模式下執行得很糟糕,而在實時模式下根本無法執行。
當數據包利用率低時,混合方法非常好。但是,如果利用率增長,則池化變得更好。
垃圾收集器確實是最大的影響因素。池化會引入很多活動對象,這些活動對象會導致偶發但很長的GC延遲。然而分配內存策略會使GC完全過載。此外,在高負載時(我們的C場景 ),無論如何可能存在許多活動對象,並且分配內存策略表現很慘。因此池化仍然是更好的策略。
G1和ZGC收集器儘管經常在批處理模式下表現較差,但它們確實在實時模式下有所改善。ZGC表現特別出色;它甚至可以以合理的性能(每秒200萬個數據包)處理C場景。
如果我們分配一千萬個緩衝區而不是一百萬個緩衝區,或者如果程序使用其他大數據結構,一切都會變得更糟。一種可能的解決方案是將這些結構移到堆外。
在不需要立即響應傳入消息的情況下,增加輸入隊列大小可能會有所幫助。我們可以考慮在C場景下引入另一層以及更高容量的中間隊列。如果我們的源隊列中可以存儲一秒鐘的數據包,則即使使用CMS,池化版本也可以正常工作。
原文地址:
https://pzemtsov.github.io/2019/01/17/allocate-or-pool.html
參考閱讀:
一種靈活的API設計模式:在Spring Boot中支持GraphQL
支付核心系統設計:Airbnb的分佈式事務方案簡介
算力提升117%,資源使用下降50%,打開集群優化正確姿勢
Golang實現單機百萬長連接服務 - 美圖的三年優化經驗
幾款流行監控系統簡介
高可用架構
改變互聯網的構建方式
閱讀更多 高可用架構 的文章