IO 一直是軟件開發中的核心部分之一,伴隨著海量數據增長和分布式系統的發展,IO 擴展能力愈發重要。幸運的是,Java 平臺 IO 機制經過不斷完善,雖然在某些方面仍有不足,但已經在實踐中證明了其構建高擴展性應用的能力。
今天我要問你的問題是,Java 提供了哪些 IO 方式? NIO 如何實現多路復用?
## 典型回答
Java IO 方式有很多種,基于不同的 IO 抽象模型和交互方式,可以進行簡單區分。
首先,傳統的 java.io 包,它基于流模型實現,提供了我們最熟知的一些 IO 功能,比如 File 抽象、輸入輸出流等。交互方式是同步、阻塞的方式,也就是說,在讀取輸入流或者寫入輸出流時,在讀、寫動作完成之前,線程會一直阻塞在那里,它們之間的調用是可靠的線性順序。
java.io 包的好處是代碼比較簡單、直觀,缺點則是 IO 效率和擴展性存在局限性,容易成為應用性能的瓶頸。
很多時候,人們也把 java.net 下面提供的部分網絡 API,比如 Socket、ServerSocket、HttpURLConnection 也歸類到同步阻塞 IO 類庫,因為網絡通信同樣是 IO 行為。
第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以構建多路復用的、同步非阻塞 IO 程序,同時提供了更接近操作系統底層的高性能數據操作方式。
第三,在 Java 7 中,NIO 有了進一步的改進,也就是 NIO 2,引入了異步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。異步 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,尤其是數據操作部分,會在專欄下一講詳細分析。
## 知識擴展
首先,需要澄清一些基本概念:
* 區分同步或異步(synchronous/asynchronous)。簡單來說,同步是一種可靠的有序運行機制,當我們進行同步操作時,后續的任務是等待當前調用返回,才會進行下一步;而異步則相反,其他任務不需要等待當前調用返回,通常依靠事件、回調等機制來實現任務間次序關系。
* 區分阻塞與非阻塞(blocking/non-blocking)。在進行阻塞操作時,當前線程會處于阻塞狀態,無法從事其他任務,只有當條件就緒才能繼續,比如 ServerSocket 新連接建立完畢,或數據讀取、寫入操作完成;而非阻塞則是不管 IO 操作是否結束,直接返回,相應操作在后臺繼續處理。
不能一概而論認為同步或阻塞就是低效,具體還要看應用和系統特征。
[對于 java.io](http://xn--java-ut5ft42e.io),我們都非常熟悉,我這里就從總體上進行一下總結,如果需要學習更加具體的操作,你可以通過[教程](https://docs.oracle.com/javase/tutorial/essential/io/streams.html)等途徑完成。總體上,我認為你至少需要理解:
* 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](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();
ServerSocketChannel serverSocket = ServerSocketChannel.open();) {// 創建 Selector 和 Channel
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 模式進行了實現與拆解。專欄下一講,我還將繼續分析 Java IO 的主題。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?留一道思考題給你,NIO 多路復用的局限性是什么呢?你遇到過相關的問題嗎?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?