網(wǎng)站建設(shè)環(huán)境分析百度推廣一般多少錢
第11講 | Java提供了哪些IO方式? NIO如何實(shí)現(xiàn)多路復(fù)用?
IO 一直是軟件開發(fā)中的核心部分之一,伴隨著海量數(shù)據(jù)增長(zhǎng)和分布式系統(tǒng)的發(fā)展,IO 擴(kuò)展能力愈發(fā)重要。幸運(yùn)的是,Java 平臺(tái) IO 機(jī)制經(jīng)過不斷完善,雖然在某些方面仍有不足,但已經(jīng)在實(shí)踐中證明了其構(gòu)建高擴(kuò)展性應(yīng)用的能力。
今天我要問你的問題是,Java 提供了哪些 IO 方式? NIO 如何實(shí)現(xiàn)多路復(fù)用?
典型回答
Java IO 方式有很多種,基于不同的 IO 抽象模型和交互方式,可以進(jìn)行簡(jiǎn)單區(qū)分。
第一,傳統(tǒng)的 java.io 包,它基于流模型實(shí)現(xiàn),提供了我們最熟知的一些 IO 功能,比如 File 抽象、輸入輸出流等。交互方式是同步、阻塞的方式,也就是說,在讀取輸入流或者寫入輸出流時(shí),在讀、寫動(dòng)作完成之前,線程會(huì)一直阻塞在那里,它們之間的調(diào)用是可靠的線性順序。
java.io 包的好處是代碼比較簡(jiǎn)單、直觀,缺點(diǎn)則是 IO 效率和擴(kuò)展性存在局限性,容易成為應(yīng)用性能的瓶頸。
很多時(shí)候,人們也把 java.net 下面提供的部分網(wǎng)絡(luò) API,比如 Socket、ServerSocket、HttpURLConnection 也歸類到同步阻塞 IO 類庫(kù),因?yàn)榫W(wǎng)絡(luò)通信同樣是 IO 行為。
第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以構(gòu)建多路復(fù)用的、同步非阻塞 IO 程序,同時(shí)提供了更接近操作系統(tǒng)底層的高性能數(shù)據(jù)操作方式。
第三,在 Java 7 中,NIO 有了進(jìn)一步的改進(jìn),也就是 NIO 2,引入了異步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。異步 IO 操作基于事件和回調(diào)機(jī)制,可以簡(jiǎn)單理解為,應(yīng)用操作直接返回,而不會(huì)阻塞在那里,當(dāng)后臺(tái)處理完成,操作系統(tǒng)會(huì)通知相應(yīng)線程進(jìn)行后續(xù)工作。
考點(diǎn)分析
我上面列出的回答是基于一種常見分類方式,即所謂的 BIO、NIO、NIO 2(AIO)。
在實(shí)際面試中,從傳統(tǒng) IO 到 NIO、NIO 2,其中有很多地方可以擴(kuò)展開來,考察點(diǎn)涉及方方面面,比如:
基礎(chǔ) API 功能與設(shè)計(jì), InputStream/OutputStream 和 Reader/Writer 的關(guān)系和區(qū)別。
NIO、NIO 2 的基本組成。
給定場(chǎng)景,分別用不同模型實(shí)現(xiàn),分析 BIO、NIO 等模式的設(shè)計(jì)和實(shí)現(xiàn)原理。
NIO 提供的高性能數(shù)據(jù)操作方式是基于什么原理,如何使用?
或者,從開發(fā)者的角度來看,你覺得 NIO 自身實(shí)現(xiàn)存在哪些問題?有什么改進(jìn)的想法嗎?
IO 的內(nèi)容比較多,專欄一講很難能夠說清楚。IO 不僅僅是多路復(fù)用,NIO 2 也不僅僅是異步 IO,尤其是數(shù)據(jù)操作部分,會(huì)在專欄下一講詳細(xì)分析。
知識(shí)擴(kuò)展
首先,需要澄清一些基本概念:
區(qū)分同步或異步(synchronous/asynchronous)。簡(jiǎn)單來說,同步是一種可靠的有序運(yùn)行機(jī)制,當(dāng)我們進(jìn)行同步操作時(shí),后續(xù)的任務(wù)是等待當(dāng)前調(diào)用返回,才會(huì)進(jìn)行下一步;而異步則相反,其他任務(wù)不需要等待當(dāng)前調(diào)用返回,通常依靠事件、回調(diào)等機(jī)制來實(shí)現(xiàn)任務(wù)間次序關(guān)系。
區(qū)分阻塞與非阻塞(blocking/non-blocking)。在進(jìn)行阻塞操作時(shí),當(dāng)前線程會(huì)處于阻塞狀態(tài),無(wú)法從事其他任務(wù),只有當(dāng)條件就緒才能繼續(xù),比如 ServerSocket 新連接建立完畢,或數(shù)據(jù)讀取、寫入操作完成;而非阻塞則是不管 IO 操作是否結(jié)束,直接返回,相應(yīng)操作在后臺(tái)繼續(xù)處理。
不能一概而論認(rèn)為同步或阻塞就是低效,具體還要看應(yīng)用和系統(tǒng)特征。
對(duì)于 java.io,我們都非常熟悉,我這里就從總體上進(jìn)行一下總結(jié),如果需要學(xué)習(xí)更加具體的操作,你可以通過教程等途徑完成。總體上,我認(rèn)為你至少需要理解一下內(nèi)容。
IO 不僅僅是對(duì)文件的操作,網(wǎng)絡(luò)編程中,比如 Socket 通信,都是典型的 IO 操作目標(biāo)。
輸入流、輸出流(InputStream/OutputStream)是用于讀取或?qū)懭胱止?jié)的,例如操作圖片文件。
而 Reader/Writer 則是用于操作字符,增加了字符編解碼等功能,適用于類似從文件中讀取或者寫入文本信息。本質(zhì)上計(jì)算機(jī)操作的都是字節(jié),不管是網(wǎng)絡(luò)通信還是文件讀取,Reader/Writer 相當(dāng)于構(gòu)建了應(yīng)用邏輯和原始數(shù)據(jù)之間的橋梁。
BufferedOutputStream 等帶緩沖區(qū)的實(shí)現(xiàn),可以避免頻繁的磁盤讀寫,進(jìn)而提高 IO 處理效率。這種設(shè)計(jì)利用了緩沖區(qū),將批量數(shù)據(jù)進(jìn)行一次操作,但在使用中千萬(wàn)別忘了 flush。
參考下面這張類圖,很多 IO 工具類都實(shí)現(xiàn)了 Closeable 接口,因?yàn)樾枰M(jìn)行資源的釋放。比如,打開 FileInputStream,它就會(huì)獲取相應(yīng)的文件描述符(FileDescriptor),需要利用 try-with-resources、 try-finally 等機(jī)制保證 FileInputStream 被明確關(guān)閉,進(jìn)而相應(yīng)文件描述符也會(huì)失效,否則將導(dǎo)致資源無(wú)法被釋放。利用專欄前面的內(nèi)容提到的 Cleaner 或 finalize 機(jī)制作為資源釋放的最后把關(guān),也是必要的。
下面是我整理的一個(gè)簡(jiǎn)化版的類圖,闡述了日常開發(fā)應(yīng)用較多的類型和結(jié)構(gòu)關(guān)系。
-
Java NIO 概覽
首先,熟悉一下 NIO 的主要組成部分:
Buffer,高效的數(shù)據(jù)容器,除了布爾類型,所有原始數(shù)據(jù)類型都有相應(yīng)的 Buffer 實(shí)現(xiàn)。
Channel,類似在 Linux 之類操作系統(tǒng)上看到的文件描述符,是 NIO 中被用來支持批量式 IO 操作的一種抽象。
File 或者 Socket,通常被認(rèn)為是比較高層次的抽象,而 Channel 則是更加操作系統(tǒng)底層的一種抽象,這也使得 NIO 得以充分利用現(xiàn)代操作系統(tǒng)底層機(jī)制,獲得特定場(chǎng)景的性能優(yōu)化,例如,DMA(Direct Memory Access)等。不同層次的抽象是相互關(guān)聯(lián)的,我們可以通過 Socket 獲取 Channel,反之亦然。
Selector,是 NIO 實(shí)現(xiàn)多路復(fù)用的基礎(chǔ),它提供了一種高效的機(jī)制,可以檢測(cè)到注冊(cè)在 Selector 上的多個(gè) Channel 中,是否有 Channel 處于就緒狀態(tài),進(jìn)而實(shí)現(xiàn)了單線程對(duì)多 Channel 的高效管理。Selector 同樣是基于底層操作系統(tǒng)機(jī)制,不同模式、不同版本都存在區(qū)別,例如,在最新的代碼庫(kù)里,相關(guān)實(shí)現(xiàn)如下:
Charset,提供 Unicode 字符串定義,NIO 也提供了相應(yīng)的編解碼器等,例如,通過下面的方式進(jìn)行字符串到 ByteBuffer 的轉(zhuǎn)換:
Charset.defaultCharset().encode("Hello world!"));
-
NIO 能解決什么問題?
下面我通過一個(gè)典型場(chǎng)景,來分析為什么需要 NIO,為什么需要多路復(fù)用。設(shè)想,我們需要實(shí)現(xiàn)一個(gè)服務(wù)器應(yīng)用,只簡(jiǎn)單要求能夠同時(shí)服務(wù)多個(gè)客戶端請(qǐng)求即可。
使用 java.io 和 java.net 中的同步、阻塞式 API,可以簡(jiǎn)單實(shí)現(xiàn)。
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));}}} // 簡(jiǎn)化實(shí)現(xiàn),不做讀取,直接發(fā)送字符串 class RequestHandler extends Thread {private Socket socket;RequestHandler(Socket socket) {this.socket = socket;}@Overridepublic void run() {try (PrintWriter out = new PrintWriter(socket.getOutputStream());) {out.println("Hello world!");out.flush();} catch (Exception e) {e.printStackTrace();}}}
其實(shí)現(xiàn)要點(diǎn)是:
服務(wù)器端啟動(dòng) ServerSocket,端口 0 表示自動(dòng)綁定一個(gè)空閑端口。
調(diào)用 accept 方法,阻塞等待客戶端連接。
利用 Socket 模擬了一個(gè)簡(jiǎn)單的客戶端,只進(jìn)行連接、讀取、打印。
當(dāng)連接建立后,啟動(dòng)一個(gè)單獨(dú)線程負(fù)責(zé)回復(fù)客戶端請(qǐng)求。
這樣,一個(gè)簡(jiǎn)單的 Socket 服務(wù)器就被實(shí)現(xiàn)出來了。
思考一下,這個(gè)解決方案在擴(kuò)展性方面,可能存在什么潛在問題呢?
大家知道 Java 語(yǔ)言目前的線程實(shí)現(xiàn)是比較重量級(jí)的,啟動(dòng)或者銷毀一個(gè)線程是有明顯開銷的,每個(gè)線程都有單獨(dú)的線程棧等結(jié)構(gòu),需要占用非常明顯的內(nèi)存,所以,每一個(gè) Client 啟動(dòng)一個(gè)線程似乎都有些浪費(fèi)。
那么,稍微修正一下這個(gè)問題,我們引入線程池機(jī)制來避免浪費(fèi)。
serverSocket = new ServerSocket(0);
executor = Executors.newFixedThreadPool(8);while (true) {Socket socket = serverSocket.accept();RequestHandler requestHandler = new RequestHandler(socket);executor.execute(requestHandler);
}
這樣做似乎好了很多,通過一個(gè)固定大小的線程池,來負(fù)責(zé)管理工作線程,避免頻繁創(chuàng)建、銷毀線程的開銷,這是我們構(gòu)建并發(fā)服務(wù)的典型方式。這種工作方式,可以參考下圖來理解。
如果連接數(shù)并不是非常多,只有最多幾百個(gè)連接的普通應(yīng)用,這種模式往往可以工作的很好。但是,如果連接數(shù)量急劇上升,這種實(shí)現(xiàn)方式就無(wú)法很好地工作了,因?yàn)榫€程上下文切換開銷會(huì)在高并發(fā)時(shí)變得很明顯,這是同步阻塞方式的低擴(kuò)展性劣勢(shì)。
NIO 引入的多路復(fù)用機(jī)制,提供了另外一種思路,請(qǐng)參考我下面提供的新的版本。
public class NIOServer extends Thread {public void run() {try (Selector selector = Selector.open();ServerSocketChannel serverSocket = ServerSocketChannel.open();) {// 創(chuàng)建Selector和ChannelserverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));serverSocket.configureBlocking(false);// 注冊(cè)到Selector,并說明關(guān)注點(diǎn)serverSocket.register(selector, SelectionKey.OP_ACCEPT);while (true) {selector.select();// 阻塞等待就緒的Channel,這是關(guān)鍵點(diǎn)之一Set<SelectionKey> selectedKeys = selector.selectedKeys();Iterator<SelectionKey> iter = selectedKeys.iterator();while (iter.hasNext()) {SelectionKey key = iter.next();// 生產(chǎn)系統(tǒng)中一般會(huì)額外進(jìn)行就緒狀態(tài)檢查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
}
這個(gè)非常精簡(jiǎn)的樣例掀開了 NIO 多路復(fù)用的面紗,我們可以分析下主要步驟和元素:
首先,通過 Selector.open() 創(chuàng)建一個(gè) Selector,作為類似調(diào)度員的角色。
然后,創(chuàng)建一個(gè) ServerSocketChannel,并且向 Selector 注冊(cè),通過指定 SelectionKey.OP_ACCEPT,告訴調(diào)度員,它關(guān)注的是新的連接請(qǐng)求。注意,為什么我們要明確配置非阻塞模式呢?這是因?yàn)樽枞J较?#xff0c;注冊(cè)操作是不允許的,會(huì)拋出 IllegalBlockingModeException 異常。
Selector 阻塞在 select 操作,當(dāng)有 Channel 發(fā)生接入請(qǐng)求,就會(huì)被喚醒。
在 sayHelloWorld 方法中,通過 SocketChannel 和 Buffer 進(jìn)行數(shù)據(jù)操作,在本例中是發(fā)送了一段字符串。
可以看到,在前面兩個(gè)樣例中,IO 都是同步阻塞模式,所以需要多線程以實(shí)現(xiàn)多任務(wù)處理。而 NIO 則是利用了單線程輪詢事件的機(jī)制,通過高效地定位就緒的 Channel,來決定做什么,僅僅 select 階段是阻塞的,可以有效避免大量客戶端連接時(shí),頻繁線程切換帶來的問題,應(yīng)用的擴(kuò)展能力有了非常大的提高。下面這張圖對(duì)這種實(shí)現(xiàn)思路進(jìn)行了形象地說明。
在 Java 7 引入的 NIO 2 中,又增添了一種額外的異步 IO 模式,利用事件和回調(diào),處理 Accept、Read 等操作。 AIO 實(shí)現(xiàn)看起來是類似這樣子:
AsynchronousServerSocketChannel serverSock = AsynchronousServerSocketChannel.open().bind(sockAddr);
serverSock.accept(serverSock, new CompletionHandler<>() { //為異步操作指定CompletionHandler回調(diào)函數(shù)@Overridepublic void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) {serverSock.accept(serverSock, this);// 另外一個(gè) write(sock,CompletionHandler{})sayHelloWorld(sockChannel, Charset.defaultCharset().encode("Hello World!"));}// 省略其他路徑處理方法...
});
鑒于其編程要素(如 Future、CompletionHandler 等),我們還沒有進(jìn)行準(zhǔn)備工作,為避免理解困難,我會(huì)在專欄后面相關(guān)概念補(bǔ)充后的再進(jìn)行介紹,尤其是 Reactor、Proactor 模式等方面將在 Netty 主題一起分析,這里我先進(jìn)行概念性的對(duì)比:
基本抽象很相似,AsynchronousServerSocketChannel 對(duì)應(yīng)于上面例子中的 ServerSocketChannel;AsynchronousSocketChannel 則對(duì)應(yīng) SocketChannel。
業(yè)務(wù)邏輯的關(guān)鍵在于,通過指定 CompletionHandler 回調(diào)接口,在 accept/read/write 等關(guān)鍵節(jié)點(diǎn),通過事件機(jī)制調(diào)用,這是非常不同的一種編程思路。
今天我初步對(duì) Java 提供的 IO 機(jī)制進(jìn)行了介紹,概要地分析了傳統(tǒng)同步 IO 和 NIO 的主要組成,并根據(jù)典型場(chǎng)景,通過不同的 IO 模式進(jìn)行了實(shí)現(xiàn)與拆解。專欄下一講,我還將繼續(xù)分析 Java IO 的主題。
一課一練
關(guān)于今天我們討論的題目你做到心中有數(shù)了嗎?留一道思考題給你,NIO 多路復(fù)用的局限性是什么呢?你遇到過相關(guān)的問題嗎?
請(qǐng)你在留言區(qū)寫寫你對(duì)這個(gè)問題的思考,我會(huì)選出經(jīng)過認(rèn)真思考的留言,送給你一份學(xué)習(xí)鼓勵(lì)金,歡迎你與我一起討論。
。
業(yè)務(wù)邏輯的關(guān)鍵在于,通過指定 CompletionHandler 回調(diào)接口,在 accept/read/write 等關(guān)鍵節(jié)點(diǎn),通過事件機(jī)制調(diào)用,這是非常不同的一種編程思路。
今天我初步對(duì) Java 提供的 IO 機(jī)制進(jìn)行了介紹,概要地分析了傳統(tǒng)同步 IO 和 NIO 的主要組成,并根據(jù)典型場(chǎng)景,通過不同的 IO 模式進(jìn)行了實(shí)現(xiàn)與拆解。專欄下一講,我還將繼續(xù)分析 Java IO 的主題。
一課一練
關(guān)于今天我們討論的題目你做到心中有數(shù)了嗎?留一道思考題給你,NIO 多路復(fù)用的局限性是什么呢?你遇到過相關(guān)的問題嗎?
請(qǐng)你在留言區(qū)寫寫你對(duì)這個(gè)問題的思考,我會(huì)選出經(jīng)過認(rèn)真思考的留言,送給你一份學(xué)習(xí)鼓勵(lì)金,歡迎你與我一起討論。
你的朋友是不是也在準(zhǔn)備面試呢?你可以“請(qǐng)朋友讀”,把今天的題目分享給好友,或許你能幫到他。