本机速度文件支持的“纯” Java大数据存储
動機
所有這一切始于意識到我買不起足夠大的計算機。 音頻處理需要大量的內存。 Audacity是一款出色的免費音頻處理器,它使用文件支持的存儲系統對其進行管理。 這是解決此類問題的常用方法,在這些問題中,我們存儲了大量信息,并希望隨機訪問這些信息。 因此,我想為Sonic Field (我的寵物音頻處理/合成項目)開發一個系統,該系統提供了相同的基于磁盤的強大存儲方法,但使用的是純Java。
去年下半年,我開始使用此工具,并在Java Advent日歷( http://www.javaadvent.com/2014/12/a-serpentine-path-to-music.html )概述中對此進行了簡要介紹。 。 基于磁盤的內存使Sonic Field能夠處理音頻系統,而這些音頻系統在我不起眼的16 GB筆記本電腦上需要大量內存。 例如,最近的一塊創建了超過50 GB的內存:
雖然這是一個突破,但效率也很低。 諸如混合之類的內存密集型操作是該系統的瓶頸。 在這里,我通過實現相同的系統將Java變成了一個存儲動力庫,但效率更高得多。 我懷疑我已經接近Java不再對C ++性能不利的極限。
去年,我對該方法進行了概述。 今年,我將深入研究執行績效的細節。 這樣做時,我將說明如何消除傳統Java內存訪問技術的開銷,然后擴展思想,以更通用的方法在JVM編程中共享和持久存儲大內存系統。
什么是分段存儲?
我承認這里有很多概念。 首先要弄清楚的是Java中大型內存系統的常規內存管理效率如何低下。 實際上,我要說的很清楚,我不是在談論垃圾回收。 多年使用Java和C ++的經驗告訴我,收集或顯式堆管理都不有效也不容易實現。 我根本不在討論這個。 JVM對大型內存系統進行管理的問題是由于其邊界檢查和對象模型。 使用內存池時,這成為了焦點。
隨著延遲或吞吐量性能變得比內存使用更為關鍵,必須要打破內存池。 我們沒有一個將所有事物混合在一起的偉大光榮的內存系統,而是擁有大小相同的對象池。 如果未充分使用池,或者如果映射到池塊中的元素小于塊本身,則與純堆相比需要更多的內存。 但是,池的管理確實非???。
在這篇文章中,我將討論池支持的分段存儲。 分段存儲基于池,但是允許分配比單個池塊更大的存儲容器。 這個想法是,一個存儲容器(例如1 GB)可以由一組塊(例如每個1 MB)組成。 分段存儲區域不一定由連續的塊組成。 確實,這是其最重要的功能。 它由來自備用池的大小相等的塊組成,但是這些塊分散在虛擬地址空間中,甚至可能沒有順序。 有了這一點,我們就具有了池的請求和釋放效率,但是卻接近于堆的內存使用效率,并且無需擔心碎片。
首先讓我們看一下游泳池的樣子。 然后我們可以返回細分。
在此討論中,池包括以下部分:
要從池中創建分段內存分配,我們有一個循環:
現在,我們有了一個分配段列表,其中至少有足夠的內存來滿足需求。 當我們釋放該內存時,我們只需將這些塊放回到空閑列表中。 從中我們可以看到,很快,空閑列表中的塊將不再是有序的,即使我們按地址對它們進行排序,它們也不會是連續的。 因此,任何分配將具有足夠的內存,但沒有任何連續的順序。
這是一個可行的例子
我們將考慮10個1兆字節的塊,我們可以將它們稱為1,2…10,它們是按順序排列的。
Start:Free List: 1 2 3 4 5 6 7 8 9 10Allocate a 2.5 megabyte store:Free List: 1 2 3 4 5 6 7Allocated Store A: 8 9 10Allocate a 6 megabyte store:Free List: 1 Allocated Store A: 8 9 10Allocated Store A: 7 6 5 4 3 2Free Allocated Store A:Free List: 10 9 8 1Allocated Store A: 7 6 5 4 3 2Allocate a 3.1 megabyte store:Free List: Allocated Store A: 7 6 5 4 3 2Allocated Store C:10 9 8 1可以注意到,這種方法對于某些情況(例如64位C ++)來說是好的,但是對于Java來說,它的真正威力是。 在當前的JVM中,最大可尋址數組或ByteBuffer僅包含2 ** 31個元素,分段存儲提供了一種有效的方式來處理大量內存,并在需要時使用內存映射文件來支持該內存。考慮到我們需要200億雙,無法將它們分配到數組或ByteBuffer中; 但是我們可以使用分段內存來實現目標。
在Java中將匿名虛擬內存用于很大的內存對象可能效率不高。 在某些情況下,我們要處理的內存比計算機上的RAM大得多,與使用匿名映射空間相比,最好使用內存映射文件。 這意味著JVM不會(在一定程度上)與其他程序爭奪交換空間,但更重要的是,垃圾收集的內存會分配對象訪問權限,這對于匿名虛擬內存來說尤其糟糕。 我們想要集中訪問時域中的特定頁面,以便我們吸引盡可能少的硬頁面錯誤。 我在這里討論了該領域的其他概念: https : //jaxenter.com/high-speed-multi-threaded-virtual-memory-in-java-105629.html 。
鑒于這種。 如果將內存映射文件的要求縮小到200億雙,那么我們甚至無法在sun.misc.Unsafe(請參閱下文)中使用magic來提供幫助。 沒有JNI,我們可以用Java管理的最大的內存映射文件“塊”僅為2 ^ 31字節。 正是由于對內存映射文件的需求以及分段存儲方法固有的分配/釋放效率,才使我將其用于Sonic Field(在此我經常需要在16G機器上管理100G以上的內存)。
深入實施
現在,我們有一套清晰的想法可以實施。 我們需要映射的字節緩沖區。 每個緩沖區是池中空閑塊的一個塊。 當我們想要分配一個存儲容器時,我們需要將一些映射的字節緩沖區塊從空閑池中取出并放入我們的容器中。 釋放容器后,我們將塊返回到空閑池。 簡單,高效,清潔。
另外,重要的一點是,映射的字節緩沖區實際上是帶有文件后備內存的java.nio.DirectByteBuffer對象。 稍后我們將使用此概念。 現在我們可以將它們視為ByteBuffers。
在Sonic Field上(這是我使用映射的字節緩沖區開發分段存儲技術的代碼。–請參閱https://github.com/nerds-central/SonicFieldRepo )。 在該代碼庫中,我定義了以下內容:
private static final long ?CHUNK_LEN ???????= 1024 * 1024;為了獲得樣本,我們可以將每個塊視為CHUNK_LEN ByteBuffer。 在我的加速工作之前,用于從分配的內存塊訪問元素的代碼是:
private static final long ?CHUNK_SHIFT ?????= 20;private static final long ?CHUNK_MASK ??????= CHUNK_LEN - 1; ...public final double getSample(int index){long bytePos = index << 3;long pos = bytePos & CHUNK_MASK;long bufPos = (bytePos - pos) >> CHUNK_SHIFT;return chunks[(int) bufPos].getDouble((int) pos);}因此,在這種情況下,分配的段列表是ByteBuffers的數組:
所有這些看起來都不錯,但是效果卻不盡如人意,因為Java在內存中布置對象的方式存在一些基本問題,這些問題會阻止對分段訪問進行適當的優化。 從表面上看,訪問分段的內存區域應該是一些非常快的移位和邏輯操作以及間接查找,但這對于Java而言并不可行。 所有的問題都發生在這一行:
return chunks[(int) bufPos].getDouble((int) pos);這是此行要做的:
真? 是的,JVM完成了所有痛苦的事情。 它不僅需要大量指令,而且還需要在內存中跳轉,從而導致所有隨后的高速緩存行刷新和內存暫停。
我們如何對此進行改進? 請記住,我們的ByteBuffers是DirectByteBuffers,這意味著它們的數據未存儲在Java堆中; 在整個對象生命周期中,它位于相同的虛擬地址位置。 我敢打賭,您已經猜到這里的關鍵是使用sun.misc.Unsafe。 是的; 我們可以通過使用堆內存來繞過所有此對象查找。 這樣做意味著彎曲一些Java和JVM規則,但是值得這樣做。
從現在開始,我討論的所有內容都與Java 1.8 x86_64有關。 將來的版本可能會破壞這種方法,因為它不符合標準。
考慮一下:
private static class ByteBufferWrapper{public long ??????address;public ByteBuffer buffer;public ByteBufferWrapper(ByteBuffer b) throwsNoSuchMethodException,SecurityException,IllegalAccessException,IllegalArgumentException,InvocationTargetException{Method addM = b.getClass().getMethod("address");addM.setAccessible(true);address = (long) addM.invoke(b);buffer = b;}}我們正在做的是獲取DirectByteBuffer中存儲的數據在內存中的地址。 為此,我使用反射,因為DirectByteBuffer是包私有的。 DirectByteBuffer上有一個稱為address()的方法,該方法返回long。 在x86_64上,地址的大小(64位)與長度相同。 雖然long的值是帶符號的,但我們只能將long用作二進制數據,而忽略其數值。 因此,從address()返回的long實際上是緩沖區存儲區起始位置的虛擬地址。
與“普通” JVM存儲(例如,數組)不同,DirectByteBuffer的存儲是“堆外”。 它是虛擬內存,與其他任何虛擬內存一樣,但不屬于垃圾收集器,不能被垃圾收集器移動; 這與我們訪問它的速度和方式有很大的不同。 請記住,對于給定的DirectByteBuffer對象,address()返回的地址永遠不會更改; 因此,我們可以“永遠”使用該地址,并避免對象查找。
介紹sun.misc.Unsafe
相信在DirectByteBuffer上調用getDouble(int)是超級高效的,雖然看起來很可愛,但事實并非如此。 盡管方法是固有的,但邊界檢查會減慢它的運行速度(JVM JIT編譯器知道并可以用機器代碼代替魔術方法而不是按常規方式編譯的魔術函數)。 但是,使用我們的地址,我們現在可以使用sun.misc.Unsafe來訪問存儲。
而不是:
b.getDouble(pos);我們可以:
unsafe.getDouble(address+pos);不安全的版本也是固有的,可以編譯成與C編譯器(如gcc)幾乎相同的機器代碼。 換句話說,它盡可能快。 沒有對象取消引用或邊界檢查,它只是從地址加載雙精度型。
商店等效項是:
unsafe.putDouble(address+pos,value);這是什么“不安全”的東西? 我們通過另一個反思技巧來解決這個問題:
private static Unsafe getUnsafe(){try{Field f = Unsafe.class.getDeclaredField("theUnsafe");f.setAccessible(true);return (Unsafe) f.get(null);}catch (Exception e){throw new RuntimeException(e);}}private static final Unsafe unsafe = getUnsafe();將不安全的單例加載到最終的靜態字段中很重要。 這使編譯器可以假設對象引用永不更改,因此將生成最佳代碼。
現在,我們可以非??焖俚貜腄irectByteBuffer中獲取數據,但是我們具有分段存儲模型,因此我們需要非常快速地獲取正確字節緩沖區的地址。 如果將它們存儲在數組中,則會冒著數組邊界檢查和數組對象取消引用步驟的風險。 我們可以通過進一步使用不安全和混亂的內存來擺脫這些漏洞。
private final long ?chunkIndex; ...try{// Allocate the memory for the index - final so do it herelong size = (1 + ((l << 3) >> CHUNK_SHIFT)) << 3;allocked = chunkIndex = unsafe.allocateMemory(size);if (allocked == 0){throw new RuntimeException("Out of memory allocating " + size);}makeMap(l << 3l);}catch (Exception e){throw new RuntimeException(e);}再次,我們使用“最終”技巧使編譯器進行最佳優化。 這里的決賽很長,只是一個地址。 我們可以不安全地直接分配堆內存。 想象中的實現此功能的函數是allocateMemory(long)。 這將返回一個存儲在chunkIndex中的long。 allocateMemory(long)實際上是分配字節,但是我們要存儲有效的long類型數組(地址); 這就是位旋轉邏輯在計算大小時所執行的操作。
現在,我們已經有了大塊的堆外內存,足以存儲我們存儲容器的DirectByteBuffer段的地址,我們可以放入地址并使用不安全的地址進行檢索。
在存儲構建過程中,我們:
// now we have the chunks we get the address of the underlying memory// of each and place that in the off heap lookup so we no longer// reference them via objects but purely as raw memorylong offSet = 0;for (ByteBufferWrapper chunk : chunks){unsafe.putAddress(chunkIndex + offSet, chunk.address);offSet += 8;}這意味著我們獲取和設置數據的新代碼確實可以非常簡單:
private long getAddress(long index){long bytePos = index << 3;long pos = bytePos & CHUNK_MASK;long bufPos = (bytePos - pos) >> CHUNK_SHIFT;long address = chunkIndex + (bufPos << 3);return unsafe.getAddress(address) + pos;}/* (non-Javadoc)* @see com.nerdscentral.audio.SFSignal#getSample(int)*/@Overridepublic final double getSample(int index){return unsafe.getDouble(getAddress(index));}/* (non-Javadoc)* @see com.nerdscentral.audio.SFSignal#setSample(int, double)*/@Overridepublic final double setSample(int index, double value){unsafe.putDouble(getAddress(index), value);return value;}這樣做的妙處在于完全沒有對象操作或邊界檢查。 好的,如果有人要求提供超出范圍的樣本,JVM將崩潰。 那可能不是一件好事。 這種編程對于許多Java程序員來說是非常陌生的,我們需要非常認真地對待它的危險。 但是,與原始版本相比,它確實相當快。
在我的實驗中,我發現默認的JVM內聯設置過于保守,無法充分利用這種方法。 通過以下命令行調整,我看到了大幅度的加速(性能提高了兩倍)。
-XX:MaxInlineSize=128 -XX:InlineSmallCode=1024這些使JVM可以更好地利用可利用的額外性能,而不必強制執行邊界檢查和對象查找。 通常,我不建議您擺弄JVM內聯設置,但是在這種情況下,我具有真正的基準測試經驗,可以顯示出對復雜的堆外訪問工作的好處。
測試–快多少?
我編寫了以下Jython進行測試:
import math from java.lang import Systemsf.SetSampleRate(192000) count=1000 ncount=100def test():t1=System.nanoTime()for i in range(1,ncount):signal=sf.Mix(+signal1,+signal2)signal=sf.Realise(signal)-signalt2=System.nanoTime()d=(t2-t1)/1000000.0print "Done: " + str(d)return dsignal1=sf.Realise(sf.WhiteNoise(count)) signal2=sf.Realise(sf.WhiteNoise(count)) print "WARM" for i in range(1,100):test()print "Real" total=0.0 for i in range(1,10):total+=test()print "Mean " + str(total/9.0)-signal1 -signal2這樣做是創建一些存儲的雙打,然后創建新的雙打,并一遍又一遍地從舊讀入。 請記住,我們正在使用由池支持的分段存儲。 因此,我們只在開始時才真正分配該存儲,然后才對“塊”進行回收。 這種體系結構意味著我們的執行時間主要由執行getSample和setSample而不是分配或任何其他工具決定。
我們的堆外系統快多少? 在裝有Java 1.8.0的Macbook Pro Retina I7機器上,我得到了“真實”(即預熱后)操作的數字(越小越好):
對于不安全的內存模型:
- 完成:187.124
- 完成:175.007
- 完成:181.124
- 完成:175.384
- 完成:180.497
- 完成:180.688
- 完成:183.309
- 完成:178.901
- 完成:181.746
- 均值180.42
對于傳統的內存模型:
- 完成:303.008
- 完成:328.763
- 完成:299.701
- 完成:315.083
- 完成:306.809
- 完成:302.515
- 完成:304.606
- 完成:300.291
- 完成:342.436
- 均值311.468
因此,我們的不安全內存模型比傳統Java方法快1.73倍 !
為什么快1.73倍
我們可以明白為什么。
如果我們回顧一下從傳統的DirectByteBuffer和數組方法中讀取雙精度數據所需的事項列表:
使用新方法,我們可以:
不僅發出的機器指令減少了很多,而且存儲器訪問的位置也變得更加本地化,??這幾乎可以肯定地提高了數據處理期間的緩存使用率。
如此處所述,用于存儲系統快速版本的源代碼為: https : //github.com/nerds-central/SonicFieldRepo/blob/cf6a1b67fb8dd07126b0b1274978bd850ba76931/SonicField/src/com/nerdscentral/audio/SFData.java
我希望您(讀者)發現一個我沒有解決的大問題! 每當我的代碼創建分段存儲容器時,我的代碼都會分配堆內存。 但是,垃圾回收器不會釋放此內存。 我們可以嘗試使用終結器來釋放代碼,但是有很多原因使它不是一個好主意。
我的解決方案是使用顯式資源管理。 Sonic Field使用try資源,通過引用計數來管理其內存。 當特定存儲容器的引用計數達到零時,該容器將被釋放,從而將其存儲塊放回空閑列表中,并使用不安全的方法來釋放地址查找內存。
其他用途和新思路
大約一年前,我發布了“ 保持相關性的Java Power功能 ”。 我猜這是一個有爭議的帖子,并不是我所談論的每個人都對我的想法感到滿意(至少可以這樣說)。 盡管如此,我仍然認為JVM面臨著挑戰。 Java和JVM本身的復雜多線程模型不一定代表人們認為它應該在多核計算領域帶來的巨大好處。 使用多個通過共享內存或套接字進行通信的小進程仍然引起了人們的極大興趣。 隨著基于RDMA的網絡的緩慢但不可避免的增長,這些方法對人們來說將越來越自然。
Java和JVM語言似乎設法使自己獨特地無法利用這些思維上的轉變。 通過開發“圍墻花園”方法,JVM在內部工作中變得非常高效,但是在與其他進程一起工作時卻表現不佳。 這既是性能問題,也是穩定性問題; 無論我們如何努力,JVM總是有可能崩潰或進入不穩定狀態(有人遇到OutOfMemoryError嗎?)。 在生產系統中,這通常需要幾個小型JVM實例一起工作,因此,如果一個實例消失了,生產系統就會停下來。 內存映射文件是幫助持久保存數據的好方法,即使JVM進程消失了。
所有這些問題導致我對另一個原因感到非常感興趣,因為我對JVM的高效偏移,映射文件體系結構非常感興趣。 這項技術位于共享內存和映射文件技術的重疊處,這些技術現在正在推動高速,穩定的生產環境。 雖然我在這里討論的系統是針對單個JVM的,但使用堆原子(請參見此處: http ://nerds-central.blogspot.co.uk/2015/05/synchronising-sunmiscunsafe-with-c.html),我們可以空閑列表亂用,并在進程之間共享。 然后,共享內存隊列還可以對分段存儲分配和利用進行進程間仲裁。 突然,分段存儲模型成為JVM和其他技術(Python,C ++等)的多個進程共享大型文件持久存儲系統的有效方法。
現在有一些問題。 其中最大的一點是,盡管Java通過內存映射文件支持共享內存,但它不通過純共享內存支持。 如果我們對大面積的內存感興趣(如本例所示),則文件映射是一個優勢,但是對于不需要持久性的快速變化的小內存區域,文件映射是不必要的性能問題。 我想在JDK中看到一個真正的共享內存庫; 這不太可能很快發生(請參閱我關于圍墻花園的觀點)。 JNI提供了一條路線,但是JNI有許多不利之處,我們對此深有體會。 也許巴拿馬項目將提供所需的功能,并最終打破JVM的壁壘。
綜上所述,我想嘗試的下一個技巧是將文件映射到ramdisk(在此處有一個有趣的文章: http : //www.jamescoyle.net/knowledge/951-the-difference-between-a -tmpfs和Ramfs-ram磁盤) 。 這在Linux上應該非常容易,并且可以讓我們在不使用JNI的情況下將進程間隊列放置在純RAM共享內存區域中。 完成此部分后,將獲得純Java高速進程間共享內存模型。 也許那將不得不等待明年的日歷?
翻譯自: https://www.javacodegeeks.com/2015/12/native-speed-file-backed-large-data-storage-pure-java.html
總結
以上是生活随笔為你收集整理的本机速度文件支持的“纯” Java大数据存储的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 与Selenium的集成测试
- 下一篇: linux脚本if的判断条件(linux