如何提高从 HttpsURLConnection.getInputStream() 反序列化对象的性能?

How to improve performance of deserializing objects from HttpsURLConnection.getInputStream()?

我有一个客户端-服务器应用程序,其中服务器向客户端发送一些二进制数据,客户端必须根据自定义二进制格式从该字节流中反序列化对象。数据通过 HTTPS 连接发送,客户端使用 HttpsURLConnection.getInputStream() 读取数据。

我实现了一个 DataDeserializer,它接受一个 InputStream 并将其完全反序列化。它的工作方式是使用小缓冲区(通常小于 100 字节)执行多个 inputStream.read(buffer) 调用。在实现更好的整体性能的过程中,我也在这里尝试了不同的实现。一项更改确实显着提高了 class' 的性能(我现在使用 ByteBuffer 来读取原始类型,而不是通过字节移位手动读取),但是与网络流结合使用时没有显示出任何差异.有关详细信息,请参阅下面的部分。

我的问题的快速总结

从网络流反序列化花费的时间太长,即使我证明了网络和反序列化器本身很快。有没有我可以尝试的常见性能技巧?我已经用 BufferedInputStream 包装了网络流。另外,我尝试了双缓冲并取得了一些成功(见下面的代码)。欢迎任何实现更好性能的解决方案。


性能测试场景

在我的测试场景中,服务器和客户端位于同一台机器上,服务器发送约 174 MB 的数据。代码片段可以在这个 post 的末尾找到。您在此处看到的所有数字都是 5 次测试运行的平均值。

首先我想知道,HttpsURLConnection中的InputStream的读取速度有多快。包裹成一个BufferedInputStream,将整个数据写入一个ByteArrayOutputStream用了26.250s。1

然后我测试了我的解串器的性能,将所有 174 MB 作为 ByteArrayInputStream 传递给它。在我改进反序列化器的实现之前,它花费了 38.151s。改进后只用了23.466s2 所以这就是它,我想......但是没有。

我实际上想做的,不知何故,是将 connection.getInputStream() 传递给解串器。奇怪的事情来了:在反序列化器改进之前,反序列化花费了 61.413 秒,改进后是 60.100 秒!3

怎么会这样?尽管反序列化器有了显着改善,但这里几乎没有任何改善。此外,与该改进无关,令我惊讶的是,这比单独的性能总和 (60.100 > 26.250 + 23.466) 花费的时间更长。为什么?不要误会我的意思,我没想到这是最好的解决方案,但我也没想到它会那么糟糕。

因此,需要注意三件事:

  1. 整体速度受网络限制最少需要26.250s。也许有一些我可以调整的 http 设置或者我可以进一步优化服务器,但现在这可能不是我应该关注的。
  2. 我的解串器实现很可能仍然不完美,但它本身比网络更快,所以我认为没有必要进一步改进它。
  3. 基于 1. 和 2. 我假设应该以某种方式 可以以组合方式(从网络读取 + 反序列化)完成整个工作应该不会超过 26.250s。欢迎就如何实现这一目标提出任何建议。

我正在寻找某种双缓冲区,允许两个线程并行读取和写入。 标准 Java 中有类似的东西吗?最好是一些 class 继承自 InputStream 允许并行写入它?如果有类似的东西,但不是从 InputStream 继承的,我也许可以更改我的 DataDeserializer 以使用那个。

因为我还没有找到任何这样的 DoubleBufferInputStream,所以我自己实现了它。 代码很长而且可能不完美,我不想打扰你为我做代码审查。它有两个 16kB 缓冲区。使用它我能够将整体性能提高到 39.885s。4 这比 60.100s 好得多,但仍比 26.250s 差得多。选择不同的缓冲区大小并没有太大改变。所以,我希望有人能引导我实现一些好的双缓冲区实现。


测试代码

1 (26.250s)

InputStream inputStream = new BufferedInputStream(connection.getInputStream());
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

byte[] buffer = new byte[16 * 1024];
int count = 0;

long start = System.nanoTime();
while ((count = inputStream.read(buffer)) >= 0) {
    outputStream .write(buffer, 0, count);
}
long end = System.nanoTime();

2 (23.466s)

InputStream inputStream = new ByteArrayInputStream(entire174MBbuffer);
DataDeserializer deserializer = new DataDeserializer(inputStream);

long start = System.nanoTime();
deserializer.Deserialize();
long end = System.nanoTime();

3 (60.100s)

InputStream inputStream = new BufferedInputStream(connection.getInputStream());
DataDeserializer deserializer = new DataDeserializer(inputStream);

long start = System.nanoTime();
deserializer.Deserialize();
long end = System.nanoTime();

4 (39.885s)

MyDoubleBufferInputStream doubleBufferInputStream = new MyDoubleBufferInputStream();

new Thread(new Runnable() {

    @Override
    public void run() {

        try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) {
            byte[] buffer = new byte[16 * 1024];
            int count = 0;
            while ((count = inputStream.read(buffer)) >= 0) {
                doubleBufferInputStream.write(buffer, 0, count);
            }
        } catch (IOException e) {
        } finally {
            doubleBufferInputStream.closeWriting(); // read() may return -1 now
        }
    }

}).start();

DataDeserializer deserializer = new DataDeserializer(doubleBufferInputStream);
long start = System.nanoTime();
deserializer.deserialize();
long end = System.nanoTime();

更新

根据要求,这是我的解串器的核心。我认为最重要的方法是 prepareForRead(),它执行流的实际读取。

class DataDeserializer {
    private InputStream _stream;
    private ByteBuffer _buffer;

    public DataDeserializer(InputStream stream) {
        _stream = stream;
        _buffer = ByteBuffer.allocate(256 * 1024);
        _buffer.order(ByteOrder.LITTLE_ENDIAN);
        _buffer.flip();
    }

    private int readInt() throws IOException {
        prepareForRead(4);
        return _buffer.getInt();
    }
    private long readLong() throws IOException {
        prepareForRead(8);
        return _buffer.getLong();
    }
    private CustomObject readCustomObject() throws IOException {
        prepareForRead(/*size of CustomObject*/);
        int customMember1 = _buffer.getInt();
        long customMember2 = _buffer.getLong();
        // ...
        return new CustomObject(customMember1, customMember2, ...);
    }
    // several other built-in and custom object read methods

    private void prepareForRead(int count) throws IOException {
        while (_buffer.remaining() < count) {
            if (_buffer.capacity() - _buffer.limit() < count) {
                _buffer.compact();
                _buffer.flip();
            }

            int read = _stream.read(_buffer.array(), _buffer.limit(), _buffer.capacity() - _buffer.limit());
            if (read < 0)
                throw new EOFException("Unexpected end of stream.");

            _buffer.limit(_buffer.limit() + read);
        }
    }

    public HugeCustomObject Deserialize() throws IOException {
        while (...) {
            // call several of the above methods
        }
        return new HugeCustomObject(/* deserialized members */);
    }
}

更新 2

我稍微修改了代码片段 #1 以更准确地了解时间花在了哪里:

InputStream inputStream = new BufferedInputStream(connection.getInputStream());
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024];

long read = 0;
long write = 0;
while (true) {
    long t1 = System.nanoTime();
    int count = istream.read(buffer);
    long t2 = System.nanoTime();
    read += t2 - t1;
    if (count < 0)
        break;
    t1 = System.nanoTime();
    ostream.write(buffer, 0, count);
    t2 = System.nanoTime();
    write += t2 - t1;
}
System.out.println(read + " " + write);

这告诉我从网络流读取需要 25.756 秒,而写入 ByteArrayOutputStream 只需要 0.817 秒。这是有道理的,因为这两个数字几乎完美地加起来等于之前测量的 26.250s(加上一些额外的测量开销)。

我以同样的方式修改了代码片段#4:

MyDoubleBufferInputStream doubleBufferInputStream = new MyDoubleBufferInputStream();

new Thread(new Runnable() {

    @Override
    public void run() {
        try (InputStream inputStream = new BufferedInputStream(httpChannelOutputStream.getConnection().getInputStream(), 256 * 1024)) {
            byte[] buffer = new byte[16 * 1024];

            long read = 0;
            long write = 0;
            while (true) {
                long t1 = System.nanoTime();
                int count = inputStream.read(buffer);
                long t2 = System.nanoTime();
                read += t2 - t1;
                if (count < 0)
                    break;
                t1 = System.nanoTime();
                doubleBufferInputStream.write(buffer, 0, count);
                t2 = System.nanoTime();
                write += t2 - t1;
            }
            System.out.println(read + " " + write);
        } catch (IOException e) {
        } finally {
            doubleBufferInputStream.closeWriting();
        }
    }

}).start();

DataDeserializer deserializer = new DataDeserializer(doubleBufferInputStream);
deserializer.deserialize();

现在我希望测量的阅读时间与前面的示例完全相同。但是,read 变量的值是 39.294s(这怎么可能?它与前面示例中测量的代码完全相同,为 25.756s!* 写入我的双缓冲区只需要 0.096 秒。同样,这些数字几乎完美地总结了代码片段 #4 的测量时间。 此外,我使用 Java VisualVM 分析了这段完全相同的代码。这告诉我 40 秒花在了这个线程的 run() 方法上,而这 40 秒中的 100% 是 CPU 时间。另一方面,它在反序列化器内部也花费了 40 秒,但这里只有 26 秒是 CPU 时间,14 秒用于等待。这与从网络读取到 ByteBufferOutputStream 的时间完全匹配。所以我想我必须改进我的双缓冲区 "buffer-switching-algorithm"。

*) 这个奇怪的现象有什么解释吗?我只能想象这种测量方式非常不准确。但是,最新测量值的读取和写入时间完美地总结了原始测量值,因此 不可能不准确...有人可以解释一下吗? 我无法在分析器中找到这些读写性能...我会尝试找到一些设置,让我可以观察这两种方法的分析结果。

改进其中任何一项的最可靠方法是改变

connection.getInputStream()

new BufferedInputStream(connection.getInputStream())

如果这没有帮助,输入流不是你的问题。

显然,我的 "mistake" 是使用 32 位 JVM(准确地说是 jre1.8.0_172)。 运行 在 64 位版本的 JVM 上使用完全相同的代码片段,而且 tadaaa...它速度很快并且在那里很有意义。

特别是查看相应代码片段的这些新数字:

  • 片段 #1:4.667s(对比 26.250s)
  • 片段 #2:11.568s(对比 23.466s)
  • 片段#3:17.185s(对比 60.100s)
  • 片段 #4:12.336s(对比 39.885s)

很显然,给 Does Java 64 bit perform better than the 32-bit version? 的答案不再正确了。或者,这个特定的 32 位 JRE 版本中存在严重错误。我还没有测试任何其他人。

如您所见,#4 仅比#2 稍慢,这完全符合我最初的假设

Based on 1. and 2. I'm assuming that it should be somehow possible to do the entire job in a combined way (reading from the network + deserializing) which should take not much more than 26.250s.

我的问题 更新 2 中描述的分析方法的非常奇怪的结果也不再出现。我还没有在 64 位中重复每一个测试,但是我 did 所做的所有分析结果现在都是合理的,即无论在哪个代码片段中,相同的代码都花费相同的时间。所以也许这真的是一个错误,或者有人有合理的解释吗?