Java BIO NIO 與 AIO

回顧

上一章咱們介紹了操做系統層面的 IO 模型。java

  • 阻塞 IO 模型。
  • 非阻塞 IO 模型。
  • IO 複用模型。
  • 信號驅動 IO 模型(用的很少,知道個概念就行)。
  • 異步 IO 模型。

而且介紹了 IO 多路複用的底層實現中,select,poll 和 epoll 的區別。程序員

幾個概念

咱們在這裏在強調一下幾個概念。windows

一個 IO 操做的具體步驟:api

對於操做系統來講,進程是沒有直接操做硬件的權限的,因此必須請求內核來幫忙完成。緩存

  • 等待數據準備好,對於一個套接字上得操做,這一步驟關係到數據從網絡到達,並將其複製到內核某個緩衝區。
  • 將數據從內核緩衝區複製到進程緩衝區。

同步和異步的區別在於第二個步驟是否阻塞,若是從內核緩衝區複製到用戶緩衝區的過程阻塞,那麼就是同步 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,異步非阻塞)。app

Java 中提供的 IO 有關的 API,在文件處理的時候,實際上是依賴操做系統層面的 IO 操做實現的。好比在 Linux 2.6 之後,Java 中的 NIO 和 AIO 都是經過 epoll(前面講過的,IO 多路複用) 來實現的。而在 windows 上,AIO 是經過 IOCP 來實現的。dom

能夠把 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 以外的一塊直接內存。

相關文章
相關標籤/搜索