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() {@Overridepublic 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);//輪詢訪問selectorwhile (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>() {//請求成功處理邏輯@Overridepublic void completed(AsynchronousSocketChannel result, Object attachment) {System.out.println("連接成功,處理數據中");//開啟新的監聽serverSocketChannel.accept(null,this);handlerData(result);}@Overridepublic 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>() {@Overridepublic void completed(Integer result, ByteBuffer attachment) {if (result>0){attachment.flip();byte[] array = attachment.array();System.out.println(new String(array));}}@Overridepublic 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。