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

Stu*_*son 31 java nio bytebuffer

我工作的一些SocketChannel至- SocketChannel代码会做最好用直接字节缓冲区- (几十到几百每个连接的兆字节),长寿命,大而散列出具有确切循环结构FileChannelS,我跑了一些微在基准测试ByteBuffer.allocate()ByteBuffer.allocateDirect()性能.

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

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

小马差距

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

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

  • MacBook Pro配双核Core 2 CPU
  • 英特尔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);      
    }
}
Run Code Online (Sandbox Code Playgroud)

bes*_*sss 27

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

首先,我有点惊讶,这不是常识,但承担它与我

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

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

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

带有直接缓冲区的主要问题 - 它们分配起来很昂贵,而且解除分配成本高昂且使用起来非常繁琐,就像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,依此类推...而且乐观地认为套接字总是如此缓冲区将完全为空.因此,您不仅要复制nKB,而且要求n× n÷ m(n= 512,m= 16(套接字缓冲区的平均空间)).

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

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

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

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


Ber*_*t F 26

线程本地分配缓冲区(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
Run Code Online (Sandbox Code Playgroud)

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

诊断打印选项

-XX:+PrintTLAB
Run Code Online (Sandbox Code Playgroud)

每个线程打印每行扫描一行(以"TLAB:gc thread:"开头,不带"s")和一个摘要行.

  • 每个容量测试分配一次堆缓冲区,它将在第一个GC之后移动到"tenured"堆,在这方面,TLAB根本不重要.TLAB可能只在大量多线程代码中(并且分配足够多),否则它会花费CASed指针凸起.麻烦的是,如果你有更多的线程做相同的位置CAS,如果你只有一个它没有那么大的成本,尤其是.如果它命中L1并且缓存行是"拥有的" (5认同)
  • Awww - darn.我想这就是为什么假设需要实验来证实它们.感谢您查看并报告回来.正如你所说,即使是错误的假设也可能具有教育意义和实用性.我学到了很多东西,只是确认了我对TLAB的理解并写出了答案. (3认同)

Har*_*arv 7

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


Har*_*ded 0

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

一些猜测:

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