I/O 一直是很多Java同学难以理解的一个知识点,这篇帖子将会从底层原理上带你理解I/O,让你看清I/O相关问题的本质。
1、I/O的概念
I/O 的全称是Input/Output。虽常谈及I/O,但想必你也一时不能给出一个完整的定义。搜索了谷哥欠,发现也尽是些冗长的论述。要想厘清I/O这个概念,我们需要从不同的视角去理解它。
1.1、计算机结构的视角
根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。其中输入是指将数据输入到计算机的设备,比如键盘鼠标;输出是指从计算机中获取数据的设备,比如显示器;以及既是输入又是输出设备,硬盘,网卡等。
用户通过操作系统才能完成对计算机的操作。计算机启动时,第一个启动的程序是操作系统的内核,它将负责计算机的资源管理和进程的调度。换句话说:操作系统负责从输入设备读取数据并将数据写入到输出设备。
1.2、程序应用的视角
根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
应用程序作为一个文件保存在磁盘中,只有加载到内存到成为一个进程才能运行。应用程序运行在计算机内存中,必然会涉及到数据交换,比如读写磁盘文件,访问数据库,调用远程API等等。但我们编写的程序并不能像操作系统内核一样直接进行I/O操作。
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
但操作系统向外提供API,其由各种类型的系统调用(System Call)组成,以提供安全的访问控制。所以应用程序要想访问内核管理的I/O,必须通过调用内核提供的系统调用(system call)进行间接访问。
所以I/O之于应用程序来说,强调的通过向内核发起系统调用完成对I/O的间接访问。换句话说应用程序发起的一次IO操作实际包含两个阶段:
- IO调用阶段:应用程序进程向内核发起系统调用。
- IO执行阶段:内核执行IO操作并返回。准备数据阶段:内核等待I/O设备准备好数据;拷贝数据阶段:将数据从内核缓冲区拷贝到用户空间缓冲区。
UNIX 系统下, IO 模型一共有 5 种:
- 同步阻塞 I/O、
- 同步非阻塞 I/O、
- I/O 多路复用、
- 信号驱动 I/O
- 异步 I/O。
推荐孙卫琴老师的书籍:
2、BIO (Blocking I/O)
2.1、BIO模型解析
BIO即同步阻塞IO,实现模型为一个连接就需要一个线程去处理。这种方式简单来说就是当有客户端来请求服务器时,服务器就会开启一个线程去处理这个请求,即使这个请求不干任何事情,这个线程都一直处于阻塞状态。
应用程序中进程在发起IO调用后至内核执行IO操作返回结果之前,若发起系统调用的线程一直处于等待状态,则此次IO操作为阻塞IO。阻塞IO简称BIO,Blocking IO。其处理流程如下图所示:
从上图可知当用户进程发起IO系统调用后,内核从准备数据到拷贝数据到用户空间的两个阶段期间用户调用线程选择阻塞等待数据返回。
因此BIO带来了一个问题:如果内核数据需要耗时很久才能准备好,那么用户进程将被阻塞,浪费性能。为了提升应用的性能,虽然可以通过多线程来提升性能,但线程的创建依然会借助系统调用,同时多线程会导致频繁的线程上下文的切换,同样会影响性能。所以要想解决BIO带来的问题,我们就得看到问题的本质,那就是阻塞二字。
BIO模型有很多缺点,最大的缺点就是资源的浪费。想象一下如果QQ使用BIO模型,当有一个人上线时就需要一个线程,即使这个人不聊天,这个线程也一直被占用,那再多的服务器资源都不管用。
2.2、BIO代码演示
使用 BIO 模型编写一个服务器端,监听 6666 端口,当有客户端连接时,就启动一个线程与之通讯。
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @title BIOServer
* @description 测试
* @author: yangyongbing
* @date: 2023/12/7 11:45
*/
public class BIOServer {
public static void main(String[] args) throws IOException {
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("服务器启动了");
while (true){
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
//监听,等待客户端连接
System.out.println("等待连接....");
final Socket socket = serverSocket.accept();
System.out.println("连接到一个客户端");
//一个客户端连接就创建一个线程,并与之建立通讯
newCachedThreadPool.execute(new Runnable() {
@Override
public void run() {
//与客户端建立通讯
handler(socket);
}
});
}
}
public static void handler(Socket socket) {
try {
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
byte[] bytes = new byte[1024];
// 通过socket 获取输入流
InputStream inputStream = socket.getInputStream();
// 循环的读取客户端发送的数据
while (true) {
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
System.out.println("read....");
int index = inputStream.read(bytes);
if (index != -1) {
// 输出客户端发送的数据
System.out.println(new String(bytes, 0, index));
} else {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("关闭和客户端的连接");
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
3、NIO (Non-blocking/New I/O)
3.1、NIO模型解析
Java NIO 全称 Java non-blocking IO,是指 JDK 提供的新 API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 NewIO),是同步非阻塞的。
就是用户进程在发起系统调用时指定为非阻塞,内核接收到请求后,就会立即返回,然后用户进程通过轮询的方式来拉取处理结果。也就是如下图所示:
应用程序中进程在发起IO调用后至内核执行IO操作返回结果之前,若发起系统调用的线程不会等待而是立即返回,则此次IO操作为非阻塞IO模型。非阻塞IO简称NIO,Non-Blocking IO。
BIO是阻塞的,如果没有多线程,BIO就需要一直占用CPU,而NIO则是非阻塞IO,NIO在获取连接或者请求时,即使没有取得连接和数据,也不会阻塞程序。NIO的服务器实现模式为一个线程可以处理多个请求(连接)。
NIO有几个知识点需要掌握,Channel(通道),Buffer(缓冲区), Selector(多路复用选择器):
- Channel既可以用来进行读操作,又可以用来进行写操作。NIO中常用的Channel有FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。
- Buffer缓冲区用来发送和接受数据。
- Selector 一般称为选择器或者多路复用器 。它是Java NIO核心组件中的一个,用于检查一个或多个NIO Channel(通道)的状态是否处于可读、可写。Java在NIO中使用Selector往往是将Channel注册到Selector中,如下图所示:
然而,非阻塞IO虽然相对于阻塞IO大幅提升了性能,但依旧不是完美的解决方案,其依然存在性能问题,也就是频繁的轮询导致频繁的系统调用,会耗费大量的CPU资源。比如当并发很高时,假设有1000个并发,那么单位时间循环内将会有1000次系统调用去轮询执行结果,而实际上可能只有2个请求结果执行完毕,这就会有998次无效的系统调用,造成严重的性能浪费。有问题就要解决,那NIO问题的本质就是频繁轮询导致的无效系统调用。
2.2、NIO代码演示
NIO服务端的执行过程是这样的:
- 创建一个ServerSocketChannel和Selector,然后将ServerSocketChannel注册到Selector上
- Selector通过select方法去轮询监听channel事件,如果有客户端要连接时,监听到连接事件
- 通过channel方法将socketchannel绑定到ServerSocketChannel上,绑定通过SelectorKey实现
- socketchannel注册到Selector上,关联读事件
- Selector通过select方法去轮询监听channel事件,当监听到有读事件时,ServerSocketChannel通过绑定的SelectorKey定位到具体的channel,读取里面的数据。
import java.io.IOException;
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;
/**
* @title NIOServer
* @description NIO测试
* @author: yangyongbing
* @date: 2023/12/7 12:02
*/
public class NIOServer {
public static void main(String[] args) throws IOException{
//创建一个socket通道,并且设置为非阻塞的方式
ServerSocketChannel serverSocketChannel=ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(9000));
//创建一个selector选择器,把channel注册到selector选择器上
Selector selector=Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true){
System.out.println("等待事件发生");
selector.select();
System.out.println("有事件发生了");
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()){
SelectionKey key = iterator.next();
iterator.remove();
handler(key);
}
}
}
private static void handler(SelectionKey key) throws IOException {
if (key.isAcceptable()){
System.out.println("连接事件发生");
ServerSocketChannel serverSocketChannel= (ServerSocketChannel) key.channel();
//创建客户端一侧的channel,并注册到selector上
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(key.selector(),SelectionKey.OP_READ);
}else if (key.isReadable()){
System.out.println("数据可读事件发生");
SocketChannel socketChannel= (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int len = socketChannel.read(buffer);
if (len!=-1){
System.out.println("读取到客户端发送的数据:"+new String(buffer.array(),0,len));
}
//给客户端发送信息
ByteBuffer wrap = ByteBuffer.wrap("hello world".getBytes());
socketChannel.write(wrap);
key.interestOps(SelectionKey.OP_READ|SelectionKey.OP_WRITE);
socketChannel.close();
}
}
}
客户端代码:NIO客户端代码的实现比BIO复杂很多,主要的区别在于,NIO的客户端也需要去轮询自己和服务端的连接情况:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
/**
* @title NIOClient
* @description NIOClient测试
* @author: yangyongbing
* @date: 2023/12/7 12:19
*/
public class NIOClient {
public static void main(String[] args) throws IOException {
//配置基本的连接参数
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
Selector selector = Selector.open();
channel.connect(new InetSocketAddress("127.0.0.1", 9000));
channel.register(selector, SelectionKey.OP_CONNECT);
//轮询访问selector
while (true) {
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
//连接事件发生
if (key.isConnectable()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
//如果正在连接,则完成连接
if (socketChannel.isConnectionPending()) {
socketChannel.finishConnect();
}
socketChannel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.wrap("客户端发送的数据".getBytes());
socketChannel.write(buffer);
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
//读取服务端发送过来的消息
read(key);
}
}
}
}
private static void read(SelectionKey key) throws IOException {
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(512);
int len = socketChannel.read(buffer);
if (len != -1) {
System.out.println("客户端收到信息:" + new String(buffer.array(), 0, len));
}
}
}
效果大概是这样的:首先服务端等待事件发生,当客户端启动时,服务器端先接受到连接的请求,接着接受到数据读取的请求,读完数据后继续等待。
NIO通过一个Selector,负责监听各种IO事件的发生,然后交给后端的线程去处理。NIO相比与BIO而言,非阻塞体现在轮询处理上。BIO后端线程需要阻塞等待客户端写数据,如果客户端不写数据就一直处于阻塞状态。而NIO通过Selector进行轮询已注册的客户端,当有事件发生时才会交给后端去处理,后端线程不需要等待。
3、AIO (Non-blocking/New I/O)
3.1、AIO模型解析
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
3.2、AIO代码演示
AIO是在JDK1.7中推出的新的IO方式–异步非阻塞IO,也被称为NIO2.0,AIO在进行读写操作时,直接调用API的read和write方法即可,这两种均是异步的方法,且完成后会主动调用回调函数。简单来讲,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。
Java提供了四个异步通道:AsynchronousSocketChannel、AsynchronousServerSocketChannel、AsynchronousFileChannel、AsynchronousDatagramChannel。
服务器端代码:AIO的创建方式和NIO类似,先创建通道,再绑定,再监听。只不过AIO中使用了异步的通道。
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.concurrent.TimeUnit;
/**
* @title AIOServer
* @description AIOServer测试
* @author: yangyongbing
* @date: 2023/12/7 12:41
*/
public class AIOServer {
public static void main(String[] args) {
try {
//创建异步通道
AsynchronousServerSocketChannel serverSocketChannel=AsynchronousServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8080));
System.out.println("等待连接中");
//在AIO中,accept有两个参数,
// 第一个参数是一个泛型,可以用来控制想传递的对象
// 第二个参数CompletionHandler,用来处理监听成功和失败的逻辑
// 如此设置监听的原因是因为这里的监听是一个类似于递归的操作,每次监听成功后要开启下一个监听
serverSocketChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
//请求成功处理逻辑
@Override
public void completed(AsynchronousSocketChannel result, Object attachment) {
System.out.println("连接成功,处理数据中");
//开启新的监听
serverSocketChannel.accept(null,this);
handlerData(result);
}
@Override
public void failed(Throwable exc, Object attachment) {
System.out.println("失败");
}
});
try {
TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
} catch (IOException e) {
e.printStackTrace();
}
}
private static void handlerData(AsynchronousSocketChannel result) {
ByteBuffer byteBuffer=ByteBuffer.allocate(1024);
//通道的read方法也带有三个参数
//1.目的地:处理客户端传递数据的中转缓存,可以不使用
//2.处理客户端传递数据的对象
//3.处理逻辑,也有成功和不成功的两个写法
result.read(byteBuffer, byteBuffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer attachment) {
if (result>0){
attachment.flip();
byte[] array = attachment.array();
System.out.println(new String(array));
}
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
System.out.println("失败");
}
});
}
}
客户端代码:主要实现数据的发送功能
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Scanner;
/**
* @title AIOClient
* @description AIOClient
* @author: yangyongbing
* @date: 2023/12/7 12:44
*/
public class AIOClient {
public static void main(String[] args) {
try {
AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));
Scanner scanner = new Scanner(System.in);
String next = scanner.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
byteBuffer.put(next.getBytes());
byteBuffer.flip();
socketChannel.write(byteBuffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
4、总结
I/O 其关键点是要将应用程序的IO操作分为两个步骤来理解:IO调用和IO执行。IO调用才是应用程序干的事情,而IO执行是操作系统的工作。在IO调用时,对待操作系统IO就绪状态的不同方式,决定了其是阻塞或非阻塞模式;在IO执行时,线程或进程是否挂起等待IO执行决定了其是否为同步或异步IO。