为什么 ByteBuffer.allocate() 和 ByteBuffer.allocateDirect() 之间的奇数性能曲线存在差异

2022-09-01 09:33:16

我正在研究一些代码,这些代码在直接字节缓冲区中效果最好 - 寿命长且很大(每个连接数十到数百兆字节)。在用s计算出确切的循环结构时,我对vs进行了一些微基准测试。 性能。SocketChannelSocketChannelFileChannelByteBuffer.allocate()ByteBuffer.allocateDirect()

结果中有一个我无法真正解释的惊喜。在下图中,传输实现的 256KB 和 512KB 处有一个非常明显的悬崖 - 性能下降了约 50%!似乎还有一个较小的性能悬崖。(%增益序列有助于可视化这些变化。ByteBuffer.allocate()ByteBuffer.allocateDirect()

缓冲区大小(字节)与时间 (MS) 的关系

The Pony Gap

为什么 ByteBuffer.allocate() 和 ByteBuffer.allocateDirect() 之间的奇数性能曲线存在差异?幕后究竟发生了什么?

它可能非常依赖于硬件和操作系统,所以以下是这些细节:

  • MacBook Pro 带双核酷睿 2 CPU
  • 英特尔 X25M 固态盘驱动器
  • OSX 10.6.4

源代码,根据要求:

package ch.dietpizza.bench;

import static java.lang.String.format;
import static java.lang.System.out;
import static java.nio.ByteBuffer.*;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;

public class SocketChannelByteBufferExample {
    private static WritableByteChannel target;
    private static ReadableByteChannel source;
    private static ByteBuffer          buffer;

    public static void main(String[] args) throws IOException, InterruptedException {
        long timeDirect;
        long normal;
        out.println("start");

        for (int i = 512; i <= 1024 * 1024 * 64; i *= 2) {
            buffer = allocateDirect(i);
            timeDirect = copyShortest();

            buffer = allocate(i);
            normal = copyShortest();

            out.println(format("%d, %d, %d", i, normal, timeDirect));
        }

        out.println("stop");
    }

    private static long copyShortest() throws IOException, InterruptedException {
        int result = 0;
        for (int i = 0; i < 100; i++) {
            int single = copyOnce();
            result = (i == 0) ? single : Math.min(result, single);
        }
        return result;
    }


    private static int copyOnce() throws IOException, InterruptedException {
        initialize();

        long start = System.currentTimeMillis();

        while (source.read(buffer)!= -1) {    
            buffer.flip();  
            target.write(buffer);
            buffer.clear();  //pos = 0, limit = capacity
        }

        long time = System.currentTimeMillis() - start;

        rest();

        return (int)time;
    }   


    private static void initialize() throws UnknownHostException, IOException {
        InputStream  is = new FileInputStream(new File("/Users/stu/temp/robyn.in"));//315 MB file
        OutputStream os = new FileOutputStream(new File("/dev/null"));

        target = Channels.newChannel(os);
        source = Channels.newChannel(is);
    }

    private static void rest() throws InterruptedException {
        System.gc();
        Thread.sleep(200);      
    }
}

答案 1

ByteBuffer是如何工作的,以及为什么直接(Byte)Buffers现在是唯一真正有用的。

首先我有点惊讶这不是常识,但忍受它w/我

直接字节缓冲区在 java 堆外部分配一个地址。

这是最重要的:所有操作系统(和本机C)函数都可以利用该地址,而无需将对象锁定在堆上并复制数据。关于复制的简短示例:为了通过Socket.getOutputStream().write(byte[])发送任何数据,本机代码必须“锁定”byte[],将其复制到java堆之外,然后调用OS函数,例如send。复制在堆栈上执行(对于较小的字节[]),或者通过malloc/free(对于较大的字节)执行。DatagramSockets也不例外,它们也复制 - 除了它们被限制为64KB并在堆栈上分配,如果线程堆栈不够大或递归程度不够深,甚至可以杀死进程。注: 锁定可防止 JVM/GC 在堆周围移动/重新分配对象

因此,随着NIO的引入,这个想法是避免复制和大量的流水线/间接传输。在数据到达目的地之前,通常有 3-4 种缓冲类型的流。(耶伊波兰用一个漂亮的镜头扳平了(!))通过引入直接缓冲区,java可以直接与C本机代码通信,而无需任何必要的锁定/复制。因此,函数可以取缓冲区的地址添加位置,并且性能与本机C大致相同。这是关于直接缓冲区的。sent

直接缓冲区的主要问题 - 它们分配成本高,解除分配成本高,使用起来非常麻烦,不像byte[]。

非直接缓冲区不提供直接缓冲区的真正本质 - 即直接桥接到本机/ OS,而是它们是轻量级的并且共享完全相同的API - 甚至更多,它们可以甚至它们的支持数组可用于直接操作 - 什么不爱?好吧,他们必须被复制!wrap byte[]

那么Sun/Oracle如何处理非直接缓冲区,因为操作系统/本机不能使用它们 - 嗯,天真。当使用非直接缓冲区时,必须创建直接计数器部分。该实现足够智能,可以通过*使用和缓存一些直接缓冲区,以避免高昂的创建成本。在复制它们时会出现幼稚的部分 - 它每次都尝试复制整个缓冲区()。ThreadLocalSoftReferenceremaining()

现在想象一下:512 KB 的非直接缓冲区转到 64 KB 套接字缓冲区,套接字缓冲区不会超过其大小。因此,第一次将 512 KB 从非直接复制到线程本地直接,但仅使用其中的 64 KB。下次复制512-64 KB但只使用64 KB,第三次复制512-64*2 KB,只使用64KB,依此类推...这是乐观的,套接字缓冲区将始终完全为空。因此,您不仅复制了 KB,而且复制了× ÷ ( = 512, = 16(套接字缓冲区剩余的平均空间))。nnnmnm

复制部分是所有非直接缓冲区的通用/抽象路径,因此实现永远不会知道目标容量。复制会丢弃缓存,而不是缓存,从而减少内存带宽等。

*有关 SoftReference 缓存的说明:这取决于 GC 实现,体验可能会有所不同。Sun的GC使用空闲堆内存来确定SoftRefences的寿命,这在释放它们时会导致一些尴尬的行为 - 应用程序需要再次分配以前缓存的对象 - 即更多的分配(直接ByteBuffers在堆中占次要部分,所以至少它们不会影响额外的缓存垃圾,而是受到影响)

我的经验法则 - 使用套接字读/写缓冲区大小的池化直接缓冲区。操作系统从不复制超过必要的内容。

这个微基准测试主要是内存吞吐量测试,操作系统将文件完全放在缓存中,因此它主要是测试 。一旦缓冲区用完 L2 缓存,性能下降就会很明显。此外,运行这样的基准测试也会增加和累积GC收集成本。(不会收集软引用的字节缓冲器)memcpyrest()


答案 2

线程本地分配缓冲区 (TLAB)

我想知道测试期间的线程本地分配缓冲区(TLAB)是否约为256K。使用 TLAB 可优化堆中的分配,以便快速实现 <=256K 的非直接分配。

通常的做法是为每个线程提供一个缓冲区,该缓冲区由该线程专门用于执行分配。您必须使用一些同步从堆中分配缓冲区,但在此之后,线程可以在不同步的情况下从缓冲区进行分配。在热点JVM中,我们将这些称为线程本地分配缓冲区(TLAB)。它们效果很好。

绕过 TLAB 的大量分配

如果我关于256K TLAB的假设是正确的,那么本文后面的信息表明,对于较大的非直接缓冲区,>256K分配可能绕过了TLAB。这些分配直接进入堆,需要线程同步,从而导致性能命中。

不能从 TLAB 进行分配并不总是意味着线程必须获取新的 TLAB。根据分配的大小和 TLAB 中剩余的未使用空间,VM 可以决定仅从堆中执行分配。从堆中分配需要同步,但获得新的 TLAB 也需要同步。如果分配被认为是很大的(当前 TLAB 大小的一些重要部分),则分配将始终在堆之外完成。这减少了浪费,并优雅地处理了比平均水平大得多的分配。

调整 TLAB 参数

这个假设可以使用后面的文章中的信息进行测试,这些信息指出了如何调整TLAB并获取诊断信息:

要试验特定的 TLAB 大小,需要设置两个 -XX 标志,一个用于定义初始大小,另一个用于禁用大小调整:

-XX:TLABSize= -XX:-ResizeTLAB

tlab 的最小大小设置为 -XX:MinTLABSize,默认为 2K 字节。最大大小是整数 Java 数组的最大大小,用于在发生 GC 清除时填充 TLAB 的未分配部分。

诊断打印选项

-XX:+PrintTLAB

在每个处打印每个线程一行(以“TLAB:gc线程:”开头,不带“的”)和一个摘要行。


推荐