1、什么是IO?
IO:计算机系统中用于与外部设备进行数据交换的过程。主要分为磁盘IO和网络IO:
-
磁盘IO:
- 读 : 将磁盘中的数据读到内存中
- 写: 将内存中的数据写到磁盘中
-
网络IO:
- 读: 将网卡中的数据读到内存中
- 写: 将内存中的数据写到网卡中
2、Linux下的网络IO模型
2.1、用户空间与内核空间
Linux把内存划分为用户空间和内核空间,当用户空间的进程要读取硬件资源时,需要调用内核空间提供的函数进行调用,分为两阶段:
- 第一阶段将硬件资源拷贝到内核缓冲区,称为等待数据
- 第二阶段将内核缓冲区拷贝到用户缓冲区,称为数据拷贝
由于用户空间的程序是无法直接访问计算机硬件的,而是要调用内核空间提供的接口,由内核空间帮我们访问
2.2、阻塞IO (BIO)
顾名思义,阻塞IO就是两个阶段都必须阻塞等待:
2.3、非阻塞IO (NIO)
顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程:
可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
recvfrom 是UDP无连接的,不知道消息何时发过来,会有一个进程一直执行recvfrom去监听
这里的用户进程指Linux上的用户应用(如:Redis服务器)
2.4、IO多路复用
复用一个线程去监听多个FD(统一资源描述符,如Socket),当有FD就绪时,通知线程处理。
socket(网卡,硬件),将socket的内容拷贝到内核空间,如果现在只有一个线程,有多个socket,处理这个socket内容复制到内核空间的时间很长,那么当前线程就会阻塞,其他socket即使已经复制完成了,也阻塞不能执行,而IO多路复用会监听这些socket,当这个socket内容复制完成就绪了,内核空间有数据,当前线程才去执行。
Linux中的IO多路复用的实现有三种:select、poll 、epoll
1、select
底层实现:
fd集合: 32 个 int ,一个int 4字节 --> 一个 int 32个字节 --> 1024位 --> 监听1024个比特位
流程如下:
select模式存在的问题:
- 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
- select无法得知具体是哪个fd就绪,需要遍历整个fd_set
- fd_set监听的fd数量不能超过1024
2、poll
底层实现:
具体流程:
- 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
- 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
- 内核遍历fd,判断是否就绪
- 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
- 用户进程判断n是否大于0
- 大于0则遍历pollfd数组,找到就绪的fd
poll模式存在的问题:
- 需要将整个pollfd从用户空间拷贝到内核空间,poll结束还要再次拷贝回用户空间
- 第四步拷贝pollfd数组是拷贝所有的pollfd元素,包括没准备就绪的,用户空间得到之后仍然要遍历所有
注意:poll模式只解决了select模式下只能存1024个元素的问题
3、epoll
底层实现:
具体流程:
epoll实现依赖于三个方法:
- epoll_create(): 在内核空间创建一棵名为rb_root的红黑树监听所有的FD,创建名为list_head链表存放所有就绪的FD
- epoll_ctl() : 将FD添加到rb_root红黑树中,并绑定一个回调事件,当FD就绪时,将就绪的FD加入到list_head就绪链表中
- epoll_wait(): 监听list_head中有没有就绪的FD事件,如果有,将就绪的FD事件拷贝到用户空间中
epoll完美解决了select模式的三种问题:
- 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的FD数量增多而下降
- 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epo_wait,无需传递任何参数,无需重复拷贝FD到内核空间
- 内核会将就绪的FD直接拷贝到用户空间的指定位置,用户进程无需遍历所有FD就能知道就绪的FD是谁
2.5、信号驱动IO
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待
2.6、异步IO (AIO)
异步IO就是一二阶段都处理完,才给你结果
3、Java中的IO模型
Java中的BIO、NIO、AIO是一种编程模型,用于处理网络通信,而Linux中的IO模型是一个操作系统概念,用于处理设备I/O。
3.1、BIO
Java BIO:同步并阻塞
(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不作任何事情会造成不必要的线程开销。
服务端代码如下:
public class SocketServer {
public static void main(String[] args) {
try {
ServerSocket socket = new ServerSocket(8082);
while (true) {
System.out.println("等待客户端连接...");
// 同步阻塞,直到建立连接
Socket client = socket.accept();
// 有客户端连接了,发送一个消息给客户端
OutputStream output = client.getOutputStream();
output.write(("客户端[" + client.getPort() + "]你好,你已成功连接到8082服务端").getBytes());
output.flush();
System.out.println("客户端已连接:" + client.getPort());
try {
InputStream input = client.getInputStream();
byte[] bytes = new byte[1024];
// 同步阻塞 读取客户端数据 直到输入数据
while (input.read(bytes) != -1) {
output.write(("收到了你的消息:" + new String(bytes)).getBytes());
output.flush();
System.out.println("收到客户端消息:" + new String(bytes));
}
} catch (IOException ex) {
// 客户端链接断开则引发异常
System.err.println("引发异常:" + ex.getMessage());
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
客户端代码:
public class SocketClient {
public static void main(String[] args) {
try {
// 接收控制台用户输入的数据
Scanner scanner = new Scanner(System.in);
// 要绑定的主机和端口号
Socket socketClient = new Socket("127.0.0.1", 8082);
OutputStream output = socketClient.getOutputStream();
while (true) {
String str = scanner.nextLine();
if ("exit".equals(str)) {
output.close();
socketClient.close();
break;
} else {
// 向服务端发送消息
output.write(str.getBytes());
output.flush();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
测试如下,多个client只有一个能连接上,尽管连接上的client没有命令,也不能处理连接:
针对这一情况,对每个连接都启用一个线程去处理:
服务器通过一个Acceptor线程负责监听客户端请求和为每个客户端创建一个新的线程进行链路处理。典型的一请求一应答模式。若客户端数量增多,频繁地创建和销毁线程会给服务器打开很大的压力。后改良为用线程池的方式代替新增线程,被称为伪异步IO。
3.2、NIO
Java NIO:同步非阻塞
,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求会被注册到多路复用器上,多路复用器轮询到有 I/O 请求就会进行处理。
三个核心组件:
- 通道(Channel):
通道提供了一个双向传输数据的路径,可以用于读取和写入数据。常见的通道类型包括文件通道(FileChannel
)和网络套接字通道(SocketChannel
、ServerSocketChannel
等)。 - 缓冲区(Buffer):
缓冲区是一种用于存储数据的对象。在Java NIO中,所有的I/O操作都是通过缓冲区进行的。缓冲区提供了对数据的统一访问操作,包括读取数据到缓冲区和从缓冲区写出数据。不同类型的数据可以使用不同类型的缓冲区来处理,例如ByteBuffer
、CharBuffer
、IntBuffer
等。缓冲区有一个内部指针,用于跟踪读写操作的位置。 - 选择器(Selector):
选择器是Java NIO中的一个核心组件,它允许一个线程同时监控多个通道的I/O事件。选择器可以管理一组通道,监听它们上注册的感兴趣的事件,如读、写、连接和接收等。一旦有就绪的事件发生,选择器就会通知应用程序进行处理。通过选择器,可以使用单个线程有效地处理多个通道的I/O操作,实现非阻塞的事件驱动编程。
在使用Java NIO时,一般的步骤如下:
- 创建一个通道(Channel),绑定到适当的I/O源。
- 创建一个缓冲区(Buffer),用于读写数据。
- 利用选择器(Selector)注册通道,并设置感兴趣的事件类型。
- 轮询选择器(Selector)以检测已就绪的事件。
- 处理已就绪的事件,读取或写入数据。
服务端代码如下:
@Slf4j
public class Server {
public static void main(String[] args) throws IOException {
//创建服务端
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
//创建Selector
Selector selector = Selector.open();
//将 ssc注册到selector上, 0表示ssc不关心任何事件
//通过 SelectionKey就是将来事件发生之后,可以拿到事件,和关心这个事件的channel
SelectionKey sscKey = ssc.register(selector, 0, null);
//关心接收连接事件
sscKey.interestOps(SelectionKey.OP_ACCEPT);
while (true) {
//没有事件发生时,线程阻塞,有事件,线程才会恢复执行
log.info("before...select...");
selector.select();
//处理事件,selectedKeys()返回的是所有发生的事件
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
if (selectionKey.isAcceptable()) {
//拿到上面注册的ServerSocketChannel
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
log.info("before...connection...");
// selectionKey.cancel();
//处理事件,接收连接
SocketChannel channel = serverSocketChannel.accept();
channel.configureBlocking(false);
log.info("after...connection...{}", channel);
ByteBuffer byteBuffer = ByteBuffer.allocate(4);
//添加附件,实现将buffer与通道关联
SelectionKey scKey = channel.register(selector, 0, byteBuffer);
scKey.interestOps(SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) {
try {
SocketChannel channel = (SocketChannel) selectionKey.channel();
ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
log.info("before...read...");
int read = channel.read(buffer);
if (read == -1 ){
//客户端都关闭了,当然可以把通道撤销,并处理事件
//这里不用担心将其他通道关闭,因为能执行到这,说明有读事件发生,且读到的是-1,说明是关闭
//如果客户端没有消息发送,虽然read也是-1,但是没有读事件,根本不会执行到这
//而真正的读事件,肯定不是-1,走的else,直接执行就好,而不会关闭通道
selectionKey.cancel();
}else {
split(buffer);
if (buffer.position() == buffer.limit()){ //没有拆分,字符串太大,buffer装不下
ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
buffer.flip();//切换读模式
newBuffer.put(buffer);
selectionKey.attach(newBuffer);
}
// System.out.println(StandardCharsets.UTF_8.decode(buffer));
// debugAll(buffer);
// log.info("after...read...");
}
} catch (IOException e) {
e.printStackTrace();
selectionKey.cancel();
}
}
//void remove(): 从集合中移除迭代器最后访问过的元素。注意,
// 这个方法只能在调用了next()方法之后才能调用一次
//删除使用过的key
iterator.remove();
}
}
}
private static void split(ByteBuffer source) {
//切换读模式
source.flip();
for (int i = 0; i < source.limit(); i++) {
//get(i) 不改变position的位置
if (source.get(i) == '\n'){
int length = i - source.position() + 1 ;
ByteBuffer target = ByteBuffer.allocate(length);
for (int j = 0; j < length; j++) {
target.put(source.get());
}
debugAll(target);
}
}
//把未读的前移
source.compact();
}
}
客户端代码如下:
public class Client {
public static void main(String[] args) throws InterruptedException {
new Bootstrap()
.group(new NioEventLoopGroup())
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel channel) throws Exception {
channel.pipeline().addLast(new StringEncoder());
}
}).connect(new InetSocketAddress("127.0.0.1",8080))
.sync()
.channel()
.writeAndFlush("hello world");
}
}
测试如下,复用一个线程监听多个client:
3.3、AIO
Java AIO:异步非阻塞
,AIO使用回调机制,应用程序发起I/O操作后不需要等待操作完成,而是可以继续执行其他任务。当I/O操作完成时,系统会通知应用程序。
主线程只负责将当前client分配给一个子线程,子线程去执行client的任务,而子线程处理完之后,可以给主线程反馈结果,主线程注册完之后,不会等待子线程执行完client任务,而是可以去执行其他的命令,这就是异步
。而NIO中,复用的线程并不能解放出来,即同步。
服务端代码:
public class AIOServer {
public static void main(String[] args) {
try {
final int port = 8080;
//首先打开一个ServerSocket通道并获取AsynchronousServerSocketChannel实例:
AsynchronousServerSocketChannel serverSocketChannel = AsynchronousServerSocketChannel.open();
//绑定需要监听的端口到serverSocketChannel:
serverSocketChannel.bind(new InetSocketAddress(port));
//实现一个CompletionHandler回调接口handler,
//之后需要在handler的实现中处理连接请求和监听下一个连接、数据收发,以及通信异常。
CompletionHandler<AsynchronousSocketChannel, Object> handler = new CompletionHandler<AsynchronousSocketChannel,
Object>() {
@Override
public void completed(final AsynchronousSocketChannel result, final Object attachment) {
// 继续监听下一个连接请求
serverSocketChannel.accept(attachment, this);
try {
System.out.println("接受了一个连接:" + result.getRemoteAddress()
.toString());
// 给客户端发送数据并等待发送完成
result.write(ByteBuffer.wrap("From Server:Hello i am server".getBytes()))
.get();
ByteBuffer readBuffer = ByteBuffer.allocate(128);
// 阻塞等待客户端接收数据
result.read(readBuffer)
.get();
System.out.println(new String(readBuffer.array()));
} catch (IOException | InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
@Override
public void failed(final Throwable exc, final Object attachment) {
System.out.println("出错了:" + exc.getMessage());
}
};
serverSocketChannel.accept(null, handler);
// 由于serverSocketChannel.accept(null, handler);是一个异步方法,调用会直接返回,
// 为了让子线程能够有时间处理监听客户端的连接会话,
// 这里通过让主线程休眠一段时间(当然实际开发一般不会这么做)以确保应用程序不会立即退出。
TimeUnit.MINUTES.sleep(Integer.MAX_VALUE);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
客户端代码:
public class AIOClient {
public static void main(String[] args) {
try {
// 打开一个SocketChannel通道并获取AsynchronousSocketChannel实例
AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
// 连接到服务器并处理连接结果
client.connect(new InetSocketAddress("127.0.0.1", 8080), null, new CompletionHandler<Void, Void>() {
@Override
public void completed(final Void result, final Void attachment) {
System.out.println("成功连接到服务器!");
try {
// 给服务器发送信息并等待发送完成
client.write(ByteBuffer.wrap("From client:Hello i am client".getBytes()))
.get();
ByteBuffer readBuffer = ByteBuffer.allocate(128);
// 阻塞等待接收服务端数据
client.read(readBuffer)
.get();
System.out.println(new String(readBuffer.array()));
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
@Override
public void failed(final Throwable exc, final Void attachment) {
exc.printStackTrace();
}
});
TimeUnit.MINUTES.sleep(Integer.MAX_VALUE);
} catch (InterruptedException | IOException e) {
e.printStackTrace();
}
}
}
运行如下: