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)组成。 分段存储区域不一定由连续的块组成。 确实,这是其最重要的功能。 它由来自备用池的大小相等的块组成,但是这些块分散在虚拟地址空间中,甚至可能没有顺序。 有了这一点,我们就具有了池的请求和释放效率,但是却接近于堆的内存使用效率,并且无需担心碎片。
首先让我们看一下泳池的外观。 然后我们可以返回细分。
在此讨论中,池包括以下部分:
- 大小相等的内存块的池(不一定全部在一个数据结构中)。
- 一个或多个已用块的列表。
- 一份免费的大块清单。
要从池中创建分段的内存分配,我们有一个循环:
- 创建一个存储块(数组或类似的东西)的容器。 将该段称为分配的段列表。
- 从空闲列表中取出一部分内存,并将其添加到段列表中。
- 查看段列表包含的总内存是否等于或大于所需的总内存。
- 如果不是,请从2开始重复。
现在,我们有了一个分配段列表,其中至少有足够的内存来满足需求。 当我们释放该内存时,我们只需将这些块放回到空闲列表中。 从中我们可以看到,很快空闲列表上的块将不再是有序的,即使我们按地址对它们进行排序,它们也不会是连续的。 因此,任何分配将具有足够的内存,但没有任何连续的顺序。
这是一个可行的例子
我们将考虑10个1兆字节的块,我们可以将它们称为1,2…10,它们是按顺序排列的。
Start:
Free List: 1 2 3 4 5 6 7 8 9 10
Allocate a 2.5 megabyte store:
Free List: 1 2 3 4 5 6 7
Allocated Store A: 8 9 10
Allocate a 6 megabyte store:
Free List: 1
Allocated Store A: 8 9 10
Allocated Store A: 7 6 5 4 3 2
Free Allocated Store A:
Free List: 10 9 8 1
Allocated Store A: 7 6 5 4 3 2
Allocate a 3.1 megabyte store:
Free List:
Allocated Store A: 7 6 5 4 3 2
Allocated 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的数组:
- 通过将所需索引除以块大小来找到列表中的索引(使用shift以获得效率)。
- 通过取模数找到找到的块中的索引(使用二进制和以提高效率)。
- 使用getDouble内在方法查找实际值(看起来像一个方法,但编译器对此有所了解,而方法调用除外)。
所有这些看起来都不错,但是效果却不尽如人意,因为Java在内存中布置对象的方式存在一些根本性的问题,这些问题妨碍了对分段访问的正确优化。 从表面上看,访问分段的内存区域应该是一些非常快的移位和逻辑操作以及间接查找,但这对于Java而言并不可行。 所有的问题都发生在这一行:
return chunks[(int) bufPos].getDouble((int) pos);
这是此行必须执行的操作:
- 从其句柄中查找块对象。
- 边界检查。
- 从其数据区域获取数据。
- 从该对象的ByteBuffer句柄中查找实际对象。
- 动态查找其长度(它可以更改,因此这是安全点和对象字段查找)。
- 边界检查。
- 检索数据。
真? 是的,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) throws
NoSuchMethodException,
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();
将不安全的单例加载到最终的静态字段中很重要。 这使编译器可以假设对象引用永不更改,因此将生成最佳代码。
现在,我们可以非常快速地从DirectByteBuffer中获取数据,但是我们有分段存储模型,因此我们需要非常快速地获取正确字节缓冲区的地址。 如果将它们存储在数组中,则会冒着数组边界检查和数组对象取消引用步骤的风险。 我们可以通过进一步使用不安全和乱堆的内存来摆脱它们。
private final long chunkIndex;
...
try
{
// Allocate the memory for the index - final so do it here
long 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 memory
long 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)
*/
@Override
public final double getSample(int index)
{
return unsafe.getDouble(getAddress(index));
}
/* (non-Javadoc)
* @see com.nerdscentral.audio.SFSignal#setSample(int, double)
*/
@Override
public 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 System
sf.SetSampleRate(192000)
count=1000
ncount=100
def test():
t1=System.nanoTime()
for i in range(1,ncount):
signal=sf.Mix(+signal1,+signal2)
signal=sf.Realise(signal)
-signal
t2=System.nanoTime()
d=(t2-t1)/1000000.0
print "Done: " + str(d)
return d
signal1=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和数组方法中读取双精度数据所需的事项列表:
- 从其句柄中查找块对象。
- 边界检查。
- 从其数据区域获取数据。
- 从该对象的ByteBuffer句柄中查找实际对象。
- 动态查找其长度(它可以更改,因此这是安全点和对象字段查找)。
- 边界检查。
- 检索数据。
使用新方法,我们可以:
- 检索块的地址
- 从该块中检索数据
不仅发出的机器指令数量减少了很多,而且存储器访问的位置更加本地化,几乎可以肯定的是,它可以提高数据处理期间的缓存使用率。
如此处所述,用于存储系统快速版本的源代码为: 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 存储数据到文件中