网络IO模型
为了弄清JavaBIO
(传统的 java.io 包)和NIO
(Java 1.4 引入的 java.nio 包)原理以及区别,首先我们先介绍一下Unix/Lniux
中的网络IO模型,阻塞IO模型、非阻塞IO模型、IO多路复用模型。
基础概念
在介绍网络IO模型的过程中,涉及到一些基础概念,下面介绍这些基本概念。
- 用户空间与内核空间
用户空间
是普通进程所处的区域,如JVM
。用户空间是非特权区域,比如在该区域执行的代码不能直接访问硬件设备。
内核空间
是特权空间,比如它能与设备控制器通讯,控制着整个用户区域进程的运行状态。注: I/O 都直接或间接通过内核空间。
为什么要将内存划分为用户空间和内核空间?
为了保证操作系统的稳定性和安全性。用户程序不可以直接访问硬件资源,如果用户程序需要访问硬件资源,必须调用操作系统提供的接口,这个调用接口的过程也就是
系统调用
。
系统调用(system call)
,每一次系统调用都会存在两个内存空间之间的数据交互,通常的网络传输也是一次系统调用,通过网络传输的数据先是从内核空间接收到远程机器的数据,然后再从内核空间复制到用户空间,供用户程序使用。
文件描述符(
fd
),Linux 下系统各组件都是以文件描述符的形式存在的,例如 socket。-
同步与异步
以 “调用” 为例,所谓同步,就是 在发出一个 “调用请求” 时,在没有得到结果之前,该 “调用请求” 就不返回,但是一旦调用返回就得到返回值了。换句话说,就是由 "调用者" 主动等待 “被调用者” 的结果。像我们平时写的,方法 A 调用
Math.random()
方法、方法 B 调用String.substring()
方法都是同步调用,因为调用者主动在等待这些方法的返回。所谓异步,则正好相反,当一个异步调用请求发出之后,调用者不会立刻得到这个请求真正的执行完后得出的结果,立即返回的可能只是一个伪结果 。因此异步调用适用于那些对数据一致性要求不是很高的场景,或者是执行过程很耗时的场景。如果这种场景下,我们希望获取异步调用的结果,"被调用者"可以通过状态、通知来通知调用者,或通过回调函数处理这个调用。
-
进程执行网络I/O中
read
操作- 内核缓冲区准备数据,等待网络数据分组到达,然后将其复制到内核缓冲区
- 内核缓冲区数据拷贝到用户缓冲区
阻塞IO模型
当用户进程开始调用了 recvfrom
函数后,就开始了 IO 的 第一阶段:内核缓冲区准备数据。对于网络 IO 来说,数据只有在积累到一定的量的时候才会发送,这个时候内核缓冲区就要等待足够的数据到来。而在用户缓冲区这边,用户进程会一直被操作系统阻塞。当内核缓冲区数据准备好了,此时就会将内核缓冲区中的数据拷贝到用户缓冲区,然后 由操作系统唤醒被阻塞的用户进程 并将结果返回给用户进程,此时用户进程才重新运行起来。所以,阻塞 IO 的特点就是在 IO 执行的两个阶段都被阻塞了。
listenfd = socket(...);
bind(listenfd, 服务器ip和端口,...);
listen(listenfd,...);
while(true){
connfd = accept(listenfd,...); // 阻塞
receive(connfd,...); // 阻塞
}
非阻塞IO模型
当用户进程开始调用了 recvfrom
函数后,如果内核缓冲区中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个 read 操作后,并没有被阻塞,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作,就这样一直进行下去,到这里第一阶段都是一直在轮训。一旦内核缓冲区中的数据准备好了,并且又再次收到了用户进程的 read 请求,那么它马上就将数据从内核缓冲区拷贝到用户缓冲区,然后返回给用户线程,这是第二阶段。所以,用户进程在第一阶段其实并没有被操作系统一直阻塞,而是需要不断的主动询问内核缓冲区数据好了没有。只有在第二阶段数据拷贝到时候会被阻塞 。
IO多路复用
IO 多路复用实际上就是通过一种机制,一个进程可以监视多个fd
,一旦某个fd
就绪(一般是读就绪或者写就绪),能够通知程序进行相应的操作,这种机制目前有 select
、poll
、epoll
,下面对它们进行具体介绍。
- select
用户调用 select ()后,用户进程会阻塞,直到有fd
就绪,它就会将它刚刚监控的所有的fd
对应的标识符的集合 fd_set
(注意,这里将内核缓冲区中数据已经就绪的fd
的标识会打上一个标记)返回给用户进程,然后用户进程再去遍历fd_set
找出其中内核缓冲区中数据已经就绪的fd
的标识符,然后调用recvfrom()
开始第二阶段。
注,select()监视的fd
分 3 类,分别是writefds
,readfds
,和exceptfds
。
select 优点:
- select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
select 缺点:
- 单个进程能监控的
fd
的数量是有一定限制的,它由FD_SETSIZE
限制,默认值是1024,如果修改的话,就需要重新编译内核,不过这会带来网络效率的下降 。- select 模型下内核地址空间和用户地址空间每次数据复制都是复制所有的
fd
。 随着fd
数目的增加,可能只有很少一部分fd
是活跃的,但是 select 每次调用时都会遍历整个fd_set
,检查每个fd
的数据就绪的状态,这就导致效率很低。
- poll
poll 本质上和 select 没有区别,它也是将整个fd_set
告诉给用户进程。和 select 不同的是它没有最大连接数的限制,原因是它是基于链表来存储的。
poll 缺点:
模型下内核地址空间和用户地址空间每次数据复制都是复制所有的
fd
。poll是水平触发的,即如果报告了
fd
处于就绪状态后,没有被处理,那么下次poll
时会再次报告该fd
;fd
增加时,线性扫描导致性能下降。
epoll
epoll
支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd
变为就绪态,并且只会通知一次。还有一个特点是,epoll
使用 事件 的就绪通知方式,通过epoll_ctl
注册fd
,一旦该fd
就绪,内核就会采用类似 callback 的回调机制来激活该 fd
,epoll_wait
就可以收到通知。
epoll
的优点:
- 没有最大并发连接的限制,它支持的
fd
上限受操作系统最大文件句柄数;- 效率提升,不同于 select 和 poll,
epoll
只会对活跃(数据处于就绪状态) 的fd
进行操作,这是因为在内核实现中epoll
是根据每个fd
上面的 callback 函数实现的,只有活跃的fd
才会主动的去调用 callback 函数,其他状态的fd
则不会。epoll
的性能不会受fd
总数的限制。- select/poll 都需要内核把
fd
消息通知给用户空间,而epoll
是通过内核和用户空间mmap
同一块内存实现。
- select,poll,
epoll
比较
类别 | select | poll | epoll |
---|---|---|---|
支持的最大连接数 | 由FD_SETSIZE 限制 |
基于链表存储,没有限制 | 受系统最大句柄数限制 |
fd 剧增的影响 |
线性扫描fd 导致性能很低 |
同 select | 基于fd 上 callback 实现,没有性能下降的问题 |
消息传递机制 | 内核需要将消息传递到用户空间,需要内核拷贝 | 同 select |
epoll 通过内核与用户空间共享内存来实现 |
IO多路复用与阻塞IO对比
- 阻塞 IO 和非阻塞 IO 用户进程都是只是调用
recvfrom
一个函数,而多路复用中用户进程还会再调用一个 select 函数,当用户进程调用了 select,那么整个进程会被阻塞,而同时,操作系统会 “监视” 所有 select 负责的 socket 所对应的的内核缓冲区的数据,当任何一个 socket 所对应的内核缓冲区中的数据准备好了,就会返回可读条件的通知。此时用户进程再调用 read 操作,将数据从内核缓冲区拷贝到用户缓冲区。- 在 IO 多路复用模型中,对于每一个 socket,一般都设置成为非阻塞的,但是,整个用户进程实际上是一直被阻塞的。只不过用户进程是被 select 这个函数阻塞的 ,而不是被 socket IO 给阻塞的(或者也可以理解为是操作系统阻塞的)。
多路复用与阻塞IO性能比较,多路复用更适合同时处理多连接请求,当连接请求数少时更适合使用阻塞IO。因为多路复用需要两个 system call (select/poll/
epoll
和recvfrom
),而阻塞 IO 只调用了一个system call (recvfrom
)。所以,如果处理的连接数不是很高的话,使用 select 的 web server 不一定比使用 多线程 + 阻塞 IO 的 web server 性能更好,可能延迟还更大。
BIO
BIO
是传统的java.io
包,它是基于流模型实现的,交互的方式是同步、阻塞方式(基于阻塞IO模型实现的),也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。
优点就是代码比较简单、直观;缺点就是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。
下面是基于BIO
实现的C/S通信
// 同步阻塞IO-服务端
public class Server {
public static void main(String[] args) {
try {
ServerSocket server = new ServerSocket(10001);
while (true) {
Socket client = server.accept();
InputStream in = client.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
System.out.print(reader.readLine());
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 同步阻塞IO-客户端
public class Client {
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1",10001);
OutputStream stream = socket.getOutputStream();
byte[] b = "hello".getBytes();
stream.write(b);
stream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
上述方案的性能是较差的,因为
accept
和read
是阻塞的,所以服务端是串行化处理客户端的请求。只有当每一个客户端请求处理完毕,才能处理下一个客户端的请求。可以将read
操作交给一个新的线程去处理,若每一个请求都使用一个新的线程去处理read
操作,当请求数量过多时,线程数过多,会影响执行效率,可以采用线程池执行read
操作。
// 伪异步IO(线程池)-服务端
public class ServerV1 {
public static void main(String[] args) throws IOException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
ServerSocket serverSocket = new ServerSocket(10001);
try {
while (true) {
Socket socket = serverSocket.accept();
executorService.execute(new ServerHandler(socket));
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
class ServerHandler implements Runnable{
Socket socket;
public ServerHandler(Socket socket) {
this.socket = socket;
}
@SneakyThrows
@Override
public void run() {
System.out.print("Thread: " + Thread.currentThread().getName() + " ");
InputStream in = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
String res;
while (true) {
if ((res = reader.readLine()) == null) break;
System.out.println(res);
}
}
}
NIO
NIO
是 Java 1.4 引入的java.nio
包,它提供了Channel
、Selector
、Buffer
等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序(基于IO多路复用模型实现的),同时提供了更接近操作系统底层高性能的数据操作方式。
1. Channel
通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。
通道与流的不同之处在于,流是单向的,通道是双向的,可以用于读、写或者同时用于读写。
- FileChannel,从文件中读取数据
- DatagramChannel,基于UDP读取网络中的数据
- SocketChannel,基于TCP读取网络中的数据
- ServerSocketChannel,监听TCP连接,为TCP连接建立SocketChannel
2. Buffer
不会直接对通道进行读写数据,而是要先经过缓冲区。即发送给一个通道的所有数据都必须首先放到缓冲区中,从通道中读取的任何数据都要先读到缓冲区中。
Buffer是在数组数组的基础上进行封装,方便使用。
Buffer中有几个重要的属性,通过这几个属性来显示数据存储的信息
- capacity,Buffer 所能容纳元素的最大个数,即底层数组的长度。在创建时被指定,不可更改。
- position,下一个要读写的数据的位置
- limit,可供读写的最大位置,用于限制 position
- mark,位置标记,用于记录某一次的读写位置,可以通过 reset 重新回到这个位置
2. Selector
Selector使用基于epoll
的多路复用模型实现的,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件
public class Server {
public static void main(String[] args) throws IOException {
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
Selector selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
ServerSocket socket = server.socket();
SocketAddress address = new InetSocketAddress(10001);
socket.bind(address);
while (true){
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()){
SelectionKey key = iterator.next();
if (key.isAcceptable()){
ServerSocketChannel server1 = (ServerSocketChannel) key.channel();
SocketChannel client = server1.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()){
SocketChannel client = (SocketChannel) key.channel();
readData(client);
client.close();
}
iterator.remove();
}
}
}
public static void readData(SocketChannel client) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
StringBuilder data = new StringBuilder();
while (true) {
buffer.clear();
int n = client.read(buffer);
if (n == -1) {
break;
}
buffer.flip();
int limit = buffer.limit();
char[] dst = new char[limit];
for (int i = 0; i < limit; i++) {
dst[i] = (char) buffer.get(i);
}
data.append(dst);
buffer.clear();
}
System.out.println(data.toString());
}
}
引用
https://www.cnblogs.com/tkzL/p/11494134.html
https://blog.csdn.net/anxpp/article/details/51512200
https://www.zhihu.com/question/337609338/answer/775135962
http://www.tianxiaobo.com/2018/03/04/Java-NIO%E4%B9%8B%E7%BC%93%E5%86%B2%E5%8C%BA/