为啥 ByteBuffer.allocate() 和 ByteBuffer.allocateDirect() 之间的奇怪性能曲线差异

Posted

技术标签:

【中文标题】为啥 ByteBuffer.allocate() 和 ByteBuffer.allocateDirect() 之间的奇怪性能曲线差异【英文标题】:Why the odd performance curve differential between ByteBuffer.allocate() and ByteBuffer.allocateDirect()为什么 ByteBuffer.allocate() 和 ByteBuffer.allocateDirect() 之间的奇怪性能曲线差异 【发布时间】:2011-04-08 18:44:27 【问题描述】:

我正在编写一些SocketChannel-to-SocketChannel 代码,这些代码最适合使用直接字节缓冲区——寿命长且大(每个连接数十到数百兆字节)。同时散列出确切的循环FileChannels 的结构,我对 ByteBuffer.allocate()ByteBuffer.allocateDirect() 的性能进行了一些微基准测试。

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

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

为什么ByteBuffer.allocate()ByteBuffer.allocateDirect() 之间出现奇怪的性能曲线差异?幕后究竟发生了什么?

这很可能取决于硬件和操作系统,所以这里有这些细节:

配备双核 Core 2 CPU 的 MacBook Pro 英特尔 X25M SSD 驱动器 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);      
    

【问题讨论】:

您是否将代码托管在某个地方?我很想看看我是否重新创建了你的结果。 @gid:已添加源代码。期待你的结果。 抱歉耽搁了,已经在 windows 7 x64 & java 1.6.20 上测试过,结果几乎一样。唯一的区别是下降发生在 256k 而不是 512k。 机器,Ubuntu 10.10 32 位,OpenJDK 1.6.0_20。我也测试过,在我的机器上,正常下降发生在 1024k,直接下降发生在 2048k。我想这种影响可能是由 OS/CPU 边界(CPU 缓存)上的某些东西引起的。 @bartosz.r:您的 CPU 的具体型号是什么?我也可以运行一些测试。 【参考方案1】:

ByteBuffer 的工作原理以及为什么 Direct (Byte)Buffers 是现在唯一真正有用的。

首先我有点惊讶,这不是常识,但请跟我一起承受

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

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

所以随着 NIO 的引入,我们的想法是避免复制和大量流水线/间接。在数据到达目的地之前,通常有 3-4 个缓冲类型的流。 (是的,波兰用漂亮的射门扳平了(!)) 通过引入直接缓冲区,java 可以直接与 C 本机代码进行通信,而无需任何锁定/复制。因此sent 函数可以将缓冲区的地址添加到位置,性能与原生 C 大致相同。 那是关于直接缓冲区的。

直接缓冲区的主要问题 - 它们对allocate and expensive to deallocate 来说很昂贵,而且使用起来非常麻烦,不像 byte[]。

非直接缓冲区不提供直接缓冲区的真正本质——即直接桥接到本机/操作系统,而是它们是轻量级的并且共享完全相同的 API——甚至更多,它们可以wrap byte[] 甚至他们的后备阵列可用于直接操作 - 有什么不喜欢的?好吧,它们必须被复制!

那么 Sun/Oracle 如何处理非直接缓冲区,因为 OS/native 不能使用它们 - 好吧,天真。当使用非直接缓冲区时,必须创建直接计数器部分。该实现非常聪明,可以使用ThreadLocal 并通过SoftReference* 缓存一些直接缓冲区,以避免创建的高昂成本。复制它们时会出现幼稚的部分 - 它每次都尝试复制整个缓冲区 (remaining())。

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

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

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

我的经验法则 - 一个池化的直接缓冲区,其大小与套接字读/写缓冲区相匹配。操作系统从不复制不必要的内容。

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

【讨论】:

【参考方案2】:

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

我想知道测试期间的线程本地分配缓冲区(TLAB)是否在 256K 左右。使用 TLAB 可以优化堆中的分配,因此

http://blogs.oracle.com/jonthecollector/entry/a_little_thread_privacy_please

通常的做法是给每个线程一个缓冲区,供该线程专门用于进行分配。您必须使用一些同步来从堆中分配缓冲区,但之后线程可以从缓冲区中分配而无需同步。在热点 JVM 中,我们将这些称为线程本地分配缓冲区 (TLAB)。他们工作得很好。

绕过 TLAB 的大分配

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

http://blogs.oracle.com/jonthecollector/entry/a_little_thread_privacy_please

无法从 TLAB 进行分配并不总是意味着线程必须获得新的 TLAB。根据分配的大小和 TLAB 中剩余的未使用空间,VM 可以决定只从堆中进行分配。堆中的分配需要同步,但获得新的 TLAB 也需要同步。 如果分配被认为很大(当前 TLAB 大小的某个重要部分),则分配将始终在堆外完成。 这样可以减少浪费并得到妥善处理远远大于平均分配。

调整 TLAB 参数

可以使用后面文章中的信息来检验这个假设,该文章指出如何调整 TLAB 并获取诊断信息:

http://blogs.oracle.com/jonthecollector/entry/the_real_thing

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

-XX:TLABSize= -XX:-ResizeTLAB

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

诊断打印选项

-XX:+PrintTLAB

在每次清除时为每个线程打印一行(以“TLAB: gc thread:”开头,没有“”)和一个摘要行。

【讨论】:

+1 哇。谢谢。我什至从未听说过这种东西。将进行实验并报告。 唉,不高兴。 :( 我尝试了更大(10MB)和更小(2KB)的值,性能曲线没有变化。但感谢您对JVM选项的教育之旅。 Awww - 该死。我想这就是为什么假设需要实验来证实它们。感谢您检查并报告。正如您所说,即使是错误的假设也可能具有教育意义和有用性。只是确认了我对 TLAB 的理解并写下答案,我学到了很多东西。 每次容量测试都会分配一次堆缓冲区,在第一次 GC 之后它将被移动到“tenured”堆,在这方面 TLAB 根本不重要。 TLAB 可能仅在大量多线程代码中很重要(以及足够的分配),否则它会花费 CASed 指针碰撞。麻烦的是,如果您有更多线程执行相同的位置 CAS,如果您只有一个,则成本不会那么大,尤其是。如果它达到 L1 并且缓存行是“拥有的”【参考方案3】:

我怀疑这些拐点是由于跨越 CPU 缓存边界造成的。与“直接”缓冲区 read()/write() 实现相比,由于额外的内存缓冲区副本,“非直接”缓冲区 read()/write() 实现更早地“缓存未命中”。

【讨论】:

我在同样具有 4MB L2 缓存的 MBP Core Duo 上应用了 Zach Smith 的内存带宽“基准”(home.comcast.net/~fbui/bandwidth.html)。该工具显示 1MB 的膝盖。直接字节缓冲区不启用 DMA。直接字节缓冲区在 JVM 中分配进程内存(即 malloc())。 JVM 文件系统 read()/write() 正在将内存复制到系统内存或从系统内存复制到直接缓冲区的进程内存中。 FWIW,我的 MBP 实际上只有 3MB L2 缓存(不是我之前所说的 4MB)。【参考方案4】:

发生这种情况的原因有很多。如果没有代码和/或有关数据的更多详细信息,我们只能猜测发生了什么。

一些猜测:

也许您达到了一次可以读取的最大字节数,因此 IOwaits 变得更高或内存消耗增加而循环数没有减少。 可能您遇到了严重的内存限制,或者 JVM 正在尝试在新分配之前释放内存。尝试使用 -Xmx-Xms 参数 可能 HotSpot 无法/不会优化,因为对某些方法的调用次数太少。 可能存在导致这种延迟的操作系统或硬件条件 也许 JVM 的实现有问题 ;-)

【讨论】:

呵呵...其中很多都是我自己推测的,但没有一个对我来说真正完全有意义。 “最大字节数?” 256KB 并不多,而且它对于直接缓冲区和非直接缓冲区的行为不同。 “256KB 和 JVM 内存设置”?同样,256KB 很小。无论它运行多少个循环,差异都是相当一致的。 “没有热点优化?” 我尝试了不同的配置,结果仍然一致。 “操作系统/硬件条件” 比如什么?为什么直接缓冲区与非直接缓冲区不同?叹息…… JVM 可能对直接和非直接缓冲区使用不同的操作系统调用,从而导致不同的运行时行为。非直接缓冲区可能比直接缓冲区稍大。但是来自 Bert 的 TLAB 东西听起来更像是问题的根源。 这不是“问题”。只是一个我想准确理解的意想不到的基准测试结果。 顺便说一句:在上述 TLAB 更改不起作用后,我尝试了 -Xmx-Xms ...不高兴 :( 谜团仍然存在。

以上是关于为啥 ByteBuffer.allocate() 和 ByteBuffer.allocateDirect() 之间的奇怪性能曲线差异的主要内容,如果未能解决你的问题,请参考以下文章

构造函数中的 ByteBuffer.allocate

如果我们超过了java中ByteBuffer.allocate(48) NIO包类中分配缓冲区的容量怎么办

ByteBuffer类学习

为啥 ByteBuffers hashCodes 是一样的?

ByteBuffer 下溢

HeapByteBuffer和DirectByteBuffer以及回收DirectByteBuffer