「後端」深入理解 Java 三種 IO 模式和 Epoll 模型

IO模型

IO 模型就是說用什麼樣的通道進行資料的傳送和接收,Java 共支援 3 種網路程式設計 IO 模式:

BIO,NIO,AIO

BIO(Blocking IO )

同步阻塞

模型,一個客戶端連線對應一個處理執行緒。

「後端」深入理解 Java 三種 IO 模式和 Epoll 模型

BIO程式碼示例:

// BIO 服務端程式碼import java。net。ServerSocket;import java。net。Socket;import java。util。logging。Handler; public class SocketServer { public static void main(String[] args) throws Exception { ServerSocket serverSocket = new ServerSocket(9000); while (true){ System。out。println(“等待連線”); // 阻塞連線 Socket clientSocket = serverSocket。accept(); System。out。println(“有客戶端連線。。。”); // 建立新執行緒執行 handle 方法 new Thread(new Runnable() { @Override public void run() { try { handle(clientSocket); } catch (Exception e) { e。printStackTrace(); } } })。start(); } } public static void handle(Socket clientSocket) throws Exception{ byte[] bytes = new byte[1024]; System。out。println(“準備read。。”); // 接收客戶端的資料,沒有資料可讀時就阻塞 int read = clientSocket。getInputStream()。read(bytes); System。out。println(“read 完畢。”); if (read !=-1){ System。out。println(“接收到客戶端資料:” + new String(bytes,0,read)); } clientSocket。getOutputStream()。write(“helloClient”。getBytes()); clientSocket。getOutputStream()。flush(); }}

// BIO 客戶端程式碼 import java。io。IOException;import java。net。Socket; public class SocketClient { public static void main(String[] args) throws IOException { Socket socket = new Socket(“localhost”, 9000); // 向服務端傳送資料 socket。getOutputStream()。write(“HelloServer”。getBytes()); socket。getOutputStream()。flush(); System。out。println(“向服務端傳送資料結束”); byte[] bytes = new byte[1024]; // 接收服務端回傳的資料 socket。getInputStream()。read(bytes); System。out。println(“接收到服務端的資料:” + new String(bytes)); socket。close(); }}

缺點:

從上面的程式碼我們可以看出來,BIO 程式碼中

連線事件和讀寫資料事件

都是

阻塞的

,所以這種模式的缺點非常的明顯。

1、如果我們連線完成以後,不做讀寫資料操作會導致執行緒阻塞,浪費資源。

2、如果沒來一個連線我們都需要啟動一個執行緒處理,那麼會導致伺服器執行緒太多,壓力太大,比如 C10K。

應用場景:

BIO 方式適用於

連線數目比較小且固定

的架構,這種方式對伺服器資源要求比較高,但是程式比較簡單。

NIO(Non Blocking IO)

同步非阻塞

模型,伺服器實現模式為一個執行緒可以處理多個請求連線,客戶端傳送的連線請求都會註冊到多路複用器(selector)上,多路複用器輪詢到連線有 IO 請求就進行處理,JDK1。4 開始引入。

// NIO 服務端程式碼(沒有引入多路複用器的程式碼) import java。net。InetSocketAddress;import java。nio。ByteBuffer;import java。nio。channels。ServerSocketChannel;import java。nio。channels。SocketChannel;import java。util。ArrayList;import java。util。Iterator;import java。util。List; public class NioServer { static List channelList = new ArrayList<>(); public static void main(String[] args) throws Exception { // 建立NIO ServerSocketChannel serverSocket = ServerSocketChannel。open(); serverSocket。socket()。bind(new InetSocketAddress(9000)); // 設定非阻塞 serverSocket。configureBlocking(false); System。out。println(“服務啟動。。”); while (true) { // 非阻塞模式 accept 方法不會阻塞,否則會阻塞 // NIO 的非阻塞模式是由作業系統內部實現,底層呼叫了 Linux 核心的 accept 函式 SocketChannel socketChannel = serverSocket。accept(); if (socketChannel != null) { System。out。println(“連線成功”); // 設定 socketchannel 為非阻塞 socketChannel。configureBlocking(false); // 儲存客戶端連線到 list channelList。add(socketChannel); } // 遍歷連線讀資料 Iterator iterator = channelList。iterator(); while (iterator。hasNext()) { SocketChannel sc = iterator。next(); ByteBuffer byteBuffer = ByteBuffer。allocate(128); // 非阻塞模式 read 方式不會阻塞,否則會阻塞 int len = sc。read(byteBuffer); if (len > 0) { System。out。println(“接收到訊息:” + new String(byteBuffer。array())); } else if (len == -1) { // 如果客戶端斷開,把socket從集合中去掉 iterator。remove(); System。out。println(“客戶端斷開連線”); } } } }}

缺點:

如果連線數太多的話,會有大量的無效遍歷,假如有 10000 個連線,其中只有 1000個 連線有寫資料,但是由於其他 9000 個連線並沒有斷開看我們還是每次輪詢遍歷一萬次,其中有 十分之一的遍歷都是無效的,這顯然是一個非常浪費資源的做法。

// NIO 服務端程式碼(引入多路複用器的程式碼) 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。security。Key;import java。util。Iterator;import java。util。Set; public class NioSelectorServer { public static void main(String[] args) throws Exception { // 建立 ServerSocketChannle ServerSocketChannel serverSocket = ServerSocketChannel。open(); serverSocket。bind(new InetSocketAddress(9000)); // 設定 ServerSocketChannel 為非阻塞 serverSocket。configureBlocking(false); // 開啟 Selector 處理 channel,即建立 epoll Selector selector = Selector。open(); // 把 ServerSocketChannel 註冊 selector 上,並且 select 對客戶端 accept 連線操作感興趣 serverSocket。register(selector, SelectionKey。OP_ACCEPT); System。out。println(“服務啟動”); while (true) { // 阻塞等待需要處理的事件發生 selector。select(); // 獲取 selector 中註冊的全部事件的 SelectionKey 例項 Set selectionKeys = selector。selectedKeys(); Iterator iterator = selectionKeys。iterator(); // 遍歷 selectionKeys 對事件進行處理 while (iterator。hasNext()) { SelectionKey key = iterator。next(); // 如果是 accept 事件,則進行連接獲取和事件註冊 if (key。isAcceptable()) { ServerSocketChannel server = (ServerSocketChannel) key。channel(); SocketChannel socketChannel = server。accept(); socketChannel。configureBlocking(false); socketChannel。register(selector, SelectionKey。OP_READ); System。out。println(“客戶端連線成功”); } else if (key。isReadable()) { // 進行資料讀取 SocketChannel socketChannel = (SocketChannel) key。channel(); ByteBuffer byteBuffer = ByteBuffer。allocate(128); int len = socketChannel。read(byteBuffer); // 如果有資料,把資料打印出來 if (len > 0) { System。out。println(“接收到訊息:” + new String(byteBuffer。array())); } else if (len == -1) { // 如果客戶端斷開連線,關閉 Socket System。out。println(“客戶端斷開連線”); socketChannel。close(); } } // 從事件集合裡刪除本次處理的 key,防止下次 select 重複處理 iterator。remove(); } } }}

上面程式碼是利用 NIO 一個執行緒處理所有請求,這種單個執行緒處理的方式肯定是存在問題的,例如現在有 10w 個請求中,有 1w 個連線進行讀寫資料,那麼 SelectionKey 就會有 1w 個請求,所以我們需要迴圈這 1w 個事件進行處理,比較費時間,如果這個時候再有連線進來,只能阻塞。

NIO 有三大核心元件:

Channel(通道),Buffer(緩衝區)Selector(多路複用器)

1、channel 類似流,每個 channel 對應一個 buffer 緩衝區,buffer 底層是個陣列。

2、channel 會註冊到 selector上,由 selector 根據 channel 的讀寫事件發生將其交由某個空閒的執行緒處理。

3、NIO 的 Buffer 和 channel 都是既可以讀又可以寫的。

「後端」深入理解 Java 三種 IO 模式和 Epoll 模型

NIO 底層在 JDK1。4 版本是用

linux 的核心函式 select() 或 poll()

來實現,跟上面的 NioServer 程式碼類似,

selector 每次都會輪詢

所有的 socktchannel 看下哪個 channel 有讀寫事件,有的話就處理,沒有就繼續遍歷,JDK1。5 開始引入了 epoll 基於事件響應機制來最佳化 NIO。

舉個例子:例如我們去酒吧喝酒,在吧檯坐下了 20 個人,中間一個服務員,select() 或者 poll() 模式就是,服務員每次都是詢問這個 20 個人是否需要喝酒,而 epoll 模型則是,20 個人誰需要喝酒誰就舉手,服務員每次只處理舉手的那幾個人即可。

NioSelectorServer 程式碼裡如下幾個方法非常重要,我們從 Hotspot 與 Linux 核心函式級別來理解下。

Selector。open() // 建立多路複用器socketChannel。register(selector, SelectionKey。OP_READ) // 將 channel 註冊到多路複用器上selector。select() // 阻塞等待需要處理的事件發生

「後端」深入理解 Java 三種 IO 模式和 Epoll 模型

總結:

NIO 整個呼叫流程就是 Java 呼叫了作業系統的核心函式來建立 Socket,獲取 Socket 檔案描述符,再建立一個 Selector 物件,對應作業系統的 Epoll 描述符,將獲取到的 Socket 連線的檔案描述符的事件繫結到 Selector 對應的檔案描述符上,進行事件的非同步通知,這樣就實現了使用一條執行緒,並且不需要太多的無效遍歷,將事件處理交給了作業系統核心(作業系統的終端程式),大大提高了效率。

Epoll 函式詳解

int epoll_create(int size);

建立一個 epoll 例項,並返回一個非負數作為檔案描述符,用於對 epoll 介面的所有後續呼叫。引數 size 代表可能會容納 size 個描述符,但 size 不是一個最大值,只是提示作業系統它的數量級,現在這個引數基本上已經棄用了。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

使用檔案描述符 epfd 引用 epoll 例項,對目標檔案描述符 fs 執行 op 操作。

引數 epfd 表示 epoll 對應的檔案描述符,引數 fd 表示 socket 對應的檔案描述符。

引數 op 有以下幾個值:

EPOLL_CTL_ADD:註冊新的 fd 到 epfd 中,並關聯事件 event;

EPOLL_CTL_MOD:修改已經註冊的 fd 的監聽事件;

EPOLL_CTL_DEL:從 epfd 中移除 fd,並且忽略掉繫結的 event,這時 event 可以為 null;

引數event是一個結構體

struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; typedef union epoll_data { void *ptr; int fd; __uint32_t u32; __uint64_t u64; } epoll_data_t;

events 有很多可選值,這裡只舉例最常見的幾個:

EPOLLIN :表示對應的檔案描述符是可讀的;

EPOLLOUT:表示對應的檔案描述符是可寫的;

EPOLLERR:表示對應的檔案描述符發生了錯誤;

成功則返回 0,失敗返回 -1。

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

等待檔案描述符 epfd 上的事件。

epfd 就是 Epoll 對應的檔案描述符,events 表示呼叫者所有可用事件的集合,maxevents 表示最大等到多少個事件就返回,timeout 是超時時間。

I/O 多路複用底層主要用 Linux 核心函式(select 、poll、epoll)來實現。

「後端」深入理解 Java 三種 IO 模式和 Epoll 模型

AIO模型

非同步非阻塞

模型,由

作業系統完成後回撥通知

服務端程式啟動執行緒去處理,一般適用於

連線數比較多且連線時間比較長

的應用。

應用場景

AIO 方式適用於

連線數多且連線比較長

(重操作)的架構,

JDK1.7

開始支援。

// AIO 服務端程式碼 import java。io。IOException;import java。net。InetSocketAddress;import java。nio。ByteBuffer;import java。nio。channels。AsynchronousChannel;import java。nio。channels。AsynchronousServerSocketChannel;import java。nio。channels。AsynchronousSocketChannel;import java。nio。channels。CompletionHandler; public class AIOServer { public static void main(String[] args) throws Exception { final AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel。open()。bind(new InetSocketAddress(9000)); serverChannel。accept(null, new CompletionHandler() { @Override public void completed(AsynchronousSocketChannel socketChannel, Object attachment) { try { System。out。println(“2——” + Thread。currentThread()。getName()); // 再此接收客戶端連線,如果不寫這行程式碼後面的客戶端連線不上服務端 serverChannel。accept(attachment,this); System。out。print(socketChannel。getRemoteAddress()); ByteBuffer buffer = ByteBuffer。allocate(1024); socketChannel。read(buffer, buffer, new CompletionHandler() { @Override public void completed(Integer result, ByteBuffer buffer) { System。out。println(“3——” + Thread。currentThread()。getName()); buffer。flip(); System。out。println(new String(buffer。array(), 0, result)); socketChannel。write(ByteBuffer。wrap(“HelloClient”。getBytes())); } @Override public void failed(Throwable exc, ByteBuffer buffer) { exc。printStackTrace(); } }); } catch (IOException e) { e。printStackTrace(); } } @Override public void failed(Throwable exc, Object attachment) { } }); System。out。println(“1——” + Thread。currentThread()。getName()); Thread。sleep(Integer。MAX_VALUE); }}

// AIO 客戶端程式碼import java。net。InetSocketAddress;import java。nio。ByteBuffer;import java。nio。channels。AsynchronousSocketChannel; public class AIOClient { public static void main(String。。。 args) throws Exception { AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel。open(); socketChannel。connect(new InetSocketAddress(“127。0。0。1”, 9000))。get(); socketChannel。write(ByteBuffer。wrap(“HelloServer”。getBytes())); ByteBuffer buffer = ByteBuffer。allocate(512); Integer len = socketChannel。read(buffer)。get(); if (len != -1) { System。out。println(“客戶端收到資訊:” + new String(buffer。array(), 0, len)); } }}

為什麼 Netty 使用 NIO 而不是 AIO?

因為在 Linux 系統上,AIO 的底層實現扔使用 Epoll 模型,沒有很好的使用 AIO,因此在效能上沒有明顯的優勢,而且被 JDK 封裝了一層不容易再次進行深度最佳化,Linux 上 AIO 還不夠成熟。Netty 是非同步非阻塞框架,Netty在 NIO 上做了很多非同步封裝。

文章來源:阿里開發者_https://zhuanlan。zhihu。com/p/587641708