32

我正在研究一些使用直接字节SocketChannel缓冲区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);      
    }
}
4

4 回答 4

30

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

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

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

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

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

直接缓冲区的主要问题 - 它们的分配和解除分配都很昂贵,而且使用起来非常麻烦,与 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,依此类推……而且总是使用套接字是乐观的缓冲区将完全为空。因此,您不仅要复制nKB,还要复制n× n÷ m( n= 512, m= 16(套接字缓冲区剩余的平均空间))。

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

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

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

This micro-benchmark is mostly memory throughput test, the OS will have the file entirely in cache, so it mostly tests memcpy. Once the buffers run out of the L2 cache the drop of performance is to be noticeable. Also running the benchmark like that imposes increasing and accumulated GC collection costs. (rest() will not collect the soft-referenced ByteBuffers)

于 2012-06-12T20:20:44.690 回答
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

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

诊断打印选项

-XX:+PrintTLAB

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

于 2010-09-06T17:35:46.707 回答
7

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

于 2010-10-01T21:44:47.650 回答
0

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

一些猜测:

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