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

  1. BIO 就是傳統的 java.io 包,它是基於流模型實現的,交互方式是同步阻塞,也就是在讀取或者寫入輸入輸出流的時候,在讀寫動作完成之前,線程會一直阻塞在那里。它的效率比較低,容易成為性能瓶頸。

  2. NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel,Buffer,Selector 等工具類,底層依賴與 IO 多路復用模型,基於 epoll 實現(根據操作系統來看)。同步非阻塞模式。

  3. 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();
    }
    
    1. 通過 Selector.open() 創建一個 selector,作為類似調度員的角色。
    2. 創建一個 ServerSocketChannel,並且像 selector 注冊,通過指定 SelectionKey.OP_ACCEPT,告訴調度員,它關注的是新的連接請求。
    3. 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);

  1. 程序使用read()系統調用。系統由用戶態轉換為內核態(第一次上線文切換),磁盤中的數據有DMA(Direct Memory Access)的方式讀取到內核緩沖區(kernel buffer)。DMA過程中CPU不需要參與數據的讀寫,而是DMA處理器直接將硬盤數據通過總線傳輸到內存中。
  2. 由於應用程序無法讀取內核地址空間的數據,如果應用程序要操作這些數據,必須把這些內容從讀取緩沖區拷貝到用戶緩沖區。系統由內核態轉換為用戶態(第二次上下文切換),當程序要讀取的數據已經完成寫入內核緩沖區以后,程序會將數據由內核緩存區,寫入用戶緩存區,這個過程需要CPU參與數據的讀寫。
  3. 程序使用write()系統調用。系統由用戶態切換到內核態(第三次上下文切換),數據從用戶態緩沖區寫入到網絡緩沖區(Socket Buffer),這個過程需要CPU參與數據的讀寫。
  4. 系統由內核態切換到用戶態(第四次上下文切換),網絡緩沖區的數據通過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)的內存地址做一個映射,也就是說系統在用戶態可以直接讀取並操作內核空間的數據。

  1. sendfile()系統調用也會引起用戶態到內核態的切換,與內存映射方式不同的是,用戶空間此時是無法看到或修改數據內容,也就是說這是一次完全意義上的數據傳輸過程。

  2. 從磁盤讀取到內存是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 之外的一塊直接內存。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM