Java Base64 解码比大文件的编码慢得多

Java Base64 Decoding much slower than Encoding for large files

提问人:JANO 提问时间:11/15/2023 最后编辑:JANO 更新时间:11/15/2023 访问量:58

问:

目前,我正在研究一个现有的应用程序,对高达 100MB 的文件进行编码和解码,并将其与 Base64 进行解码。我注意到使用 Java 的 Base64 类进行编码和解码在性能上存在巨大差异。解码速度可能比编码慢 100 倍以上。根据我的理解,从 Base64 解码相同内容时,没有增加复杂性(除了稍微大 (~33%) 的输入文件)。

我为三种文件大小的编码/解码计时,并将其与在我的 Ubuntu 终端上使用预装的 bash64 命令进行编码/解码进行了比较。这表明使用我的终端时,编码和解码之间并没有真正的区别。然而,Java Base64 解码似乎明显不合时宜。

我的测量:

文件大小 100 KB 1 兆字节 61兆字节
Java Base64 编码 5 毫秒 36 毫秒 325 毫秒
Java Base64 解码 151 毫秒 1 522 毫秒 83 134 毫秒
终端 Base64 编码 8 毫秒 21 毫秒 257 毫秒
终端 Base64 解码 10 毫秒 46毫秒 385毫秒

Java 的解码方法这么慢有什么具体原因吗?我不确定是什么会使 Java 的解码方法比终端命令的 base64 解码更复杂。我认为“大”文件的编码和解码不是典型的用例,但我仍然希望获得合理的性能。

我将不胜感激任何见解或建议来解决我的问题!

Java 复制器:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;

public class Base64Test {

    public static void main(String[] args) throws IOException {
        String inputFileName = "input.txt";
        String outputFileName = "encoded.txt";
        String outputFileName2 = "decoded.txt";

        long time1 = System.nanoTime();

        try (FileInputStream in = new FileInputStream(inputFileName);
             FileOutputStream out = new FileOutputStream(outputFileName)) {
            in.transferTo(Base64.getEncoder().wrap(out));
        }

        System.out.println("Base64 encoding  : " + outputFileName + " Time: " + (System.nanoTime() - time1) / 1000 / 1000);

        long time2 = System.nanoTime();
        try (FileInputStream fis = new FileInputStream(outputFileName);
             FileOutputStream out = new FileOutputStream(outputFileName2)) {
            InputStream in = Base64.getDecoder().wrap(fis);
            in.transferTo(out);
        }

        System.out.println("Base64 decoding: " + outputFileName + " Time: " + (System.nanoTime() - time2) / 1000 / 1000);
    }
}

终端命令:

$ time base64 input.txt > encoded.txt
$ time base64 --decode encoded.txt > decoded.txt

系统:

  • Ubuntu 22.04
  • 16GB 内存
  • OpenJDK 17.0.9 版本
  • 智能 2023.2.5

输入文件由我用键盘输入的随机字符组成,然后复制直到达到特定的文件大小。我还尝试了使用 JDK 17 的不同 Ubuntu 笔记本,并得到了相同的结果。

编辑:缓冲结果:

文件大小 100 KB 1 兆字节 61兆字节 100 兆字节
Java Base64 编码 5 毫秒 30 毫秒 311 毫秒 423 毫秒
Java Base64 解码 12 毫秒 46 毫秒 1684 毫秒 2875 毫秒
java ubuntu base64

评论

2赞 aled 11/15/2023
没有缓冲?我会尝试添加缓冲输入,至少看看它是否会影响结果。
0赞 JANO 11/15/2023
多谢!通过缓冲,解码仅比我的示例编码慢 4-7 倍。原始代码中有很多流(也是缓冲)封装在一起,可能这些流的重新排序已经大大提高了性能。4-7 的系数似乎仍然很高吗?
0赞 VGR 11/15/2023
您只是在运行单个测试,这些测试受类加载时间和 JIT 优化时间的影响。请参阅 stackoverflow.com/questions/504103/...
0赞 Arfur Narf 11/15/2023
测试无法将编码/解码与文件 I/O 时间区分开来。若要比较实际的编码/解码时间,请使用内存中的数据。将整个测试文件读入数组或类似结构中;然后从那里进行编码/解码。

答:

2赞 rzwitserloot 11/15/2023 #1

这里有多个问题。

缓冲区

调用(例如,读取单个字节)可能在某种程度上或非常低效,具体取决于基础资源。它可能需要向操作系统询问一个字节,这涉及一些需要一点时间的基础设施,但如果你为每个字节都这样做,它真的会加起来。如果底层资源是基于块的,则需要花费大量时间,因此读取整个块,然后抛弃除您想要的一个字节之外的所有内容。如果执行此操作:.read().read()

for (int i = 0; i < 1024; i++) in.read();

并表示在 1024 大小的块中工作的资源,它将读取 1024 个字节,扔掉除第一个字节之外的所有字节,然后返回该字节。冲洗并重复:读取 1024*1024 字节。从字面上看,它比 慢 1024 。inbyte[] b = new byte[1024]; in.read(b);

如果你想编写的代码只是更易于编写,而不是 ,没问题!将资源包装在 中,BIS 将负责制作一个小缓冲区。 在 BIS 上导致底层资源,然后对 BIS 的进一步调用只是全内存、全 java 事务,BIS 只是从其缓冲区返回字节,直到您获得所有字节,然后下一次调用才会再次到达底层资源。in.read()in.read(buffer)new BufferedInputStream().read()read(sizableBuffer).read().read()

transferTo不需要缓冲器;它本身是“智能”的,并且可以在补丁中传输。

但是,深入研究 的来源,甚至其实现都需要底层资源。奇怪的是,编码器确实缓冲了更多(大约 8192 字节,这至少可以真正消除边缘,对于大多数基于块的系统来说已经绰绰有余了)。Base64.getDecoder().wrap(someInputStream).read(sizableByteArray)in.read()

只需将它们包裹起来,这个问题就会消失,这会给你带来 100 到 1000 倍的加速。new BufferedInputStreamnew BufferedOutputStream

微基准测试

那些关于计算机如何工作的简单解释是彻头彻尾的谎言。他们根本不是这样工作的。CPU 执行流水线和预测性指针前工作,JVM 运行速度很慢(甚至比您想象的还要慢:例如,它执行一些簿记,例如添加代码来计算某个触发频率和跳过频率)。这是因为绝大多数应用程序实际上将 99% 的时间花在 1% 的代码上,因此缓慢运行代码绝对没有任何区别。除了那 1%,它有很大的不同。这种簿记让 JVM 知道 1% 是什么,而分支的东西意味着 JVM 可以为它制作非常低效的字节码,它会这样做,然后运行它。这称为JIT(Just-in-time)/热点。if

问题是,在启动时,JVM 不知道。因此,“热路径”的性能(过于简化)如下所示:

  • 迭代 1-1000:每次迭代需要 50 毫秒。
  • 迭代 1001:需要 800 毫秒,因为 JVM 编译了一个由所有簿记告知的机器代码版本。
  • 迭代 1002+:每次迭代需要 4 毫秒。

您希望迭代足够多,以使迭代 1-1001 的影响消失在噪音中。但是你怎么知道你已经过了“JIT预热阶段”呢?这很棘手。而这只是性能测量比“启动计时器、运行代码、停止计时器”复杂得多的众多原因之一。

除非你花 5 年时间研究 JVM 和 CPU 设计,否则你将对此毫无希望,无法胜任这项工作。幸运的是,您不必这样做:您可以使用现有工具为您执行性能测试。最常用的微基准测试工具是JMH按照本教程操作

Base64 和磁盘的性质

对 Base64 进行编码将每个 3 字节的块转换为 4 个字节。这意味着你写的比你读的多 33%。(读取 3MB,写入 4MB)。

解码将每个 4 字节的块变成 3 个字节。这意味着你阅读的比你写的多 33%。(读取 4MB,写入 3MB)。

如果底层源具有不同的读取和写入性能特征,则其中一个源自然会比另一个慢。

但。。这是一个很大的差距!

我会解决上述所有问题(添加缓冲,将所有这些代码挂到 JMH harnes 中),但与命令行相比,10x+ 因素仍然很大。以下是我将采取的后续步骤:base64

  • 在新下载的 OpenJDK21 安装上运行此命令。
  • 使用番石榴的解码器而不是内置的解码器。

如果差异消失了,您现在可以安全地“责怪”劣质实现。