5.2. Linux 2.4之后的底层实现
在内核为2.4或者以上版本的linux系统上,socket缓冲区描述符将被用来满足这个需求。这个方式不仅减少了内核用户态间的切换,而且也省去了那次需要cpu参与的复制过程。 从用户角度来看依旧是调用transferTo()方法,但是其本质发生了变化:
- 调用transferTo方法后数据被DMA从文件复制到了内核的一个缓冲区中。
- 数据不再被复制到socket关联的缓冲区中了,仅仅是将一个描述符(包含了数据的位置和长度等信息)追加到socket关联的缓冲区中。DMA直接将内核中的缓冲区中的数据传输给协议引擎,消除了仅剩的一次需要cpu周期的数据复制。
5.3 对于JAVA普通字节流IO与NIOFileChannel实现的零拷贝性能:
直接上源码:
import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.channels.FileChannel; public class FileCopyTest { /** * 通过字节流的方式复制文件 * @param fromFile 源文件 * @param toFile 目标文件 * @throws FileNotFoundException 未找到文件异常 */ public static void fileCopyNormal(File fromFile, File toFile) throws FileNotFoundException { InputStream inputStream = null; OutputStream outputStream = null; try { inputStream = new BufferedInputStream(new FileInputStream(fromFile)); outputStream = new BufferedOutputStream(new FileOutputStream(toFile)); //用户态缓冲有1kB这么大,不算小了 byte[] bytes = new byte[1024]; int i; //读取到输入流数据,然后写入到输出流中去,实现复制 while ((i = inputStream.read(bytes)) != -1) { outputStream.write(bytes, 0, i); } } catch (Exception e) { e.printStackTrace(); } finally { try { if (inputStream != null) { inputStream.close(); } if (outputStream != null) { outputStream.close(); } } catch (IOException e) { e.printStackTrace(); } } } /** * 用filechannel进行文件复制 * * @param fromFile 源文件 * @param toFile 目标文件 */ public static void fileCopyWithFileChannel(File fromFile, File toFile) { FileInputStream fileInputStream = null; FileOutputStream fileOutputStream = null; FileChannel fileChannelInput = null; FileChannel fileChannelOutput = null; try { fileInputStream = new FileInputStream(fromFile); fileOutputStream = new FileOutputStream(toFile); //得到fileInputStream的文件通道 fileChannelInput = fileInputStream.getChannel(); //得到fileOutputStream的文件通道 fileChannelOutput = fileOutputStream.getChannel(); //将fileChannelInput通道的数据,写入到fileChannelOutput通道 fileChannelInput.transferTo(0, fileChannelInput.size(), fileChannelOutput); } catch (IOException e) { e.printStackTrace(); } finally { try { if (fileInputStream != null) { fileInputStream.close(); } if (fileChannelInput != null) { fileChannelInput.close(); } if (fileOutputStream != null) { fileOutputStream.close(); } if (fileChannelOutput != null) { fileChannelOutput.close(); } } catch (IOException e) { e.printStackTrace(); } } } public static void main(String[] args) throws IOException { File fromFile = new File("D:/readFile.txt"); File toFile = new File("D:/outputFile.txt"); //预热 fileCopyNormal(fromFile, toFile); fileCopyWithFileChannel(fromFile, toFile); //计时 long start = System.currentTimeMillis(); for (int i = 0; i < 1000; i++) { fileCopyNormal(fromFile, toFile); } System.out.println("fileCopyNormal time: " + (System.currentTimeMillis() - start)); start = System.currentTimeMillis(); for (int i = 0; i < 1000; i++) { fileCopyWithFileChannel(fromFile, toFile); } System.out.println("fileCopyWithFileChannel time: " + (System.currentTimeMillis() - start)); } }
测试结果:
fileCopyNormal time: 14271 fileCopyWithFileChannel time: 6632
差了一倍多的时间(文件大小大概8MB),如果文件更大这个差距应该更加明显。
6. DirectBuffer分配
Java中NIO的核心缓冲就是ByteBuffer,所有的IO操作都是通过这个ByteBuffer进行的;Bytebuffer有两种: 分配HeapByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(int capacity);
分配DirectByteBuffer
ByteBuffer buffer = ByteBuffer.allocateDirect(int capacity);
两者的区别:
6.1. 为何HeapByteBuffer会多一次拷贝?
6.1.1. FileChannel的force api说明
FileChannel的force方法: FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入到FileChannel里的数据一定会即时写到磁盘上。要保证这一点,需要调用force()方法。 force()方法有一个boolean类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上。
6.1.3. 为何一定要复制到DirectByteBuffer来读写(系统调用)
首先,先说一点,执行native方法的线程,被认为是处于SafePoint,所以,会发生 NIO 如果不复制到 DirectByteBuffer,就会有 GC 发生重排列对象内存的情况(可以参考我的另一篇文章: https://blog.csdn.net/zhxdick/article/details/107450858)。
传统 BIO 是面向 Stream 的,底层实现可以理解为写入的是 byte 数组,调用 native 方法写入 IO,传的参数是这个数组,就算GC改变了内存地址,但是拿这个数组的引用照样能找到最新的地址,,对应的方法时是:FileOutputStream.write
private native void writeBytes(byte b[], int off, int len, boolean append) throws IOException;
但是NIO,为了提升效率,传的是内存地址,省去了一次间接应用,但是就必须用 DirectByteBuffer 防止内存地址改变,对应的是 NativeDispatcher.write
abstract int write(FileDescriptor fd, long address, int len) throws IOException;
那为何内存地址会改变呢?GC会回收无用对象,同时还会进行碎片整理,移动对象在内存中的位置,来减少内存碎片。DirectByteBuffer不受GC控制。如果不用DirectByteBuffer而是用HeapByteBuffer,如果在调用系统调用时,发生了GC,导致HeapByteBuffer内存位置发生了变化,但是内核态并不能感知到这个变化导致系统调用读取或者写入错误的数据。所以一定要通过不受GC影响的DirectByteBuffer(之前有笔误,
感谢指正)来进行IO系统调用。
假设我们要从网络中读入一段数据,再把这段数据发送出去的话,采用Non-direct ByteBuffer的流程是这样的:
网络 –> 临时的DirectByteBuffer –> 应用 Non-direct ByteBuffer –> 临时的Direct ByteBuffer –> 网络
这种方式是直接在堆外分配一个内存(即,native memory)来存储数据, 程序通过JNI直接将数据读/写到堆外内存中。因为数据直接写入到了堆外内存中,所以这种方式就不会再在JVM管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。
采用Direct ByteBuffer的流程是这样的:
网络 –> 应用 Direct ByteBuffer –> 网络
可以看到,除开构造和析构临时Direct ByteBuffer的时间外,起码还能节约两次内存拷贝的时间。那么是否在任何情况下都采用Direct Buffer呢?
不是。对于大部分应用而言,两次内存拷贝的时间几乎可以忽略不计,而构造和析构DirectBuffer的时间却相对较长。在JVM的实现当中,某些方法会缓存一部分临时Direct ByteBuffer,意味着如果采用Direct ByteBuffer仅仅能节约掉两次内存拷贝的时间, 而无法节约构造和析构的时间。就用Sun的实现来说,write(ByteBuffer)和read(ByteBuffer)方法都会缓存临时Direct ByteBuffer,而write(ByteBuffer[])和read(ByteBuffer[])每次都生成新的临时Direct ByteBuffer。
6.1.2. FileChannel和SocketChannel依赖的IOUtil源码解析
无论是FileChannel还是SocketChannel,他们的读写方法都依赖IOUtil的相同方法,我们这里来看下: IOUtil.java
static int write(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException { //如果是DirectBuffer,直接写 if (var1 instanceof DirectBuffer) { return writeFromNativeBuffer(var0, var1, var2, var4); } else { //非DirectBuffer //获取已经读取到的位置 int var5 = var1.position(); //获取可以读到的位置 int var6 = var1.limit(); assert var5 <= var6; //申请一个源buffer可读大小的DirectByteBuffer int var7 = var5 <= var6 ? var6 - var5 : 0; ByteBuffer var8 = Util.getTemporaryDirectBuffer(var7); int var10; try { var8.put(var1); var8.flip(); var1.position(var5); //通过DirectBuffer写 int var9 = writeFromNativeBuffer(var0, var8, var2, var4); if (var9 > 0) { var1.position(var5 + var9); } var10 = var9; } finally { //回收分配的DirectByteBuffer Util.offerFirstTemporaryDirectBuffer(var8); } return var10; } } //读的方法和写类似,这里省略
6.1.3. 为何一定要复制到DirectByteBuffer来读写(系统调用)
首先,先说一点,执行native方法的线程,被认为是处于SafePoint,所以,会发生 NIO 如果不复制到 DirectByteBuffer,就会有 GC 发生重排列对象内存的情况(可以参考我的另一篇文章: https://blog.csdn.net/zhxdick/article/details/107450858)。
传统 BIO 是面向 Stream 的,底层实现可以理解为写入的是 byte 数组,调用 native 方法写入 IO,传的参数是这个数组,就算GC改变了内存地址,但是拿这个数组的引用照样能找到最新的地址,,对应的方法时是:FileOutputStream.write
private native void writeBytes(byte b[], int off, int len, boolean append) throws IOException;
但是NIO,为了提升效率,传的是内存地址,省去了一次间接应用,但是就必须用 DirectByteBuffer 防止内存地址改变,对应的是 NativeDispatcher.write
abstract int write(FileDescriptor fd, long address, int len) throws IOException;
那为何内存地址会改变呢?GC会回收无用对象,同时还会进行碎片整理,移动对象在内存中的位置,来减少内存碎片。DirectByteBuffer不受GC控制。如果不用DirectByteBuffer而是用HeapByteBuffer,如果在调用系统调用时,发生了GC,导致HeapByteBuffer内存位置发生了变化,但是内核态并不能感知到这个变化导致系统调用读取或者写入错误的数据。所以一定要通过不受GC影响的DirectByteBuffer(之前有笔误,
感谢指正)来进行IO系统调用。
假设我们要从网络中读入一段数据,再把这段数据发送出去的话,采用Non-direct ByteBuffer的流程是这样的:
网络 –> 临时的DirectByteBuffer –> 应用 Non-direct ByteBuffer –> 临时的Direct ByteBuffer –> 网络
这种方式是直接在堆外分配一个内存(即,native memory)来存储数据, 程序通过JNI直接将数据读/写到堆外内存中。因为数据直接写入到了堆外内存中,所以这种方式就不会再在JVM管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。
采用Direct ByteBuffer的流程是这样的:
网络 –> 应用 Direct ByteBuffer –> 网络
可以看到,除开构造和析构临时Direct ByteBuffer的时间外,起码还能节约两次内存拷贝的时间。那么是否在任何情况下都采用Direct Buffer呢?
不是。对于大部分应用而言,两次内存拷贝的时间几乎可以忽略不计,而构造和析构DirectBuffer的时间却相对较长。在JVM的实现当中,某些方法会缓存一部分临时Direct ByteBuffer,意味着如果采用Direct ByteBuffer仅仅能节约掉两次内存拷贝的时间, 而无法节约构造和析构的时间。就用Sun的实现来说,write(ByteBuffer)和read(ByteBuffer)方法都会缓存临时Direct ByteBuffer,而write(ByteBuffer[])和read(ByteBuffer[])每次都生成新的临时Direct ByteBuffer。