将多个 ByteBuffer 读入单个字符串的节省空间的方法是什么?

Sam*_*Sam 5 nio bytebuffer nonblocking kotlin kotlin-flow

我正在编写一个解码器,它将接收一系列字节缓冲区并将内容解码为单个String. 可以有任意数量的字节缓冲区,每个缓冲区包含任意数量的字节。缓冲区不一定在字符边界上分割,因此根据编码,它们可能在开头或结尾包含部分字符。这就是我想要做的,StringByteStreamDecoder我需要编写的新类在哪里。

suspend fun decode(data: Flow<ByteBuffer>, charset: Charset): String {
    val decoder = StringByteStreamDecoder(charset)
    data.collect { bytes ->
        decoder.feed(bytes)
    }
    decoder.endOfInput()
    return decoder.toString()
}
Run Code Online (Sandbox Code Playgroud)

尝试1

最简单的方法是将所有字节缓冲区收集到一个字节数组中。我拒绝了这种方法,因为它具有显着的内存开销。它需要为完整消息分配空间至少两次:一次为原始字节,一次为解码字符。这是我的简单实现,使用 aByteArrayOutputStream作为扩展字节缓冲区。

class StringByteStreamDecoder(private val charset: Charset) {
    private val buffer = ByteArrayOutputStream()

    fun feed(data: ByteBuffer) {
        if (data.hasArray()) {
            buffer.write(data.array(), data.position() + data.arrayOffset(), data.remaining())
        } else {
            val array = ByteArray(data.remaining())
            data.get(array)
            buffer.write(array, 0, array.size)
        }
    }

    fun endOfInput() {
        buffer.flush()
    }

    override fun toString(): String {
        return buffer.toString(charset)
    }
}
Run Code Online (Sandbox Code Playgroud)

尝试2

为了避免在内存中缓冲整个字节流,我想动态解码字符。不可能将每个字节缓冲区直接解码为字符数据,因为它可能在开头和结尾包含部分字符。字符解码器(据我了解)没有缓冲部分字符的能力,并且只会消耗完整的字符。因此,对于每个传入字节缓冲区,我的方法是:

  1. 将一些数据从传入字节缓冲区读取到一个小的临时字节缓冲区中
  2. 从临时字节缓冲区中解码尽可能多的字符
  3. 重复直到传入字节缓冲区没有剩余数据

接收到所有数据后,可以刷新临时字节缓冲区中的任何剩余字节。这解决了部分字符的问题,前提是临时字节缓冲区至少与字符集最宽的字符一样大。

class StringByteStreamDecoder(charset: Charset, bufferSize: Int = 1024) {
    private val decoder = charset.newDecoder()
    private val tmpBytes = ByteBuffer.allocate(bufferSize)
    private val tmpChars = CharBuffer.allocate((tmpBytes.capacity() * decoder.maxCharsPerByte()).toInt() + 1)
    private val stringBuilder = StringBuilder()

    fun feed(data: ByteBuffer) {
        do {
            tmpBytes.put(data.nextSlice(maxSize = tmpBytes.remaining()))
            flushBytes()
        } while (data.hasRemaining())
    }

    fun endOfInput() {
        flushBytes(endOfInput = true)
    }

    override fun toString(): String = stringBuilder.toString()

    private fun ByteBuffer.nextSlice(maxSize: Int): ByteBuffer {
        val size = minOf(maxSize, remaining())
        val slice = slice(position(), size)
        position(position() + size)
        return slice
    }

    private fun flushBytes(endOfInput: Boolean = false) {
        tmpBytes.flip()
        decoder.decode(tmpBytes, tmpChars, endOfInput)
        tmpBytes.compact()
        flushChars()
    }

    private fun flushChars() {
        tmpChars.flip()
        stringBuilder.append(tmpChars)
        tmpChars.clear()
    }
}
Run Code Online (Sandbox Code Playgroud)

由于额外的临时缓冲区,我对这种方法仍然不完全满意。我希望能够使临时字节缓冲区最多容纳一个(部分)字符。但是,如果我这样做,我必须以某种方式将其添加到下一个传入的数据块中。这意味着分配一个新的字节缓冲区来包含缓冲的部分字符以及新的传入数据。将所有数据从传入缓冲区复制到串联缓冲区并不比首先使用更大的临时缓冲区更有效。

但是,对于小字符串,临时字节缓冲区会带来很大的开销。我可以使临时缓冲区变小,但这可能会影响解码较大字符串时的性能。

我还知道,将StringBuilder根据输入动态调整大小,并且可能不是为结果分配空间的最有效方法String

我认为如果我能够访问本答案中描述的链缓冲区之类的东西,我可以避免一些额外的内存分配。这将允许我创建传入字节缓冲区的串联窗口视图。然后,字符解码器可以直接使用连接的视图,而不需要额外的临时缓冲区。但是,我在标准库中找不到提供此类功能的任何内容。

是否可以解决这个问题,而无需在传入数据和结果String本身之外分配额外的内存?如果不是,那么需要的最小额外内存量是多少,以及实现该最小量的方法是什么?

bro*_*oot 1

我怀疑是否有可能在 Java 中创建字符串而不复制数据。无论我们是从byte[]、 from创建它char[],还是连接其他字符串,我们使用StringBuilder/ StringBuffer- 我们总是必须复制数据。看来您错误地认为以StringBuilder某种方式直接创建字符串。不,它复制 中的数据toString()。可能substring()可以避免在某些 JVM 中创建数据副本,但我不知道在实践中是否是这样实现的。

这很可能是由于字符串保证是不可变的,但数据源通常是可变的,因此需要复制数据。

如果您事先知道数据的大小或者怀疑其大小,我认为最有效的方法是分配字节数组,将所有内容写入其中,然后进行转换。所以你最初的尝试。

如果内存对你来说真的是一个大问题,那么你可以寻找 JVM,它可以让你访问一些高级的东西,也许它们允许从byte[]/char[]不复制创建一个字符串。但首先,您应该重新考虑是否应该真正担心这一点。或者这可能只是一个不成熟的优化。