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

时间:2010-09-06 13:15:24

标签: java nio bytebuffer

我正在研究一些SocketChannel - 到 - SocketChannel代码,这些代码最适合使用直接字节缓冲区 - 长寿命和大型(每个连接几十到几百兆字节)。使用FileChannel s确切的循环结构,我在ByteBuffer.allocate()ByteBuffer.allocateDirect()性能上运行了一些微基准测试。

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

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

The Pony Gap

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

很可能硬件和操作系统依赖,所以这些是详细信息:

  • MacBook Pro w /双核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);      
    }
}

4 个答案:

答案 0 :(得分:27)

ByteBuffer如何工作以及为什么Direct(字节)缓冲区现在是唯一真正有用的。

首先我有点惊讶它不是常识,但是带着它和我一起

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

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

因此,在引入NIO时,我们的想法就是避免复制流和大量流管道/间接。在数据到达目的地之前,通常有3-4种缓冲类型的流。 (yay波兰用漂亮的镜头均衡(!)) 通过引入直接缓冲区,java可以直接与C本机代码进行通信,而无需任何锁定/复制。因此sent函数可以取缓冲区的地址添加位置,并且性能与本机C大致相同。 那是关于直接缓冲的。

主要问题是w / direct缓冲区 - 它们对allocate and expensive to deallocate来说很昂贵而且使用起来非常麻烦,没有像byte []那样。

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

那么Sun / Oracle如何处理非直接缓冲区,因为操作系统/本机不能使用'em - 好吧,天真。当使用非直接缓冲区时,必须创建直接计数器部分。该实现非常智能,可以使用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使用空闲堆内存来确定SoftRefences的生命周期,当它们被释放时会导致一些尴尬的行为 - 应用程序需要再次分配先前缓存的对象 - 即更多分配(直接ByteBuffers占用堆中的一小部分,所以至少他们不会影响额外的缓存垃圾,但会受到影响) 功能

我的拇指规则 - 一个用套接字读/写缓冲区调整大小的直接缓冲区。操作系统永远不会复制超过必要的。

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

答案 1 :(得分:26)

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

我想知道测试期间线程本地分配缓冲区(TLAB)是否在256K左右。使用TLAB优化堆的分配,以便&lt; = 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 thread:”开头,不带“s”和一条汇总行。

答案 2 :(得分:7)

我怀疑这些膝盖是由于跨越CPU缓存边界而跳闸。与“直接”缓冲区read()/ write()实现相比,由于额外的内存缓冲区复制,“非直接”缓冲区read()/ write()实现“缓存未命中”。

答案 3 :(得分:0)

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

一些猜测:

  • 也许你达到了一次可以读取的最大字节数,因此IOwaits会增加或消耗内存而不会减少循环。
  • 也许你达到了一个关键的内存限制,或者JVM试图在新的分配之前释放内存。尝试使用-Xmx-Xms参数
  • 也许HotSpot无法/不会优化,因为对某些方法的调用次数太少。
  • 可能存在导致此类延迟的操作系统或硬件条件
  • 也许JVM的实现只是错误; - )