Java堆外內存的使用
最近經常有人問我在Java中使用堆外(off heap)內存的好處與用途何在。我想其他面臨幾樣選擇的人應該也會對這個答案感興趣吧。
堆外內存其實并無特別之處。線程棧,應用程序代碼,NIO緩存用的都是堆外內存。事實上在C或者C++中,你只能使用未托管內存,因為它們默認是沒有托管堆(managed heap)的。在Java中使用托管內存或者“堆”內存是這門語言的一個特性。注意:Java并非唯一這么做的語言。
new Object() vs 對象池 vs 堆外內存
new Object()
在Java 5.0以前,對象池一度非常流行。那個時候創建對象的開銷是非常昂貴的。然而,從Java 5.0以后,對象創建及垃圾回收已經變得非常廉價了,開發人員發現性能得到了提升后,便簡化了代碼,廢棄了對象池,需要的時候就去創建新的對象就好了。在 Java 5.0以前,幾乎所有對象,包括對象池本身,都通過對象池來提升性能,而在5.0以后,只有那些特別昂貴的對象才有必要池化了,比方說線程,Socket,以及數據庫連接。
對象池
在低時延領域它仍是有一定的用武之處的,由于可變對象的循環使用減輕了CPU緩存的壓力,進而使得性能得到了提升。這些對象的生命周期和結構都必須盡可能簡單,但這么做之后你會發現系統性能及抖動都會得到大幅度的改善。
還有一個領域也比較適合使用對象池,譬如需要加載海量數據且其中包含許多冗余對象時。使用對象池能顯著減少內存的使用量以及需要GC的對象數,進而換來更短的GC時間以及更高的吞吐量。
這類對象池通常都會設計得比較輕量級,而非簡單地使用一個同步的HashMap,因此它們仍是有存在的價值的。
拿StringInterner類來作一個例子。你可以將一個包含你想要的文本的可重復使用的可變StringBuilder作為參數傳給它,它會返回你一個匹配的字符串。直接傳遞String對象的效率會很低,因為你已經把這個對象創建出來了。StringBuilder則是可以重復使用的。
注意:這個結構有一個很有意思的特性就是它不需要額外的線程安全的機制,比方說volatile或者synchronized,僅需Java所保障的最低限度的線程安全就足夠了。你能正確地訪問到String內部的final字段,頂多就是讀到了不一致的引用而已。
public class StringInterner {
private final String[] interner;
private final int mask;
public StringInterner(int capacity) {
int n = Maths.nextPower2(capacity, 128);
interner = new String[n];
mask = n - 1;
}
private static boolean isEqual(@Nullable CharSequence s, @NotNull CharSequence cs) {
if (s == null) return false;
if (s.length() != cs.length()) return false;
for (int i = 0; i < cs.length(); i++)
if (s.charAt(i) != cs.charAt(i))
return false;
return true;
}
@NotNull
public String intern(@NotNull CharSequence cs) {
long hash = 0;
for (int i = 0; i < cs.length(); i++)
hash = 57 * hash + cs.charAt(i);
int h = (int) Maths.hash(hash) & mask;
String s = interner[h];
if (isEqual(s, cs))
return s;
String s2 = cs.toString();
return interner[h] = s2;
}
}
堆外內存的使用
使用堆外內存與對象池都能減少GC的暫停時間,這是它們唯一的共同點。生命周期短的可變對象,創建開銷大,或者生命周期雖長但存在冗余的可變對象都比較適合使用對象池。生命周期適中,或者復雜的對象則比較適合由GC來進行處理。然而,中長生命周期的可變對象就比較棘手了,堆外內存則正是它們的菜。
堆外內存的好處是:
- 可以擴展至更大的內存空間。比如超過1TB甚至比主存還大的空間。
- 理論上能減少GC暫停時間。
- 可以在進程間共享,減少JVM間的對象復制,使得JVM的分割部署更容易實現。
- 它的持久化存儲可以支持快速重啟,同時還能夠在測試環境中重現生產數據。
站在系統設計的角度來看,使用堆外內存可以為你的設計提供更多可能。最重要的提升并不在于性能,而是決定性的。
堆外內存及測試
高性能計算領域最大的一個難點在于重現那些隱蔽的BUG,并證實問題已經得到修復。通過將輸入事件及數據以持久化的形式存儲到堆外內存中,你可以將你的關鍵系統變成一系列的復雜狀態機。(簡單的情況下只有一個狀態機)。這樣的話在測試環境便能夠復現出生產環境出現的行為及性能問題了。
許多投行都通過這項技術來可靠地重現當天系統對某個事件的響應,并分析出該事件之所以這么處理的原因。更為重要的是,你能夠立即證明線上的故障已經得到了解決,而不是發現一個問題后,寄希望于它就是引發線上故障的根源。確定性的行為還伴隨著確定性的性能。
你可以在測試環境中按照真實的時間來回放事件,由此得到的時延分布也必定是生產環境中所出現的。由于硬件的不同,一些系統的抖動可能難以復現,不過這在數據分析的角度而言已經相當接近真實的情況了。為了避免出現花一整天的時間來回話前一天的數據的情況,你還可以增加一個閾值,比方說,如果兩個事件的間隔超過10ms的話你可以就只等待10ms。這樣你能夠在一個小時內根據實際的時間來回放出一天的事件,來檢查下你的改動是否對時延分布有所改善。
這樣做是否就損失了“一次編譯,處處執行”的好處了?
一定程度上來講是這樣的,但其實的影響比你想像的要小得多。越接近處理器,你就更依賴于處理器或者操作系統的行為。所幸的是,絕大多數系統使用的都是AMD/Intel的CPU,甚至是ARM處理器在底層上也越來越與這兩家兼容了。操作系統之間也存在差別,因此相對于Windows而言,這項技術更適合在Linux系統上使用。如果你是在Mac OS X或者Windows上開發,然后生產環境是部署在Linux上的話,就一點問題都沒有了。我們在Higher Frequency Trading中也是這么做的。
使用堆外內存會引入什么新的問題
天下沒有免費的午餐,堆外內存也不例外。最大的問題在于你的數據結構變得有些別扭。要么就是需要一個簡單的數據結構以便于直接映射到堆外內存,要么就使用復雜的數據結構并序列化及反序列化到內存中。很明顯使用序列化的話會比較頭疼且存在性能瓶頸。使用序列化比使用堆對象的性能還差。
在金融領域,許多高頻率的數據都是扁平的簡單結構,全部由基礎類型組成,非常適合映射到堆外內存。然而,并非所有的應用程序都是這樣的,可能會有一些嵌套得很深的數據結構,比如說圖,你還不得不將這些對象緩存在堆上。
另外一個問題就是JVM會制約到你對操作系統的使用。你不用再擔心JVM會給系統造成過重的負載。使用堆外內存后,某些限制已經不復存在了,你可以使用比主存還大的數據結構,不過如果你這么做的話又得考慮一下使用的是什么磁盤子系統了。比如說,你肯定不會希望分頁到一塊只有80 IOPS(Input/Ouput Operations per Second,每秒的IO操作)的HDD硬盤上,最好是IOPS能到80,000的SSD硬盤,當然了,1000x的話更好。
OpenHFT能做些什么?
OpenHFT包含許多類庫,它們向你屏蔽了使用本地內存來存儲數據的細節。這些數據結構都是持久化的,使用它們不會產生垃圾或者只有很少。使用了它的應用程序可以運行一整天也沒有一次Minor GC.
Chronicle Queue——持久化的事件隊列。支持同一臺機器上多個JVM的并發寫,以及多臺機器間的并發讀。微秒級的延遲,并能持續保持每秒上百萬消息的吞吐量。
Chronicle Map——kv表的本地或持久化存儲。它能在同一臺機器的不同JVM間共享,數據是通過UDP或者TCP來復制的,并通過TCP來進行遠程訪問。微秒級的延遲,單臺機器能保持每秒百萬級的讀寫操作。
Thread Affinity ——將關鍵線程綁定到獨立的CPU核或者邏輯CPU上,以減少系統抖動。抖動可以減小到原來的千分之一。
使用哪個API?
如果你需要記錄每個事件的話 ——> Chronicle Queue
如果你只需要某個唯一主鍵最近的一條結果 ——> Chronicle Map
如果你更關心那20微秒的抖動的話 ——> Thread Affinity
總結
堆外內存是把雙刃劍。它的價值你已經看到了,可以和別的實現可伸縮性的方案進行下比較。與在堆緩存或者消息隊列,甚至是進程外的數據庫中進行分區/ 分片相比,使用堆外內存要更為簡單高效。不僅如此,以前用來提升性能的某些技巧也已經不再需要了。比如說,堆外內存可以支持操作系統的同步寫,就不再需要異步去執行了,那樣還會面臨數據丟失的風險。不過最大的好處應該就是啟動時間了,生產環境下的系統的重啟速度會大大縮短,映射1 TB的數據只需要10毫秒,同時你還能在測試環境按生產環境的順序復現每一個事件,以還原線上現場。通過它你可以建立起一個可靠的質量體系。
原創文章轉載請注明出處:Java堆外內存的使用