Java BIO NIO 與 AIO
回顧
上一章我們介紹了作業系統層面的 IO 模型。
- 阻塞 IO 模型。
- 非阻塞 IO 模型。
- IO 複用模型。
- 訊號驅動 IO 模型(用的不多,知道個概念就行)。
- 非同步 IO 模型。
並且介紹了 IO 多路複用的底層實現中,select,poll 和 epoll 的區別。
幾個概念
我們在這裡在強調一下幾個概念。
一個 IO 操作的具體步驟:
對於作業系統來說,程序是沒有直接操作硬體的許可權的,所以必須請求核心來幫忙完成。
- 等待資料準備好,對於一個套接字上得操作,這一步驟關係到資料從網路到達,並將其複製到核心某個緩衝區。
- 將資料從核心緩衝區複製到程序緩衝區。
同步和非同步的區別在於第二個步驟是否阻塞,如果從核心緩衝區複製到使用者緩衝區的過程阻塞,那麼就是同步 IO,否則就是非同步 IO。所以上面提到的前四種 IO 模型都是同步 IO,最後一種是非同步 IO。
阻塞和非阻塞的區別在於第一步,發起 IO 請求是否會被阻塞,如果阻塞直到完成那麼就是傳統的阻塞 IO,否則就是非阻塞 IO。所以上面提到的第一種 IO 模型是阻塞 IO,其餘的都是非阻塞 IO。
Java IO API
介紹完作業系統層面的 IO 模型,我們來看看,Java 提供的 IO 相關的 API。
Java 中提供三種 IO 操作的 API,阻塞 IO(BIO,同步阻塞),非阻塞 IO(NIO,同步非阻塞)和非同步 IO (AIO,非同步非阻塞)。
Java 中提供的 IO 有關的 API,在檔案處理的時候,其實是依賴作業系統層面的 IO 操作實現的。比如在 Linux 2.6 以後,Java 中的 NIO 和 AIO 都是通過 epoll(前面講過的,IO 多路複用) 來實現的。而在 windows 上,AIO 是通過 IOCP 來實現的。
可以把 Java 中的 BIO,NIO 和 AIO 理解為是 Java 語言對作業系統的各種 IO 模型的封裝。程式設計師在使用這些 API 的時候,不需要關心作業系統層面的知識,只需要使用 Java API 就可以了。
Java BIO NIO 與 AIO
BIO 就是傳統的 java.io 包,它是基於流模型實現的,互動方式是同步阻塞,也就是在讀取或者寫入輸入輸出流的時候,在讀寫動作完成之前,執行緒會一直阻塞在那裡。它的效率比較低,容易成為效能瓶頸。
- NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel,Buffer,Selector 等工具類,底層依賴與 IO 多路複用模型,基於 epoll 實現(根據作業系統來看)。同步非阻塞模式。
AIO 是 Java 1.7 引入的包,是 NIO 的升級版本,提供了非同步非阻塞的 IO 操作方式,所以人們叫它 AIO,非同步 IO 是基於事件回撥機制實現的,也就是應用操作之後會直接返回,不會阻塞在那裡,當後臺處理完成,作業系統會通知相應的執行緒進行後續操作。底層也是依賴於 IO 多路複用模型,基於 epoll 實現,非同步非阻塞模式。
從程式碼看 BIO NIO 於 AIO 的區別
傳統的 Socket 實現
//服務端 ServerSocket serverSocket = ...... serverSocket.bind(8899); while(true){ Socket sokcet = serverSocket.accept(); //阻塞方法 new Thread(socket); run(){ socket.getInputStream(); .... .... } } //客戶端 Socket socket = new Socket("localhost",8899); socket.connect(); 8899 是用於客戶端向服務端發起連線的埠號,並不是傳遞資料的埠號,服務端會根據每個連線也就是 Socket 選擇一個埠與客戶端進行通訊。
在 Java 中,執行緒的實現是比較重量級的,所以執行緒的啟動和銷燬是很消耗伺服器資源的,即使使用執行緒池來實現,使用上述傳統的 Socket 方式,當連線數急劇上升也會帶來效能瓶頸,原因是執行緒的上下文切換開銷會在高併發的時候體現的很明顯,並且以上方式是同步阻塞,效能問題在高併發的時候會體現的尤為明顯。
NIO 多路複用
Java new IO 底層是基於 IO 多路複用模型實現的。NIO 是利用了單執行緒輪訓事件的機制,通過高效地地位就緒的 Channel,來決定做什麼,僅僅 select 階段是阻塞的,可以避免大量的客戶端連線時,頻繁切換執行緒帶來的問題,應用的擴充套件能力有了非常大的提高。
// NIO 多路複用 ThreadPoolExecutor threadPool = new ThreadPoolExecutor(4, 4, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()); threadPool.execute(new Runnable() { @Override public void run() { try (Selector selector = Selector.open(); ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();) { serverSocketChannel.bind(new InetSocketAddress(InetAddress.getLocalHost(), port)); serverSocketChannel.configureBlocking(false); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); // 阻塞等待就緒的Channel Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); try (SocketChannel channel = ((ServerSocketChannel) key.channel()).accept()) { channel.write(Charset.defaultCharset().encode("你好,世界")); } iterator.remove(); } } } catch (IOException e) { e.printStackTrace(); } } }); // Socket 客戶端(接收資訊並列印) try (Socket cSocket = new Socket(InetAddress.getLocalHost(), port)) { BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(cSocket.getInputStream())); bufferedReader.lines().forEach(s -> System.out.println("NIO 客戶端:" + s)); } catch (IOException e) { e.printStackTrace(); }
- 通過 Selector.open() 建立一個 selector,作為類似排程員的角色。
- 建立一個 ServerSocketChannel,並且像 selector 註冊,通過指定 SelectionKey.OP_ACCEPT,告訴排程員,它關注的是新的連線請求。
- Selector 阻塞在 select 操作,當有 channel 發生接入請求,就會被喚醒。
AIO 版的 Socket 實現
// AIO執行緒複用版 Thread sThread = new Thread(new Runnable() { @Override public void run() { AsynchronousChannelGroup group = null; try { group = AsynchronousChannelGroup.withThreadPool(Executors.newFixedThreadPool(4)); AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(group).bind(new InetSocketAddress(InetAddress.getLocalHost(), port)); server.accept(null, new CompletionHandler<AsynchronousSocketChannel, AsynchronousServerSocketChannel>() { @Override public void completed(AsynchronousSocketChannel result, AsynchronousServerSocketChannel attachment) { server.accept(null, this); // 接收下一個請求 try { Future<Integer> f = result.write(Charset.defaultCharset().encode("你好,世界")); f.get(); System.out.println("服務端傳送時間:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())); result.close(); } catch (InterruptedException | ExecutionException | IOException e) { e.printStackTrace(); } } @Override public void failed(Throwable exc, AsynchronousServerSocketChannel attachment) { } }); group.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS); } catch (IOException | InterruptedException e) { e.printStackTrace(); } } }); sThread.start(); // Socket 客戶端 AsynchronousSocketChannel client = AsynchronousSocketChannel.open(); Future<Void> future = client.connect(new InetSocketAddress(InetAddress.getLocalHost(), port)); future.get(); ByteBuffer buffer = ByteBuffer.allocate(100); client.read(buffer, null, new CompletionHandler<Integer, Void>() { @Override public void completed(Integer result, Void attachment) { System.out.println("客戶端列印:" + new String(buffer.array())); } @Override public void failed(Throwable exc, Void attachment) { exc.printStackTrace(); try { client.close(); } catch (IOException e) { e.printStackTrace(); } } }); Thread.sleep(10 * 1000);
AIO 就是在 NIO 的基礎上提供了回撥函式。
NIO 中的重要概念
零拷貝
我們讀取磁碟檔案讀取到記憶體中,以流的形式傳送或者傳輸,這種形式我們使用的太多,太多了。我們可以 new InputStream 指向一個檔案,讀取完畢後在寫到目標中,這樣整個流程就結束了。
一個從磁碟檔案讀取並且通過socket寫出的過程,對應的系統呼叫如下:
File.read(file, buf, len);
Socket.send(socket, buf, len);
- 程式使用read()系統呼叫。系統由使用者態轉換為核心態(第一次上線文切換),磁碟中的資料有DMA(Direct Memory Access)的方式讀取到核心緩衝區(kernel buffer)。DMA過程中CPU不需要參與資料的讀寫,而是DMA處理器直接將硬碟資料通過匯流排傳輸到記憶體中。
- 由於應用程式無法讀取核心地址空間的資料,如果應用程式要操作這些資料,必須把這些內容從讀取緩衝區拷貝到使用者緩衝區。系統由核心態轉換為使用者態(第二次上下文切換),當程式要讀取的資料已經完成寫入核心緩衝區以後,程式會將資料由核心快取區,寫入使用者快取區,這個過程需要CPU參與資料的讀寫。
- 程式使用write()系統呼叫。系統由使用者態切換到核心態(第三次上下文切換),資料從使用者態緩衝區寫入到網路緩衝區(Socket Buffer),這個過程需要CPU參與資料的讀寫。
- 系統由核心態切換到使用者態(第四次上下文切換),網路緩衝區的資料通過DMA的方式傳輸到網絡卡的驅動(儲存緩衝區)中(protocol engine)
傳統的I/O方式會經過4次使用者態和核心態的切換(上下文切換),兩次CPU中記憶體中進行資料讀寫的過程。這種拷貝過程相對來說比較消耗資源。
在整個過程中,過程1和4是由DMA負責,並不會消耗CPU,只有過程2和3的拷貝需要CPU參與。
我們思考一個問題,如果在應用程式中,不需要操作內容,過程2和3就是多餘的,如果可以直接把核心態讀取快取衝區資料直接拷貝到套接字相關的快取區,是不是可以達到優化的目的?
在Java中,正好FileChannel的transferTo() 方法可以實現這個過程,該方法將資料從檔案通道傳輸到給定的可寫位元組通道, 上面的file.read()
和 socket.send()
呼叫動作可以替換為 transferTo()
呼叫。
public void transferTo(long position, long count, WritableByteChannel target);
在 UNIX 和各種 Linux 系統中,此呼叫被傳遞到 sendfile()
系統呼叫中,最終實現將資料從一個檔案描述符傳輸到了另一個檔案描述符。
NIO 的零拷貝依賴於作業系統的支援,我們來看看作業系統意義上的零拷貝的流程(沒有核心空間和使用者空間資料拷貝)。相比於傳統 IO,減少了兩次上下文切換和資料拷貝,從作業系統角度稱為零拷貝。如果熟悉 JVM 的同學應該知道,NIO 會使用一塊 JVM 之外的記憶體區域,直接在該區域進行操作。
這種方式的I/O原理就是將使用者緩衝區(user buffer)的記憶體地址和核心緩衝區(kernel buffer)的記憶體地址做一個對映,也就是說系統在使用者態可以直接讀取並操作核心空間的資料。
sendfile()系統呼叫也會引起使用者態到核心態的切換,與記憶體對映方式不同的是,使用者空間此時是無法看到或修改資料內容,也就是說這是一次完全意義上的資料傳輸過程。
從磁碟讀取到記憶體是DMA的方式,從核心讀緩衝區讀取到網路傳送緩衝區,依舊需要CPU參與拷貝,而從網路傳送緩衝區到網絡卡中的緩衝區依舊是DMA方式。
從上面我們可以看出,零拷貝的是指在操作過程中,CPU 不需要為資料在記憶體之間拷貝消耗資源,傳統的 IO 操作需用從使用者態轉為核心態,核心拿到資料後還需要由核心態轉為使用者態將資料拷貝到使用者空間,而零拷貝不需要將檔案拷貝到使用者空間,而直接在核心空間中傳輸到網路的方式。
核心空間操作檔案的過程對使用者來說是不透明的,使用者只能請求和接受結果,如果使用者想要參與這個過稱怎麼辦?這時候就需要一個記憶體對映檔案(將磁碟上的檔案對映到記憶體之中,修改記憶體就可以修改磁碟上的檔案),直接操作核心空間。
MappedByteBuffer,檔案在記憶體中的對映,Java 程式不用和磁碟打交道,應用程式只需要對記憶體進行操作,這塊記憶體是一個堆外記憶體。作業系統負責將我們對記憶體對映檔案的修改更新到磁碟。
Java 中的實現
File file = new File("test.zip");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
FileChannel fileChannel = raf.getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("", 1234));
// 直接使用了transferTo()進行通道間的資料傳輸
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
NIO 的零拷貝由 transferTo() 方法實現。transferTo() 方法將資料從 FileChannel 物件傳送到可寫的位元組通道(如Socket Channel等)。在內部實現中,由 native 方法 transferTo0() 來實現,它依賴底層作業系統的支援。在UNIX 和 Linux 系統中,呼叫這個方法將會引起 sendfile() 系統呼叫。
我們上面也說過,核心空間操作檔案的過程對使用者來說是不透明的,使用者只能請求和接受結果,如果使用者想要參與這個過稱怎麼辦?
File file = new File("test.zip");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
FileChannel fileChannel = raf.getChannel();
MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
首先,它的作用位置處於傳統IO(BIO)與零拷貝之間,為何這麼說?
- IO,可以把磁碟的檔案經過核心空間,讀到 JVM 空間(使用者空間),然後進行各種操作,最後再寫到磁碟或是傳送到網路,效率較慢但支援資料檔案操作。
- 零拷貝則是直接在核心空間完成檔案讀取並轉到磁碟(或傳送到網路)。由於它沒有讀取檔案資料到JVM這一環,因此程式無法操作該檔案資料,儘管效率很高!
MappedByteBuffer 使用的是 JVM 之外的一塊直接記憶體