网络编程(二):Java NIO

451 阅读18分钟

网络编程(一):Thrift详解 中学习了Thrift是如何进行服务调用的,在Thrift中多个服务端都使用到了NIO来提高监听效率,那什么是NIO呢?

什么是NIO?

NIO是同步非阻塞I/O,在JDK 1.4引入的面向缓冲区的I/O(基于块),基于ChannelBuffer进行操作,数据总是从Channel读取到Buffer中Buffer相当于一个容器),或者从Buffer写入到Channel中。Selector用于监听多个Channel的事件(比如连接打开、数据到达),因此,单个线程可以监听多个数据通道。

NIO将最耗时的I/O操作转移回操作系统,极大了的提高了速度。

传统I/O基于流,每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方(数据直接写入或读取到Stream对象中)。

I/O模型

NIO是同步非阻塞I/O,那什么是同步I/O?什么是阻塞I/O呢?

一个I/O操作分为两个步骤:发起I/O请求和实际的I/O操作。

阻塞I/O

发起I/O请求后阻塞等待数据,也就是发起recvfrom调用后到数据返回的过程用户进程都是阻塞的。

老李去火车站买票,排队三天买到一张退票。在车站吃喝拉撒睡3天,其他事一件没干。

非阻塞I/O

发起I/O请求后立即返回,通过轮询的方式询问内核数据是否准备好,如果数据已准备好则拷贝数据。非阻塞I/O在每次轮询的间隙可以去处理别的任务。

老李去火车站买票,隔12小时去火车站问有没有退票,三天后买到一张票。往返车站6次,路上6小时,其他时间做了好多事。

阻塞I/O和非阻塞I/O的区别就是发起I/O请求是否阻塞,阻塞I/O和非阻塞I/O实际的I/O操作都是阻塞的。

I/O多路复用

用户进程通过select()调用使一个进程监听多个I/O对象,当I/O对象有变化的时候通知用户进程发起recvfrom调用拷贝数据。

I/O多路复用发起I/O请求和实际的I/O操作都是阻塞的。

select:老李去火车站买票,委托黄牛,然后每隔6小时电话黄牛询问,黄牛三天内买到票,然后老李去火车站交钱领票。往返车站2次,路上2小时,黄牛手续费100元,打电话17次

epoll:老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。耗费:往返车站2次,路上2小时,黄牛手续费100元,无需打电话。

信号驱动

用户进程建立SIGIO信号处理程序,并通过系统调用sigaction执行一个信号处理函数,内核准备好数据后发送SIGIO信号通知用户进程发起recvfrom调用拷贝数据。

信号驱动数据拷贝是阻塞的。

老李去火车站买票,给售票员留下电话,有票后售票员电话通知老李,然后老李去火车站交钱领票。往返车站2次,路上2小时,免黄牛费100元,无需打电话

异步I/O

发起I/O请求后立即返回,由内核准备好数据并拷贝到用户空间后,通过信号或回调函数通知用户进程数据拷贝完成。

老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李并快递送票上门。往返车站1次,路上1小时,免黄牛费100元,无需打电话。

同步I/O和异步I/O的区别就是实际I/O操作是否阻塞,如果实际的I/O操作阻塞则是同步I/O,如果非阻塞那就是异步I/O。

阻塞I/O、非阻塞I/O、信号驱动、I/O多路复用都是同步I/O。

NIO组成

Channel(通道)

1. 作用

Channel是双向的,可以用于读、写或同时读/写。Channel只负责传输数据,不直接操作数据,对Channel的读写必须通过Buffer对象。

  • 写入时需要先将数据写入Buffer,再通过Channel传输数据;
  • 读取时需要先将数据从Channel读入Buffer,再从Buffer获取数据。

Channel是双向的,传统I/O流是单向的(一个流必须是InputStream或者OutputStream的子类)。

2. 类型

  • FileChannel:从文件读取数据。
  • DatagramChannel:读写UDP网络协议数据。
  • SocketChannel:读写TCP网络协议数据。
  • ServerSocketChannel:可以监听TCP连接。

3. 使用

获取Channel

FileInputStream fin = new FileInputStream( "xxx.txt" );
FileChannel fc = fin.getChannel();

Buffer(缓冲区)

Buffer中包含需要读取/写入的数据。

1. 组成

每一个读/写操作都会改变缓冲区的状态,通过记录和跟踪状态变化,缓冲区就能够内部地管理自己的资源。Buffer包含以下几个状态变量:

  • position:指定了下一个被读/写字节的位置,position总是小于或等于limit

  • limit:表示当前缓冲区中可以操作数据的大小,不能大于capacity

  • capacity:缓冲区的大小(能容纳的最大数量,指定了底层数组的大小),不可变。

  • mark:记录上一次被读/写的位置。

(1)通过ByteBuffer.allocate(10);创建一个10 byte大小的数组缓冲区 (2)向Buffer写入5字节数据 (3)调用buf.filp()Buffer中的5字节写入Channellimit置为当前positionposition置为0

2. ByteBuffer的3钟实现方式

HeapByteBuffer:将数据保存在JVM堆内存中。可能造成堆内存过大,影响GC

DirectByteBuffer:将数据保存在堆外内存。只能被FULL GC回收,或者手动调用Cleaner.clean()方法回收。

MappedByteBuffer:文件映射(数据存储到文件中)。不受JVM管理,可能导致内存泄漏。

3. 使用

读取数据:

//创建通道
FileInputStream fin = new FileInputStream( "xxx.txt" );
FileChannel fc = fin.getChannel();
//创建缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
//将数据从Channel读取到Buffer
int bytesRead = fileChannel.read(buf);
//使用get()方法从Buffer中读取数据
buf.get();

写入数据:

//创建通道
FileInputStream fin = new FileInputStream( "xxx.txt" );
FileChannel fc = fin.getChannel();
//创建缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
//将数据写入Buffer
for (int i=0; i<message.length; ++i) {
     buffer.put( message[i] );
}
buf.flip();
//	将数据从Buffer写入Channel
fc.write( buffer );
buffer.clear();

buf.flip():将Buffer从写模式切换到读模式,表示可以读取数据(将limit置为当前positionposition置为0)。

public final Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
}

buf.clear():清空Buffer,数据将被覆盖(将limit=capacityposition置为0)。

public final Buffer clear() {
    position = 0;
    limit = capacity;
    mark = -1;
    return this;
}

buf.compact():清空Buffer,所有未读的数据会被拷贝到Buffer起始处,position设为最后一个未读元素的下一位置。

DirectByteBuffer:
public ByteBuffer compact() {
    int pos = position();
    int lim = limit();
    assert (pos <= lim);
    int rem = (pos <= lim ? lim - pos : 0);

    unsafe.copyMemory(ix(pos), ix(0), (long)rem << 0);
    position(rem);
    limit(capacity());
    discardMark();
    return this;
}

HeapByteBuffer:
public ByteBuffer compact() {
    System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
    position(remaining());
    limit(capacity());
    discardMark();
    return this;
}

Selector(选择器)

1. 作用

一个Selector可以监听多个Channel的事件。

2. 使用

创建Selector

Selector selector = Selector.open();

将需要监听的Channel注册到Selector

//register()会返回一个SelectionKey对象,SelectionKey包含所有注册的事件、已就绪事件等
channel.register(selector, SelectionKey.OP_ACCEPT);

Selector一起使用的Channel必须是非阻塞的,FileChannel是阻塞的,不能和Selector一起使用。(ServerSocketChannel是非阻塞的)

循环监听阻塞等待事件就绪:

selector.select();

select()Linux默认使用epoll方式。

获取已就绪事件:

Set<SelectionKey> selectedKeys = selector.selectedKeys();

为什么NIO快?

Linux系统中的磁盘文件访问方式

  • 缓存I/O:标准I/O,在缓存I/O模式,读文件操作时,数据先从磁盘复制到内核空间缓冲区,然后再从内核缓冲区复制到应用程序地址空间。
  • 内存映射:Linux提供的一种访问磁盘的特殊方式,通过mmap()系统调用将用户进程虚拟内存地址和磁盘文件直接建立映射关系,从而把对内存的访问直接转换为对磁盘的访问。
  • 直接I/O:直接I/O模式下,应用程序读写文件时直接访问磁盘数据,不经过内核缓冲区,适用于数据库等程序自己管理缓冲的场景。

NIO的零拷贝

1.NIO的零拷贝

NIO的零拷贝由FileChannel.transferTo()方法实现,能节省一次缓冲区的复制过程,直接写入另外一个Channel 通道上。

transferTo()将数据从FileChannel对象传送到可写的字节通道(如SocketChannel)。在transferTo()内部实现中,由native方法transferTo0()来实现,它依赖底层操作系统的支持。在UNIXLinux系统中,调用transferTo0()会发起sendfile()系统调用,实现了数据直接从内核的读缓冲区拷贝到socket缓冲区,避免了用户态与内核态之间的数据拷贝。

2. 适用场景

  • 传输的数据不需要经过应用程序的处理。
  • 网络/文件的数据传输。
  • 大文件拷贝。

NIO的直接内存

传统I/O在拷贝数据的时候会先经过Native堆,再传给JVMNIO能直接在Native堆中分配内存,以避免JVM堆和Native堆之间数据拷贝带来的性能损耗(JVM堆内存和Native堆内存分别对应Linux缓存I/O模式中的应用程序内存和内核内存)。

堆外内存是直接调用系统malloc()分配的内存,这部分内存不属于JVM直接管理,也不受JVM最大内存的限制,通过引用指向这段内存。

1. 创建直接内存

NIO使用ByteBuffer创建缓冲区,ByteBuffer.allocate()创建JVM堆内缓冲区HeapByteBufferByteBuffer.allocateDirect()创建堆外缓冲区DirectByteBuffer

DirectByteBuffer继承自MappedByteBuffer,实现了DirectBuffer接口,不被Minor GC管理,只有Full GC能回收内存,内部维护一个Cleaner对象来完成内存回收,可以调用clean()方法来进行回收。

2. 为什么使用直接内存?

  1. 减少JVM堆和Native堆之间数据拷贝次数;
  2. 堆外内存不受JVM垃圾回收机制管理,是直接受操作系统管理,使用直接内存能保持JVM较小的堆内存,减少垃圾回收对应用的影响;

3. 使用直接内存的问题

  1. 当直接内存不足时会触发FULL GC
  2. 堆外内存只能通过序列化和反序列化来存储,保存对象速度比堆内存慢,不适合存储很复杂的对象;
  3. 直接内存的访问速度会快于堆内存,但在申请内存空间时堆内存速度快于直接内存;

NIO的内存映射

1. 什么是内存映射?

把一个磁盘文件(整体或部分内容)映射到用户进程虚拟内存的一块区域,这样就可以采用指针的方式直接操作内存当中的数据,而无须每次都通过 I/O从磁盘上读取文件,所以在效率上有很大提升。

2. NIO的内存映射

MappedByteBufferNIO引入的文件内存映射方案,使用mmap()系统调用来实现文件内存映射过程。在内存映射的过程中,只是逻辑上被放入了内存(建立并初始化了相关的数据结构),并没有实际的数据拷贝,文件没有被载入内存,所以建立内存映射的效率很高。当此文件的内容要被访问的时候,才会触发操作系统加载内存页(涉及缺页中断)。

MappedByteBuffer可以将文件直接映射到Native堆内存(不被Minor GC管理,只有在发生Full GC时才能被回收),读写性能极高。

FileChannel提供了map()方法将文件映射到虚拟内存,返回一个MappedByteBuffer对象。

//position:开始位置
//size:大小
//mode:指定可访问该内存文件的方式(只读、读/写、写时复制)
public MappedByteBuffer map(MapMode mode, long position, long size);

3. 适用场景

  • 基于文件共享的高性能进程间通信。
  • 大文件高性能读写访问。

比较

public class NIOTest {

    private static final String prefix = "input文件地址前缀";

    public static void main(String[] args) throws Exception {
        streamCopy("input.pdf", "output1.txt");
        bufferCopy("input.pdf", "output2.txt");
        directBufferCopy("input.pdf", "output3.txt");
        mappedByteBufferCopy("input.pdf", "output4.txt");
        zeroCopy("input.pdf", "output5.txt");
    }

    /**
     * 使用stream(传统IO)
     */
    private static void streamCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();
        File inputFile = new File(prefix + from);
        File outputFile = new File(prefix + to);
        FileInputStream fis = new FileInputStream(inputFile);
        FileOutputStream fos = new FileOutputStream(outputFile);
        byte[] bytes = new byte[1024];
        int len;
        while ((len = fis.read(bytes)) != -1) {
            fos.write(bytes, 0, len);
        }
        fos.flush();
        fis.close();
        fos.close();
        long endTime = System.currentTimeMillis();
        System.out.println("streamCopy:" + (endTime - startTime));
    }


    /**
     * 使用JVM堆内存
     */
    private static void bufferCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();
        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");
        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();
        //使用JVM堆内存
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        while (inputChannel.read(byteBuffer) != -1) {
            byteBuffer.flip();
            outputChannel.write(byteBuffer);
            byteBuffer.clear();
        }
        inputChannel.close();
        outputChannel.close();
        long endTime = System.currentTimeMillis();
        System.out.println("bufferCopy:" + (endTime - startTime));
    }


    /**
     * 使用堆外内存
     */
    private static void directBufferCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();
        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");
        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();
        //使用堆外内存
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
        while (inputChannel.read(byteBuffer) != -1) {
            byteBuffer.flip();
            outputChannel.write(byteBuffer);
            byteBuffer.clear();
        }
        inputChannel.close();
        outputChannel.close();
        long endTime = System.currentTimeMillis();
        System.out.println("directBufferCopy:" + (endTime - startTime));
    }


    /**
     * 内存映射
     */
    private static void mappedByteBufferCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();
        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");
        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();
        MappedByteBuffer iBuffer = inputChannel.map(FileChannel.MapMode.READ_ONLY, 0, inputFile.length());
        MappedByteBuffer oBuffer = outputChannel.map(FileChannel.MapMode.READ_WRITE, 0, inputFile.length());
        oBuffer.put(iBuffer);
        inputChannel.close();
        outputChannel.close();
        long endTime = System.currentTimeMillis();
        System.out.println("mappedByteBufferCopy:" + (endTime - startTime));
    }

    /**
     * 零拷贝
     */
    private static void zeroCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();
        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");
        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();
        //零拷贝
        inputChannel.transferTo(0, inputFile.length(), outputChannel);
        inputChannel.close();
        outputChannel.close();
        long endTime = System.currentTimeMillis();
        System.out.println("zeroCopy:" + (endTime - startTime));
    }
}

输入文件大小为180M,运行结果:

  • streamCopy:2832 [传统I/O]
  • bufferCopy:1479 [速度翻倍]
  • directBufferCopy:1302 [速度翻倍,稍高于使用堆内内存]
  • mappedByteBufferCopy:241 [非常快]
  • zeroCopy:263 [非常快]

零拷贝

什么是零拷贝

零拷贝是一种避免CPU将数据从一块存储拷贝到另外一块存储的技术。

零拷贝的零指的是数据在用户态的状态下经过了0次拷贝。

作用

零拷贝技术可以减少数据拷贝次数(用户态和内核态之间的数据拷贝),减少上下文切换带来的开销,从而有效地提高数据传输效率。

实现方式

1. 传统I/O

  • 数据拷贝过程:当服务器端需要读取一个文件并且发送到已连接的socket:应用程序通过read()系统调用请求数据(用户态→内核态),内核首先判断该文件是否在内核缓冲区,如果不在,则通过DMA的方式从磁盘将数据读入内核缓冲区,再将内核缓冲区的数据拷贝到用户缓冲区(内核态→用户态),通过write()系统调用再将用户缓冲区的数据拷贝到内核的socket缓冲区(用户态→内核态),最后socket通过DMA的方式将缓冲区的数据发送到网卡上,write()系统调用返回(内核态→用户态)。
while((n = read(diskfd, buf, BUF_SIZE)) > 0)
  write(sockfd, buf , n);
  • 不足:传统的标准I/O接口是基于数据拷贝操作的,I/O操作会导致数据在操作系统内核空间的缓冲区和应用程序空间定义的缓冲区之间进行传输。这样做的好处是可以减少磁盘I/O操作,如果所请求的数据已经存放在操作系统的高速缓冲区,那么就不需要再进行磁盘I/O操作。但是数据传输过程中的数据拷贝操作却导致了极大的 CPU 开销,限制了操作系统有效进行数据传输操作的能力。

在这个过程中总共产生了4次拷贝(2次DMA,2次CPU拷贝),上下文切换(用户态←→内核态)了4次。真正消耗资源的是第2/3次CPU拷贝,而且需要来回切换内核态和用户态。

DMA(直接存储器访问),将数据从一个地址空间复制到另一个地址空间,提供在外设和存储器之间或者存储器和存储器之间的高速数据传输。当CPU初始化这个传输动作,传输动作本身是由DMA控制器来实现和完成的。DMA传输方式无需CPU直接控制传输,也没有中断处理方式那样保留现场和恢复现场过程,通过硬件为RAM和IO设备开辟一条直接传输数据的通道,使得CPU的效率大大提高。

2. 直接I/O —— 避免拷贝

  • 适用于传输的数据不需要经过操作系统内核的处理。
  • 设计思想:应用程序使用库函数直接访问硬件设备,数据直接跨过内核进行传输,内核只是辅助数据传输。针对操作系统内核并不需要对数据进行直接处理的情况,数据可以在应用程序缓冲区和磁盘之间直接进行传输,完全不需要Linux操作系统内核提供的页缓存的支持。

在这个过程中总共产生了2次拷贝(2次DMA,0次CPU拷贝),上下文切换(用户态←→内核态)了4次。

3. mmap() + write() —— 避免拷贝

  • 数据拷贝过程:使用mmap()替代read(),应用程序调用了mmap()之后,数据会先通过DMA拷贝到内核的缓冲区中去。接着,应用程序跟操作系统共享这个缓冲区(内核空间地址与用户空间的虚拟地址映射到同一个物理地址),这样操作系统内核和应用程序存储空间就不需要再进行任何的数据拷贝操作。应用程序调用了write()之后,操作系统内核将数据从原来的内核缓冲区中拷贝到socket缓冲区中。接下来,数据从内核socket缓冲区拷贝到网卡中。
  • 不足:mmap()主要的用处是提高I/O性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是4KB,一个5KB的文件映射将会占用8KB内存,也就会浪费3KB内存。

在这个过程中总共产生了3次拷贝(2次DMA,1次CPU拷贝),上下文切换(用户态←→内核态)了4次。

4. sendfile() —— 避免拷贝

  • 适用于传输的数据不需要经过应用程序的处理,只适用于文件到socket之间进行数据传输。
  • 数据拷贝过程:sendfile()利用DMA将磁盘中的数据拷贝到操作系统内核缓冲区中,再将数据拷贝到socket缓冲区中去。最后通过DMA将数据从socket缓冲区中拷贝到网卡中去。sendfile()数据传输始终只发生在内核。

在这个过程中总共产生了3次拷贝(2次DMA,1次CPU拷贝),上下文切换(用户态←→内核态)了2次。

5. sendfile() + DMA —— 避免拷贝

将内核缓冲区中对应的数据描述信息(内存地址+偏移量)记录到相应的socket缓冲区当中(此过程不需要将数据从内核缓冲区拷贝到socket缓冲区中),由DMA根据内存地址、偏移量将数据批量地从内核缓冲区拷贝到网卡设备中。避免了sendfile()的1次CPU拷贝。

5. 写时复制 —— 优化拷贝过程贝

  • 适用于那种写时复制事件发生比较少的情况。
  • 定义:当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将数据拷贝到自己的进程地址空间中,避免该进程对这块数据做的更改被其他应用程序看到。这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行数据拷贝。如果应用程序永远不会对所访问的这块数据进行任何更改,那么就永远不需要将数据拷贝到应用程序自己的地址空间中去。
  • 作用:在某些情况下,内核缓冲区可能被多个进程所共享,如果某个进程想要对这个共享区进行write()操作,由于write()不提供任何的锁操作,那么就会对共享区中的数据造成破坏,写时复制的引入就是Linux用来保护数据的。
  • 实现:写时复制的实现需要MMU的支持,MMU需要知道进程地址空间中哪些特殊的页面是只读的,当需要往这些页面中写数据的时候,MMU就会发出一个异常给操作系统内核,操作系统内核就会分配新的物理存储空间,即将被写入数据的页面需要与新的物理存储位置相对应。

6. 比较

BIO/NIO/AIO区别

BIO:同步阻塞I/O。

NIO:同步非阻塞I/O,非阻塞I/O操作基于epoll方式。

AIO:异步非阻塞I/O,异步I/O操作基于事件和回调机制。

NIO应用

  • Thrift远程服务调用框架使用NIO监听请求。
  • Kafka的网络通信模型基于NIOReactor多线程模型设计。
  • Kafka采用NIO的内存映射MappedByteBuffer来处理消息日志文件。
  • Netty