Java-NIO相关问题记录

Posted by W-M on November 29, 2017

本文主要记录了个人在学习NIO相关知识时遇到的问题及自己的思考,主要用于备忘,错误难免,敬请指出!


Java NIO中非阻塞体现在哪里?

对于进程的一次I/O访问(包含网络IO与文件IO),以read操作为例,有两个阶段可能发生阻塞:

  (1)等待要读取的设备状态变为可读,之后将数据从设备拷贝到内核数据缓冲区。
  (2)将数据从内核数据缓冲区拷贝到用户进程空间。

对于Java NIO来说,通过selector模型(在linux上实际是通过select底层系统调用实现)实现的1:N的线程模型来尽量消除第一阶段的阻塞,在第二个阶段的数据拷贝过程中,相应线程实际还是阻塞的。selector模型的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

更多IO模型相关知识,参考:IO模型:同步、异步、阻塞、非阻塞

在Java NIO的类库中,只有SelectableChannel的子类如SocketChannel、ServerSocketChannel等才能应用selector模型,对于FileChannel来说,selector模型是不能在其上应用的。


使用Java NIO中的FileChannel类进行文件复制操作真的可以提升效率吗?

对于传统的文件复制操作,可以通过FileInputStream与FileOutputStream两个类来配合实现(个人观点,对于传统的文件复制操作,使用BufferedInputStream与BufferedOutputStream不仅不会带来性能上的提升,还会由于多了一次数据拷贝而拖慢操作效率),传统文件复制操作示例如下:

/**
 * 传统文件复制操作
 *
 * @param sourceFile
 * @param targetFile
 * @throws IOException
 */
public static void copyFileWithOutNIO(File sourceFile, File targetFile) throws IOException {
    System.out.println("copyFileWithOutNIO");
    try(FileInputStream fin = new FileInputStream(sourceFile);
        FileOutputStream fout = new FileOutputStream(targetFile)
    ) {
        byte[] buff = new byte[1024 * 64];
        int len;
        while ((len = fin.read(buff)) != -1) {
            fout.write(buff, 0, len);
        }
    }
}

有了Java NIO类库之后,我们除此之外又多了几种文件复制操作:

(1)使用FileChannel和普通Buffer完成文件复制

private static void copyFileNioWithChannelAndCommonBuffer(final File from, final File to) throws IOException {
    System.out.println("copyFileNioWithChannelAndCommonBuffer");
    try (final RandomAccessFile inFile = new RandomAccessFile(from, "r");
         final RandomAccessFile outFile = new RandomAccessFile( to, "rw" )
    ) {
        final FileChannel inChannel = inFile.getChannel();
        final FileChannel outChannel = outFile.getChannel();
		//注意此处分配的是普通Buffer,占用的是堆空间的内存
        ByteBuffer buffer = ByteBuffer.allocate(1024 * 64);
        int bytesRead = inChannel.read(buffer);
        while (bytesRead != -1) {
            buffer.flip();
            while (buffer.hasRemaining()) {               
                outChannel.write(buffer);
            }
            buffer.clear();
            bytesRead = inChannel.read(buffer);
        }
    }
}

个人观点:使用普通Buffer与FileChannel结合来完成文件复制操作相比于传统BIO方式并没有什么优势,因为这种处理方式对于传统BIO方式可能发生阻塞的两个阶段都没有进行优化。

(2)使用FileChannel和DirectByteBuffer完成文件复制

private static void copyFileNioWithChannelAndDirectBuffer(final File from, final File to) throws IOException {
    System.out.println("copyFileNioWithChannelAndDirectBuffer");
    try (final RandomAccessFile inFile = new RandomAccessFile(from, "r");
         final RandomAccessFile outFile = new RandomAccessFile( to, "rw" )
    ) {
        final FileChannel inChannel = inFile.getChannel();
        final FileChannel outChannel = outFile.getChannel();
		//注意此处分配的是DirectBuffer,占用的是直接内存,属于堆外内存区
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 64);
        int bytesRead = inChannel.read(buffer);
        while (bytesRead != -1) {
            buffer.flip();
            while (buffer.hasRemaining()) {               
                outChannel.write(buffer);
            }
            buffer.clear();
            bytesRead = inChannel.read(buffer);
        }
    }
}

理论上讲,使用DirectBuffer配合FileChannel完成文件复制相比于使用普通Buffer的优势在于少了一次IO时从普通ByteBuffer向DirectByteBuffer进行数据复制的操作,但是DirectBuffer也没有消除用户态与内核态的数据copy过程。

更多可参见:DirectBuffer-DirectBuffer优缺点部分

(3)使用FileChannel配合MappedByteBuffer来完成文件复制

MappedByteBuffer提升数据传输效率的原理是通过将一块内核地址空间地址与用户空间的虚拟地址映射到同一个物理地址,这样DMA硬件就可以填充对于内核空间与用户空间同时可见的缓冲区了,减少了从内核空间向用户空间拷贝数据的操作。示例代码如下:

//一次映射整个文件方式:速度很慢
private static void nioMappedBufferCommonCopy(final File source, final File dest) throws Exception {
    if (!dest.exists()) {
        dest.createNewFile();
    }
    RandomAccessFile inFile = new RandomAccessFile(source, "r");
    RandomAccessFile outFile = new RandomAccessFile(dest, "rw");
    FileChannel sourceCh = inFile.getChannel();
    FileChannel destCh = outFile.getChannel();
    System.out.println("nioCommonCopy");
    //通过这种一次映射整个文件的映射方式,在文件较大时copy速度可能比传统BIO方式还慢
    MappedByteBuffer buffer = sourceCh.map(FileChannel.MapMode.READ_ONLY, sourceCh.position(), sourceCh.size());
    destCh.write(buffer);
    sourceCh.close();
    destCh.close();
	unmap(buffer);
}

//文件分段映射方式,合理分段可以大幅提高传输速度
private static void nioMappedBufferSegmentCopy(final File source, final File dest) throws Exception {
    System.out.println("nioMappedBufferSegmentCopy");
    if (!dest.exists()) {
        dest.createNewFile();
    }
    RandomAccessFile inFile = new RandomAccessFile(source, "r");
    RandomAccessFile outFile = new RandomAccessFile(dest, "rw");
    FileChannel sourceCh = inFile.getChannel();
    FileChannel destCh = outFile.getChannel();
    long srcChSize = sourceCh.size();
    long tempSize;
    long srcCurrentPosition = sourceCh.position();
    long destCurrentPosition = destCh.position();
    while (srcChSize > 0L) {
        tempSize = Math.min(srcChSize, 8 * 1024 * 1024);//一次映射最大8MB
        MappedByteBuffer buffer = sourceCh.map(FileChannel.MapMode.READ_ONLY, srcCurrentPosition, tempSize);
        try {
            long writeBytesNum = destCh.write(buffer, destCurrentPosition);
            srcCurrentPosition += writeBytesNum;
            destCurrentPosition += writeBytesNum;
            srcChSize -= tempSize;
        } finally {
            unmap(buffer);//注意手动释放直接内存
        }
    }
    inFile.close();
    outFile.close();
}

//手动回收MappedByteBuffer占用的直接内存
private static void unmap(MappedByteBuffer buffer) {
    Cleaner cleaner = ((DirectBuffer)buffer).cleaner();
    if(cleaner != null) {
        cleaner.clean();
    }
}

在上述代码中使用了两种映射方式,nioMappedBufferCommonCopy是一次性映射整个文件的方式,nioMappedBufferSegmentCopy是分段映射文件的方式,经过测试,在文件较大时,合理的分段映射比一次性映射整个文件的方式效率要高得多。在使用MappedByteBuffer时还要注意的一点是MappedByteBuffer属于DirectByteBuffer的一种,其占用的是堆外内存,GC不能对其回收,所以使用之后要手动回收,如上述代码示例中的unmap方法。

遗留问题:学习了解Java内存文件映射方式;解决对于一个大文件,如何确定一次映射的Buffer的大小多大才合理???

(4)使用FileChannel.transferTo()方法实现零拷贝

零拷贝概念解析:Java零拷贝磁盘及网络IO工作方式解析

拷贝示例如下所示:

private static void copyFileWithNioTransfer(final File source, final File dest) throws Exception {
    System.out.println("copyFileWithNioTransfer");
    if (!dest.exists()) {
        dest.createNewFile();
    }
    FileInputStream fis = new FileInputStream(source);
    FileOutputStream fos = new FileOutputStream(dest);
    FileChannel sourceCh = fis.getChannel();
    FileChannel destCh = fos.getChannel();
    System.out.println("source size: " + sourceCh.size() + " position: " + sourceCh.position());
//        destCh.transferFrom(sourceCh, destCh.position(), sourceCh.size());
    sourceCh.transferTo(sourceCh.position(), sourceCh.size(), destCh);
    sourceCh.close();
    destCh.close();
}

只有transferTo方法能实现零拷贝,transferFrom方法不能实现零拷贝是吗?

//从当前Channel中给定position开始读出count个字节写到target Channel中
public long transferTo(long position, long count,
                           WritableByteChannel target) throws IOException
	{
	    ensureOpen();
	    if (!target.isOpen())
	        throw new ClosedChannelException();
	    if (!readable)
	        throw new NonReadableChannelException();
	    if (target instanceof FileChannelImpl &&
	        !((FileChannelImpl)target).writable)
	        throw new NonWritableChannelException();
	    if ((position < 0) || (count < 0))
	        throw new IllegalArgumentException();
	    long sz = size();
	    if (position > sz)
	        return 0;
	    int icount = (int)Math.min(count, Integer.MAX_VALUE);
	    if ((sz - position) < icount)
	        icount = (int)(sz - position);
	
	    long n;
		//使用transferTo方法向target channel中进行数据传输时,会尝试三种方式

		//方式一:如果内核支持零拷贝的方式的话,首选零拷贝
	    // Attempt a direct transfer, if the kernel supports it
	    if ((n = transferToDirectly(position, icount, target)) >= 0)
	        return n;
		
		//方式二:对于TrustedChannel,尝试使用内存文件映射方式
	    // Attempt a mapped transfer, but only to trusted channel types
	    if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
	        return n;
	
		//方式三:若以上两种情况对当前情形都不支持,则采用ByteBuffer.allocateDirect()分配的直接内存来传输数据
	    // Slow path for untrusted targets
	    return transferToArbitraryChannel(position, icount, target);
	}

//从src Channel中当前位置开始读出count个字节,写到以position开始的当前channel中
public long transferFrom(ReadableByteChannel src,
                             long position, long count) throws IOException
    {
        ensureOpen();
        if (!src.isOpen())
            throw new ClosedChannelException();
        if (!writable)
            throw new NonWritableChannelException();
        if ((position < 0) || (count < 0))
            throw new IllegalArgumentException();
        if (position > size())
            return 0;
		//从srcChannel中向当前Channel中写数据时,会尝试两种方式:
		//方式一:如果src Channel属于FileChannel,尝试使用内存文件映射方式
        if (src instanceof FileChannelImpl)
           return transferFromFileChannel((FileChannelImpl)src,
                                          position, count);
		//方式一:如果第一种方式不支持,尝试使用ByteBuffer.allocateDirect()分配的直接内存来传输数据
        return transferFromArbitraryChannel(src, position, count);
    }

从transferTo()方法的实现中也可以大致看出在要传输的数据时不需要经过用户态处理时哪种方式更高效:

  效率比较:零拷贝 > 内存文件映射 > DirectBuffer > 普通Buffer

就我个人理解,零拷贝相比于内存文件映射高效的原因在于少了一次需要由CPU来完成的数据拷贝过程;以从磁盘向网卡中拷贝数据向外发送为例,对于内存文件映射,需要由CPU将数据从内核ReadBuffer拷贝到内核SocketBuffer中之后再发出,而零拷贝不需要这一拷贝过程。

采用内存文件映射方式,可以消除用户态与内核态之间多余数据拷贝带来的开销;采用零拷贝方式,减少了内核态之间的多余数据拷贝,相比于内存文件映射方式又进一步的提升了文件传输效率。所以我认为合理分析使用情形,使用NIO的FileChannel传输数据是可以提升效率的。

使用FileChannel.transferTo()的另外一种较多的情况是从FileChannel向SocketChannel中写数据,具体的SocketChannel实现也许会选择从FileChannel中读数据直到sendBuffer读满为止;或者使用FileChannel.transferFrom()从SocketChannel向FileChannel中写出准备好的数据,如果socketChannel配置为非阻塞的,即使当前SocketChannel中准备好的字节数小于transferFrom方法中要求的count个字节,也会直接写出。所以我们虽然在transferFrom中提供了count参数,但是不能保证一定可以读取到count个字节。

遗留问题:是不是只有在FileChannel与FileChannel之间、FileChannel与SelectableChannel及其子类之间才可以实现零拷贝?

能否实现零拷贝与底层操作系统支持有关,需要看FileChannelImpl.transferTo()相关源码确定。

NIO相比于IO在文件操作上提供了FileLock类,可以更好的控制对文件的并发访问。


为什么FileChannel不设计为支持非阻塞?

因为对于普通磁盘文件,read/write总是可以立即返回的,除非磁盘出现故障。而对于网络连接,或者管道等类型的文件,read/write是有可能发送阻塞的,而且是一种常态模式。


什么情况下connect、read、write在os底层select过程才被认为是准备好呢?

其实对于这三种状态是否就绪的判断与阻塞操作时的判断方式是相同的:

  • 对于Connect事件,当呼入连接请求队列中有连接未被处理时就认为是connect就绪,至于具体有多少连接并不能判断。
  • 对于read事件,当数据输入缓冲区中有数据可读时就认为是读就绪,具体有多少数据可读并不能确定。
  • 对于write事件,当数据发送缓冲区中有数据可写入时就认为是写就绪,具体可写入多少数据并不能确定。

使用Java NIO的selector模型在任何情况下都可以提高效率吗?如果不是,Selector模型适用于什么情况?

Selector实现的1:N线程模型的优势并不在于对单个连接可以处理的很快,而是可以处理更多的连接,对于连接数较大,每个连接数据传输不频繁的情况适合使用多路复用模型。

实际使用时不应拘泥于1:1或1:N中的一种,对于数据传输很频繁或处理比较耗时的连接可以单独建立线程进行处理,对于数据传输不频繁的连接可以注册到Selector线程上进行处理。