Java BIO、NIO、AIO


IO
NIO/同步、异步、阻塞、非阻塞
(1)BIO、NIO、AIO 总结
Java 中的 BIO、NIO和 AIO 理解为是 Java 对操作系统的各种 IO 模型的封装。先来回顾一下这样几个概念:同步与异步,阻塞与非阻塞。

同步: 同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。
异步: 异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时可以处理其他的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。
同步和异步的区别最大在于 异步调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。

阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。

BIO (Blocking I/O) 同步阻塞

     服务端创建一个 ServerSocket , 然后就是客户端用一个Socket 去连接服务端的那个 ServerSocket, ServerSocket 接收到了一个的连接请求就创建一个Socket和一个线程去跟那个 Socket 进行通讯。接着客户端和服务端就进行阻塞式的通信,客户端发送一个请求,服务端 Socket 进行处理后返回响应,在响应返回前,客户端那边就阻塞等待,什么事情也做不了。
     这种方式的缺点, 每次一个客户端接入,都需要在服务端创建一个线程来服务这个客户端,这样大量客户端来的时候,就会造成服务端的线程数量可能达到了几千甚至几万,这样就可能会造成服务端过载过高,最后崩溃死掉。

NIO(Non-blocking IO) 同步非阻塞

     NIO支持 面向缓冲的,基于通道。
      ==通道Channel,==用于接收及存储不同的连接与状态 key。
     有 Selector选择器,负责轮询查看不同通道内的请求,做出相应的选择处理。

Java中对于NIO的实现:
     首先程序会向选择器 Selector 中注册通道 Channel,和通道所关注的事件,选择器轮询 Channel ,如果其中有某个事件状态符合所注册的通道事件,那么 Selector 就会将它作为 key 集返回给程序,与此同时,类似于读和写这样的事件,就已经将内容存储到了 buffer 中,程序通过 key 感知到对应事件后,可以直接通过 buffer 去做相应的操作。

(比方说,Tomcat 从 6 开始支持 NIO 模型,客户端发送的连接请求都会注册到 多路复用器上,多路复用器轮询到 连接 有 I/O 请求时才启动一个线程进行处理。)

AIO

     异步非阻塞。
     每个连接发送过来的请求,都会绑定一个Buffer,然后通知操作系统去完成异步的读,这个时间你就可以去做其他的事情,等到操作系统完成读之后,就会调用接口,返回操作系统异步读完的数据。这个时候就可以拿到数据进行处理,将数据往回写,在往回写的过程,同样是给操作系统一个 Buffer ,让操作系统去完成写,写完了来通知你。
     这里面的主要的区别在于将数据写入的缓冲区后,就不去管它,剩下的去交给操作系统去完成。操作系统写回数据也是一样,写到Buffer里面,写完后通知客户端来进行读取数据。

操作系统中的 I/O

      以上是 Java 对操作系统的各种 IO 模型的封装,【文件的输入、输出】在文件处理时,其实依赖操作系统层面的 IO 操作实现的。【把磁盘的数据读到内存种】操作系统中的 IO 有 5 种:
阻塞、
非阻塞、【轮询】
异步、
IO复用、【多个进程的 IO 注册到管道上】
信号驱动 IO

Java NIO 的 三大组件 Buffer、Channel、Selector

1.Buffer

      一个 Buffer 本质上是内存中的一块,我们可以将数据写入这块内存,之后从这块内存获取数据,Buffer 是抽象类,java.nio 定义了以下几个 Buffer 的实现:
在这里插入图片描述

      核心是 ByteBuffer,其他的类只是包装了一下它而已。
      我们应该将 Buffer 理解为一个数组,IntBuffer、CharBuffer、DoubleBuffer 等分别对应 int[ ]、char[ ]、double[ ] 等。MappedByteBuffer 用于实现内存映射文件。
      Buffer 中的几个重要属性和几个重要方法:

属性:position【下标】、limit、capacity【容量】、mark

(1)capacity
      首先是 capacity,它代表这个缓冲区的容量,一旦设定就不可以更改。比如 capacity 为 1024 的 IntBuffer,代表其一次可以存放 1024 个 int 类型的值。一旦 Buffer 的容量达到 capacity,需要清空 Buffer,才能重新写入值。
      position 和 limit 是变化的,我们分别看下读和写操作下,它们是如何变化的。
(2)position
      position 的初始值是 0,每往 Buffer 中写入一个值,position 就自动加 1,代表下一次的写入位置。读操作的时候也是类似的,每读一个值,position 就自动加 1。

      从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了。
(3)limit
      写操作模式下,limit 代表的是最大能写入的数据,这个时候 limit 等于 capacity。
      写结束后,切换到读模式,此时的 limit 等于 Buffer 中实际的数据大小,因为 Buffer 不一定被写满了。
在这里插入图片描述
     除了 position、limit、capacity 这三个基本的属性外,还有一个常用的属性就是 mark。
     mark 用于临时保存 position 的值,每次调用 mark() 方法都会将 mark 设值为当前的 position,便于后续需要的时候使用。

public final Buffer mark() {
    mark = position;
    return this;
}

(4)mark
     那到底什么时候用呢?考虑以下场景,我们在 position 为 5 的时候,先 mark() 一下,然后继续往下读,读到第 10 的时候,我想重新回到 position 为 5 的地方重新来一遍,那只要调一下 reset() 方法,position 就回到 5 了。

public final Buffer reset() {
    int m = mark;
    if (m < 0)
        throw new InvalidMarkException();
    position = m;
    return this;
}
初始化 Buffer

(1)
      每个 Buffer 实现类都提供了一个静态方法 allocate(int capacity) 帮助我们快速实例化一个 Buffer。如:

ByteBuffer byteBuf = ByteBuffer.allocate(1024);

IntBuffer intBuf = IntBuffer.allocate(1024);

LongBuffer longBuf = LongBuffer.allocate(1024);
// ...

(2)
      另外,我们经常使用 wrap 方法来初始化一个 Buffer。

public static ByteBuffer wrap(byte[] array) {
    ...
}
填充 Buffer

     各个 Buffer 类都提供了一些 put 方法用于将数据填充到 Buffer 中,如 ByteBuffer 中的几个 put 方法:

// 填充一个 byte 值
public abstract ByteBuffer put(byte b);

// 在指定位置填充一个 int 值
public abstract ByteBuffer put(int index, byte b);

// 将一个数组中的值填充进去
public final ByteBuffer put(byte[] src) {...}

public ByteBuffer put(byte[] src, int offset, int length) {...}

     上述这些方法需要控制 Buffer 大小,不能超过 capacity,超过会抛 java.nio.BufferOverflowException 异常。
     对于 Buffer 来说,另一个常见的操作中就是,我们要将来自 Channel 的数据填充到 Buffer 中,在系统层面上,这个操作我们称为操作,因为数据是从外部(文件或网络等)读到内存中。

int num = channel.read(buf);

     上述方法会返回从 Channel 中读入到 Buffer 的数据大小。

提取 Buffer 中的值

     如果要读 Buffer 中的值,需要切换模式,从写入模式切换到读出模式。注意,通常在说 NIO 的操作的时候,我们说的是从 Channel 中读数据到 Buffer 中,对应的是对 Buffer 的写入操作
     调用 Buffer 的 flip() 方法,可以从写入模式切换到读取模式。 其实这个方法也就是设置了一下 position 和 limit 值罢了。

public final Buffer flip() {
// 将 limit 设置为实际写入的数据数量
    limit = position; 
// 重置 position 为 0
    position = 0; 
// mark 之后再说
    mark = -1; 
    return this;
}

     对应写入操作的一系列 put 方法,读操作提供了一系列的 get 方法:

// 根据 position 来获取数据
public abstract byte get();

// 获取指定位置的数据
public abstract byte get(int index);

// 将 Buffer 中的数据写入到数组中
public ByteBuffer get(byte[] dst)

附一个经常使用的方法:
new String(buffer.array()).trim();

     当然了,除了将数据从 Buffer 取出来使用,更常见的操作是将我们写入的数据传输到 Channel 中,如通过 FileChannel 将数据写入到文件中,通过 SocketChannel 将数据写入网络发送到远程机器等对应的这种操作,我们称之为写操作。

int num = channel.write(buf);
mark() & reset()
rewind() & clear() & compact()

(1)rewind():
     会重置 position 为 0,通常用于重新从头读写 Buffer。

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

(2)clear():
     有点重置 Buffer 的意思,相当于重新实例化了一样。
     通常,我们会先填充 Buffer,然后从 Buffer 读取数据,之后我们再重新往里填充新的数据,我们一般在重新填充之前先调用 clear()。

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

(3)compact():
     和 clear() 一样的是,它们都是在准备往 Buffer 填充新的数据之前调用。
     前面说的 clear() 方法会重置几个属性,但是我们要看到,clear() 方法并不会将 Buffer 中的数据清空,只不过后续的写入会覆盖掉原来的数据,也就相当于清空了数据了。
     而 compact() 方法有点不一样,调用这个方法以后,会先 position 到 limit 之间的数据(还没有读过的数据),先将这些数据移到左边,也就是从 [0] 开始,然后在这时的 position 基础上再开始写入。因为是 “写入” ,所以此时 limit 等于 capacity。

在这里插入图片描述
在这里插入图片描述

2.Channel

     所有的 NIO 操作始于通道,通道是数据来源或数据写入的目的地,主要地,我们将关心 java.nio 包中实现的以下几个 Channel:
8

(2)DatagramChannel:
     用于 UDP 连接的接收和发送
(3)SocketChannel:
     把它理解为 TCP 连接通道,简单理解就是 TCP 客户端
(4)ServerSocketChannel:
     TCP 对应的服务端,用于监听某个端口进来的请求。

     Channel 经常翻译为通道,类似 IO 中的流,用于读取和写入。它与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。
在这里插入图片描述
在这里插入图片描述

(1)FileChannel:

     文件通道,用于文件的读和写,FileChannel 是不支持非阻塞的。

  • 初始化:
FileInputStream inputStream = new FileInputStream(new File("/data.txt"));
FileChannel fileChannel = inputStream.getChannel();

     当然了,我们也可以 RandomAccessFile.getChannel 来得到 FileChannel。

  • 读取文件内容:
ByteBuffer buffer = ByteBuffer.allocate(1024);
int num = fileChannel.read(buffer);

     前面我们也说了,所有的 Channel 都是和 Buffer 打交道的。

  • 写入文件内容:
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("随机写入一些内容到 Buffer 中".getBytes());

// Buffer 切换为读模式
buffer.flip();
while(buffer.hasRemaining()) {
    // 将 Buffer 中的内容写入文件
    fileChannel.write(buffer);
}
(2)SocketChannel

     我们前面说了,我们可以将 SocketChannel 理解成一个 TCP 客户端。虽然这么理解有点狭隘,因为我们在介绍 ServerSocketChannel 的时候会看到另一种使用方式。

  • 打开一个 TCP 连接:
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("https://www.javadoop.com", 80));

     当然了,上面的这行代码等价于下面的两行:

// 打开一个通道
SocketChannel socketChannel = SocketChannel.open();

// 发起连接
socketChannel.connect(new InetSocketAddress("https://www.javadoop.com", 80));

//SocketChannel 的读写和 FileChannel 没什么区别,就是操作缓冲区。
// 读取数据
socketChannel.read(buffer);

// 写入数据到网络连接中
while(buffer.hasRemaining()) {
    socketChannel.write(buffer);   
}
(3)ServerSocketChannel

     之前说 SocketChannel 是 TCP 客户端,这里说的 ServerSocketChannel 就是对应的服务端。
     ServerSocketChannel 用于监听机器端口,管理从这个端口进来的 TCP 连接。

// 实例化
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

// 监听 8080 端口
serverSocketChannel.socket().bind(new InetSocketAddress(8080));

while (true) {
    // 一旦有一个 TCP 连接进来,就对应创建一个 SocketChannel 进行处理
    SocketChannel socketChannel = serverSocketChannel.accept();}

     这里我们可以看到 SocketChannel 的第二个实例化方式。到这里,我们应该能理解 SocketChannel 了,它不仅仅是 TCP 客户端,它代表的是一个网络通道,可读可写。
     ServerSocketChannel 不和 Buffer 打交道了,因为它并不实际处理数据,它一旦接收到请求后,实例化 SocketChannel,之后在这个连接通道上的数据传递它就不管了,因为它需要继续监听端口,等待下一个连接。

(4)DatagramChannel

     UDP 和 TCP 不一样,DatagramChannel 一个类处理了服务端和客户端。
     UDP 是面向无连接的,不需要和对方握手,不需要通知对方,就可以直接将数据包投出去,至于能不能送达,它是不知道的

  • 监听端口:
DatagramChannel channel = DatagramChannel.open();
channel.socket().bind(new InetSocketAddress(9090));
ByteBuffer buf = ByteBuffer.allocate(48);
channel.receive(buf);
  • 发送数据:
String newData = "New String to write to file..."
                    + System.currentTimeMillis();

ByteBuffer buf = ByteBuffer.allocate(48);

buf.put(newData.getBytes());

buf.flip();

int bytesSent=channel.send(buf, new InetSocketAddress("jenkov.com", 80));
3.Selector

     Selector 建立在非阻塞的基础之上,大家经常听到的 多路复用 在 Java 世界中指的就是它,用于实现一个线程管理多个 Channel。
     这里先介绍一些基本的接口操作。
     首先,我们开启一个 Selector。

Selector selector = Selector.open();

     将 Channel 注册到 Selector 上。前面我们说了,Selector 建立在非阻塞模式之上,所以注册到 Selector 的 Channel 必须要支持非阻塞模式,FileChannel 不支持非阻塞,我们这里讨论最常见的 SocketChannel 和 ServerSocketChannel。

// 将通道设置为非阻塞模式,因为默认都是阻塞模式的
channel.configureBlocking(false);

// 注册
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

     register 方法的第二个 int 型参数(使用二进制的标记位)用于表明需要监听哪些感兴趣的事件,共以下四种事件:

  • SelectionKey.OP_READ: 1
    对应 00000001,通道中有数据可以进行读取
  • SelectionKey.OP_WRITE: 4
    对应 00000100,可以往通道中写入数据
  • SelectionKey.OP_CONNECT: 8
    对应 00001000,成功建立 TCP 连接
  • SelectionKey.OP_ACCEPT: 16
    对应 00010000,接受 TCP 连接

     我们可以同时监听一个 Channel 中的发生的多个事件,比如我们要监听 ACCEPT 和 READ 事件,那么指定参数为二进制的 00010001 即十进制数值 17 即可。

     注册方法返回值是 SelectionKey 实例,它包含了 Channel 和 Selector 信息,也包括了一个叫做 Interest Set 的信息,即我们设置的我们感兴趣的正在监听的事件集合。

     调用 select() 方法获取通道信息。用于判断是否有我们感兴趣的事件已经发生了。
     Selector 的操作就是以上 3 步,这里来一个简单的示例,大家看一下就好了。之后在介绍非阻塞 IO 的时候,会演示一份可执行的示例代码。

Selector selector = Selector.open();

channel.configureBlocking(false);

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

while(true) {
  // 判断是否有事件准备好
  int readyChannels = selector.select();
  if(readyChannels == 0) continue;

  // 遍历
  Set<SelectionKey> selectedKeys = selector.selectedKeys();
  Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
  while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();

    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.

    } else if (key.isConnectable()) {
        // a connection was established with a remote server.

    } else if (key.isReadable()) {
        // a channel is ready for reading

    } else if (key.isWritable()) {
        // a channel is ready for writing
    }

    keyIterator.remove();
  }
}

     对于 Selector,我们还需要非常熟悉以下几个方法:

select()

     调用此方法,会将上次 select 之后的准备好的 channel 对应的 SelectionKey 复制到 selected set 中。如果没有任何通道准备好,这个方法会阻塞,直到至少有一个通道准备好。

selectNow()

     功能和 select 一样,区别在于如果没有准备好的通道,那么此方法会立即返回 0。

select(long timeout)

     如果没有通道准备好,此方法会等待一会。

wakeup()

     这个方法是用来唤醒等待在 select() 和 select(timeout) 上的线程的。如果 wakeup() 先被调用,此时没有线程在 select 上阻塞,那么之后的一个 select() 或 select(timeout) 会立即返回,而不会阻塞,当然,它只会作用一次。

小结

     Buffer 和数组差不多,它有 position、limit、capacity 几个重要属性。put() 一下数据、flip() 切换到读模式、然后用 get() 获取数据、clear() 一下清空数据、重新回到 put() 写入数据。

     Channel 基本上只和 Buffer 打交道,最重要的接口就是 channel.read(buffer) 和 channel.write(buffer)。

     Selector 用于实现非阻塞 IO。


     非阻塞 IO 的核心在于使用一个 Selector 来管理多个通道,通道 可以是 SocketChannel,也可以是 ServerSocketChannel,将各个通道注册到 Selector 上,指定监听的事件。
     之后可以只用一个线程来轮询这个 Selector,看看上面是否有通道是准备好的,当通道准备好可读或可写,然后才去开始真正的读写,这样速度就很快了。我们就完全没有必要给每个通道都起一个线程。
     NIO 中 Selector 是对底层操作系统实现的一个抽象,管理通道状态其实都是底层系统实现的,这里简单介绍下在不同系统下的实现:

  • select:
         上世纪 80 年代就实现了,它支持注册 FD_SETSIZE(1024) 个 socket,在那个年代肯定是够用的,不过现在嘛,肯定是不行了。
  • poll:
         1997 年,出现了 poll 作为 select 的替代者,最大的区别就是,poll 不再限制 socket 数量。

     select 和 poll 都有一个共同的问题,那就是它们都只会告诉你有几个通道准备好了,但是不会告诉你具体是哪几个通道。所以,一旦知道有通道准备好以后,自己还是需要进行一次扫描,显然这个不太好,通道少的时候还行,一旦通道的数量是几十万个以上的时候,扫描一次的时间都很可观了,时间复杂度 O(n)。所以,后来才催生了以下实现。

  • epoll:
         2002 年随 Linux 内核 2.5.44 发布,epoll 能直接返回具体的准备好的通道,时间复杂度 O(1)。

Tomcat 里的 NIO 实现

     Tomcat 使用 Connector 处理连接,一个Tomcat 可以配置多个 Connector,分别监听不同端口,或处理不同协议。
     8.5 以后的 Tomcat 的 start 方法中,会自动配置一个 非阻塞 IO 的 connector ,可以 指定 Protocol,初始化相应的 Endpoint,我们分析的是 NioEndpoint
(1)init 过程
     调用 NioEndpoint 的 bind 监视操作;
     在 bind() 中会通过 ServerSocketChannel.open() 开启 ServerSocketChannel,并设置 acceptor 线程数为1 ,poller 线程数为2(单核 CPU 为 1,多核为 2) 。
(2)start 过程:
     启动 worker 线程池,启动 1 个 Acceptor 线程 和 2 个 Poller 线程,当然它们都是默认值,可配;
(3)Acceptor
     Acceptor 循环调用 ServerSocketChannel 的 accept() 方法获取新的连接,就会创建一个 SocketChannel 实例,然后getPoller0() 获取其中一个 Poller,然后把这个 SocketChannel 注册 register 到 Poller 中;
(4)Poller
     Poller 内部有个SynchronizedQueue类型的 events 队列,events() 方法取出当前队列中的 PollerEvent 对象,逐个执行 run() ,run() 方法主要将这个新连接 SocketChannel 注册到该 poller 的 Selector 中,(每个 poller 会关联一个 selector)监听 OP_READ 事件,一旦该 SocketChannel 是 readable 的状态,那么就会进入到 processKey 方法,会创建 SocketProcessor 实例,把实例提交到线程池中。

源码参见:https://www.javadoop.com/post/tomcat-nio

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值