Netty—NIO万字详解

文章目录

  • NIO基本介绍
  • 同步、异步、阻塞、非阻塞
  • IO的分类
  • NIO 和 BIO 的比较
  • NIO 三大核心原理示意图
    • NIO的多路复用说明
  • 核心一:缓存区 (Buffer)
    • Buffer类及其子类
    • Buffer缓冲区的分类
    • MappedByteBuffer类说明:
  • 核心二:通道 (Channel)
    • Channel类及其子类
    • SelectableChannel类说明:
    • SelctionKey
  • 核心三:Selector(选择器)
    • Selector类
  • 案例说明
    • 栗子一:本地文件写数据
    • 栗子二:本地文件写数据
    • 栗子三:使用一个Buffer 完成文件读取、写入
    • 栗子四:从目标通道中复制原通道数据
    • 栗子五:把原通道数据复制到目标通道
    • 栗子六:分散 (Scatter) 和聚集 (Gather)
    • 栗子七:NIO非阻塞 网络编程入门案例
  • 分析小结
  • NIO-零拷贝
    • 用户态和内核态简介
    • 零拷贝简介
    • 传统IO
    • MMAP 优化
    • sendFile 优化
    • sendfile+DMA Scatter/Gather
    • NIO的零拷贝
  • 总结
  • 参考文献

NIO基本介绍

Java NIO全称 java non-blocking IO,是指 JDK 提供的新 API。从JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO),是同步非阻塞的。NIO的设计目标是在处理I/O操作时提供更好的性能和可扩展性。它在BIO功能的基础上实现了非阻塞的特性,位于java.nio包下。

  • NIO 有三大核心部分:Channel(通道),Buffer(缓冲区),Selector(选择器)
  • NIO支持面向缓冲区(面向块)的、基于通道的IO操作。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络
  • 通道和缓冲区的配合: 通道从缓冲区读取数据或将数据写入缓冲区。通常,数据首先被写入缓冲区,然后通过通道传输到目标。同样,从通道读取的数据也首先被读入缓冲区,然后从缓冲区中提取。

通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有10000个请求过来,根据实际情况,可以分配50或者100个线程来处理。不像之前的阻塞10那样,非得分配10000个。

同步、异步、阻塞、非阻塞

对于同步、异步、阻塞、非阻塞来说,其实很好理解,这里只做简单的介绍

同步、异步、阻塞和非阻塞是与 I/O 操作相关的概念,用于描述程序中的任务调度和执行方式。

同步(Synchronous):

  • 同步指的是程序按照顺序执行,一个任务完成后才能开始另一个任务。
  • 在同步模型中,一个操作的完成会导致程序等待,直到该操作完成后才能继续执行下一个操作。
  • 同步操作通常会阻塞程序的执行,因为程序必须等待操作完成才能继续。

异步(Asynchronous):

  • 异步指的是程序不必等待一个操作完成,而是可以继续执行后续的操作。
  • 在异步模型中,一个操作的启动不会导致程序阻塞,而是可以继续执行其他任务。
  • 异步操作通常会通过回调、事件处理或者异步任务来处理操作的完成。

阻塞(Blocking):

  • 阻塞指的是当一个任务执行时,程序暂停执行,直到该任务完成。这是同步操作的典型特征。
  • 阻塞会导致程序的资源被浪费,因为在等待任务完成时,程序无法执行其他任务。

非阻塞(Non-blocking):

  • 非阻塞指的是在执行一个任务时,程序不会暂停等待任务完成,而是会立即返回执行其他任务。
  • 非阻塞操作通常需要使用轮询或者回调等机制来检查任务是否完成。

其实这样说还是很难理解,下面让我们用实例说明:

例子:咖啡馆的服务员

  • 同步: 顾客在咖啡馆点了一杯咖啡后,服务员开始制作咖啡。在咖啡制作完成之前,服务员不会处理其他顾客的订单,必须等待当前订单完成后再接受下一个订单。这就是同步操作,一个任务完成后才能开始下一个。
  • 异步: 现在,服务员接受了顾客的订单,并将订单传给咖啡师傅。而服务员并不等待咖啡制作完成,而是继续接受其他顾客的订单。咖啡制作完成后,咖啡师傅通过呼叫服务员或者使用订单号通知服务员。这就是异步操作,服务员不必等待咖啡制作完成,而是可以继续处理其他订单。
  • 阻塞: 如果服务员在等待咖啡制作完成的期间什么也不做,直到咖啡师傅通知制作完成,那么这是阻塞操作。服务员一直被阻塞,无法执行其他任务。
  • 非阻塞: 现在,服务员在等待咖啡的同时可以接受其他订单,或者询问其他师傅是否需要帮助。即使在咖啡制作的过程中,服务员也可以执行其他任务,这就是非阻塞操作。

Netty—NIO万字详解_第1张图片

  • 同步与阻塞,异步与非阻塞,很多人都会对这两组概念产生疑惑,都会有些区分不清,这是由于它们之间的确是存在关系的,而且是相辅相成的关系,从某种意义上来说:“同步天生就是阻塞的,异步天生就是非阻塞的”

  • 但实际上又有点不一样,这是我没有画非阻塞的原因(上面画的异步就可以理解成非阻塞的),其实是相辅相成的关系:

  • 非阻塞操作可以是同步的(例如非阻塞 I/O 操作),也可以是异步的。

  • 异步操作可以是阻塞的(例如在异步操作的结果返回前一直等待),也可以是非阻塞的(因为可以去干别的事)。

  • 有点绕,希望你们可以理解

IO的分类

根据上述情况,IO总共可被分为四大类:同步阻塞式IO、同步非阻塞式IO、异步阻塞式IO、异步非阻塞式IO,当然,由于异步执行在一定程度上而言,天生就是非阻塞式的,因此不存在异步阻塞式IO的说法,也就对应着BIO(同步阻塞)、NIO(同步非阻塞)、AIO(异步非阻塞)。

BIO是常见的IO,像我们平时写的接口大多都是BIO,很好理解,这里不做说明;

这里通过烧水来举例说明什么是同步非阻塞,以及异步非阻塞

烧水步骤:打开烧水壶的开关—> 烧水中 —> 水开了

同步非阻塞: 现在想象你是传统的烧水壶,它没有任何功能,当你打开烧水壶的开关后,你可以去做别的事,但你会一直隔一会就来看水好了吗?水有没有开呀?直到最终水开;

解释: 这类似于 NIO 模型,其中你可以发起一个 I/O 操作(启动开关),然后继续执行其他任务,定期检查状态以确定操作是否完成。

Netty—NIO万字详解_第2张图片

异步非阻塞:现在想象你的水壶是那种响壶,水开了它就会提醒你水开了;当你打开烧水壶开关后,你可以去做别的任何事,直到水壶响了,你就知道水开了,中间你无需反复去检查水是否开

解释: 这类似于 AIO 模型,其中你发起一个 I/O 操作,但不需要定期检查状态。相反,系统会在操作完成时通知你。

Netty—NIO万字详解_第3张图片

NIO 和 BIO 的比较

NIOBIO是两种不同的I/O模型,它们在处理数据的方式和效率上有所不同。

  1. 处理方式:BIO以流的方式处理数据,而NIO以块的方式处理数据。这意味着在BIO中,数据是按流逐个字节读取的,而在NIO中,数据是按块一次读取多个字节。
  2. 效率:由于块I/O的效率比流I/O高很多,因此NIO的效率通常比BIO高。这是因为NIO可以一次性读取多个字节,减少了CPU和内存的访问次数,提高了数据的处理效率。
  3. 阻塞与非阻塞:BIO是阻塞的,而NIO是非阻塞的。在BIO中,当一个线程进行I/O操作时,它会一直等待直到操作完成。这会导致线程被阻塞,无法处理其他任务。而在NIO中,I/O操作不会阻塞线程,线程可以继续执行其他任务。
  4. 数据传输:在BIO中,数据是从字节流或字符流中读取的。而在NIO中,数据是从通道(Channel)和缓冲区(Buffer)之间传输的。这意味着在NIO中,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。
  5. 选择器(Selector):NIO使用选择器(Selector)来监听多个通道的事件,如连接请求、数据到达等。因此,使用单个线程就可以监听多个客户端通道。这提高了程序的效率和并发性。

总的来说,NIO相对于BIO的优势在于更高的效率、非阻塞性以及使用选择器监听多个通道的能力。然而,这也增加了编程的复杂性。因此,在实际应用中,需要根据具体需求和场景来选择合适的I/O模型。

NIO 三大核心原理示意图

三大核心原理包括:通道(Channel)、缓冲区(Buffer)和选择器(Selector)。

通道(Channel):

  • 通道是数据传输的路径,类似于流。通道可以打开和关闭,可以读取和写入数据。在 NIO 中,数据通过通道进行传输,通道是双向的,既可以用于读取数据,也可以用于写入数据。通道可以连接到文件、网络套接字等。

缓冲区(Buffer):

  • 缓冲区是一个存储数据的区域,它实际上是一个数组。在 NIO 中,数据是从通道读取到缓冲区,或者从缓冲区写入到通道。缓冲区提供了对数据的结构化访问,可以轻松地读取、写入、或者处理数据。本质上是一个可以读写数据的内存块

选择器(Selector):

  • 选择器是 NIO 的多路复用器,它允许单个线程处理多个通道。选择器会不断地轮询注册在其上的通道,如果某个通道有数据可读或者可写,就会通知该通道。这种机制使得一个线程可以有效地管理多个通道,提高了系统的性能和资源利用率。

Netty—NIO万字详解_第4张图片

记住这张图,对后面理解NIO很有帮助!!!不理解没事,后面会详细说明

至于什么是多路复用下面有举例说明

  • 每个Channel对应一个BufferBuffer是一个可以读写的内存块,是双向通道,既可以读也可以写。
  • 当Channel向Selector注册时,都会创建一个SelectionKey
  • 可以根据SelectionKey找到对应Channel
  • Selector对应一个线程,一个线程对应多个Channel连接Selector用于监听多个Channel的事件,例如连接请求、数据到达等。每个线程可以处理多个Channel的连接和事件。
  • 每个channel都会注册到Selector选择器上ChannelNIO中的核心概念之一,用于建立连接并传输数据。每个Channel都会注册到Selector上,以便Selector能够监听该Channel的事件。

NIO的多路复用说明

  • 想象一个大型餐厅,其中有许多客人等待就餐。传统的方式是,每个客人都要有一个服务员单独服务,这样每个服务员只能为一个客人服务。但这种方式非常低效,因为当一个服务员忙于服务一个客人时,其他客人只能等待。

  • 现在想象一个改进的餐厅,其中只有一个多路复用器服务员。这个服务员可以在多个桌子之间巡回,为每个客人提供服务。当一个客人需要点菜时,多路复用器服务员会记下客人的需求,然后继续为其他客人服务。当所有的客人都点完菜后,多路复用器服务员会回到第一个客人那里,为他上菜。

在这个例子中,多路复用器服务员就像NIO中的Selector。它可以在多个Channel之间进行选择,当其中一个Channel有数据可读或可写时,它会通知相应的线程进行操作。这种方式极大地提高了餐厅的效率和服务质量,因为服务员可以同时为多个客人服务,而不是只能为一个客人服务。

核心一:缓存区 (Buffer)

缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer;

Netty—NIO万字详解_第5张图片

Buffer类及其子类

Netty—NIO万字详解_第6张图片

  1. ByteBuffer:字节缓冲区,用于处理字节数据。
  2. CharBuffer:字符缓冲区,用于处理字符数据。
  3. DoubleBuffer:双精度浮点缓冲区,用于处理双精度浮点数据。
  4. FloatBuffer:单精度浮点缓冲区,用于处理单精度浮点数据。
  5. IntBuffer:整数缓冲区,用于处理整数数据。
  6. LongBuffer:长整型缓冲区,用于处理长整型数据。
  7. ShortBuffer:短整型缓冲区,用于处理短整型数据。

这些缓冲区类都继承自Buffer类,具有一些通用的方法和属性,如position、limit、capacity等。它们的主要区别在于存储的数据类型不同,因此在使用时需要根据具体需求选择合适的缓冲区类。

使用Buffer读写数据一般遵循以下四个步骤:

  1. 写入数据到Buffer
  2. 调用flip()方法,转换为读取模式
  3. 从Buffer中读取数据
  4. 调用buffer.clear()方法或者buffer.compact()方法清除缓冲区

对于Java中缓冲区的定义,首先要明白,当缓冲区被创建出来后,同一时刻只能处于读/写中的一个状态,同一时间内不存在即可读也可写的情况。理解这点后再来看看它的成员变量,重点理解下述三个成员:

  • pasition:表示当前操作的索引位置(下一个要读/写数据的下标)。
  • capacity:表示当前缓冲区的容量大小。
  • limit:表示当前可允许操作的最大元素位置(不是下标,是正常数字)。

图示:

Netty—NIO万字详解_第7张图片

Buffer缓冲区的分类

Java中的缓冲区也被分为了两大类:本地直接内存缓冲区与堆内存缓冲区,前面Buffer类的所有子实现类xxxBuffer本质上还是抽象类,每个子抽象类都会有DirectXxxBuffer、HeapXxxBuffer两个具体实现类,这两者的主要区别在于:创建缓冲区的内存是位于堆空间之内还是之外。

  1. 堆内存缓冲区:
    • 堆内存缓冲区是在Java堆内存中分配的。
    • 它们是Java标准的一部分,可以使用ByteBuffer类的静态工厂方法来创建,如ByteBuffer.allocate()
    • 堆内存缓冲区在垃圾回收时可能会被回收,因此它们可能会在任何时候被释放。
  2. 直接内存缓冲区:
    • 直接内存缓冲区不是Java标准的一部分,但它们是在Java堆外分配的内存。
    • 直接内存缓冲区是通过使用NIOByteBuffer类的allocateDirect()方法创建的。
    • 直接内存缓冲区不会受到Java垃圾回收的影响,因为它们是在Java堆外分配的。这意味着它们在程序运行期间不会被释放,除非显式地调用release()方法。

优缺点:

堆内存缓冲区在创建和销毁时相对较轻量级,但可能会受到垃圾回收的影响。

而直接内存缓冲区虽然不会受到垃圾回收的影响,但创建和销毁时可能更消耗资源。

Netty—NIO万字详解_第8张图片

MappedByteBuffer类说明:

MappedByteBuffer是Java NIO(New I/O)中引入的文件内存映射方案,它允许Java程序直接从内存中读取文件内容。通过将整个或部分文件映射到内存,由操作系统来处理加载请求和写入文件,应用只需要和内存打交道,这使得IO操作非常快。

MappedByteBuffer的设计使得它能够高效地处理大文件。传统的文件IO操作中,我们需要调用操作系统提供的底层标准IO系统调用函数(如read()、write()),此时调用此函数的进程(在JAVA中即java进程)由当前的用户态切换到内核态,然后OS的内核代码负责将相应的文件数据读取到内核的IO缓冲区,然后再把数据从内核IO缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次IO操作。而通过MappedByteBuffer,我们可以直接从内存中读取文件内容,避免了上述的IO操作过程,从而提高了IO操作的效率。

public class MappedByteBufferTest {
    public static void main(String[] args) throws Exception {

        RandomAccessFile randomAccessFile = new RandomAccessFile("1.txt", "rw");
        //获取对应的通道
        FileChannel channel = randomAccessFile.getChannel();

        /**
         * 参数1: FileChannel.MapMode.READ_WRITE 使用的读写模式
         * 参数2: 0 : 可以直接修改的起始位置
         * 参数3:  5: 是映射到内存的大小(不是索引位置) ,即将 1.txt 的多少个字节映射到内存
         * 可以直接修改的范围就是 0-5
         * 实际类型 DirectByteBuffer
         */
        MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);

        mappedByteBuffer.put(0, (byte) 'H');
        mappedByteBuffer.put(3, (byte) '9');
        mappedByteBuffer.put(5, (byte) 'Y');//IndexOutOfBoundsException

        randomAccessFile.close();
        System.out.println("修改成功~~");
        
    }
}

核心二:通道 (Channel)

NIO中的通道(Channel)是一个用于数据传输的双向通道,既可以读也可以写。与流(Stream)相比,通道具有更好的扩展性和灵活性,能够更好地映射底层操作系统的API

  • 具体来说,通道和流的主要区别在于通道是双向的,而流只是在一个方向上移动。流必须是InputStreamOutputStream的子类,而通道可以用于读、写或者同时用于读写。另外,通道是全双工的,可以同时进行读写操作,而流只能是单向的。

  • 通道可以分为两大类:用于网络读写的SelectableChannel和用于文件操作的FileChannelSelectableChannel可以被选择器(Selector)选择,从而实现多路复用。FileChannel则主要用于文件的读写操作,支持异步读写和文件区域操作等高级功能。

  • 通道的使用方式也与流有所不同。从一个通道中读取数据很简单:只需创建一个缓冲区,然后让通道将数据读到这个缓冲区中。写入也相当简单:创建一个缓冲区,用数据填充它,然后让通道用这些数据来执行写入操作。

  • 与流相比,通道的优势在于它们可以更好地映射底层操作系统的API,具有更高的效率和更好的扩展性。另外,通道还支持异步读写操作,这使得它们在处理大量并发操作时更加高效。

Channel类及其子类

通道不是打开就是关闭。通道在创建时是开放的,一旦关闭,它就保持关闭状态。一旦通道关闭,任何对其调用I/O操作的尝试都将导致抛出closechannelexception。通道是否打开可以通过调用它的isOpen方法来测试。 一般来说,通道对于多线程访问是安全的,这在扩展和实现该接口的接口和类的规范中有描述。

// NIO包中定义的Channel通道接口
public interface Channel extends Closeable {
    // 判断通道是否处于开启状态
    public boolean isOpen();
    // 关闭通道
    public void close() throws IOException;
}

Netty—NIO万字详解_第9张图片

其中常用的FileChannel以及ServerSocketChannel分别位于ReadableByteChannelSelectableChannel

常用的Channel类有:

  • FileChannel:用于读取、写入、映射和操作文件的通道。
  • DatagramChannel:通过 UDP 读写网络中的数据通道。
  • SocketChannel:通过 TCP 读写网络中的数据。
  • ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel

ServerSocketChanne 类似 ServerSocket , SocketChannel 类似 Socket

FileChannel类常用方法:

 public abstract class FileChannel
            extends AbstractInterruptibleChannel
            implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel
    {
        // 从Channel 到 中读取数据到  ByteBuffer
        public int read(ByteBuffer dst);

        //将Channel中的数据“分散”到  ByteBuffer[]
        public long  read(ByteBuffer[] dsts);

        //将ByteBuffer中的数据写入到  Channel
        public int  write(ByteBuffer src) ;

        //将ByteBuffer[] 到 中的数据“聚集”到  Channel
        public long write(ByteBuffer[] srcs);

        //返回此通道的文件位置
        public long position();

        //设置此通道的文件位置
        public FileChannel position(long p);

        //返回此通道的文件的当前大小
        public long size() ;

        //将此通道的文件截取为给定大小
        public FileChannel truncate(long s) ;

        //强制将所有对此通道的文件更新写入到存储设备中
        public void force(boolean metaData);
    }

在案例一、二、三说明!

SelectableChannel类说明:

SelectableChannel其中存在两个重要的子类:分别是ServerSockerChannel和SockerChannel

SocketChannel和ServerSocketChannel的关系

  • SocketChannel是用于客户端的网络通信,它可以通过建立与ServerSocket的连接来与服务器进行通信。而ServerSocketChannel则是用于服务器端的网络通信,它可以通过监听新进来的连接请求来接受客户端的连接。

  • 在传统的Socket编程中,客户端需要建立一个Socket对象来与服务器建立连接。而在Java NIO中,客户端可以使用SocketChannel来代替Socket进行网络通信。同样地,服务器端也可以使用ServerSocketChannel来代替ServerSocket进行网络通信。

  • SocketChannelServerSocketChannel之间的主要区别在于它们的使用场景不同。SocketChannel主要用于客户端,而ServerSocketChannel主要用于服务器端。但是,它们都提供了异步、高效的网络I/O操作能力,使得客户端和服务器之间的通信更加高效和可靠。

ServerSockerChannel类常用说明:

// 服务端通道抽象类
public abstract class ServerSocketChannel
    extends AbstractSelectableChannel
    implements NetworkChannel
{
    // 构造方法:需要传递一个选择器进行初始化构建
    protected ServerSocketChannel(SelectorProvider provider);
    // 打开一个ServerSocketChannel通道
    public static ServerSocketChannel open() throws IOException;
    // 绑定一个IP地址作为服务端
    public final ServerSocketChannel bind(SocketAddress local);
    // 绑定一个IP并设置并发连接数大小,超出后的连接全部拒绝
    public abstract ServerSocketChannel bind(SocketAddress local, int backlog);
    // 监听客户端连接的方法(会发生阻塞的方法)
    public abstract SocketChannel accept() throws IOException;
    // 获取一个ServerSocket对象
    public abstract ServerSocket socket();
    // .....省略其他方法......
}

ServerSocketChannel类似 ServerSocket。主要作用是接受客户端的连接请求,并建立TCP连接。当有客户端尝试连接到服务器时,ServerSocketChannel可以监听到这个连接请求,并接受该连接。一旦连接建立,ServerSocketChannel就可以将接收到的数据传递给对应的处理程序进行进一步处理。

可以这么理解ServerSocketChanel本生也是channel,他也需要selector去监听事件发生,有新客户端连接,selector就监听到了该事件

SockerChannel类常用说明:

public abstract class SocketChannel extends AbstractSelectableChannel
    implements ByteChannel, ScatteringByteChannel, 
               GatheringByteChannel, NetworkChannel{
    // 打开一个通道
    public static SocketChannel open();
    // 根据指定的远程地址,打开一个通道
    public static SocketChannel open(SocketAddress remote);
    // 如果调用open()方法时未给定地址,可以通过该方法连接远程地址
    public abstract boolean connect(SocketAddress remote);
    // 将当前通道绑定到本地套接字地址上
    public abstract SocketChannel bind(SocketAddress local);
    // 把当前通道注册到Selector选择器上:
    // sel:要注册的选择器、ops:事件类型、att:共享属性。
    public final SelectionKey register(Selector sel,int ops,Object att);
    // 省略其他......
    // 关闭通道    
    public final void close();
    
    // 向通道中写入数据,数据通过缓冲区的方式传递
    public abstract int write(ByteBuffer src);
    // 根据给定的起始下标和数量,将缓冲区数组中的数据写入到通道中
    public abstract long write(ByteBuffer[] srcs,int offset,int length);
    // 向通道中批量写入数据,批量写入一个缓冲区数组    
    public final long write(ByteBuffer[] srcs);
    // 从通道中读取数据(读取的数据放入到dst缓冲区中)
    public abstract int read(ByteBuffer dst);
    // 根据给定的起始下标和元素数据,在通道中批量读取数据
    public abstract long read(ByteBuffer[] dsts,int offset,int length);
    // 从通道中批量读取数据,结果放入dits缓冲区数组中
    public final long read(ByteBuffer[] dsts);
    
    // 返回当前通道绑定的本地套接字地址
    public abstract SocketAddress getLocalAddress();
    // 判断目前是否与远程地址建立上了连接关系
    public abstract boolean isConnected();
    // 判断目前是否与远程地址正在建立连接
    public abstract boolean isConnectionPending();
    // 获取当前通道连接的远程地址,null代表未连接
    public abstract SocketAddress getRemoteAddress();
    // 设置阻塞模式,true代表阻塞,false代表非阻塞
    public final SelectableChannel configureBlocking(boolean block);
    // 判断目前通道是否为打开状态
    public final boolean isOpen();
}

SocketChannel所提供的方法大体分为三类:

  • 管理类:如打开通道、连接远程地址、绑定地址、注册选择器、关闭通道等。
  • 操作类:读取/写入数据、批量读取/写入、自定义读取/写入等。
  • 查询类:检查是否打开连接、是否建立了连接、是否正在连接等。

看到这里如果还有什么不清楚的话可以先去看看案例一、二、三、四

SelctionKey

SelectionKey是Java NIO中的一个抽象类,表示selectableChannel在Selector中注册的标识。每个Channel向Selector注册时,都会创建一个SelectionKey,将Channel与Selector建立了关系,并维护了channel事件。

SelectionKey有四个操作类型:OP_READ(当操作系统读缓冲区有数据可读时)、OP_WRITE(当操作系统写缓冲区有数据可写时)、OP_CONNECT(当连接被成功建立时)、OP_ACCEPT(当新连接被接受时)。

在编程时,通过SelectionKey可以获得通道的IO事件类型,比方说SelectionKey.OP_READ;还可以获得发生IO事件所在的通道;

public abstract class SelectionKey {
        //得到与之关联的通道
        public abstract SelectableChannel channel();
        //得到与之关联的Selector 对象
        public abstract Selector selector();
        //设置或改变监听事件
        public abstract SelectionKey interestOps(int ops);
        ///是否可以读
        public final boolean isReadable();
        // 是否可以写
        public final boolean isWritable();
        // 是否接受新的连接
        public final boolean isAcceptable();
        //得到与之关联的共享数据(Buffer)
        public final Object attachment();
    }

核心三:Selector(选择器)

Selector是一个选择器,它用于检测一个或者多个NIO通道的状态是否处于可读、可写。Selector的使用可以实现单线程管理多个Channel,也就是可以管理多个网络链接。

使用Selector的好处在于,只需要更少的线程就可以来处理通道,避免了线程上下文切换带来的开销。但是,不是所有的Channel都可以被Selector复用,只有继承了SelectableChannel的Channel才能被Selector复用

Selector是非阻塞IO的核心。

要使用Selector,首先需要创建一个Selector对象,然后通过Channel的register()方法将Channel注册到Selector上,注册时需要指定监听的事件类型。注册成功后,就可以通过Selector的select()方法来检测是否有事件发生,如果有事件发生,就可以通过Selector的selectedKeys()方法获取到发生事件的所有Channel,然后进行处理。

通道一共支持4中事件:

  • SelectionKey.OP_READ/1:读取就绪事件,通道内的数据已就绪可被读取。

  • SelectionKey.OP_WRITE/4:写入就绪事件,一个通道正在等待数据写入。

  • SelectionKey.OP_CONNECT/8:连接就绪事件,通道已成功连接到服务端。

  • SelectionKey.OP_ACCEPT/16:接收就绪事件,服务端通道已准备好接收新的连接。

当一个通道注册时,会为其绑定对应的事件,当该通道触发了一个事件,就代表着该事件已经准备就绪,可以被线程操作了。当然,如果要为一条通道绑定多个事件,那可通过位或操作符拼接:

Selector类

public abstract class Selector implements Closeable {
    // 创建一个选择器
    public static Selector open() throws IOException;
    // 判断一个选择器是否已打开
    public abstract boolean isOpen();
    // 获取创建当前选择器的生产者对象
    public abstract SelectorProvider provider();
    // 获取所有注册在当前选择的通道连接
    public abstract Set<SelectionKey> keys();
    // 获取所有数据已准备就绪的通道连接
    public abstract Set<SelectionKey> selectedKeys();
    // 非阻塞式获取就绪的通道,如若没有就绪的通道则会立即返回
    public abstract int selectNow() throws IOException;
    // 在指定时间内,阻塞获取已注册的通道中准备就绪的通道数量
    public abstract int select(long timeout) throws IOException;
    // 获取已注册的通道中准备就绪的通道数量(阻塞式)
    public abstract int select() throws IOException;
    // 唤醒调用Selector.select()方法阻塞后的线程
    public abstract Selector wakeup();
    // 关闭创建的选择器(不会关闭通道)
    public abstract void close() throws IOException;
}

案例说明

栗子一:本地文件写数据

需求:使用前面学习后的 **ByteBuffer(缓冲)**和 FileChannel(通道), 将数据写入到 data.txt 中

public class NIOFileChannel01 {
    public static void main(String[] args) throws Exception{
        String str = "hello,邱俊杰";
        //创建一个输出流->channel
        FileOutputStream fileOutputStream = new FileOutputStream("d:\\data.txt");

        //通过 fileOutputStream 获取 对应的 FileChannel
        //这个 fileChannel 真实 类型是  FileChannelImpl
        FileChannel fileChannel = fileOutputStream.getChannel();

        //创建一个缓冲区 ByteBuffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

        //将 str 放入 byteBuffer
        byteBuffer.put(str.getBytes());

        //对byteBuffer 进行flip
        byteBuffer.flip();

        //将byteBuffer 数据写入到 fileChannel
        fileChannel.write(byteBuffer);
        fileOutputStream.close();
    }
}

栗子二:本地文件写数据

使用前面学习后的ByteBuffer(缓存)FileChannel(通道),将file01.txt中的数据读入到程序,并显示在控制台屏幕

public class NIOFileChannel02 {
    public static void main(String[] args) throws Exception {

        //创建文件的输入流
        File file = new File("d:\\file01.txt");
        FileInputStream fileInputStream = new FileInputStream(file);

        //通过fileInputStream 获取对应的FileChannel -> 实际类型  FileChannelImpl
        FileChannel fileChannel = fileInputStream.getChannel();

        //创建缓冲区(创建文件一样大小的Buffer)
        ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

        //将 通道的数据读入到Buffer
        fileChannel.read(byteBuffer);

        //将byteBuffer 的 字节数据 转成String
        System.out.println(new String(byteBuffer.array()));
        fileInputStream.close();
    }
}

栗子三:使用一个Buffer 完成文件读取、写入

使用 FileChannel(通道)和 方法 readwrite,完成文件的拷贝(将1.txt中的数据拷贝到2.txt)

public class NIOFileChannel03 {
    public static void main(String[] args) throws Exception {

        FileInputStream fileInputStream = new FileInputStream("1.txt");
        FileChannel fileChannel01 = fileInputStream.getChannel();

        FileOutputStream fileOutputStream = new FileOutputStream("2.txt");
        FileChannel fileChannel02 = fileOutputStream.getChannel();

        ByteBuffer byteBuffer = ByteBuffer.allocate(512);
		//循环读取
        while (true) { 
            //这里有一个重要的操作,清空buffer
            byteBuffer.clear(); 
            int read = fileChannel01.read(byteBuffer);
            System.out.println("read =" + read);
            if(read == -1) { //表示读完
                break;
            }
            //将buffer 中的数据写入到 fileChannel02 -- 2.txt
            byteBuffer.flip();
            fileChannel02.write(byteBuffer);
        }

        //关闭相关的流
        fileInputStream.close();
        fileOutputStream.close();
    }
}

栗子四:从目标通道中复制原通道数据

使用FileChannel(通道)和方法transferFrom,完成文件的拷贝

public class NIOFileChannel04 {
    public static void main(String[] args)  throws Exception {

        //创建相关流
        FileInputStream fileInputStream = new FileInputStream("d:\\a.jpg");
        FileOutputStream fileOutputStream = new FileOutputStream("d:\\a2.jpg");

        //获取各个流对应的filechannel
        FileChannel sourceCh = fileInputStream.getChannel();
        FileChannel destCh = fileOutputStream.getChannel();

        //使用transferForm完成拷贝
        destCh.transferFrom(sourceCh,0,sourceCh.size());
        //关闭相关通道和流
        sourceCh.close();
        destCh.close();
        fileInputStream.close();
        fileOutputStream.close();
    }
}

栗子五:把原通道数据复制到目标通道

使用FileChannel(通道)和方法transferTo,完成文件的复制

public class NIOFileChannel05 {
    public static void main(String[] args)  throws Exception {
          // 1、字节输入管道
        FileInputStream is = new FileInputStream("E:\\test\\Aurora-4k.jpg");
        FileChannel isChannel = is.getChannel();
        // 2、字节输出流管道
        FileOutputStream fos = new FileOutputStream("E:\\test\\Aurora-4knew4.jpg");
        FileChannel osChannel = fos.getChannel();
        // 3、复制
        isChannel.transferTo(isChannel.position() , isChannel.size() , osChannel);
        isChannel.close();
        osChannel.close();
    }
}

栗子六:分散 (Scatter) 和聚集 (Gather)

Scattering:将数据写入到buffer时,可以采用buffer数组,依次写入 [分散]

Gathering:从buffer读取数据时,可以采用buffer数组,依次读

public class ScatteringAndGatheringTest {
    public static void main(String[] args) throws Exception {

        //使用 ServerSocketChannel 和 SocketChannel 网络
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        InetSocketAddress inetSocketAddress = new InetSocketAddress(7000);

        //绑定端口到socket ,并启动
        serverSocketChannel.socket().bind(inetSocketAddress);

        //创建buffer数组
        ByteBuffer[] byteBuffers = new ByteBuffer[2];
        byteBuffers[0] = ByteBuffer.allocate(5);
        byteBuffers[1] = ByteBuffer.allocate(3);

        //等客户端连接(telnet)
        SocketChannel socketChannel = serverSocketChannel.accept();
        int messageLength = 8;   //假定从客户端接收8个字节
        //循环的读取
        while (true) {
            int byteRead = 0;
            while (byteRead < messageLength ) {
                long l = socketChannel.read(byteBuffers);
                byteRead += l; //累计读取的字节数
                System.out.println("byteRead=" + byteRead);
                //使用流打印, 看看当前的这个buffer的position 和 limit
                Arrays.asList(byteBuffers).stream().map(buffer -> "postion=" + buffer.position() + ", limit=" + buffer.limit()).forEach(System.out::println);
            }

            //将所有的buffer进行flip
            Arrays.asList(byteBuffers).forEach(buffer -> buffer.flip());

            //将数据读出显示到客户端
            long byteWirte = 0;
            while (byteWirte < messageLength) {
                long l = socketChannel.write(byteBuffers); //
                byteWirte += l;
            }

            //将所有的buffer 进行clear
            Arrays.asList(byteBuffers).forEach(buffer-> {
                buffer.clear();
            });

            System.out.println("byteRead:=" + byteRead + " byteWrite=" + byteWirte + ", messagelength" + messageLength);
        }
    }
}

栗子七:NIO非阻塞 网络编程入门案例

需求:服务端接收客户端的连接请求,并接收多个客户端发送过来的事件。

Server端代码实现:

public class NIOServer {
    public static void main(String[] args) throws Exception{

        //创建ServerSocketChannel -> ServerSocket
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

        //得到一个Selecor对象
        Selector selector = Selector.open();

        //绑定一个端口6666, 在服务器端监听
        serverSocketChannel.socket().bind(new InetSocketAddress(6666));
        //设置为非阻塞
        serverSocketChannel.configureBlocking(false);

        //把 serverSocketChannel 注册到  selector 关心 事件为 OP_ACCEPT
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        System.out.println("注册后的selectionkey 数量=" + selector.keys().size()); // 1

        //循环等待客户端连接
        while (true) {

            //这里我们等待1秒,如果没有事件发生, 返回
            if(selector.select(1000) == 0) { //没有事件发生
                System.out.println("服务器等待了1秒,无连接");
                continue;
            }

            //如果返回的>0, 就获取到相关的 selectionKey集合
            //1.如果返回的>0, 表示已经获取到关注的事件
            //2. selector.selectedKeys() 返回关注事件的集合
            //   通过 selectionKeys 反向获取通道
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            System.out.println("selectionKeys 数量 = " + selectionKeys.size());

            //遍历 Set, 使用迭代器遍历
            Iterator<SelectionKey> keyIterator = selectionKeys.iterator();

            while (keyIterator.hasNext()) {
                //获取到SelectionKey
                SelectionKey key = keyIterator.next();
                //根据key 对应的通道发生的事件做相应处理
                if(key.isAcceptable()) { //如果是 OP_ACCEPT, 有新的客户端连接
                    //该该客户端生成一个 SocketChannel
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    System.out.println("客户端连接成功 生成了一个 socketChannel " + socketChannel.hashCode());
                    //将  SocketChannel 设置为非阻塞
                    socketChannel.configureBlocking(false);
                    //将socketChannel 注册到selector, 关注事件为 OP_READ, 同时给socketChannel
                    //关联一个Buffer
                    socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));

                    System.out.println("客户端连接后 ,注册的selectionkey 数量=" + selector.keys().size()); //2,3,4..

                }
                if(key.isReadable()) {  //发生 OP_READ

                    //通过key 反向获取到对应channel
                    SocketChannel channel = (SocketChannel)key.channel();

                    //获取到该channel关联的buffer
                    ByteBuffer buffer = (ByteBuffer)key.attachment();
                    channel.read(buffer);
                    System.out.println("form 客户端 " + new String(buffer.array()));

                }

                //手动从集合中移动当前的selectionKey, 防止重复操作
                keyIterator.remove();

            }
        }
    }
}

Client端代码实现:

public class NIOClient {
    public static void main(String[] args) throws Exception{

        //得到一个网络通道
        SocketChannel socketChannel = SocketChannel.open();
        //设置非阻塞
        socketChannel.configureBlocking(false);
        //提供服务器端的ip 和 端口
        InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
        //连接服务器
        if (!socketChannel.connect(inetSocketAddress)) {

            while (!socketChannel.finishConnect()) {
                System.out.println("因为连接需要时间,客户端不会阻塞,可以做其它工作..");
            }
        }

        //...如果连接成功,就发送数据
        String str = "hello, 邱俊杰~";
        //将字节数组包装到缓冲区中
        ByteBuffer buffer = ByteBuffer.wrap(str.getBytes());
        //发送数据,将 buffer 数据写入 channel
        socketChannel.write(buffer);
        System.in.read();

    }
}

分析小结

现在返回来看以下这个图的话,相信大家会有一个新的理解了

Netty—NIO万字详解_第10张图片

对上图说明:

  1. 当客户端连接时,会通过ServerSocketChannel 得到SocketChannel
  2. Selector进行监听 select方法,返回有事件发生的通道的个数.
  3. socketChannel注册到Selector上,register(Selector sel,int ops),一个selector上可以注册多个SocketChannel
  4. 注册后返回一个SelectionKey,会和该Selector关联(集合)
  5. 进一步得到各个SelectionKey有事件发生
  6. 在通过SelectionKey反向获取 SocketChannel,方法 channel()
  7. 可以通过得到的channel,完成业务处理

NIO-零拷贝

要弄清楚什么是零拷贝,首先得理解两个重要的概念,即:用户态与内核态

那什么是用户态和内核态呢?想看图片!

用户态和内核态简介

Netty—NIO万字详解_第11张图片

操作系统中的用户态(User Mode)和内核态(Kernel Mode)是两种不同的运行模式,涉及到程序执行时与操作系统内核的交互方式。这两种模式有不同的权限和特权级别。

  1. 用户态(User Mode):
    • 在用户态执行的程序通常是应用程序,这些程序运行在较低的权限级别。在用户态,程序只能访问自己的地址空间和有限的系统资源,而无法直接访问操作系统的核心部分或硬件资源。
    • 大多数应用程序都在用户态执行,因为用户态提供了一种安全的环境,防止应用程序直接操作核心系统资源,从而提高了系统的稳定性和安全性。
  2. 内核态(Kernel Mode):
    • 内核态是操作系统内核执行的特权级别,也被称为特权模式。在内核态,操作系统拥有对系统内所有资源的完全访问权限,包括硬件、内存和其他关键系统资源。
    • 操作系统内核在内核态下运行,它可以执行特权指令、直接访问硬件设备、处理中断和系统调用等。内核态具有更高的权限和更广泛的访问权限,但也需要更小心地管理,以确保不会破坏系统的稳定性。

切换模式:

在操作系统中,程序从用户态切换到内核态需要通过系统调用(System Call)或者中断(Interrupt)等方式。这是为了防止应用程序滥用对系统资源的访问权限。当应用程序需要进行一些特权操作时,例如文件读写、网络通信等,它会通过系统调用进入内核态执行相应的操作,然后再返回用户态。

为什么OS要区分用户态和内核态

区分用户态和内核态的主要目的是保护操作系统程序,并确保计算机系统的运行安全。在多道程序环境下,为了保障计算机系统的运行安全,将计算机系统中的指令分为两类:特权指令和非特权指令。能引起系统损害的机器指令称为特权指令,否则称为非特权指令。操作系统模式(内核态)下可执行特权指令和非特权指令,用户模式(用户态)下只能执行非特权指令。当CPU处于用户态时只能执行非特权指令,并且只能访问当前运行进程(运行的用户程序)的地址空间,这样才能有效地保护操作系统内核及内存中其他用户程序不受该运行进程(程序)的侵害。

用户态和内核态的切换是由处理机状态寄存器中的上下文信息控制的。当处理机从一个状态切换到另一个状态时,它会保存当前的上下文信息,以便在需要时可以恢复到之前的状态。这种机制使得处理机可以在不同状态下执行不同的任务,从而提高了系统的效率和可靠性。总之,处理机区分内核态和用户态是为了保护系统的安全性和稳定性,这种机制可以提高系统的效率和可靠性。(摘取《操作系统概念》

举例说明:

  • 其实学过网络编程的应该知道Socket,一般来说,或者我们听到的,都是Socket协议,或者Socket连接;

  • 但Socket 其实并不是一个协议,而是为了方便使用TCPUDP而抽象出来的一层,是位于应用层和传输控制层之间的一组接口。 Socket本身并不是一个协议,它工作在OSI模型会话层,是一个套接字,TCP/IP网络的API,是为了方便大家直接使用。

  • 更底层协议而存在的一个抽象层。Socket其实就是一个门面模式(用户态),它把复杂的**TCP/IP协议族(内核态)**隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
    WebSocket则是一个典型的应用层协议。

这样可能还是不好理解,我们重新用生活中的例子说明:

  1. 用户态(驾驶者):
    • 当你驾驶汽车时,你处于用户态。你能够直接操控方向盘、刹车、油门等汽车的用户接口。这就好比用户态的应用程序,它能够执行各种任务,但受到一定的限制。
  2. 内核态(汽车引擎控制系统):
    • 与此同时,汽车的引擎控制系统工作在内核态。这个系统负责管理引擎的运行、燃油供应、排放控制等核心功能。这些功能对于汽车的正常运行至关重要,就像操作系统内核管理系统的核心资源一样。
  3. 用户态和内核态的切换:
    • 当你需要进行某些高级操作,比如调整引擎映射、查看车辆诊断信息等时,你可能需要将汽车引擎控制系统切换到用户模式,这就好比进行系统调用。但大多数时间,你只需在用户态进行驾驶,引擎控制系统在内核态默默地处理所有必要的事务。
  4. 保护核心功能:
    • 想象一下,如果任何人都能够直接干预引擎的内部工作,那么汽车的安全性和可靠性就会大大降低。引擎控制系统运行在内核态,提供了对核心功能的保护,防止不懂引擎工作原理的人随意操作。

到这里,我们应该对用户态和内核态有了一个大概的了解!!那回到我们的主题;什么是零拷贝?

零拷贝简介

零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,进而减少上下文切换以及CPU的拷贝时间。它是一种IO操作优化技术。具体来说,在数据传输过程中,源节点到目的节点之间需要将数据从一个存储区复制到另一个存储区,而这个过程会产生一些额外的CPU指令和上下文切换,从而导致一定的性能损失。而零拷贝技术可以避免这个过程,从而提高了数据传输的效率。

总结起来就是一句话:零拷贝从操作系统角度,就是没有cpu拷贝

在Java 程序中,常用的零拷贝有 mmap(内存映射)和 sendFile。那么,他们在 OS里,到底是怎么样的一个的设计?

传统IO

Java传统IO和网络编程的一段代码:

public class BIOModel {
    public static void main(String[] args) throws Exception{
        File file = new File("1.txt");
        RandomAccessFile raf = new RandomAccessFile(file, "rw");

        byte[] bytes = new byte[(int) file.length()];
        raf.read(bytes);
        Socket socket = new ServerSocket(8080).accept();
        socket.getOutputStream().write(bytes);
    }
}

上面代码的图解:

Netty—NIO万字详解_第12张图片

DMA:direct memory access直接内存拷贝(不使用 CPU)

在传统IO中一共经过了4次切换以及4次拷贝,具体过程:

  1. 用户进程调用 read 方法,向操作系统发出 I/O 请求(上下文从用户态转向内核态),请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;

  2. 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;

  3. DMA 进一步将 I/O 请求发送给磁盘;

  4. 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;

  5. DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务

  6. 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;

  7. CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回(上下文从内核态转为用户态);

  8. 用户进程调用 write方法,向操作系统发出 I/O 请求(上下文从用户态转为内核态),CPU将读缓冲区中数据拷贝到socket缓冲区。

  9. DMA控制器把数据从socket缓冲区拷贝到网卡,(上下文从内核态切换回用户态),write()返回。

这里DMA拷贝2次,CPU拷贝2次,所谓的零拷贝,也可以理解成没有CPU参与的拷贝

MMAP 优化

mmap通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。如下图:

Netty—NIO万字详解_第13张图片

mmap方式的零拷贝经过了4次上下文切换和3次数据拷贝

具体流程如下:

  1. 用户进程通过mmap()方法向操作系统发起调用,上下文从用户态转向内核态。
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区,上下文从内核态转为用户态,mmap调用返回。
  3. 用户进程通过write()方法发起调用,上下文从用户态转为内核态,CPU将读缓冲区中数据拷贝到socket缓冲区。
  4. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回。

相比mmap,传统IO多了一次CPU拷贝。

在传统IO中,数据首先被读取到内核缓冲区,然后再从内核缓冲区复制到用户程序缓冲区。而使用mmap技术,数据可以直接从文件映射到内存中,用户程序可以直接对内存进行读写操作,避免了额外的数据复制。因此,相比于传统的IO,mmap方式少了一次CPU拷贝。

sendFile 优化

Linux 2.1 版本 提供了sendFile函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换

Netty—NIO万字详解_第14张图片

整个过程发生了2次用户态和内核态的上下文切换3次拷贝,具体流程如下:

  1. 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区
  3. CPU将读缓冲区中数据拷贝到socket缓冲区
  4. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile调用返回

sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。

sendfile+DMA Scatter/Gather

Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather 分散/收集功能。

Netty—NIO万字详解_第15张图片

整个过程发生了2次用户态和内核态的上下文切换2次拷贝,其中更重要的是完全没有CPU拷贝,具体流程如下:

  1. 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储
  3. CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
  4. DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
  5. sendfile()调用返回,上下文从内核态切换回用户态

真正意义上的零拷贝!!!

NIO的零拷贝

其实在上面的案例四和案例五中,我们已经用到了零拷贝;也就是NIO中的transferTo方法和transferFrom方法:

public class NIOFileChannel05 {
    public static void main(String[] args)  throws Exception {
          // 1、字节输入管道
        FileInputStream is = new FileInputStream("E:\\test\\Aurora-4k.jpg");
        FileChannel isChannel = is.getChannel();
        // 2、字节输出流管道
        FileOutputStream fos = new FileOutputStream("E:\\test\\Aurora-4knew4.jpg");
        FileChannel osChannel = fos.getChannel();
        // 3、复制
        isChannel.transferTo(isChannel.position() , isChannel.size() , osChannel);
        isChannel.close();
        osChannel.close();
    }
}

注意:

  • 在linux下一个transferTo 方法就可以完成传输
  • 在windows 下 一次调用 transferTo 只能发送8m , 就需要分段传输文件

如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。


总结

NIO(Non-blocking I/O,非阻塞I/O)是一种基于ChannelBuffer的I/O模型,它支持异步和多路复用。在NIO中,Channel是用于进行I/O操作的通道,Buffer是用于存储数据的容器。NIO通过Selector来监听Channel的事件,从而实现非阻塞的I/O操作。

NIO的核心是Channel和Buffer,它们之间的关系是Channel通过Buffer进行数据的读写操作。在NIO中,所有的I/O操作都是异步的,这意味着不需要等待操作完成就可以继续执行其他任务。当操作完成后,会通知相关线程进行后续处理。

NIO的优点包括:

  1. 非阻塞性:NIO使用异步I/O操作,可以避免阻塞线程,提高并发处理能力。
  2. 多路复用:NIO通过Selector监听多个Channel的事件,可以同时处理多个I/O操作,提高效率。
  3. 高效性:NIO使用零拷贝技术,减少了数据在内存中的复制次数,提高了数据传输效率。

NIO的实战场景包括:

  1. 高并发网络通信:NIO可以用于构建高性能的网络服务器和客户端,支持大量的并发连接和数据传输。
  2. 大数据处理:NIO可以用于读取和写入大量数据,例如日志文件、数据库等,提高数据处理效率。
  3. 实时系统:NIO可以用于构建实时系统,例如实时通信、实时监控等,支持低延迟的数据传输和处理。

参考文献

[尚硅谷Netty教程](027_尚硅谷_SelectionKey API_哔哩哔哩_bilibili)

零拷贝

你可能感兴趣的:(nio,后端,java,安全,jetty,java-rocketmq)