我正在研究一些SocketChannel
- 到 - SocketChannel
代码,这些代码最适合使用直接字节缓冲区 - 长寿命和大型(每个连接几十到几百兆字节)。使用FileChannel
s确切的循环结构,我在ByteBuffer.allocate()
与ByteBuffer.allocateDirect()
性能上运行了一些微基准测试。
结果出人意料,我无法解释。在下图中,ByteBuffer.allocate()
传输实现的256KB和512KB有一个非常明显的悬崖 - 性能下降了~50%! ByteBuffer.allocateDirect()
似乎也是一个较小的性能悬崖。 (%-gain系列有助于可视化这些变化。)
缓冲区大小(字节)与时间(MS)
为什么ByteBuffer.allocate()
和ByteBuffer.allocateDirect()
之间的奇怪性能曲线差异? 幕后究竟发生了什么?
很可能硬件和操作系统依赖,所以这些是详细信息:
源代码,按要求:
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);
}
}
答案 0 :(得分:27)
首先我有点惊讶它不是常识,但是带着它和我一起
直接字节缓冲区在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
÷m
(n
= 512,m
= 16(套接字缓冲区留下的平均空间))。
复制部分是所有非直接缓冲区的公共/抽象路径,因此实现永远不会知道目标容量。复制会破坏缓存以及不缓存,减少内存带宽等。
* 关于SoftReference缓存的说明:它取决于GC的实现,而且经验可能会有所不同。 Sun的GC使用空闲堆内存来确定SoftRefences的生命周期,当它们被释放时会导致一些尴尬的行为 - 应用程序需要再次分配先前缓存的对象 - 即更多分配(直接ByteBuffers占用堆中的一小部分,所以至少他们不会影响额外的缓存垃圾,但会受到影响) 功能 的
我的拇指规则 - 一个用套接字读/写缓冲区调整大小的直接缓冲区。操作系统永远不会复制超过必要的。
这个微基准测试主要是内存吞吐量测试,操作系统将文件完全放在缓存中,所以它主要测试memcpy
。一旦缓冲区用完L2缓存,性能下降就会明显。同样运行基准也会增加和累积GC收集成本。 (rest()
不会收集软引用的ByteBuffers)
答案 1 :(得分:26)
我想知道测试期间线程本地分配缓冲区(TLAB)是否在256K左右。使用TLAB优化堆的分配,以便&lt; = 256K的非直接分配很快。
通常做的是为每个线程提供一个缓冲区,该缓冲区由该线程专门用于执行分配。您必须使用一些同步来从堆中分配缓冲区,但之后线程可以从缓冲区分配而不进行同步。在热点JVM中,我们将这些称为线程本地分配缓冲区(TLAB)。他们运作良好。
如果我关于256K TLAB的假设是正确的,那么本文后面的信息表明,大型非直接缓冲区的> 256K分配可能会绕过TLAB。这些分配直接进入堆,需要线程同步,从而导致性能命中。
无法从TLAB进行的分配并不总是意味着线程必须获得新的TLAB。根据分配的大小和TLAB中剩余的未使用空间,VM可以决定只从堆中进行分配。来自堆的分配需要同步,但是获得新的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)
有很多原因导致这种情况发生。如果没有代码和/或有关数据的更多细节,我们只能猜测发生了什么。
一些猜测:
-Xmx
和-Xms
参数