NIO2.0時代
1. 變更通知(因為每個事件都需要一個監聽者)
對NIO和NIO.2有興趣的開發者的共同關注點在于JAVA應用的性能。根據我的經驗,NIO.2里的文件變更通知者(file change notifier)是新輸入/輸出API里最讓人感興趣(被低估了)的特性。
很多企業級應用需要在下面的情況時做一些特殊的處理:
- 當一個文件上傳到一個FTP文件夾里時
- 當一個配置里的定義被修改時
- 當一個草稿文檔被上傳時
- 其他的文件系統事件出現時
這些都是變更通知或者變更響應的例子。在Java(以及其他語言)的早期版本里,輪詢(polling)是檢測這些變更事件的最好方式。輪詢是一種特殊的無限循環:檢查文件系統或者其他對象,并且和之前的狀態對比,如果沒有變化,在大概幾百個毫秒或者10秒的間隔后,繼續檢查。就這一直無限循環下去。
NIO.2提供了一個更好地方式來進行變更檢測。列表1是一個簡單的示例。
列表1. NIO.2里的變更通知機制
public class Watcher { public static void main(String[] args) { Path this_dir = Paths.get("."); System.out.println("Now watching the current directory ..."); try { WatchService watcher = this_dir.getFileSystem().newWatchService(); this_dir.register(watcher, StandardWatchEventKinds.ENTRY_CREATE); WatchKey watckKey = watcher.take(); List<WatchEvent<<64;>> events = watckKey.pollEvents(); for (WatchEvent event : events) { System.out.println("Someone just created the file '" + event.context().toString() + "'."); } } catch (Exception e) { System.out.println("Error: " + e.toString()); } } }
編譯這段代碼,然后在命令行里執行。在相同的目錄下,創建一個新的文件,例如運行touch example或者copy Watcher.class example命令。你會看到下面的變更通知消息:
Someone just create the fiel ‘example1′.
這個簡單的示例展示了怎么開始使用Java NIO的功能。同時,它也介紹了NIO.2的Watcher類,它相比較原始的I/O中的輪詢方案而言,顯得更加直接和易用。
注意拼寫錯誤
當你從這篇文章里拷貝代碼時,注意拼寫錯誤。例如,列表1種的StandardWatchEventKinds 對象是復數的形式。即使在Java.net的文檔里都把它給拼寫錯了。
小技巧
NIO里的通知機制比老的輪詢方式使用起來更加簡單,這樣會誘導你忽略對具體需求的詳細分析。當你在你第一次使用一個監聽器的時候,你需要仔細考慮你所使用的這些概念的語義。例如,知道一個變更什么時候會結束比知道它什么時候開始更加重要。這種分析需要非常仔細,尤其是像移動FTP文件夾這種常見的場景。NIO是一個功能非常強大的包,但同時它還會有一些微妙的“陷阱”,這會給那些不熟悉它的人帶來困擾。
2. 選擇器和異步IO:通過選擇器來提高多路復用
NIO新手一般都把它和“非阻塞輸入/輸出”聯系在一起。NIO不僅僅只是非阻塞I/O,不過這種認知也不完全是錯的:Java的基本I/O是阻塞式I/O——意味著它會一直等待到操作完成——然而,非阻塞或者異步I/O是NIO里最常用的一個特點,而非NIO的全部。
NIO的非阻塞I/O是事件驅動的,并且在列表1里文件系統監聽示例里進行了展示。這就意味著給一個I/O通道定義一個選擇器(回調或者監聽器),然后程序可以繼續運行。當一個事件發生在這個選擇器上時——例如接收到一行輸入——選擇器會“醒來”并且執行。所有的這些都是通過一個單線程來實現的,這和Java的標準I/O有著顯著的差別的。
列表2里展示了使用NIO的選擇器實現的一個多端口的網絡程序echo-er,這里是修改了Greg Travis在2003年創建的一個小程序(參考資源列表)。Unix和類Unix系統很早就已經實現高效的選擇器,它是Java網絡高性能編程模型的一個很好的參考模型。
列表2. NIO選擇器
public class MultiPortEcho { private int ports[]; private ByteBuffer echoBuffer = ByteBuffer.allocate( 1024 ); public MultiPortEcho( int ports[] ) throws IOException { this.ports = ports; configure_selector(); } private void configure_selector() throws IOException { // Create a new selector Selector selector = Selector.open(); // Open a listener on each port, and register each one // with the selector for (int i=0; i<ports.length; ++i) { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); ServerSocket ss = ssc.socket(); InetSocketAddress address = new InetSocketAddress(ports[i]); ss.bind(address); SelectionKey key = ssc.register(selector, SelectionKey.OP_ACCEPT); System.out.println("Going to listen on " + ports[i]); } while (true) { int num = selector.select(); Set selectedKeys = selector.selectedKeys(); Iterator it = selectedKeys.iterator(); while (it.hasNext()) { SelectionKey key = (SelectionKey) it.next(); if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) { // Accept the new connection ServerSocketChannel ssc = (ServerSocketChannel)key.channel(); SocketChannel sc = ssc.accept(); sc.configureBlocking(false); // Add the new connection to the selector SelectionKey newKey = sc.register(selector, SelectionKey.OP_READ); it.remove(); System.out.println( "Got connection from "+sc ); } else if ((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) { // Read the data SocketChannel sc = (SocketChannel)key.channel(); // Echo data int bytesEchoed = 0; while (true) { echoBuffer.clear(); int number_of_bytes = sc.read(echoBuffer); if (number_of_bytes <= 0) { break; } echoBuffer.flip(); sc.write(echoBuffer); bytesEchoed += number_of_bytes; } System.out.println("Echoed " + bytesEchoed + " from " + sc); it.remove(); } } } } }
編譯這段代碼,然后通過類似于java MultiPortEcho 8005 8006這樣的命令來啟動它。一旦這個程序運行成功,啟動一個簡單的telnet或者其他的終端模擬器來連接8005和8006接口。你會看到這個程序會回顯它接收到的所有字符——并且它是通過一個Java線程來實現的。
java 11的新 Selector 回調方式
public int select(Consumer<SelectionKey> action, long timeout) throws IOException { if (timeout < 0) throw new IllegalArgumentException("Negative timeout"); return doSelect(Objects.requireNonNull(action), timeout); }
public int select(Consumer<SelectionKey> action) throws IOException { return select(action, 0); }
public int selectNow(Consumer<SelectionKey> action) throws IOException { return doSelect(Objects.requireNonNull(action), -1); }
java 11新的 SelectionKey
ops為感興趣的事件
public int interestOpsOr(int ops) { synchronized (this) { int oldVal = interestOps(); interestOps(oldVal | ops); return oldVal; } }
ops為感興趣的事件
public int interestOpsAnd(int ops) { synchronized (this) { int oldVal = interestOps(); interestOps(oldVal & ops); return oldVal; } }
3. 通道:承諾與現實
在NIO里,一個通道(channel)可以表示任何可以讀寫的對象。它的作用是為文件和套接口提供抽象。NIO通道支持一系列一致的方法,這樣就使得編碼的時候不需要去特別關心不同的對象,無論它是標準輸出,網絡連接還是正在使用的通道。通道的這個特性是繼承自Java基本I/O中的流(stream)。流(stream)提供了阻塞式的IO;通道支持異步I/O。
NIO經常會因為它的性能高而被推薦,不過更準確地是因為它的響應快速。在有些場景下NIO會比基本的Java I/O的性能要差。例如,對于一個小文件的簡單的順序讀寫,簡單通過流來實現的性能可能比對應的面向事件的基于通道的編碼實現的快兩到三倍。同時,非多路復用(non-multiplex)的通道——也就是每個線程一個單獨的通道——要比多個通道把各自的選擇器注冊在同一個線程里要慢多了。
下面你在考慮是使用流還是通道的時候,試著問自己下面幾個問題:
- 你需要讀寫多少個I/O對象?
- 不同的I/O對象直接是否有有順序,還是他們都需要同時發生的?
- 你的I/O對象是需要持續一小段時間還是在你的進程的整個聲明周期都存在?
- 你的I/O是適合在單個線程里處理還是在幾個不同的線程里?
- 網絡通信和本地I/O是看起來一樣,還是各自有著不同的模式?
這樣的分析是決定使用流還是通道的一個最佳實踐。記住:NIO和NIO.2不是基本I/O的替代,而它的一個補充。
4. 內存映射——好鋼用在刀刃上
NIO里對性能提升最顯著的是內存映射(memory mApping)。內存映射是一個系統層面的服務,它把程序里用到的文件的一段當作內存來處理。
內存映射存在很多潛在的影響,比我這里提供的要多。在一個更高的層次上,它能夠使得文件訪問的I/O的性能達到內存訪問的速度。內存訪問的速度往往比文件訪問的速度快幾個數量級。列表3是一個NIO內存映射的一個簡單示例。
列表3. NIO里的內存映射
public class mem_map_example { private static int mem_map_size = 20 * 1024 * 1024; private static String fn = "example_memory_mapped_file.txt"; public static void main(String[] args) throws Exception { RandomaccessFile memoryMappedFile = new RandomAccessFile(fn, "rw"); //Mapping a file into memory MappedByteBuffer out = memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, mem_map_size); //Writing into Memory Mapped File for (int i = 0; i < mem_map_size; i++) { out.put((byte) 'A'); } System.out.println("File '" + fn + "' is now " + Integer.toString(mem_map_size) + " bytes full."); // Read from memory-mapped file. for (int i = 0; i < 30 ; i++) { System.out.print((char) out.get(i)); } System.out.println("nReading from memory-mapped file '" + fn + "' is complete."); } }
在列表3中,這個簡單的示例創建了一個20M的文件example_memory_mapped_file.txt,并且用字符A對它進行填充,然后讀取前30個字節。在實際的應用中,內存映射不僅僅擅長提高I/O的原始速度,同時它也允許多個不同的reader和writer同時處理同一個文件鏡像。這個技術功能強大但是也很危險,不過如果正確使用的話,它會使得你的IO速度提高數倍。眾所周知,華爾街的交易操作為了能夠贏得秒級甚至是毫秒級的優勢,都使用了內存映射技術。
5. 字符編碼和搜索
我在這篇文章里要講解的NIO的最后一個特性是charset,一個用來轉換不同字符編碼的包。在NIO之前,Java通過getByte方法內置實現了大部分相同的功能。charset很受歡迎,因為它比getBytes更加靈活,并且能夠在更底層去實現,這樣就能夠獲得更好的性能。這個對于搜索那些對于編碼、順序以及其他語言特點比較敏感的非英語語言而言更加有價值。
列表4展示了一個把Java里的Unicode字符轉換成Latin-1的示例
列表4. NIO里的字符
String some_string = "This is a string that Java natively stores as Unicode."; Charset latin1_charset = Charset.forName("ISO-8859-1"); CharsetEncode latin1_encoder = charset.newEncoder(); ByteBuffer latin1_bbuf = latin1_encoder.encode(CharBuffer.wrap(some_string));
注意Charset和通道被設計成能夠放在一起進行使用,這樣就能夠使得程序在內存映射、異步I/O以及編碼轉換進行協作的時候,能夠正常運行。
總結:當然還有更多需要去了解
這篇文章的目的是為了讓Java開發者能夠熟悉NIO和NIO.2里的一些最主要(也是最有用)的功能。你可以通過這些示例建立起來的一些基礎來理解NIO的一些其他方法;例如,你所學習的關于通道的知識能夠幫助你去理解NIO的Path里對于文件系統里的符號鏈接的處理。你也可以參考一下我后面給出的資源列表,里面給出了一些深入學習Java新I/O API的文檔。