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

33

我正在处理一些SocketChannel-to-SocketChannel代码,最好使用直接的字节缓冲区 -- 长时间存活且大(每个连接数十到数百兆字节)。在使用FileChannel并确定确切的循环结构时,我对ByteBuffer.allocate()ByteBuffer.allocateDirect()的性能进行了微型基准测试。

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

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

The Pony Gap

ByteBuffer.allocate()ByteBuffer.allocateDirect()之间的奇怪性能曲线差异是什么原因? 究竟发生了什么?

这可能很大程度上取决于硬件和操作系统,因此这里提供这些细节:

  • MacBook Pro带双核Core 2 CPU
  • Intel 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);      
    }
}

机器、Ubuntu 10.10 32位、OpenJDK 1.6.0_20。我也测试过,对于普通的测试,我的机器在1024k处出现丢失,对于直接测试,在2048k处出现丢失。我猜这个影响可能是由操作系统/ CPU边界(CPU缓存)上的某些东西引起的。 - bartosz.r
@bartosz.r:你的CPU是什么型号?我也可以运行一些测试。 - Stu Thompson
@bartosz.r:此外,我认为每个缓冲区的悬崖应该在同一位置,但它们似乎在两个不同的点上。 - Stu Thompson
这是Intel(R) Core(TM)2 Duo CPU P8600 @ 2.40GHz。也许它们在两个地方,因为间接版本需要复制两次(使用中间缓冲区)。我不知道,但这真的很有趣。这非常依赖于硬件 - 整个高速缓存行对齐、高速缓存大小或甚至CPU缓存通过隐式线程同步。 - bartosz.r
你应该真的尝试使用Java微基准测试工具(jmh)来测试这个。这个基准测试看起来仍然有缺陷。例如,你正在测量从源读取和写入目标的时间,但没有基准来测试它本身需要多长时间,基准测试没有预热期,所以你也在测量JIT编译时间,allocate和allocateDirect情况没有正确分离(类加载+JIT实际上可能会撤销一些推测内联)。不确定这是否影响了你的结果,但JIT可能是颠簸的原因之一。 - Brixomatic
显示剩余5条评论
4个回答

32

ByteBuffer的工作原理以及为什么直接(Byte)缓冲区是目前唯一真正有用的。

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

直接字节缓冲区在Java堆之外分配了一个地址。

这非常重要:所有操作系统(和本地C)函数都可以利用该地址,而无需锁定堆上的对象并复制数据。举个简单的例子:为了通过Socket.getOutputStream().write(byte[])发送任何数据,本地代码必须“锁定”byte[],将其复制到Java堆之外,然后调用操作系统函数,例如send。复制操作可以在堆栈上执行(对于较小的byte[])或通过malloc/free执行(对于较大的byte[])。 DatagramSockets也是一样,它们也会进行复制操作-只不过它们的限制是64KB,并且分配在堆栈上,如果线程堆栈不够大或者递归深度过深,甚至可能导致进程崩溃。 注意:锁定会阻止JVM/GC在堆中移动/重新分配对象

所以引入NIO的想法是避免复制和多个流管道/间接引用。通常,在数据到达目的地之前,有3-4种缓冲类型的流。通过引入直接缓冲区,Java可以直接与C本地代码通信,无需任何锁定/复制。因此,`sent`函数可以获取缓冲区的地址、位置,并且性能与本地C代码几乎相同。这就是关于直接缓冲区的内容。
直接缓冲区的主要问题是它们在分配和释放方面都很昂贵,并且使用起来相当麻烦,与`byte[]`完全不同。
非直接缓冲区不能提供直接缓冲区所具有的真正本质,即直接与本地/操作系统进行桥接,而它们是轻量级的,并且共享完全相同的API。而且,它们可以包装`byte[]`,甚至可以直接操作其支持数组,这一切都很棒,对吧?但是它们必须被复制!
那么Sun/Oracle如何处理非直接缓冲区,因为操作系统/本地无法使用它们 - 嗯,很天真。当使用非直接缓冲区时,必须创建一个直接缓冲区的对应物。实现足够聪明,使用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 ÷ m (n = 512,m = 16,即套接字缓冲区平均剩余空间)。
复制部分是所有非直接缓冲区的一个常见/抽象路径,因此实现永远不知道目标容量。复制会破坏缓存等内容,降低内存带宽等等。
关于SoftReference缓存的说明:它取决于GC实现,经验可能会有所不同。Sun的GC使用空闲堆内存来确定SoftReference的生命周期,这会导致一些尴尬的行为,当它们被释放时,应用程序需要重新分配之前缓存的对象,即更多的分配(直接ByteBuffer在堆中占据较小的部分,所以至少它们不会影响额外的缓存破坏,而是受到影响)。
我的经验法则是,使用与套接字读/写缓冲区大小相同的池化直接缓冲区。操作系统永远不会复制超过必要的数据。
这个微基准测试主要是内存吞吐量测试,操作系统会将文件完全缓存在内存中,所以它主要测试的是memcpy。一旦缓冲区超出L2缓存,性能下降将会明显。此外,以这种方式运行基准测试会导致逐渐增加的GC收集成本。(rest()不会收集软引用的ByteBuffer)

26

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

我想知道在测试期间,线程本地分配缓冲区(TLAB)是否大约为256K。使用TLAB可以优化从堆中分配内存的速度,使得小于等于256K的非直接分配更加快速。

通常的做法是为每个线程提供一个缓冲区,该线程独占该缓冲区进行分配。您必须使用一些同步机制来从堆中分配缓冲区,但之后线程可以无需同步地从缓冲区中进行分配。在热点JVM中,我们将其称为线程本地分配缓冲区(TLAB)。它们运作良好。

绕过TLAB的大型分配

如果我的关于256K TLAB的假设是正确的,那么文章后面的信息表明,对于更大的非直接缓冲区而言,大于256K的分配可能会绕过TLAB并直接进入堆,需要线程同步,因此会带来性能损失。

无法从TLAB中分配的分配并不总是意味着线程必须获取一个新的TLAB。根据分配的大小和TLAB中剩余的未使用空间,虚拟机可以决定直接从堆中进行分配。这种从堆中分配的操作需要同步,但获取新的TLAB也需要同步。如果分配被认为很大(与当前TLAB大小相比的某个显著部分),则分配将始终从堆中进行。这样可以减少浪费并优雅地处理比平均分配更大的情况。

调整TLAB参数

可以使用稍后文章中提供的信息来测试此假设,该文章说明了如何调整TLAB并获取诊断信息:

要尝试具有特定TLAB大小的实验,需要设置两个-XX标志,一个用于定义初始大小,另一个用于禁用重新分配:

-XX:TLABSize= -XX:-ResizeTLAB

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

诊断打印选项

-XX:+PrintTLAB

每次清理时,每个线程都会打印一行(以 "TLAB: gc thread: " 开头,没有引号),并输出一个汇总行。


+1 哇,谢谢。我甚至从未听说过这些东西。我会进行实验并回报的。 - Stu Thompson
1
唉,没有成功。:( 我尝试了更大(10MB)和更小(2KB)的值,但性能曲线没有变化。但感谢您带我了解JVM选项。 - Stu Thompson
3
啊哦,我猜这就是为什么假设需要实验来证实它们的原因。谢谢你的查看并反馈。就像你所说,即使一个错误的假设也可以具有教育和有用性。确认我的对TLABs的理解并撰写答案让我学到了很多。 - Bert F
5
每次容量测试都会分配堆缓冲区,第一次 GC 后它将被移动到 "tenured" 堆上,在这方面 TLAB 并不重要。TLAB 只在重度多线程代码(以及足够的分配)中才会有影响,否则它只会造成一个 CAS 指针的成本。问题是如果您有更多的线程执行相同位置的 CAS,则成本就会更高,如果只有一个线程,则成本不会那么大,特别是如果它命中 L1 缓存并且缓存行已经被“拥有”。 - bestsss

7

我怀疑这些膝部问题是由于跨越CPU缓存边界而引起的。"非直接"缓冲区read()/write()实现由于与"直接"缓冲区read()/write()实现相比需要进行额外的内存缓冲区拷贝,因此会更早地发生"缓存未命中"。


我在我的同样具有4MB L2高速缓存的MBP Core Duo上应用了Zach Smith 的内存带宽“基准测试”(http://home.comcast.net/~fbui/bandwidth.html)。该工具显示在1MB处出现了拐点。 直接字节缓冲区不启用DMA。 直接字节缓冲区在JVM中分配进程内存(即malloc())。 JVM文件系统read()/write()将内存复制到/从系统内存到直接缓冲区的进程内存中。 - Harv
就我所知,我的MBP实际上只有3MB的L2缓存(而不是我先前说的4MB)。 - Harv

0

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

一些猜测:

  • 也许您达到了一次可读取的最大字节数,因此IO等待时间增加或内存消耗增加而循环次数没有减少。
  • 也许您达到了关键的内存限制,或者JVM正在尝试在新分配之前释放内存。尝试调整-Xmx-Xms参数。
  • 也许HotSpot无法/不会进行优化,因为对某些方法的调用次数太少。
  • 也许有操作系统或硬件条件导致了这种延迟。
  • 也许JVM的实现只是有缺陷的 ;-)

呵呵...这些问题我自己也猜测过很多,但是没有一个完全让我明白。 “最大字节数?”256KB并不算多,而且对于直接和非直接缓冲区的行为也不同。“256KB和JVM内存设置”?再次强调,256KB很小。无论运行多少次循环,差异都是相当一致的。“没有热点优化?”我尝试了不同的配置,结果仍然是一致的。“操作系统/硬件条件”像什么?为什么直接缓冲区和非直接缓冲区不同?唉... - Stu Thompson
JVM可能会针对直接和非直接缓冲区使用不同的操作系统调用,因此导致运行时行为不同。非直接缓冲区可能略大于直接缓冲区。但Bert的TLAB(线程本地分配缓冲区)听起来更像是您问题的根源。 - Hardcoded
1
这不是一个“问题”。只是一个意外的基准测试结果,我想要准确地理解它。 - Stu Thompson
顺便说一句:在进行了上述 TLAB 更改无效后,我尝试了“-Xmx”和“-Xms”...但没有成功 :( 这个谜团仍然存在。 - Stu Thompson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接