11、BIO、NIO、AIO(1)

目錄

Java 提供了哪些 IO 方式? NIO 如何實現多路複用?

典型回答

考點分析

知識擴展

1.Java NIO 概覽

2.NIO 能解決什麼問題?


Java 提供了哪些 IO 方式? NIO 如何實現多路複用?

典型回答

Java IO 方式有很多種,基於不同的 IO 抽象模型和交互方式,可以進行簡單區分。

第一 BIO,傳統的 java.io 包,它基於流模型實現,提供了我們最熟知的一些 IO 功能,比如 File 抽象、輸入輸出流等。交互方式是同步、阻塞的方式,也就是說,在讀取輸入流或者寫入輸出流時,在讀、寫動作完成之前,線程會一直阻塞在那裏,它們之間的調用是可靠的線性順序

java.io 包的好處是代碼比較簡單、直觀,缺點則是 IO 效率和擴展性存在侷限性,容易成爲應用性能的瓶頸。

很多時候,人們也把 java.net 下面提供的部分網絡 API,比如 Socket、ServerSocket、HttpURLConnection 
也歸類到同步阻塞 IO 類庫,因爲網絡通信同樣是 IO 行爲。

第二 NIO,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以構建多路複用的、同步非阻塞 IO 程序,同時提供了更接近操作系統底層的高性能數據操作方式。

第三 AIO,在 Java 7 中,NIO 有了進一步的改進,也就是 NIO 2AIO),引入了異步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。異步 IO 操作基於事件和回調機制,可以簡單理解爲,應用操作直接返回,而不會阻塞在那裏,當後臺處理完成,操作系統會通知相應線程進行後續工作。

 

BIO:同步阻塞,從數據是否準備就緒到數據拷貝都是由用戶線程完成;

NIO:同步非阻塞,數據是否準備就緒由內核判斷,數據拷貝還是用戶線程完成;

AIO:異步非阻塞,數據是否準備就緒到數據拷貝都是內核來完成。

所以真正的異步IO一定是非阻塞的。

 

考點分析

我上面列出的回答是基於一種常見分類方式,即所謂的 BIO、NIO、NIO 2(AIO)。

在實際面試中,從傳統 IO 到 NIO、NIO 2,其中有很多地方可以擴展開來,考察點涉及方方面面,比如:

  •   基礎 API 功能與設計, InputStream/OutputStream 和 Reader/Writer 的關係和區別。
  •   NIO、NIO 2 的基本組成。
  •   給定場景,分別用不同模型實現,分析 BIO、NIO 等模式的設計和實現原理。
  •   NIO 提供的高性能數據操作方式是基於什麼原理,如何使用?
  •   或者,從開發者的角度來看,你覺得 NIO 自身實現存在哪些問題?有什麼改進的想法嗎?


IO 的內容比較多,專欄一講很難能夠說清楚。IO 不僅僅是多路複用,NIO 2 也不僅僅是異步 IO,尤其是數據操作部分,會在專欄下一講詳細分析。

 

知識擴展

首先,需要澄清一些基本概念:

  1. 區分同步或異步(synchronous/asynchronous)。簡單來說,同步是一種可靠的有序運行機制,當我們進行同步操作時,後續的任務是等待當前調用返回,纔會進行下一步;而異步則相反,其他任務不需要等待當前調用返回,通常依靠事件、回調等機制來實現任務間次序關係。
  2. 區分阻塞與非阻塞(blocking/non-blocking)。在進行阻塞操作時,當前線程會處於阻塞狀態,無法從事其他任務,只有當條件就緒才能繼續,比如 ServerSocket 新連接建立完畢,或數據讀取、寫入操作完成;而非阻塞則是不管 IO 操作是否結束,直接返回,相應操作在後臺繼續處理。

不能一概而論認爲同步或阻塞就是低效,具體還要看應用和系統特徵。

 

對於 java.io,我們都非常熟悉,我這裏就從總體上進行一下總結,如果需要學習更加具體的操作,你可以通過教程等途徑完成。總體上,我認爲你至少需要理解:

  • IO 不僅僅是對文件的操作,網絡編程中,比如 Socket 通信,都是典型的 IO 操作目標。 
  • 輸入流、輸出流(InputStream/OutputStream)是用於讀取或寫入字節的,例如操作圖片文件
  • Reader/Writer 則是用於操作字符,增加了字符編解碼等功能,適用於從文件中讀取或者寫入文本信息。本質上計算機操作的都是字節,不管是網絡通信還是文件讀取,Reader/Writer出相當於構建了應用邏輯和原始數據之間的橋樑。
  • BufferedOutputStream 等帶緩衝區的實現,可以避免頻繁的磁盤讀寫,進而提高 IO 處理效率。這種設計利用了緩衝區,將批量數據進行一次操作,但在使用中千萬別忘了 flush。
  • 參考下面這張類圖,很多 IO 工具類都實現了 Closeable 接口,因爲需要進行資源的釋放。比如,打開 FileInputStream,它就會獲取相應的文件描述符(FileDescriptor),需要利用 try-with-resources、 try-finally 等機制保證 FileInputStream 被明確關閉,進而相應文件描述符也會失效,否則將導致資源無法被釋放。利用專欄前面的內容提到的 Cleaner 或 finalize 機制作爲資源釋放的最後把關,也是必要的。


下面是我整理的一個簡化版的類圖,闡述了日常開發應用較多的類型和結構關係。

 

1.Java NIO 概覽

首先,熟悉一下 NIO 的主要組成部分:

  • Buffer,高效的數據容器,除了布爾類型,所有原始數據類型都有相應的 Buffer 實現。
  • Channel,類似在 Linux 之類操作系統上看到的文件描述符,是 NIO 中被用來支持批量式 IO 操作的一種抽象。 

       File 或者 Socket,通常被認爲是比較高層次的抽象,而 Channel 則是更加操作系統底層的一種抽象,這也使得 NIO  得以充分利用現代操作系統底層機制,獲得特定場景的性能優化,例如,DMA(Direct Memory  Access)等。不同層次的抽象是相互關聯的,我們可以通過 Socket 獲取 Channel,反之亦然。

 

  • Selector,是 NIO 實現多路複用的基礎,它提供了一種高效的機制,可以檢測到註冊在 Selector 上的多個 Channel 中,是否有 Channel 處於就緒狀態,進而實現了單線程對多 Channel 的高效管理。

       Selector 同樣是基於底層操作系統機制,不同模式、不同版本都存在區別,例如,在最新的代碼庫裏,相關實現如下:

       Linux 上依賴於epoll

(http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java)。 

       Windows 上 NIO2(AIO)模式則是依賴於 iocp

    (http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/windows/classes/sun/nio/ch/Iocp.java)。

 

  • Chartset,提供 Unicode 字符串定義,NIO 也提供了相應的編解碼器等,例如,通過下面的方式進行字符串到 ByteBuffer 的轉換:
Charset.defaultCharset().encode("Hello world!"));

 

2.NIO 能解決什麼問題?

下面我通過一個典型場景,來分析爲什麼需要 NIO,爲什麼需要多路複用。設想,我們需要實現一個服務器應用,只簡單要求能夠同時服務多個客戶端請求即可。

使用 java.io 和 java.net 中的同步、阻塞式 API,可以簡單實現。

public class DemoServer extends Thread {
    private ServerSocket serverSocket;
    public int getPort() {
        return  serverSocket.getLocalPort();
    }
    public void run() {
        try {
            serverSocket = new ServerSocket(0);
            while (true) {
                Socket socket = serverSocket.accept();
                RequestHandler requestHandler = new RequestHandler(socket);
                requestHandler.start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (serverSocket != null) {
                try {
                    serverSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                ;
            }
        }
    }
    public static void main(String[] args) throws IOException {
        DemoServer server = new DemoServer();
        server.start();
        try (Socket client = new Socket(InetAddress.getLocalHost(), server.getPort())) {
            BufferedReader bufferedReader = new BufferedReader(new                   InputStreamReader(client.getInputStream()));
            bufferedReader.lines().forEach(s -> System.out.println(s));
        }
    }
 }
// 簡化實現,不做讀取,直接發送字符串
class RequestHandler extends Thread {
    private Socket socket;
    RequestHandler(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run() {
        try (PrintWriter out = new PrintWriter(socket.getOutputStream());) {
            out.println("Hello world!");
            out.flush();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
 }

其實現要點是:

  •   服務器端啓動 ServerSocket,端口 0 表示自動綁定一個空閒端口。
  •   調用 accept 方法,阻塞等待客戶端連接。
  •   利用 Socket 模擬了一個簡單的客戶端,只進行連接、讀取、打印。
  •   當連接建立後,啓動一個單獨線程負責回覆客戶端請求。

這樣,一個簡單的 Socket 服務器就被實現出來了。

 

思考一下,這個解決方案在擴展性方面,可能存在什麼潛在問題呢?

大家知道 Java 語言目前的線程實現是比較重量級的,啓動或者銷燬一個線程是有明顯開銷的,每個線程都有單獨的線程棧等結構,需要佔用非常明顯的內存,所以,每一個 Client 啓動一個線程似乎都有些浪費。

那麼,稍微修正一下這個問題,我們引入線程池機制來避免浪費。

serverSocket = new ServerSocket(0);
executor = Executors.newFixedThreadPool(8);
 while (true) {
    Socket socket = serverSocket.accept();
    RequestHandler requestHandler = new RequestHandler(socket);
    executor.execute(requestHandler);
}

這樣做似乎好了很多,通過一個固定大小的線程池,來負責管理工作線程,避免頻繁創建、銷燬線程的開銷,這是我們構建併發服務的典型方式。這種工作方式,可以參考下圖來理解。

如果連接數並不是非常多,只有最多幾百個連接的普通應用,這種模式往往可以工作的很好。但是,如果連接數量急劇上升,這種實現方式就無法很好地工作了,因爲線程上下文切換開銷會在高併發時變得很明顯,這是同步阻塞方式的低擴展性劣勢。

NIO 引入的多路複用機制,提供了另外一種思路,請參考我下面提供的新的版本。

public class NIOServer extends Thread {
    public void run() {
        try (Selector selector = Selector.open();
            // 創建 Selector 和 Channel
            ServerSocketChannel serverSocket = ServerSocketChannel.open();) {
            serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
            serverSocket.configureBlocking(false);
            // 註冊到 Selector,並說明關注點
            serverSocket.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                selector.select();// 阻塞等待就緒的 Channel,這是關鍵點之一
                Set<SelectionKey> selectedKeys = selector.selectedKeys();
                Iterator<SelectionKey> iter = selectedKeys.iterator();
                while (iter.hasNext()) {
                    SelectionKey key = iter.next();
                   // 生產系統中一般會額外進行就緒狀態檢查
                    sayHelloWorld((ServerSocketChannel) key.channel());
                    iter.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private void sayHelloWorld(ServerSocketChannel server) throws IOException {
        try (SocketChannel client = server.accept();) {     
                     client.write(Charset.defaultCharset().encode("Hello world!"));
        }
    }
   // 省略了與前面類似的 main
}

這個非常精簡的樣例掀開了 NIO 多路複用的面紗,我們可以分析下主要步驟和元素:

  •   首先,通過 Selector.open() 創建一個 Selector,作爲類似調度員的角色。
  •   然後,創建一個 ServerSocketChannel,並且向 Selector 註冊,通過指定  SelectionKey.OP_ACCEPT,告訴調度員,它關注的是新的連接請求。

        注意,爲什麼我們要明確配置非阻塞模式呢?這是因爲阻塞模式下,註冊操作是不允許的,會拋出

        IllegalBlockingModeException 異常。

  •   Selector 阻塞在 select 操作,當有 Channel 發生接入請求,就會被喚醒。
  •   在 sayHelloWorld 方法中,通過 SocketChannel 和 Buffer 進行數據操作,在本例中是發送了一段字符串。


可以看到,在前面兩個樣例中,IO 都是同步阻塞模式,所以需要多線程以實現多任務處理。而 NIO 則是利用了單線程輪詢事件的機制,通過高效地定位就緒的 Channel,來決定做什麼,僅僅 select 階段是阻塞的,可以有效避免大量客戶端連接時,頻繁線程切換帶來的問題,應用的擴展能力有了非常大的提高。下面這張圖對這種實現思路進行了形象地說明。

在 Java 7 引入的 NIO 2 中,又增添了一種額外的異步 IO 模式,利用事件和回調,處理 Accept、Read 等操作。 AIO 
實現看起來是類似這樣子:

AsynchronousServerSocketChannel serverSock = AsynchronousServerSocketChannel.open().bind(sockAddr);
serverSock.accept(serverSock, new CompletionHandler<>() { // 爲異步操作指定 CompletionHandler 回調函數
    @Override
    public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) {
        serverSock.accept(serverSock, this);
        // 另外一個 write(sock,CompletionHandler{})
        sayHelloWorld(sockChannel, Charset.defaultCharset().encode
                ("Hello World!"));
    }
  // 省略其他路徑處理方法...
});

鑑於其編程要素(如 Future、CompletionHandler 等),我們還沒有進行準備工作,爲避免理解困難,我會在專欄後面相關概念補充後的再進行介紹,尤其是 Reactor、Proactor 模式等方面將在 Netty 主題一起分析,這裏我先進行概念性的對比:

  •   基本抽象很相似,AsynchronousServerSocketChannel 對應於上面例子中的 ServerSocketChannel;AsynchronousSocketChannel 則對應 SocketChannel。
  •   業務邏輯的關鍵在於,通過指定 CompletionHandler 回調接口,在 accept/read/write 等關鍵節點,通過事件機制調用,這是非常不同的一種編程思路。


今天我初步對 Java 提供的 IO 機制進行了介紹,概要地分析了傳統同步 IO 和 NIO 的主要組成,並根據典型場景,通過不同的 IO 
模式進行了實現與拆解。

發佈了96 篇原創文章 · 獲贊 19 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章