java.nio中的Channel系列(2)-FileChannel与零拷贝原理

简介

本文主要是用来记录Channel接口相关实现类的功能和特性

FileChannel

FileChannel主要是从文件中中读写数据的Channel,其实现的接口和继承的对象如下:

public abstract class FileChannel extends AbstractInterruptibleChannel
    implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel{
....
}

FileChannel是连接到一个文件的通道,对于它所连接的文件,会维护一个position用来指向文件内容的绝对位置,该绝对位置可以通过position()查询和position(long)进行修改,如果该position修改后,那么输出文件内容到指定ByteBuffer时,将从该position处开始;另外read(ByteBuffer,position)会从指定position开始读取文件内容到ByteBuffer,但并不会修改通道本身position的位置;

URL path = FileChannelTest.class.getClassLoader().getResource("text.txt");
RandomAccessFile accessfile = new RandomAccessFile(new java.io.File(path.getFile()), "r");
        FileChannel fileChannel=accessfile.getChannel();
        fileChannel.position(5);
        ByteBuffer byteBuffer=ByteBuffer.allocate(10);
        fileChannel.read(byteBuffer);
        byteBuffer.flip();
        System.out.println(new String(byteBuffer.array()));

FileChannel具有以下特性:

  • 可以利用read(ByteBuffer,position)或者write(ByteBuffer,position)来在文件的绝对位置上读取或者写入,但是不会改变通道本身的position;

  • 可以利用map(MapMode,position,size)方法将文件映射到内存中,其中position指的是通道的绝对位置,size映射大小,映射方式有三种:

    • MapMode.READ_ONLY:只读的方式映射到内存,修改文件将抛出ReadOnlyBufferException;
    • MapMode.READ_WRITE:读写的方式映射到内存,修改后的内存可以通过force()方法写入内存,但是对其他关联到该文件进程可见性是不确定的,可能会出现并发性问题,同时在该模式下,通道必须以rw的方式打开;
    • MapMode.PRIVATE:私有方式,可以修改映射到内存的文件,但是该修改不会写入内存,同时对其他进程也是不可见的
      另外该map中的数据只能等到gc的时候才能清理,同时map一旦创建,将和FileChannel无关,FileChannel关闭也不会对其有影响;
      map方法因为将文件直接映射到内存中,因此其读写性能相比FileInputStream和FileOutputStream来说要好一些,但是资源消耗代价也会大些,因此比较适合大文件的读写;
    RandomAccessFile accessfile = new RandomAccessFile(
                new java.io.File("C:\\Users\\Administrator\\git\\javabase\\JavaBase\\resources\\text.txt"), "rw");
        FileChannel fileChannel = accessfile.getChannel();
        MappedByteBuffer map = fileChannel.map(MapMode.READ_WRITE, 0, fileChannel.size());
        
        Charset charset=Charset.forName("utf-8");
        CharBuffer decode = charset.decode(map.asReadOnlyBuffer());
        System.out.println(decode.toString());//读取测试
        byte[] chars = "hao hi yo".getBytes();

        map.put(chars,0,chars.length);//写入测试,写入位置和position有关
        map.force();
        fileChannel.close();
  • 可以利用transferTo()/transferFrom()来将bytes数组在两个通道之间来回传递,该性能相对来较快,可以快速实现文件复制,因为FileChannel是将通过JNI(本地方法接口)将文件读取到native堆即堆外内存中,通过DirectrByteBuffer来引用这些数据,这样在实现文件复制或传输时,无需将文件从堆外内存拷贝到java堆中,本质上这就是减少了内核内存和用户内存之间的数据拷贝,从而提升性能;

  • 可以利用lock(position,size,isShared)方法实现对指定文件区域进行加锁,加锁的方式分为共享或互斥,有些操作系统不支持共享锁,因此可通过isShared()方式来判断是否能进行互斥操作;

  • FileChannel是线程安全的,对于多线程操作,只有一个线程能对该通道所在文件进行修改,

  • 可以通过open()方法开启一个通道,同时也可以通过FileInputStream或者FileOutputStream,RandomAccessFile调用方法getChannel()来获取;

Linux零拷贝

普通传输文件

在linux系统中,用户程序要访问某个文件,传输到网络,可以通过如下代码进行访问

while((n = read(diskfd, buf, BUF_SIZE)) > 0)
    write(sockfd, buf , n);

代码虽然简单,但是IO实际上的操作,会经过如下过程:

  • 操作系统会根据read系统调用中的diskfd文件描述父中的位置和长度判断该文件是否存在于内核缓存中,如果是,则直接通过cpu copy直接拷贝到用户程序空间;如果不是,那么操作系统会通过DMA(Direct Memory Access)将文件拷贝到内核页缓存中,然后再通过cpu copy拷贝到用户程序空间;
  • 输出文件时,用户程序会将该文件缓存拷贝的网络堆栈的内核缓存中,然后操作系统再将该缓存通过DMA发送给网络端口;

具体如下图:


java.nio中的Channel系列(2)-FileChannel与零拷贝原理_第1张图片
文件网络传输过程.png

在这种场景下,一次文件传输一般需要两次cpu copy,两次DMA copy,同时也发生了多次用户态和内核态之间的上下文切换,这无疑加大了cpu的负担;

零拷贝技术

什么是零拷贝技术?在上面场景中,磁盘文件传输到网络端口,需要经过多次cpu copy,加大了cpu的负担,而零拷贝就是指为了避免CPU做大量的拷贝和减少不必要的拷贝而采用的一些技术,这些技术包括采用其他组件来进行简单的文件网络传输;

内核缓存区主要是缓存本地读写文件并与用户程序交换数据的缓存区,而socket缓存区则是用来发送到网络或者从网络读取的文件数据;

mmap

mmap函数可以将用户空间的一块内存地址和内核中的一块内存地址同时映射到真正的物理内存上,从而这块物理内存对于内核和用户空间都是可见的,需要注意的是映射的文件大小最好是内核缓存页大小(PAGE_SIZE)的整数倍,如果不是则会进行强制内存对齐,最后一页没被使用的空间会被填充零;
mmap系统调用代码如下:

buf = mmap(diskfd, len);
write(sockfd, buf, len);

其主要作用如下:

  • mmap会将diskfd描述的文件通过DMA的方式拷贝到内核页缓存区,同时这个相关缓存区对用户程序是共享的,这样可以减少一次cpu copy;
  • write会之间将页内核缓存区中文件通过cpu copy到内核中网络堆栈相关的缓存去,然后进行传输;
    如下图所示:


    java.nio中的Channel系列(2)-FileChannel与零拷贝原理_第2张图片
    mmap文件传输.png
mmap隐藏问题

使用mmap了一个文件,那么当write这个文件过程中,如果存在另一个进程对该文件进行truncate操作(truncate操作可以改变文件大小),那么write系统调用会因为访问非法地址而被SIGBUS终止,这样SIGBUS会杀掉你的进程,同时留下一个coredump文件(coredump文件用来存储进程崩溃时的内存快照,可以用来定位问题);
针对于这类问题的处理方法,就是避免write在文件被truncate后继续访问,具体方法如下:

  • 1.SIGBUS信号处理程序:
    当遇到SIGBUS信号时,信号处理程序简单放回,停止write系统调用,返回已经写入的字节数,同时将errno设置为success,虽然这是一种解决方式,但是该方式比较糟糕,并没有解决核心问题;
  • 2.为文件描述符使用租借锁
    在mmap文件的文件描述符上使用租借锁,可以向内核为文件申请一个租借锁,这样在其他进程通过内核修改文件时,内核会通过实时信号RT_SIGNAL_LEASE通知我们文件正在被破坏,这种情况下为了避免访问非法地址,程序可以中断write系统调用,返回已经写入的字节数,同时把errno设置为success;
    文件租借锁使用代码,使用前加锁,使用后解锁如下:
if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
    perror("kernel lease set signal");
    return -1;
}
/* l_type can be F_RDLCK F_WRLCK  加锁*/
/* l_type can be  F_UNLCK 解锁*/
if(fcntl(diskfd, F_SETLEASE, l_type)){
    perror("kernel lease set type");
    return -1;
}

sendFile

关于sendFile,可以先看下sendFile方法:

#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

其中in_fd必须是mmap文件,out_fd必须是套接字,这样就可以通过sendFile直接将内核缓存区的文件拷贝到网络缓存区,减少了用户态和内核态的上下文切换,和减少了文件拷贝次数,同时数据拷贝只发生在内核层,如下图所示:


java.nio中的Channel系列(2)-FileChannel与零拷贝原理_第3张图片
sendFile.png

另外sendfile即使不做任何信号程序处理,如果调用sendFile时其他进程truncate文件,sendFile会被中断调用,返回中断前读取的字节,将errno设置为success,但是不会因为读取非法地址而中断进程;如果给文件使用租借锁,情况没有变化,但是会返回一个RT_SIGNAL_LEASE信号;

sendFile改进

在上面的场景中,文件数据从内核缓存区到socket缓存区同样会经历一次拷贝,那么有没有办法减少这次拷贝呢?借助硬件是可以实现的,我们可以使用sendFile将页缓存区的关于文件缓存的描述符如位置,大小添加到socket端口,这一步不会复制文件缓存,这样DMA控制引擎可以根据文件描述符直接将内核页缓存区的文件拿到协议引擎中,避免最后一次拷贝,如下图:


java.nio中的Channel系列(2)-FileChannel与零拷贝原理_第4张图片
sendFile2.png
splice

sendFile可以将数据拷贝到一个套接字上面,这就限制了它的一些适用范围;linux可以通过splice的方法将文件数据在两个文件描述符中进行移动,其方法如下:

#define _GNU_SOURCE         /* See feature_test_macros(7) */
#include 
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

splice系统调用会在两个文件描述符中进行文件移动,但是其中一个一方必须是管道设备;
flags参数有以下取值:

  • SPLICE_F_MOVE:尝试去移动数据而不是拷贝数据。这仅仅是对内核的一个小提示:如果内核不能从pipe移动数据或者pipe的缓存不是一个整页面,仍然需要拷贝数据。Linux最初的实现有些问题,所以从2.6.21开始这个选项不起作用,后面的Linux版本应该会实现。
  • SPLICE_F_NONBLOCK:splice 操作不会被阻塞。然而,如果文件描述符没有被设置为不可被阻塞方式的 I/O ,那么调用 splice 有可能仍然被阻塞
  • SPLICE_F_MORE:后面的splice调用会有更多的数据;

扩展:linux写时复制
linux为了减少数据文件在内核和用户缓存区进行复制,采用的一种机制,其主要原理是:多个进程访问同一个文件时,那么该文件被拷贝到内核缓存区,对所有进程都是可见的,但是不是所有线程都需要去修改该文件,所以针对这一现象,linux采用了只用当进程需要修改该文件时,才将该文件复制到用户空间,这就是写时复制;

FileChannel与零拷贝

FileChannel中的map()方法其实就是利用mmap()系统调用,而transferTo()、transferFrom()的实现也是根据情况采用了相应的零拷贝技术;

你可能感兴趣的:(java.nio中的Channel系列(2)-FileChannel与零拷贝原理)