本机速度文件支持的“纯” Java大数据存储
動機(jī)
所有這一切始于意識到我買不起足夠大的計(jì)算機(jī)。 音頻處理需要大量的內(nèi)存。 Audacity是一款出色的免費(fèi)音頻處理器,它使用文件支持的存儲系統(tǒng)對其進(jìn)行管理。 這是解決此類問題的常用方法,在這些問題中,我們存儲了大量信息,并希望隨機(jī)訪問這些信息。 因此,我想為Sonic Field (我的寵物音頻處理/合成項(xiàng)目)開發(fā)一個系統(tǒng),該系統(tǒng)提供了相同的基于磁盤的強(qiáng)大存儲方法,但使用的是純Java。
去年下半年,我開始使用此工具,并在Java Advent日歷( http://www.javaadvent.com/2014/12/a-serpentine-path-to-music.html )概述中對此進(jìn)行了簡要介紹。 。 基于磁盤的內(nèi)存使Sonic Field能夠處理音頻系統(tǒng),而這些音頻系統(tǒng)在我不起眼的16 GB筆記本電腦上需要大量內(nèi)存。 例如,最近的一塊創(chuàng)建了超過50 GB的內(nèi)存:
雖然這是一個突破,但效率也很低。 諸如混合之類的內(nèi)存密集型操作是該系統(tǒng)的瓶頸。 在這里,我通過實(shí)現(xiàn)相同的系統(tǒng)將Java變成了一個存儲動力庫,但效率更高得多。 我懷疑我已經(jīng)接近Java不再對C ++性能不利的極限。
去年,我對該方法進(jìn)行了概述。 今年,我將深入研究執(zhí)行績效的細(xì)節(jié)。 這樣做時,我將說明如何消除傳統(tǒng)Java內(nèi)存訪問技術(shù)的開銷,然后擴(kuò)展思想,以更通用的方法在JVM編程中共享和持久存儲大內(nèi)存系統(tǒng)。
什么是分段存儲?
我承認(rèn)這里有很多概念。 首先要弄清楚的是Java中大型內(nèi)存系統(tǒng)的常規(guī)內(nèi)存管理效率如何低下。 實(shí)際上,我要說的很清楚,我不是在談?wù)摾厥铡?多年使用Java和C ++的經(jīng)驗(yàn)告訴我,收集或顯式堆管理都不有效也不容易實(shí)現(xiàn)。 我根本不在討論這個。 JVM對大型內(nèi)存系統(tǒng)進(jìn)行管理的問題是由于其邊界檢查和對象模型。 使用內(nèi)存池時,這成為了焦點(diǎn)。
隨著延遲或吞吐量性能變得比內(nèi)存使用更為關(guān)鍵,必須要打破內(nèi)存池。 我們沒有一個將所有事物混合在一起的偉大光榮的內(nèi)存系統(tǒng),而是擁有大小相同的對象池。 如果未充分使用池,或者如果映射到池塊中的元素小于塊本身,則與純堆相比需要更多的內(nèi)存。 但是,池的管理確實(shí)非常快。
在這篇文章中,我將討論池支持的分段存儲。 分段存儲基于池,但是允許分配比單個池塊更大的存儲容器。 這個想法是,一個存儲容器(例如1 GB)可以由一組塊(例如每個1 MB)組成。 分段存儲區(qū)域不一定由連續(xù)的塊組成。 確實(shí),這是其最重要的功能。 它由來自備用池的大小相等的塊組成,但是這些塊分散在虛擬地址空間中,甚至可能沒有順序。 有了這一點(diǎn),我們就具有了池的請求和釋放效率,但是卻接近于堆的內(nèi)存使用效率,并且無需擔(dān)心碎片。
首先讓我們看一下游泳池的樣子。 然后我們可以返回細(xì)分。
在此討論中,池包括以下部分:
要從池中創(chuàng)建分段內(nèi)存分配,我們有一個循環(huán):
現(xiàn)在,我們有了一個分配段列表,其中至少有足夠的內(nèi)存來滿足需求。 當(dāng)我們釋放該內(nèi)存時,我們只需將這些塊放回到空閑列表中。 從中我們可以看到,很快,空閑列表中的塊將不再是有序的,即使我們按地址對它們進(jìn)行排序,它們也不會是連續(xù)的。 因此,任何分配將具有足夠的內(nèi)存,但沒有任何連續(xù)的順序。
這是一個可行的例子
我們將考慮10個1兆字節(jié)的塊,我們可以將它們稱為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來說,它的真正威力是。 在當(dāng)前的JVM中,最大可尋址數(shù)組或ByteBuffer僅包含2 ** 31個元素,分段存儲提供了一種有效的方式來處理大量內(nèi)存,并在需要時使用內(nèi)存映射文件來支持該內(nèi)存。考慮到我們需要200億雙,無法將它們分配到數(shù)組或ByteBuffer中; 但是我們可以使用分段內(nèi)存來實(shí)現(xiàn)目標(biāo)。
在Java中將匿名虛擬內(nèi)存用于很大的內(nèi)存對象可能效率不高。 在某些情況下,我們要處理的內(nèi)存比計(jì)算機(jī)上的RAM大得多,與使用匿名映射空間相比,最好使用內(nèi)存映射文件。 這意味著JVM不會(在一定程度上)與其他程序爭奪交換空間,但更重要的是,垃圾收集的內(nèi)存會分配對象訪問權(quán)限,這對于匿名虛擬內(nèi)存來說尤其糟糕。 我們想要集中訪問時域中的特定頁面,以便我們吸引盡可能少的硬頁面錯誤。 我在這里討論了該領(lǐng)域的其他概念: https : //jaxenter.com/high-speed-multi-threaded-virtual-memory-in-java-105629.html 。
鑒于這種。 如果將內(nèi)存映射文件的要求縮小到200億雙,那么我們甚至無法在sun.misc.Unsafe(請參閱下文)中使用magic來提供幫助。 沒有JNI,我們可以用Java管理的最大的內(nèi)存映射文件“塊”僅為2 ^ 31字節(jié)。 正是由于對內(nèi)存映射文件的需求以及分段存儲方法固有的分配/釋放效率,才使我將其用于Sonic Field(在此我經(jīng)常需要在16G機(jī)器上管理100G以上的內(nèi)存)。
深入實(shí)施
現(xiàn)在,我們有一套清晰的想法可以實(shí)施。 我們需要映射的字節(jié)緩沖區(qū)。 每個緩沖區(qū)是池中空閑塊的一個塊。 當(dāng)我們想要分配一個存儲容器時,我們需要將一些映射的字節(jié)緩沖區(qū)塊從空閑池中取出并放入我們的容器中。 釋放容器后,我們將塊返回到空閑池。 簡單,高效,清潔。
另外,重要的一點(diǎn)是,映射的字節(jié)緩沖區(qū)實(shí)際上是帶有文件后備內(nèi)存的java.nio.DirectByteBuffer對象。 稍后我們將使用此概念。 現(xiàn)在我們可以將它們視為ByteBuffers。
在Sonic Field上(這是我使用映射的字節(jié)緩沖區(qū)開發(fā)分段存儲技術(shù)的代碼。–請參閱https://github.com/nerds-central/SonicFieldRepo )。 在該代碼庫中,我定義了以下內(nèi)容:
private static final long ?CHUNK_LEN ???????= 1024 * 1024;為了獲得樣本,我們可以將每個塊視為CHUNK_LEN ByteBuffer。 在我的加速工作之前,用于從分配的內(nèi)存塊訪問元素的代碼是:
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的數(shù)組:
所有這些看起來都不錯,但是效果卻不盡如人意,因?yàn)镴ava在內(nèi)存中布置對象的方式存在一些基本問題,這些問題會阻止對分段訪問進(jìn)行適當(dāng)?shù)膬?yōu)化。 從表面上看,訪問分段的內(nèi)存區(qū)域應(yīng)該是一些非常快的移位和邏輯操作以及間接查找,但這對于Java而言并不可行。 所有的問題都發(fā)生在這一行:
return chunks[(int) bufPos].getDouble((int) pos);這是此行要做的:
真? 是的,JVM完成了所有痛苦的事情。 它不僅需要大量指令,而且還需要在內(nèi)存中跳轉(zhuǎn),從而導(dǎo)致所有隨后的高速緩存行刷新和內(nèi)存暫停。
我們?nèi)绾螌Υ诉M(jìn)行改進(jìn)? 請記住,我們的ByteBuffers是DirectByteBuffers,這意味著它們的數(shù)據(jù)未存儲在Java堆中; 在整個對象生命周期中,它位于相同的虛擬地址位置。 我敢打賭,您已經(jīng)猜到這里的關(guān)鍵是使用sun.misc.Unsafe。 是的; 我們可以通過使用堆內(nèi)存來繞過所有此對象查找。 這樣做意味著彎曲一些Java和JVM規(guī)則,但是值得這樣做。
從現(xiàn)在開始,我討論的所有內(nèi)容都與Java 1.8 x86_64有關(guān)。 將來的版本可能會破壞這種方法,因?yàn)樗环蠘?biāo)準(zhǔn)。
考慮一下:
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中存儲的數(shù)據(jù)在內(nèi)存中的地址。 為此,我使用反射,因?yàn)镈irectByteBuffer是包私有的。 DirectByteBuffer上有一個稱為address()的方法,該方法返回long。 在x86_64上,地址的大小(64位)與長度相同。 雖然long的值是帶符號的,但我們只能將long用作二進(jìn)制數(shù)據(jù),而忽略其數(shù)值。 因此,從address()返回的long實(shí)際上是緩沖區(qū)存儲區(qū)起始位置的虛擬地址。
與“普通” JVM存儲(例如,數(shù)組)不同,DirectByteBuffer的存儲是“堆外”。 它是虛擬內(nèi)存,與其他任何虛擬內(nèi)存一樣,但不屬于垃圾收集器,不能被垃圾收集器移動; 這與我們訪問它的速度和方式有很大的不同。 請記住,對于給定的DirectByteBuffer對象,address()返回的地址永遠(yuǎn)不會更改; 因此,我們可以“永遠(yuǎn)”使用該地址,并避免對象查找。
介紹sun.misc.Unsafe
相信在DirectByteBuffer上調(diào)用getDouble(int)是超級高效的,雖然看起來很可愛,但事實(shí)并非如此。 盡管方法是固有的,但邊界檢查會減慢它的運(yùn)行速度(JVM JIT編譯器知道并可以用機(jī)器代碼代替魔術(shù)方法而不是按常規(guī)方式編譯的魔術(shù)函數(shù))。 但是,使用我們的地址,我們現(xiàn)在可以使用sun.misc.Unsafe來訪問存儲。
而不是:
b.getDouble(pos);我們可以:
unsafe.getDouble(address+pos);不安全的版本也是固有的,可以編譯成與C編譯器(如gcc)幾乎相同的機(jī)器代碼。 換句話說,它盡可能快。 沒有對象取消引用或邊界檢查,它只是從地址加載雙精度型。
商店等效項(xiàng)是:
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();將不安全的單例加載到最終的靜態(tài)字段中很重要。 這使編譯器可以假設(shè)對象引用永不更改,因此將生成最佳代碼。
現(xiàn)在,我們可以非常快速地從DirectByteBuffer中獲取數(shù)據(jù),但是我們具有分段存儲模型,因此我們需要非常快速地獲取正確字節(jié)緩沖區(qū)的地址。 如果將它們存儲在數(shù)組中,則會冒著數(shù)組邊界檢查和數(shù)組對象取消引用步驟的風(fēng)險。 我們可以通過進(jìn)一步使用不安全和混亂的內(nèi)存來擺脫這些漏洞。
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);}再次,我們使用“最終”技巧使編譯器進(jìn)行最佳優(yōu)化。 這里的決賽很長,只是一個地址。 我們可以不安全地直接分配堆內(nèi)存。 想象中的實(shí)現(xiàn)此功能的函數(shù)是allocateMemory(long)。 這將返回一個存儲在chunkIndex中的long。 allocateMemory(long)實(shí)際上是分配字節(jié),但是我們要存儲有效的long類型數(shù)組(地址); 這就是位旋轉(zhuǎn)邏輯在計(jì)算大小時所執(zhí)行的操作。
現(xiàn)在,我們已經(jīng)有了大塊的堆外內(nèi)存,足以存儲我們存儲容器的DirectByteBuffer段的地址,我們可以放入地址并使用不安全的地址進(jìn)行檢索。
在存儲構(gòu)建過程中,我們:
// 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;}這意味著我們獲取和設(shè)置數(shù)據(jù)的新代碼確實(shí)可以非常簡單:
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程序員來說是非常陌生的,我們需要非常認(rèn)真地對待它的危險。 但是,與原始版本相比,它確實(shí)相當(dāng)快。
在我的實(shí)驗(yàn)中,我發(fā)現(xiàn)默認(rèn)的JVM內(nèi)聯(lián)設(shè)置過于保守,無法充分利用這種方法。 通過以下命令行調(diào)整,我看到了大幅度的加速(性能提高了兩倍)。
-XX:MaxInlineSize=128 -XX:InlineSmallCode=1024這些使JVM可以更好地利用可利用的額外性能,而不必強(qiáng)制執(zhí)行邊界檢查和對象查找。 通常,我不建議您擺弄JVM內(nèi)聯(lián)設(shè)置,但是在這種情況下,我具有真正的基準(zhǔn)測試經(jīng)驗(yàn),可以顯示出對復(fù)雜的堆外訪問工作的好處。
測試–快多少?
我編寫了以下Jython進(jìn)行測試:
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這樣做是創(chuàng)建一些存儲的雙打,然后創(chuàng)建新的雙打,并一遍又一遍地從舊讀入。 請記住,我們正在使用由池支持的分段存儲。 因此,我們只在開始時才真正分配該存儲,然后才對“塊”進(jìn)行回收。 這種體系結(jié)構(gòu)意味著我們的執(zhí)行時間主要由執(zhí)行g(shù)etSample和setSample而不是分配或任何其他工具決定。
我們的堆外系統(tǒng)快多少? 在裝有Java 1.8.0的Macbook Pro Retina I7機(jī)器上,我得到了“真實(shí)”(即預(yù)熱后)操作的數(shù)字(越小越好):
對于不安全的內(nèi)存模型:
- 完成:187.124
- 完成:175.007
- 完成:181.124
- 完成:175.384
- 完成:180.497
- 完成:180.688
- 完成:183.309
- 完成:178.901
- 完成:181.746
- 均值180.42
對于傳統(tǒng)的內(nèi)存模型:
- 完成:303.008
- 完成:328.763
- 完成:299.701
- 完成:315.083
- 完成:306.809
- 完成:302.515
- 完成:304.606
- 完成:300.291
- 完成:342.436
- 均值311.468
因此,我們的不安全內(nèi)存模型比傳統(tǒng)Java方法快1.73倍 !
為什么快1.73倍
我們可以明白為什么。
如果我們回顧一下從傳統(tǒng)的DirectByteBuffer和數(shù)組方法中讀取雙精度數(shù)據(jù)所需的事項(xiàng)列表:
使用新方法,我們可以:
不僅發(fā)出的機(jī)器指令減少了很多,而且存儲器訪問的位置也變得更加本地化,??這幾乎可以肯定地提高了數(shù)據(jù)處理期間的緩存使用率。
如此處所述,用于存儲系統(tǒng)快速版本的源代碼為: https : //github.com/nerds-central/SonicFieldRepo/blob/cf6a1b67fb8dd07126b0b1274978bd850ba76931/SonicField/src/com/nerdscentral/audio/SFData.java
我希望您(讀者)發(fā)現(xiàn)一個我沒有解決的大問題! 每當(dāng)我的代碼創(chuàng)建分段存儲容器時,我的代碼都會分配堆內(nèi)存。 但是,垃圾回收器不會釋放此內(nèi)存。 我們可以嘗試使用終結(jié)器來釋放代碼,但是有很多原因使它不是一個好主意。
我的解決方案是使用顯式資源管理。 Sonic Field使用try資源,通過引用計(jì)數(shù)來管理其內(nèi)存。 當(dāng)特定存儲容器的引用計(jì)數(shù)達(dá)到零時,該容器將被釋放,從而將其存儲塊放回空閑列表中,并使用不安全的方法來釋放地址查找內(nèi)存。
其他用途和新思路
大約一年前,我發(fā)布了“ 保持相關(guān)性的Java Power功能 ”。 我猜這是一個有爭議的帖子,并不是我所談?wù)摰拿總€人都對我的想法感到滿意(至少可以這樣說)。 盡管如此,我仍然認(rèn)為JVM面臨著挑戰(zhàn)。 Java和JVM本身的復(fù)雜多線程模型不一定代表人們認(rèn)為它應(yīng)該在多核計(jì)算領(lǐng)域帶來的巨大好處。 使用多個通過共享內(nèi)存或套接字進(jìn)行通信的小進(jìn)程仍然引起了人們的極大興趣。 隨著基于RDMA的網(wǎng)絡(luò)的緩慢但不可避免的增長,這些方法對人們來說將越來越自然。
Java和JVM語言似乎設(shè)法使自己獨(dú)特地?zé)o法利用這些思維上的轉(zhuǎn)變。 通過開發(fā)“圍墻花園”方法,JVM在內(nèi)部工作中變得非常高效,但是在與其他進(jìn)程一起工作時卻表現(xiàn)不佳。 這既是性能問題,也是穩(wěn)定性問題; 無論我們?nèi)绾闻?#xff0c;JVM總是有可能崩潰或進(jìn)入不穩(wěn)定狀態(tài)(有人遇到OutOfMemoryError嗎?)。 在生產(chǎn)系統(tǒng)中,這通常需要幾個小型JVM實(shí)例一起工作,因此,如果一個實(shí)例消失了,生產(chǎn)系統(tǒng)就會停下來。 內(nèi)存映射文件是幫助持久保存數(shù)據(jù)的好方法,即使JVM進(jìn)程消失了。
所有這些問題導(dǎo)致我對另一個原因感到非常感興趣,因?yàn)槲覍VM的高效偏移,映射文件體系結(jié)構(gòu)非常感興趣。 這項(xiàng)技術(shù)位于共享內(nèi)存和映射文件技術(shù)的重疊處,這些技術(shù)現(xiàn)在正在推動高速,穩(wěn)定的生產(chǎn)環(huán)境。 雖然我在這里討論的系統(tǒng)是針對單個JVM的,但使用堆原子(請參見此處: http ://nerds-central.blogspot.co.uk/2015/05/synchronising-sunmiscunsafe-with-c.html),我們可以空閑列表亂用,并在進(jìn)程之間共享。 然后,共享內(nèi)存隊(duì)列還可以對分段存儲分配和利用進(jìn)行進(jìn)程間仲裁。 突然,分段存儲模型成為JVM和其他技術(shù)(Python,C ++等)的多個進(jìn)程共享大型文件持久存儲系統(tǒng)的有效方法。
現(xiàn)在有一些問題。 其中最大的一點(diǎn)是,盡管Java通過內(nèi)存映射文件支持共享內(nèi)存,但它不通過純共享內(nèi)存支持。 如果我們對大面積的內(nèi)存感興趣(如本例所示),則文件映射是一個優(yōu)勢,但是對于不需要持久性的快速變化的小內(nèi)存區(qū)域,文件映射是不必要的性能問題。 我想在JDK中看到一個真正的共享內(nèi)存庫; 這不太可能很快發(fā)生(請參閱我關(guān)于圍墻花園的觀點(diǎn))。 JNI提供了一條路線,但是JNI有許多不利之處,我們對此深有體會。 也許巴拿馬項(xiàng)目將提供所需的功能,并最終打破JVM的壁壘。
綜上所述,我想嘗試的下一個技巧是將文件映射到ramdisk(在此處有一個有趣的文章: http : //www.jamescoyle.net/knowledge/951-the-difference-between-a -tmpfs和Ramfs-ram磁盤) 。 這在Linux上應(yīng)該非常容易,并且可以讓我們在不使用JNI的情況下將進(jìn)程間隊(duì)列放置在純RAM共享內(nèi)存區(qū)域中。 完成此部分后,將獲得純Java高速進(jìn)程間共享內(nèi)存模型。 也許那將不得不等待明年的日歷?
翻譯自: https://www.javacodegeeks.com/2015/12/native-speed-file-backed-large-data-storage-pure-java.html
總結(jié)
以上是生活随笔為你收集整理的本机速度文件支持的“纯” Java大数据存储的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 与Selenium的集成测试
- 下一篇: linux脚本if的判断条件(linux