IO模型
BIO: 同步阻塞式
适用于连接数目比较少且固定的架构
NIO: 同步非阻塞式
适用于连接数目多, 但是连接比较短(轻量操作)的架构
AIO: 异步非阻塞式
适用于连接数目多, 且连接比较长(重操作)的架构
对于同步、异步和阻塞、非阻塞的理解
同步和异步
- 同步: 顺序执行,例如线程同步
- 异步: 通过回调函数执行
阻塞和非阻塞
- 阻塞: 执行代码后,除非事件发生,否则不会继续向下执行。
- 非阻塞: 可以继续向下执行代码
accept()
和 read()
会被阻塞
BIO
BIO(Blocking IO)是同步阻塞式的IO通信, 服务器实现模式为一个连接一个线程, 如果这个连接不做任何事情则会造成不必要的开销, 因此可以使用线程池机制来改善, 但是线程池机制也会引入新的问题: 如果请求数量超过线程池的最大连接数量, 则会造成后续请求失败. 因此BIO模式适用于连接数目比较少且固定的架构.
客户端与服务器通信过程(BIO模式)
服务端流程
- 定义一个ServerSocket对象, 进行服务端的端口注册
- 监听客户端的Socket连接请求
- 从Socket管道中得到InputStream, 获取客户端发送过来的请求数据
- 进行业务逻辑的处理
- 向Socket管道中推送OutputStream, 向客户端返回响应数据
客户端流程
- 连接服务器端口
- 发送请求信息(即向Socket中发送OutputStream)
- 等待服务器响应, 从Socket中得到InputStream
总交互流程
- 服务端: 进行端口注册, 并监听客户端的Socket连接请求
- 客户端: 连接服务器端口, 发送请求数据
- 服务端: 从Socket中获取客户端发送的请求数据, 进行业务处理, 然后返回响应数据
- 客户端: 从Socket中获取服务器返回的响应数据
伪异步IO
伪异步IO的概念来源于实践, 表示通过线程池来做缓冲区的方法.
同步阻塞案例的演示
简单案例
public class Server {
//为了简化处理, 并没有实现BIO模式中的一个客户端一个线程, 而是将处理逻辑写在了主线程中
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8888);
Socket socket = serverSocket.accept();
InputStream is = socket.getInputStream();
// 按行读取字符需要使用BufferedReader
// BufferedReader只能通过Reader获取, 因此需要将InputStream->Reader, 即使用InputStreamReader
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String msg;
while ((msg = br.readLine()) != null) {
System.out.println("msg = " + msg);
}
}
}
public class Client {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("127.0.0.1", 8888);
OutputStream os = socket.getOutputStream();
PrintStream ps = new PrintStream(os);
//由于服务端的处理逻辑是读取完一行后打印, 所以这里故意使用print()而不是使用println()
ps.print("hello world");
ps.flush();
//客户端线程睡5s,更易观察效果
Thread.sleep(5000);
}
}
阻塞原因分析
- 在总交互流程的第3步中, 从
br.readLine()
的代码逻辑中, 服务端Server会一直等待客户端发送至少一行数据. - 而客户端Client在发送
“hello world”
之后并没有换行符, 服务端接收到“hello world”
之后并不会认为这是一行数据, 所以阻塞继续等待客户端发送剩下的数据. - 客户端由于
Thread.sleep(5000)
阻塞了5s之后(此时服务端线程也阻塞了5s)会结束, 此时服务端发现客户端的Socket连接断开, 因此会报错.
多发和多收消息的演示案例
多客户端演示案例
需要引入线程, 每有一个socket连接请求则创建一个线程
NIO
JVM是一把双刃剑,它提供了统一的操作系统,与特定操作系统平台的细枝末节都被隐藏起来,因此方便编程,但是隐藏操作系统同时意味着特定操作系统下那些独具特色、功能强大的特性被挡在JVM之外。现代操作系统底层提供一些高效的I/O操作,新的IO就是为了利用上这些操作系统提供的新特性。总结:本质上还是操作系统的进步。
Java NIO 需要看所处的操作系统环境来判断是 非阻塞式IO(Windows系统) 还是 IO多路复用(Linux系统)。非阻塞式IO 和 IO多路复用 的区别在于是用户态进行轮询还是在内核态进行轮询。非阻塞式IO是在用户态进行轮询,则循环中每次调用
read()
都需要进行一次用户态和内核态的切换,开销过大。而IO多路复用则是在内核态进行轮询,减少了用户态和内核态的切换次数。IO多路复用技术包括:select、poll 和 epoll。假如有 100w 个文件描述符(客户端连接),那么 select 会调用 100w 次 read 操作,而可能其中只有 2 个文件描述符中存在数据读写,使用 epoll 则只会调用 2 次 read 操作。目前理解:select 和 epoll 都不可避免的去判断这 100w 个文件描述符中是否有事件发生,但是 epoll 只需要再进行 2 次 read 系统调用;但 select 需要进行 100w 次 read 系统调用,根据每一次 read 的返回结果来读取数据,没有数据的客户端连接也会调用一次,只是没有读取到数据而已。
NIO开发步骤
Server端
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
public class Server {
public static void main(String[] args) throws Exception {
// 这一部分可以放置到
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//Selector只能和非阻塞模式的Channel配合使用, 而FileChannel是不可以配置成非阻塞模式的
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(8888));
Selector selector = Selector.open();
//ServerSocketChannel-> OP_ACCEPT服务器准备好接受连接
//SocketChannel->OP_CONNECT客户端可以连接到服务器
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (selector.select() > 0) {
//Server服务端只需要监听ACCEPT和READ事件
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey selectionKey = it.next();
if (selectionKey.isAcceptable()) {
// 所有客户端的第一次请求都是向serverSocketChannel中发送连接请求
// 每有一个连接请求, 分配一个通道socketChannel
// 而socketChannel中监听读事件, 也就是服务端等待客户端发送请求信息, 然后在isReadable()的处理逻辑中执行处理逻辑然后返回给Client客户端响应数据
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) {
//读就绪
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
//读逻辑处理
//todo: 可以在try-catch中处理Client离线的逻辑: (1).SelectionKey取消, (2).Channel关闭
//todo: 优化时可以将处理逻辑另开一个线程, 而选择器只作轮询的选择和判断就绪操作
//todo: 更进一步, 可以使用线程池来执行处理逻辑
ByteBuffer buffer = ByteBuffer.allocate(1024);
int readCounts;
while ((readCounts = socketChannel.read(buffer)) > 0) {
buffer.flip();
String s = new String(buffer.array(), 0, readCounts);
System.out.println(s);
buffer.clear();
}
}
//将选择键从Selected Key Set中移除. 需要使用迭代器的remove(), 而不是使用selectionKey.cancel().
//cancel()方法<->register()方法, 会取消Channel和Selector的注册关系
//使用增强for的缺点就是不能访问下标和删除集合中的元素
it.remove();
}
}
}
}
Client端
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
public class Client {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress(8888));
FileInputStream fis = new FileInputStream("C:/test.txt");
FileChannel fileChannel = fis.getChannel();
//transferTo底层使用到了零拷贝
//在Windows系统中, 一次transferTo最多传送8MB, 对于大文件需要多次发送, position, count也需要计算得出
//在Linux系统中, 一次transferTo即可
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
}
}
Buffer缓冲区
Channel通道
Channel是双向操作的, 既可以用于读(ReadableByteChannel接口), 也可以用于写(WritableByteChannel接口), 还可以用于读写同时操作(ReadableByteChannel接口和WritableByteChannel接口), 所有的Channel类都实现了读写接口, 即Channel是双向操作的.
Channel相较于Stream而言, Channel是全双工的, 而Stream是单向的.
I/O广义上可以分为两大类:
- File IO(文件IO)
- Stream IO(流IO)
通道作为I/O服务的导管, 相应地也有两种类型地Channel:
File文件通道
FileChannel
: 文件Socket套接字通道
DatagramChannel
: UDPSocketChannel
: TCPServerSocketChannel
: 服务器TCP
FileChannel类
打开Channel
SocketChannel可以直接创建, 而FileChannel只能通过调用下面三类实例对象的getChannel()
方法来获取:
- FileInputStream
- FileOutputStream
- RandomAccessFile
RandomAccessFile file = new RandomAccessFile("C:/test.txt", "rw");
FileChannel channel = file.getChannel();
关闭Channel
从Channel中读取数据(从 Channel 到 Buffer)
channel.read(buffer)
操作
int bufferSize = 40;
ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
// readCounts代表实际读取数据的字节长度
// 计算逻辑:
// 1. readCounts = min(bufferSize, 文件剩余数据长度);
// 2. readCounts = (readCounts != 0) ? readCounts : -1;
int readCounts = channel.read(buffer);
//此时buffer中存在数据
向Channel中写入(从 Buffer 到 Channel)
channel.write(buffer)
操作
DatagramChannel类
SocketChannel类
ServerSocketChannel类
Channel源码
import java.io.IOException;
import java.io.Closeable;
public interface Channel extends Closeable {
public boolean isOpen();
public void close() throws IOException;
}
Selector选择器
Selector,SelectableChannel 和 SelectionKey 这三个类组成了使得在 Java 平台上使得就绪检查变得可行的三驾马车.
SelectableChannel类
SelectableChannel并不是Selector选择器的一个组成部分, 而是属于Channel, 所有的SocketChannel都属于SelectableChannel, 而FileChannel不是SelectableChannel.
由于Selector中涉及使用到的Channel都属于SelectableChannel, 所以将SelectableChannel在这部分引入一下.
SelectionKey类
取消逻辑

当Channel关闭, 所有相关的键会被自动取消(即添加到相应Selector的取消键集合中)
当Selector关闭, 所有注册到该Selector的Channel都被注销, 相关的键被取消
Selector类
Selector是注册各种IO事件的地方, 当我们关注的事件发生时, 由Selector对象进行通知.
每个Selector对象维护三个与SelectionKey相关集合:
- Registered Key Set (已注册键的集合)
- Selected Key Set (已选择键的集合)
- Cancelled Key Set (已取消键的集合)
选择过程select()
检查
Cancelled Key Set
.遍历每一个已取消的键, 从集合中移除, 也从其它两个集合(
Registered Key Set
和Selected Key Set
)中移除, 注销相关的通道检查
Registered Key Set
中每一个SelectionKey
键的interest
集合
停止选择过程wakeup()
wakeup()
提供了使线程从被阻塞的select()
方法中优雅退出的能力.
wakeup()
作用:
- 如果当前正在执行
select()
方法, 那么使得Selector选择器上的第一个还没有返回的select()
操作立即返回. - 如果当前没有执行
select()
方法, 那么后续第一次(下一次)对select()
的调用将立即返回. 除后续第一次外的select()
方法将正常进行. (延迟处理特性) - 两次
select()
方法中连续多次调用wakeup()
和只调用一次wakeup()
的作用相同