在上篇《Java IO(2)阻塞式輸入輸出(BIO)》的末尾談到了什么是阻塞式輸入輸出,通過Socket編程對其有了大致了解。現在再重新回顧梳理一下,對於只有一個“客戶端”和一個“服務器端”來講,服務器端需要阻塞式接收客戶端的請求,這里的阻塞式表示服務器端的應用代碼會被掛起直到客戶端有請求過來,在高並發的應用場景有多個客戶端發起連接下非阻塞式IO(NIO)是不二之選(且只需要在服務器端使用1個線程來管理,並不需要多個線程來處理多個連接)。在現實情況下,Tomcat、Jetty等很多Web服務器均使用了NIO技術。
接下來對於非阻塞式輸入輸出(NIO)的學習以及理解首先從它的三個基礎概念講起。
Channel(通道)
在NIO中,你需要忘掉“流”這個概念,取而代之的是“通道”。舉例在網絡應用程序中有多個客戶端連接,此時數據傳輸的概念並不是“流”而“通道”,通道與流最大的不同就是,通道是雙向的,而流是單向的(例如InputStream、OutputStream)。
Buffer(緩沖區)
在NIO中並不是簡單的將流的概念替換為了通道,與通道搭配的是緩沖區。在BIO的字節流中並不會使用到緩沖區,而是直接操作文件通過字節方式直接讀取,而NIO則不同,它會將通道中的數據讀入緩存區,或者將緩存區的數據寫入通道。
Selector(選擇器)
如果使用NIO的應用程序中只有一個Channel,選擇器則是可以不需要的,而如果有多個Channel,換言之有多個連接時,此時通過選擇器,在服務器端的應用程序中就只需要1個線程對多個連接進行管理。
當然從最開始就說到Channel是雙向的,所以在最終圖的示例為下圖所示:
下面再重新回到這三個概念,詳細解釋它們是如何協同工作的。
Channel & Buffer
通常情況下Channel會和Buffer配合使用,但可以不使用Channel。首先需要明確的是,應用程序不管是從文件(包括網絡或者其他什么地方)中讀取數據,還是寫入數據到文件(包括網絡或者其他什么地方)都需要Buffer。
1. 直接將數據寫入Buffer,應用程序從Buffer中獲取數據
1 ByteBuffer buffer = ByteBuffer.allocate(1024); 2 byte b = 121; 3 buffer.put(b); 4 buffer.flip(); //讀寫轉換,由“寫模式”轉換為“讀模式” 5 System.out.println((char)buffer.get());
第1行,分配一個1KB大小的Buffer緩沖區,ByteBuffer.allcoate返回HeapByteBuffer實例。
第3行,向Buffer中寫入一個字節。
第4行,Buffer由“寫模式”轉換為“讀模式”。
第5行,ByteBuffer.get方法讀取Buffer中的數據,並且position索引+1。 在上面的代碼中有一個重點——flip方法,這個方法的存在是由於Buffer兼顧了讀和寫的操作,在ByteBuffer的實現中有三個重要的成員變量需要注意: capacity——Buffer容量 position——索引位置 limit——讀時表示最大容量,即limit = capacity;寫時表示最后一個數據所在的索引位置。 用圖例來說明上面代碼的執行過程。
從上圖可以清晰的看到Buffer內部是如何進行讀寫操作的,其中調用flip方法是很關鍵且重要的一個步驟,試想如果不調用flip進行讀寫轉換,此時position、limit、capacity的索引位置將會如下圖所示。
此時進行讀的操作將會得到一個錯誤數據(0)。 盡管在講這個小標題“直接將數據寫入Buffer,應用程序從Buffer中獲取數據”,但實際上已經簡要介紹了Buffer的內部實現原理。
通過上面的例子可以看到,Channel和Buffer並不一定要在一起,單獨使用Buffer也是可以的,但要使用Chnnel那就必須得配合Buffer。
2. 從文件中讀取數據寫入Buffer,應用程序從Buffer中獲取數據
此時的數據來源是文件,開頭提過在NIO中忘掉“流”,記住“通道”。在NIO中可以通過傳統的流獲取通道。例如從輸入流FileInputSteram中調用getChannel,或者從輸出流FileOutputStream中調用getChannel,當然還有兼顧輸入和輸出的RandomAccessFile類從中調用getChannel。
BIO中首先獲取流,NIO中首先獲取通道。
1 RandomAccessFile file = new RandomAccessFile("/Users/yulinfeng/Documents/Coding/Idea/simplenio/src/main/java/com/demo/test.json", "rw"); 2 FileChannel channel = file.getChannel(); 3 ByteBuffer buffer = ByteBuffer.allocate(1024); 4 channel.read(buffer); 5 buffer.flip(); 6 System.out.println(new String(buffer.array()));
看到這段NIO讀取文件數據的代碼,心中默寫傳統的BIO是如何讀取文件數據的。

1 InputStream in = new FileInputStream("/Users/yulinfeng/Documents/Coding/Idea/simplenio/src/main/java/com/demo/test.json"); 2 byte[] bytes = new byte[1024]; 3 in.read(bytes); 4 System.out.println(new String(bytes));
展開代碼可以看到,基本上如出一轍,在NIO中就是多了Buffer這個媒介來讀取數據。
回到NIO讀取文件數據的代碼。 第1行,獲取文件流。 第2行,獲取Channel通道。 第3-6行,創建Buffer緩沖區,並將數據讀取從通道讀取到緩沖區。 同樣還是用圖例來說明上面代碼的執行過程。
最后調用ByteBuffer.array方法返回緩沖區中的值,此時並未移動position的數組下標。這個例子結合圖例我相信能很清楚地看到NIO是如何從文件中讀取數據的,下面這個例子將輸出數據到文件。
3. 從應用程序中將數據輸出到文件中
前面都是應用程序從Buffer中獲取數據並且用圖例的方式了解了它的內部運行原理。本例將把數據通過Buffer寫到文件中,當然得記住還需要通過Channel才能寫入文件。
1 RandomAccessFile file = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\out\\test.json", "rw"); 2 FileChannel channel = file.getChannel(); 3 ByteBuffer buffer = Charset.forName("utf-8").encode("{\"name\": \"Kevin\"}"); //這里會自動進行讀寫轉換,第1個例子需要手動調用flip方法進行讀寫模式的轉換
通過上面的例子很容易想到,首先需要通道,那么就利用可讀可寫的RandomAccessFile獲取通道;其次需要緩沖區;最后將緩沖區的數據寫入到通道中即可。這段代碼其實可以把重點放到是如何從緩沖區寫到管道的。
第1-2行,通過可讀可寫的RandomAccessFile類獲取Channel通道。(要是只需要寫文件,也可以通過FileOutputStream.getChannel獲得)
第3行,將字符串{“name”: “Kevin”}通過UTF-8編碼寫入Buffer緩沖區,NIO會對自動對其進行讀寫模式的轉換,不需要手動調用flip方法。
第4行,將Buffer中的數據寫入通道。
4. 從一個文件讀數據,再寫到另一個文件
NIO不易掌握,需要反復練習,所以本文會給出多個例子反復操練並領會NIO的設計哲學。
這個例子有兩種實現方式,第一種基於上面的例子就能拼湊出來,第二種則需要掌握一個新的API——transferFrom / transferTo
4.1通過上面的知識讀文件再寫文件
1 RandomAccessFile readFile = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\inout\\from.json", "rw"); 2 FileChannel readChannel = readFile.getChannel(); 3 ByteBuffer buffer = ByteBuffer.allocate(1024); 4 readChannel.read(buffer); 5 buffer.flip(); //讀寫轉換 6 RandomAccessFile writeFile = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\inout\\to.json", "rw"); 7 FileChannel writeChannel = writeFile.getChannel(); 8 writeChannel.write(buffer);
經過上面的幾個例子寫出這個示例應該沒什么問題,需要注意的是第x行的buffer.flip方法是讀寫轉換,這在上面有提到過。
4.2 通過新的API——transferFrom讀文件並寫文件
1 RandomAccessFile fromFile = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\inout\\from.json", "rw"); 2 FileChannel fromChannel = fromFile.getChannel(); 3 RandomAccessFile toFile = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\inout\\to.json", "rw"); 4 FileChannel toChannel = toFile.getChannel(); 5 6 toChannel.transferFrom(fromChannel, 0, fromChannel.size());
通過transferFrom就能將一個通道直接輸出到另一個通道而不需要緩沖區做中轉。
5. Socket網絡應用程序是如何使用NIO的
前面的例子全是有關本地文件的讀寫操作,在一個應用程序中有可能免不了通過網絡來傳輸數據,傳統的Socket編程利用的是BIO,也就是阻塞式輸入輸出。而NIO同樣也可應用到Socket網絡編程中。下面兩個例子均是1個客戶端對應1個服務器端。此時並不能很好的體會BIO和NIO的區別,若多個客戶端對應1個服務器端,此時NIO的優點便很快顯現,不過要實現多個客戶端對應1個服務器端則需要Selector(選擇器),由於現在還並未詳細認識它所以將“多個客戶端對應1個服務器端”放置在后面提及。
5.1 阻塞式網絡編程(BIO Socket)
BIO Socket是我取的名字,意思是利用傳統的阻塞式IO來進行Socket編程,本文雖主講NIO,但也需要了解並熟練掌握BIO。故,在此先使用傳統的IO來進行Socket編程以便能對下文的NIO Socket有一個類比。在本例中使用UDP協議傳輸數據。
1 /** 2 * BIO客戶端 3 * Created by Kevin on 2017/12/18. 4 */ 5 public class Client { 6 public static void main(String[] args) throws Exception{ 7 String data = "this is Client."; 8 DatagramSocket socket = new DatagramSocket(); 9 DatagramPacket packet = new DatagramPacket(data.getBytes(), data.getBytes().length, InetAddress.getByName("127.0.0.1"), 8989); 10 socket.send(packet); 11 } 12 }
1 /** 2 * 服務器端 3 * BIO Created by Kevin on 2017/12/18. 4 */ 5 public class Server { 6 public static void main(String[] args) throws Exception{ 7 DatagramSocket socket = new DatagramSocket(8989); 8 byte[] data = new byte[1024]; 9 DatagramPacket packet = new DatagramPacket(data, data.length); 10 socket.receive(packet); //服務器端在未收到數據時,會在此處被阻塞掛起 11 System.out.println(new String(packet.getData())); 12 } 13 }
這是我們比較熟悉的Socket編程,其中有特點的就是在服務器端的第x行代碼,此處若未收到來自客戶端的數據,服務器端將會被阻塞。
5.2 非阻塞式網絡編程(NIO Socket)
在通常情況下,對於網絡編程用的比較多的還是阻塞式。非阻塞式在應用程序中並不是特別常見,但它在Tomcat等Web服務器中卻很常見。這是因為對於非阻塞式的網絡編程其最大的優點或者說是最大的使用場景就是面對多個客戶端時良好的性能表現。
此處我們還是在單一的客戶端場景下使用非阻塞式網絡編程(多個客戶端就會使用到Selector選擇器,下文會展開)。同樣在本例中使用UDP協議傳輸數據。
1 /** 2 * NIO客戶端 3 * Created by Kevin on 2017/12/18. 4 */ 5 public class Client { 6 public static void main(String[] args) throws Exception{ 7 DatagramChannel channel = DatagramChannel.open(); //類似讀取本地文件,首先都需要建立一個通道 8 ByteBuffer buffer = Charset.forName("utf-8").encode("this is Client."); //其次建立一個緩沖區 9 channel.send(buffer, new InetSocketAddress("127.0.0.1", 8989)); 10 } 11 }
1 /** 2 *NIO 服務器端 3 * Created by Kevin on 2017/12/18. 4 */ 5 public class Server { 6 public static void main(String[] args) throws Exception{ 7 DatagramChannel channel = DatagramChannel.open(); 8 channel.socket().bind(new InetSocketAddress("127.0.0.1", 8989)); 9 ByteBuffer buffer = ByteBuffer.allocate(1024); 10 channel.receive(buffer); //服務器端沒有收到來自客戶端的數據,會在這里和BIO Socket一樣被阻塞 11 System.out.println(new String(buffer.array())); 12 } 13 }
對於NIO Socket的服務器端第10行可能會感到疑惑,既然是非阻塞的那么為什么在這個地方還是被阻塞了呢?在未收到客戶端的數據時為什么還是被阻塞掛起了呢?這就需要用開頭提到的這是1個客戶端對應1個服務器端的場景,BIO和NIO並無明顯區別,對於BIO或許更有優勢,因為它的API相對來說更簡單一些。而如果是多個客戶端,如果使用NIO,服務器端會利用Selector(選擇器)來選擇准備好了的數據,而不會想此例一樣一直等待一個客戶端傳輸數據。接下來就是對Selector選擇器的進一步認識。
Selector
看到這里對於NIO似乎還只有一個認識,API變得負責了,莫名其妙地從“流”的概念轉換為了“通道”“+“緩沖區”,並且似乎和BIO並無多大區別。要我說,最大的區別和改進莫過於徹底理解NIO中的Selector(選擇器)。 在《Java IO(2)阻塞式輸入輸出(BIO)》一文的末尾提到了在服務器端利用線程來處理數據以便使得程序能擁有更大的吞吐量,這種利用新開一個線程來處理接收到的數據不失為一種常用的計策。但是,在程序中,我個人認為還是要謹慎使用多線程,畢竟線程的上下文切換是有一定的開銷的,況且線程如果過多還有可能造成Java虛擬機的棧溢出。Selector選擇器的出現就可以使用1個線程來管理。
上面的示例程序都只有一個通道,也就是說同時只會讀取或寫入一個文件,如果現在有多個客戶端,此時也就有多個通道,Selector選擇器將會選擇已經准備好了的通道讀取數據。
要使用Selector選擇器,免不了大致會經過以下幾個流程:創建Selector選擇器;將Channel通道修改為非阻塞模式(只有Socket才能修改為非阻塞模式,FileChannel不能修改),並將通道注冊至Selector;Selector調用select方法對通道進行選擇。
1 /** 2 * NIO 客戶端,此處只有一個客戶端連接 3 * Created by Kevin on 2017/12/24. 4 */ 5 public class Client { 6 public static void main(String[] args) throws Exception{ 7 DatagramChannel channel = DatagramChannel.open(); 8 ByteBuffer buffer = Charset.forName("utf-8").encode("this is Client."); 9 channel.send(buffer, new InetSocketAddress("127.0.0.1", 8989)); 10 } 11 }
如上注釋所說,此處的示例仍然是只有一個客戶端連接,對於服務器端的連接下面將會使用Selector選擇器,重要部分在注釋中已說明。
1 /** 2 * NIO 服務器端 3 * Created by Kevin on 2017/12/23. 4 */ 5 public class Server { 6 public static void main(String[] args) throws Exception{ 7 Selector selector = Selector.open(); //Selector選擇器 8 DatagramChannel channel = DatagramChannel.open(); //Channel通道 9 channel.configureBlocking(false); 10 channel.bind(new InetSocketAddress("127.0.0.1", 8989)); 11 channel.register(selector, SelectionKey.OP_READ); //此通道注冊在Selector時關注是否可讀 12 while (true) { 13 selector.select(); //如果沒有一個注冊到此Selector上的通道就緒,則阻塞;反之,只要有一個通道就緒則不會被阻塞。selectNow方法不論是否有通道就緒,都不會阻塞。 14 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); //選擇就緒的通道 15 while (iterator.hasNext()) { 16 SelectionKey key = iterator.next(); 17 iterator.remove(); 18 if (key.isReadable()) { //收到客戶端數據 19 receive(key); 20 } 21 if (key.isWritable()) { //服務器端通道准備好向客戶端發送數據 22 send(key); 23 } 24 } 25 } 26 } 27 28 /** 29 * 服務器端收到客戶端數據,並做處理 30 * @param key 31 */ 32 private static void receive(SelectionKey key) throws Exception{ 33 DatagramChannel channel = (DatagramChannel) key.channel(); 34 ByteBuffer byteBuffer = ByteBuffer.allocate(1024); 35 channel.receive(byteBuffer); 36 System.out.println(new String(byteBuffer.array())); 37 } 38 /** 39 * 服務器端通道已准備好向客戶端發送數據 40 * @param key 41 */ 42 private static void send(SelectionKey key) { 43 44 } 45 }
對於使用Selector選擇器,可以使得服務器端只使用1個線程來管理多個連接,盡管在上面的例子沒有給出示例代碼,但這種場景在Web應用中可以說是必然的,因為對於客戶端(瀏覽器)一定是很多的,而服務器就只有一個,此時正是NIO場景的最大使用,當然上面的例子也可以看到JDK原生NIO編程相比於BIO是略微有點復雜的,市面上也有很多優秀的第三方NIO框架——Netty、Mina均是對NIO的再次封裝,這在以后也會提到,此篇關於NIO的了解暫到此處,以后將會在對此有更深刻的理解時再次講解。下篇將介紹——AIO(異步輸入輸出)。
這是一個能給程序員加buff的公眾號