(一) Tomcat 源码系列之网络 I/O 模型

今天开始学习 Tomcat 源码, 本系列采用的是 Tomcat 8.5.57 源码构建, 在学习 Tomcat 源码之前, 先来学习基础知识 : 网络 I/O 模型

Java 共支持 3 种网络 /IO 模型:BIO、NIO、AIO

BIO

Blocking IO, 同步并阻塞 (传统阻塞型),一般搭配线程池来使用, 否则只有一条线程在工作! 其中 : ServerSocket#accept() 和 Socket#read()/write() 会阻塞, 如果没有线程池, 只能 一次处理一个连接, 其他连接都会阻塞

BIO 方式适用于 连接数目比较小且固定 的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4 以前的唯一选择,程序简单易理解

编写BIO程序

// 线程池应该用 ThreadPoolExcuter 创建
ThreadPoolExecutor pool = new ThreadPoolExecutor(5, 10,
               5, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10));

try {
    ServerSocket serverSocket = new ServerSocket(6666);
    while (true) {
        // accept 会阻塞
        Socket socket = serverSocket.accept()
        pool.execute(() -> link(socket));
    }....
}

private static void link(Socket socket) {
    byte[] bytes = new byte[1024];
    try {
        InputStream inputStream = socket.getInputStream();
        while (true) {
            // read 也会阻塞
            int read = inputStream.read(bytes);
            if (read > 0) {
                System.out.println("客户端发送 : " + new String(bytes, 0, read));
            }

    	} ......
}

BIO存在的问题

  • 每个请求都需要创建独立的线程,与对应的客户端进行数据读写,业务处理
  • 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
  • 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 read 操作上,造成线程资源浪费

NIO

Non-Blocking IO (也叫 New IO) 它是 同步非阻塞 的. 在 JDK1.4 中被引入

  • NIO 有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)
  • NIO 是面向缓冲区 ,或者面向块编程的。数据读取到一个它稍后处理的 buffer,需要时可在 buffer 中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络
  • Java NIO 的非阻塞模式,使一个线程从 channel 发送请求或者读取数据,仅能得到可用的数据

    如果没有数据可用时,并不会使线程阻塞,该线程可以继续做其他的事情。(直至数据可以读取)
    非阻塞 write 也是如此,一个线程请求写入一些数据到 channel,但不需要等待它完全写入, 这个线程同时可以去做别的事情

  • 通俗理解:NIO 是可以做到用一个线程来处理多个操作的

    假设有 10000 个请求过来,根据实际情况,可以分配 50 或者 100 个线程来处理。不像之前的阻塞 IO 那样,非得分配 10000 个。

NIO 和 BIO 的比较

  • BIO 以流的方式处理数据, 而 NIO 以块的方式处理数据,块 I/O 的效率比流 I/O 高很多
  • BIO 是阻塞的,NIO 则是非阻塞的
  • BIO 基于字节流和字符流进行操作,而 NIO 基于 Channel (通道) 和 Buffer (缓冲区) 进行操作

    数据总是从 channel 读取到 buffer 中,或者从 buffer 写入到 channel 中 Selector(选择器) 用于监听多个 channel 的事件(比如:连接请求, 数据到达等),因此使用单个线程就可以处理多个客户端的请求

Buffer&Channel&Selector关系

  • 每个 channel 都会对应一个 buffer
  • selector 对应一个线程, 一个线程对应 (监听) 多个 channel (连接)
  • 程序切换到哪个 channel 是由事件决定的, Event 就是一个重要的概念
  • selector 会根据不同的事件,在各个 channel 上切换
  • buffer 就是一个内存块 , 底层是有一个数组
  • buffer 数据的读写是双向的 (需要调用 flip), 不像 BIO 那样, 要么是输入流, 要么是输出流.

缓冲区 (Buffer)

缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块, 缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化 情况。

Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer, Buffer 类的几个重要属性

 private int mark = -1;
 private int position = 0;//标志位
 private int limit;//最大标志位
 private int capacity;//数组容量

Buffer有七个子类, 最常用的就是 ByteBuffer

需要注意的是 ByteBuffer.allocateDirect(int capability); 分配的是堆外内存, 不属于GC管辖范围, 不需要内存拷贝, 所以速度较快
ByteBuffer.allocation(int capability) 分配的是堆内内存, 属于GC管辖范围, 需要内存之间的拷贝, 速度较慢
再提一嘴 : 可以使用 虚引用 (PhantomReference) 指向堆外内存的对象, 当堆外内存需要清理时, 会将这个虚引用放入一个引用队列. JVM 有一条 GC 线程专门监视这个引用队列, 当这个队列有存在引用时, 就会去清理堆外内存

通道 (Channel)

NIO 的通道类似于流, 但又有这些区别 :

  • channel 可以同时进行读写,而流只能读或者只能写
  • channel 可以实现异步读写数据
  • channel 和 buffer 中的数据是双向的, 可以把 buffer 中的数据写入 channel , 也可以把 channel 中的数据读入 buffer

Channel 是一个接口, 常用的实现类有 :

  • FileChannel : 用于文件的读写 (实现类为 : FileChannelImpl)

    主要的方法有 : read(ByteBuffer) : 从 channel 读取数据并放到 buffer 中
    write(ByteBuffer) : 把 buffer 的数据写到 channel 中
    transferFrom(ReadableByteChannel, long, long) : 从目标 channel 中复制数据到当前 channel
    transferTo(long, long, WritableByteChannel) : 把数据从当前 channel 复制给目标 channel

  • DatagramChannel : 用于 UDP 的数据的读写, (实现类为 : DatagramChannelImpl )
  • ServerSocketChannel : 相当 BIO 中的 ServerSocket (实现类为 : ServerSocketChannelImpl )
  • SocketChannel : 相当于 BIO 中的 Socket (实现类为 : SocketChannelImpl )

关于 Buffer 和 Channel 的注意事项和细节

  • ByteBuffer 支持类型化的 put 和 get, put 放入的是什么数据类型,get 就取出什么数据类型,否则可能抛出 BufferUnderflowException 异常
  • 可以将一个普通 Buffer 转成只读 Buffer
  • NIO 还提供了 MappedByteBuffer, 可以让文件直接在内存(堆外的内存)中进行修改, 操作系统不需要拷贝一次
  • NIO 还支持通过多个 Buffer (即 Buffer 数组) 完成读写操作,即 Scattering 和 Gathering

    数组在内存中是连续性的, 如果一次性分配大数组, 有可能会导致JVM堆中没有这么大的连续空间从而提前引发GC
    使用多个数组就可以使得内存碎片的到充分利用

选择器 (Selector)

也叫多路复用器, Selector 能够检测多个注册的 channel 上是否有事件发生 (多个 Channel 以事件的方式可以注册到同一个 Selector)

如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个 channel,也就是管理多个连接和请求

只有在 channel 真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程 (避免了多线程之间的上下文切换导致的开销)

相关的API

Selector open();//得到一个选择器对象
int select(long timeout);//监听所有注册的channel, 返回有事件发生的channel数量
Set<SelectionKey> selectKeys();//得到保存所有 SelectionKey内部集合
 
select();//该方法时阻塞的, 当所有注册的channel没有事件发生时, 就会阻塞
select(long timeout);//超时退出
wakeup();//唤醒selector对象
selectNow();//不阻塞 , 立刻返回

NIO原理

  • Selector 调用 select() 进行监听, 可以得到此时有事件发生的 SelectionKey 数量
  • 当 Selector 监听到连接事件的时候, 通过 ServerSocketChannel 得到一个 SocketChannel 对象
  • 调用 register(Selector, int, Object) , 将 SocketChannel 对象注册到 Selector上, 并返回一个 SelectionKey 对象, Selector 将 SelectionKey 对象保存在内部的 Set 集合中
  • 根据 SelectionKey 的事件进行对应处理
  • Selector 对象通过调用 selectKeys() 得到所有的 SelectionKey 对象, 调用 selectedKeys() 得到所有的有事件发生的 SelectionKey 对象
  • SelectionKey 对象 调用 channel(), 得到 Channel 对象, 可以用该 Channel 对象对事件进行处理

NIO程序

public static void main(String[] args) throws IOException {
    Selector selector = Selector.open();

    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    //绑定端口
    serverSocketChannel.socket().bind(new InetSocketAddress(6001));
    //设置为非阻塞
    serverSocketChannel.configureBlocking(false);
    //将ServerSocketChannel注册到 Selector上
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    while (true) {
        if (selector.select(1000) == 0) {
            System.out.println("没有任何事件发生....., 已等待一秒!");
            continue;
        }

        //如果有事件发生
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
        while (keyIterator.hasNext()) {
            SelectionKey key = keyIterator.next();
            if (key.isAcceptable()) {//此时有注册事件
                SocketChannel socketChannel = serverSocketChannel.accept();
                //设置为非阻塞
                socketChannel.configureBlocking(false);
                //将该 SocketChannel 对象注册到 Selector 上
                socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                System.out.println("此时有一个连接注册成功!");
            }
            if (key.isReadable()) {// 此时有 OP_READ 事件
                SocketChannel channel = (SocketChannel) key.channel();
                ByteBuffer buffer = (ByteBuffer) key.attachment();
                channel.read(buffer);
                System.out.println("客户端发送数据 : " + new String(buffer.array()));
            }
            keyIterator.remove();
        }
    }
}
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);

InetSocketAddress address = new InetSocketAddress("127.0.0.1", 6001);

//如果连接不成功!
if (!socketChannel.connect(address)){
    //如果释放连接失败
    while (!socketChannel.finishConnect()) {
        System.out.println("连接失败! 客户端可以作其他事情!");
    }
}

ByteBuffer wrap = ByteBuffer.wrap("Hello NIO!".getBytes());
socketChannel.write(wrap);
//不释放连接
System.in.read();

AIO

  • JDK 7 引入了 Asynchronous I/O,即异步IO 。在进行 I/O 编程中,常用到两种模式:Reactor 和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理
  • AIO 即 NIO2.0,它是 异步非阻塞的。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写, 有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接 数较多且连接时间较长的应用
  • 目前 AIO 还没有广泛应用,Netty 也是基于 NIO, 而不是 AIO

三者的对比

在这里插入图片描述

零拷贝

传统的 IO 拷贝

系统有三次用户态和内核态之间切换, 及其影响性能

在这里插入图片描述

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

mmap 优化

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

sendFile 优化

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

提示:零拷贝从操作系统角度,是没有 cpu 拷贝

mmap 和 sendFile 的区别

  • mmap 适合小数据量读写,sendFile 适合大文件传输。
  • mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
  • sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)

零拷贝

Linux 在 2.4 版本中,做了一些修改,实现真正的零拷贝. 避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议栈, 从而再一次减少了数据拷贝。

在这里插入图片描述

这里其实有 一次 cpu 拷贝 kernel buffer -> socket buffer 但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略

我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有 一份数据)。

零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如 更少的上下文切换更少的 CPU 缓存伪 共享以及无 CPU 校验和计算

猜你喜欢

转载自blog.csdn.net/Gp_2512212842/article/details/107392497