Java中的多线程解压缩

7
因此,我试图在Java中以只读方式访问zip文件,并以多线程方式进行解压缩,因为使用枚举和输入流等标准简单的单线程ZipFile / ZipEntry解决方案会导致将50兆字节的zip文件解压缩到内存需要约五秒钟,而我的硬盘最多只需一秒钟即可读取而不需要解压缩。
然而,整个Java zip库都被同步到了令人难以忍受的程度,毫无疑问,这是因为它们都是抽象的,用于读/写等,而不是具有良好效率的非同步只读代码。
我已查看了第三方Java库,所有这些库都是巨大的VFS库,比使用大象枪射击苍蝇还要糟糕,或者之所以具有性能优势的唯一原因是它们多线程到大多数线程被阻止在磁盘IO上。
我想做的就是将zip文件拉入byte[],fork一些线程并对其进行处理。没有任何理由需要任何同步方式,因为我单独在内存中使用每个未压缩的文件,没有任何交互。
为什么这必须如此困难?

1
你是否对输入和输出进行缓冲处理? - Thorbjørn Ravn Andersen
“将zip文件拉入byte[]”是什么意思?您是否指将其全部读取到字节数组中?如果是这样,它是压缩格式还是解压缩格式?对于那些太大而无法将所有内容放入字节数组的文件,您会怎么做?我认为,如果您不将所有内容放入RAM中,您将回到一个单个文件被多个线程使用的问题,这通常不会比单个线程给您带来任何优势。 - android developer
从查看JDK源代码存档中的代码来看,如果我只是删除本地库中的"synchronized"关键字,那么这将完美地工作。没有非同步版本可用于只读模式是令人恼火的事实。 - JAKJ
1
@JAKJ,我仍然不太明白使用多线程解压缩的意义,因为瓶颈主要在于磁盘而非CPU。也许如果你有一块比CPU更快的硬盘,这样做就有意义了,但现今的硬件并不是这样运作的... - android developer
2
@JAKJ 当然可以。这还取决于压缩技术和文件数量(特别是在实际将数据解压缩为文件的情况下)。我认为通常 M 大于 N,而且通常相差很大。无论如何,我认为无论你找到哪种解决方案,它都不能成为所有文件类型的全局解决方案,因为它们每个都以不同的方式工作,有些甚至可能由于其压缩技术的本质而不允许这样做。当然,朝着这个方向进行优化也是一件好事。 - android developer
显示剩余5条评论
3个回答

3

仅为了记录,经过一些来回的测试,我最终使用的答案如下(完全从头开始,在 while (true) 循环中关闭文件):

  • 使用 DataInputStream.readFully 将整个(在本例中为 50MB)zip 文件读入到一个 byte[] 中。

  • 生成工作线程(每个物理 CPU 核心一个线程,在我的情况下为 4 个),每个线程都取出那个 byte[] 并创建一个 ZipInputStream(ByteArrayInputStream)。第一个工作线程跳过 0 个条目,第二个跳过 1 个,第三个跳过 2 个,以此类推,因此它们之间都有一个偏移量。工作线程不进行任何同步,因此它们都有自己的 zip 文件元数据等本地副本。这是线程安全的,因为 zip 文件是只读的,工作线程不共享解压缩数据。

  • 每个工作线程读取一个条目并处理它,然后跳过足够多的条目,使它们再次相互偏移一个。因此,第一个线程读取 0、4、8... 条目,第二个线程读取 1、5、9... 条目,依此类推。

  • 所有工作线程都使用 .join() 汇总回来。

我的时间如下:

  • 仅将 zip 文件读入 byte[] 中,没有解压缩(只是 IO),每次迭代平均需要 0.1 秒。

  • 直接在底层文件上使用普通的 ZipFile,会产生一个初始峰值为 0.5 秒,其后每次迭代平均需要 0.26 秒(从关闭前一个 ZipFile 开始重新开始)。

  • 将 ZipFile 读入到一个 byte[] 中,在不进行任何多线程处理的情况下使用 ZipInputStream(ByteArrayInputStream) 创建它,结果产生一个初始峰值为 0.3 秒,其后每次迭代平均需要 0.26 秒,显示磁盘缓存正在发挥作用,使随机访问和初始读取等效。

  • 将 ZipFile 读入到一个 byte[] 中,使用上述描述的 4 个工作线程,并等待它们完成,将时间降低到每次迭代平均需要 0.1 秒。

因此,结论是,通过这种方法,我已经成功地将处理中等大小的 zip 文件与适度强大的计算机的时间降至仅需读取文件所需的时间,而额外的解压缩步骤则完全不可见。显然,在具有数万个条目的巨大 zip 文件上使用相同的方法仍将产生巨大的加速。

看起来我并没有试图优化什么,因为我已经将处理时间减少到了我的样本文件(大约是我需要处理的最大文件的大小)的38%,而这是一个简单单线程方法的时间。

考虑到这个“hack-job”如此出色,想象一下使用专门设计进行此操作的本机Java zip-reader类所可能实现的速度提升。


2
使用Java实现最快的方法是使用NIO。您可以通过使用MappedByteBuffer将文件直接映射到内存中。
FileChannel channel = FileChannel.open(Paths.get("/path/to/zip"),
    StandardOpenOption.READ);
MappedByteBuffer buffer = channel.map(MapMode.READ_ONLY, 0, channel.size());

现在buffer包含了您整个文件的内存映射区域。您可以随心所欲地使用它,例如将offsetlength传递给线程。我不知道哪个zip库支持这样的操作,但显然您已经有了类似的东西。
FYI,我测试了一个50 MB的单文件归档,使用通常的ZipInputStream读取它平均只需要不到200毫秒 - 我认为您试图优化的几乎没有什么意义。

@JAKJ 你也可以通过使用一些代码将缓冲区与输入流桥接,从而在缓冲区上叠加ZipInputStream。请参见:https://dev59.com/OW855IYBdhLWcg3wc0Dy#6603018 - Thomas Jungblut
但这有什么好处呢?内存映射文件的内容仍然在磁盘上,直到被访问。而直接访问磁盘缓存的RAM页面的低级API与通过常规文件API到达相同页面之间的差异非常微不足道。将其包装成顺序的“InputStream”会消除任何可能存在的随机访问文件的优势。 - Marko Topolnik
就本地ZIP API而言,它们肯定不能在Java数组上工作。 - Marko Topolnik
@MarkoTopolnik的主要优点是您不需要将另一个副本复制到Java内部字节数组中。使用ZipInputStream下面的BufferedInputStream需要一些时间(200ms vs. 270ms;600ms未缓冲)。对于他的多线程计划,顺序InputStream显然是无用的。顺便说一下,我仍然不知道他想要什么样的并行性:每个文件一个线程? - Thomas Jungblut
那么你的意思是说没有第三方API可以提供更细粒度的控制,并将解压步骤作为单独的问题公开?这实际上可能表明这确实没有带来任何实质性的好处。 - Marko Topolnik
显示剩余2条评论

1

正如您所注意到的,ZipFile 中的所有方法都是同步的。但这仅阻止了在磁盘上打开的相同确切zipfile的不同 ZipFile 实例之间同时运行的多个线程。

如果您希望多个线程以可扩展的方式从同一zipfile中读取,则必须为每个线程打开一个 ZipFile 实例。这样,ZipFile 方法中的每个线程锁定不会阻止除一个线程外的所有线程同时从zipfile中读取。这也意味着当每个线程完成读取后关闭 ZipFile 时,它们关闭自己的实例而不是共享实例,因此您不会在第二个及随后的关闭时出现异常。

提示:如果您真的关心速度,可以通过从第一个ZipFile实例中读取所有ZipEntry对象并与所有线程共享它们来获得更高的性能,以避免在每个线程单独读取ZipEntry对象时重复工作。 ZipEntry对象本身并不与特定的ZipFile实例绑定,ZipEntry只记录元数据,可与表示ZipEntry来自的相同zipfile的任何ZipFile对象一起使用。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接