Java IO流深入理解

阻塞(Block)和非阻塞(Non-Block)

阻塞和非阻塞是進程在訪問數據的時候,數據是否準備就緒的一種處理方式,當數據沒有準備的時候。

**阻塞:**往往需要等待緩沖區中的數據準備好過後才處理其他的事情,否者一直等待在那裡

**非阻塞:**當我們進程訪問我們的數據緩沖區的時候,如果數據沒有準備好則直接返回,不會等待。如果數據已經準備好,也直接返回。

同步(Synchronization)和異步(Asynchronous)

同步和異步都是基於應用程序和操作系統處理IO事件所采用的方式。比如同步:是應用程序要直接參與IO讀寫的操作。異步:所有的IO讀寫交給操作系統去處理,應用程序隻需要等待通知。

同步方式在處理IO事件的時候,必須阻塞在某個方法上面等待我們的IO事件完成(阻塞IO事件或者通過輪詢IO事件的方式),對於異步來說,所有的IO讀寫都交給瞭操作系統。這個時候,我們可以去做其他的事情,並不需要去完成真正的IO操作,當操作完成iO後,會給我們的應用程序一個通知。

**同步:**阻塞到IO事件,阻塞到read或者write。這個時候我們就完全不能做自己的事情。讓讀寫方法加入到線程裡面,然後阻塞線程來實現,對線程的性能開銷比較大。

BIO與NIO對比

IO模型 BIO NIO
通信 面向流(鄉村公路) 面向緩沖(高速公路,多路復用技術)
處理 阻塞IO(多線程) 非阻塞IO(反應堆Reactor)
觸發 選擇器 輪詢機制

面向流與面向緩沖

java NIO和BIO之間第一個最大的區別是,BIO是面向流的,NIO是面向緩沖的。Java BIO面向流意味著每次從流中讀一個或多個字節,直至讀取所有的字節,它們沒有被緩存在任何地方。此外,它不能前後移動流中的數據。如果需要前後移動從流中讀取的數據,需要先將它緩存到一個緩沖區。Java NIO的緩沖導向方法略有不同。數據讀取到一個它稍後處理的緩沖區,需要時可在緩沖區前後移動。這就增加瞭處理過程中的靈活性。但是,還需要檢查是否緩沖區包含瞭所有您需要處理的數據。而且,需確保當更多的數據讀入緩沖區時,不要覆蓋緩沖區裡尚未處理的數據。

阻塞與非阻塞

Java BIO的各種流是阻塞的。這意味著,當一個線程調用read()和write()時,該線程被阻塞,直到有一些數據被讀取,或數據完全寫入。該線程在此期間不能再幹任何事情瞭。java NIO的非阻塞模式,使一個線程從某通道發送請求讀取數據,但是它僅能得到目前可用的數據,如果目前沒有數據可用時,就什麼多不會獲取。而不是保持線程阻塞,所以直至數據變得可以讀取之前,該線程可以繼續做其他的事情。

非阻塞寫也是如此。一個線程請求寫入一些數據到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情。線程通常將非阻塞IO的空閑時間用於在其他通道上執行IO操作,所以一個單獨的線程現在可以管理多個輸入和輸出通道。

選擇器的問世

java NIO的選擇器(Selector)允許一個單獨的線程來監視多個輸入通道,你可以註冊多個通道使用一個選擇器,然後使用一個單獨的線程來“選擇”通道,這些通道裡已經有可以處理的輸入,或者選擇已準備寫入的通道。這種選擇機制,使得一個單獨的線程很容易來管理多個通道。

Java NIO三件套

在NIO中有幾個核心對象需要掌握:緩沖器(Buffer)選擇器(Selector)通道(Channel)

緩沖區Buffer

緩沖區實際上是一個容器對象,更直接的說,其實就是一個數組,在NIO庫中,所有數據都是用緩沖區出來的。在讀取數據時,它是直接讀到緩沖區的;在寫入數據時,它也是寫入到緩沖區的;任何時候訪問NIO中的數據,都是將它放到緩沖區中。而在面向流I/O系統中,所有數據都是直接寫入或者直接將數據讀取到Stream對象中。

在NIO中,所有的緩沖區類型都繼承於抽象類Buffer,最常用的就是ByteBuffer,對於java中的基本類型,基本都有一個具體Buffer類型與之相對於,他們之間的extend關系如下圖所示。

在這裡插入圖片描述

eg:

  public static void main(String[] args) {
        //new NIOServerDemo(8080).listen();

        // 分配新的 int 緩沖區,參數為緩沖區容量
        // 新緩沖區的當前位置將為零,其界限(限制位置)將為其容量。它將具有一個底層實現數組,其數組偏移量將為零。
        IntBuffer buffer = IntBuffer.allocate(8);
        for (int i = 0; i < buffer.capacity(); ++i) {
            int j = 2 * (i + 1);
            // 將給定整數寫入此緩沖區的當前位置,當前位置遞增
            buffer.put(j);
        }
        // 重設此緩沖區,將限制設置為當前位置,然後將當前位置設置為 0
        buffer.flip();
        // 查看在當前位置和限制位置之間是否有元素
        while (buffer.hasRemaining()) {
            // 讀取此緩沖區當前位置的整數,然後當前位置遞增
            int j = buffer.get();
            System.out.print(j + " ");
        }
    }
2 4 6 8 10 12 14 16 
Process finished with exit code 0

Buffer的基本的原理

在談到緩沖區時,我們說緩沖區對象本質上是一個數組,但它其實是一個特殊的數組,緩沖區對象內置瞭一些機制,能夠跟蹤和記錄緩沖區的狀態變化情況。如果我們使用get()方法從緩沖區獲取數據或者使用put()方法把數據寫入緩沖區,都會引起緩沖區狀態的變化。

在緩沖區中,最重要的屬性有下面三個,它們一起合作完成對緩沖區內部的狀態的變化跟蹤。

position: 指定下一個將要被寫入或者讀取的元素索引,它的值由get()/put()方法自動更新,在新創建一個Buffer對象時,position被初始化為0.

limit:指定還有多少數據需要取出(在從緩沖區寫入通道時),或者還有多少空間可以放入數據(在從通道讀入緩沖區時)

**capacity:**制定瞭可以存儲在緩沖區中的最大數據容量,實際上,它制定瞭底層數組的大小,或者至少是指定瞭準許我們使用的底層數組的容量。

以上三個屬性值之間有一些相對大小的關系: 0<=positon<=limit<=capacity。如果我們創建一個新的容量大小為10的ByteBuffer對象,在初始化的時候,positon設置為0,limit和capacity被設置為10,在以後使用ByteBuffer對象過程中,capacity的值不會再發生變化,而其他兩個將會隨著使用而變化。

eg:

package com.evan.netty.nio.demo;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * @author evanYang
 * @version 1.0
 * @date 2021/7/26 11:29
 */
public class BufferDemo {
    public static void main(String[] args) throws IOException {

        FileInputStream fin = new FileInputStream("D://evan.txt");
        FileChannel fc = fin.getChannel();
        //先分配一個10大小的緩沖區
        ByteBuffer buffer = ByteBuffer.allocate(10);
        outPut("初始化",buffer);

        fc.read(buffer);
        outPut("調用read()方法",buffer);

        buffer.flip();
        outPut("調用flip()",buffer);

        //判斷有沒有可讀數據
        while (buffer.remaining()>0){
            byte b = buffer.get();
        }

        outPut("調用get()",buffer);

        //可以理解為解鎖
        buffer.clear();
        outPut("調用clear()",buffer);

        fin.close();
    }

    /**
     * 打印緩存實時狀況
     * @param step
     * @param buffer
     */
    public static void outPut(String step, Buffer buffer){
        System.out.println(step+":");
        System.out.println("capacity: "+buffer.capacity()+",");
        System.out.println("position: "+buffer.position()+",");
        System.out.println("limit: "+buffer.limit());
        System.out.println();
    }
}

文件中的數據

在這裡插入圖片描述

輸出結果:

在這裡插入圖片描述

運行結果我們已經可以知道,四個屬性值分別如圖所示:

在這裡插入圖片描述

我們可以從管道中讀取一些數據到緩沖區,註意從通道讀取數據,相當於往緩沖區寫入數據。如果讀取4個自己的數據,則此時position的值為4,即下一個將要被下入的字節索引是4,而limit仍然是10,如下圖所示:

在這裡插入圖片描述

下一步把讀取的數據寫入到輸出管道中,相當於從緩沖區中讀取數據,在此之前,必須調用flip()方法,該方法將會完成兩件事:

1,把limit設置為當前的positon值

2,把position設置為0

由於position被設置為0,所以可以保證在下一步輸出時讀取到的是緩沖區中的第一個字節,而limit被設置為當前的position,可以保證讀取的數據正好是之前寫入到緩沖區的數據,如下圖所示。

在這裡插入圖片描述

現在調用get()方法從緩沖區讀取數據寫入到輸出通道,這會導致position的增加而limit保持不變,單position不會超過limit的值,所以在讀取我們之前寫入到緩沖區中的4個自己之後,position和limit的值都為4.如下圖所示。

在這裡插入圖片描述

在從緩沖區讀取數據完畢後,limit的值仍然保持在我們調用flip()方法時的值,調用clean()方法能夠把所有的狀態設置為初始值。

緩沖區分配

在前面的幾個例子中,我們已經看過瞭,在創建一個緩沖對象時,會調用靜態方法allocate()來指定緩沖區的容量,其實調用allocate()相當於創建一個指定大小的數組,並把它包裝為緩沖區對象。或者我們也可以直接將一個現有的數組,包裝為緩沖區對對象

選擇器Selector

傳統的Server/Client 模式會基於TPR(Thread per Request),服務器會為每個客戶端請求建立一個線程,由該線程單獨負責處理一個客戶請求。這種模式帶來的一個問題就是線程數量的劇增,大量的線程會增大服務器的開銷。大多數的實現為瞭避免這個問題,都采用瞭線程池模型,並設置線程池模型的最大數量,這又帶來瞭新的問題,如果線程池中有200個線程,而有200個用戶都在進行大文件下載,會導致第201個用戶的請求無法及時處理,即便第201個用戶隻想請求一個幾kb大小的頁面。傳統的Server/Client模式如下圖所示。

在這裡插入圖片描述

NIO 中非阻塞I/O采用瞭基於Reactor模式的工作模式,I/O調用不會被阻塞,相反是註冊感興趣的特定I/O事件,如可讀數據到達,新的套接字連接等等,在發生特定事件時,系統在通知我們。NIO中實現非阻塞I/O的核心對象就是Selector,Selector就是註冊各種I/O事件地方,而且當那些事件發生時,就是這個對象告訴我們所發生的事件。如下圖所示。

在這裡插入圖片描述

從圖中可以看出,當有讀或寫等任何註冊的時間發生時,可以從Selector中獲得相應的SelectionKey,同時從SelectionKey中可以找到發生的事件和該事件所發生的具體SelectableChannel,以獲得客戶端發送過來的數據。

使用NIO中非阻塞I/O編寫服務器處理程序,大體上可以分為下面三個步驟:

1,向Selector對象註冊感興趣的事件。

2,從Selector中獲取感興趣的事件。

3,根據不同的事件進行相應的處理。

通道Channel

通道是一個對象,通過它可以讀取和寫入數據,當然瞭所有數據都通過Buffer對象來處理。我們永遠不會將字節直接寫入通道中,相反是將數據寫入包含一個或者多個字節的緩存區。同樣不會直接從通道中讀取字節,而是將數據從通道讀入緩存區,再從緩沖區獲取這個字節,而是將數據從通道讀入緩沖區,再從緩沖區獲取這個字節。

在NIO中,提供瞭多種通道對象,而所有的通道對象都實現瞭Channel接口。它們之間的繼承關系如下圖所示:

在這裡插入圖片描述

使用NIO讀取數據

在前面我們說過,任何時候讀取數據,都不是直接從通道讀取,而是從通道讀取到緩沖區。所以使用NIO讀取數據可以分為下面三個步驟:

1,從FileInputStream獲取Channel

2,創建Buffer

3,將數據從Channel讀取到Buffer中

package com.evan.netty.nio.demo;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * @author evanYang
 * @version 1.0
 * @date 2021/7/26 16:15
 */
public class FileInputDemo {
    public static void main(String[] args) throws IOException {
        FileInputStream fin=new FileInputStream("D://evan.txt");
        FileChannel channel = fin.getChannel();

        ByteBuffer allocate = ByteBuffer.allocate(1024);
        //讀取數據到緩沖區
        channel.read(allocate);

        allocate.flip();
        while (allocate.remaining()>0){
            byte b = allocate.get();
            System.out.println(b);
        }
        fin.close();
    }
}

使用NIO寫入數據

使用NIO寫入數據與讀取數據的過程類似,同樣數據不是直接寫入通道,而是寫入緩沖區,可以分為下面三個步驟:

1,從FileputStream獲取channel。

2,創建Buffer

3,將數據從Channel寫入到Buffer中,

package com.evan.netty.nio.demo;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * @author evanYang
 * @version 1.0
 * @date 2021/7/26 16:33
 */
public class FileOutPutDemo {
    static private final byte message[] ={83, 111, 109, 101, 32, 98, 121, 116, 101, 115, 46 };
    public static void main(String[] args) throws IOException {
        FileOutputStream fout=new FileOutputStream("D://evan.txt");

        FileChannel channel = fout.getChannel();

        ByteBuffer buffer = ByteBuffer.allocate(1024);
        for (int i = 0; i < message.length; i++) {
            buffer.put(message[i]);
        }
        buffer.flip();
        channel.write(buffer);
        fout.close();
    }
}

在這裡插入圖片描述

IO多路復用

我們試想一下這樣的現實場景。

100桌客人到店點菜

方法A:

服務員都把僅有的一份菜單遞給其中一桌客人,然後站在這個客人身旁等待客人完成點菜過程。。。。。

方法B:

老板馬上新雇傭99名服務員,同時印制99本新的菜單。沒人服務一桌客人。

在這裡插入圖片描述

方法C:

改進點菜的方式,當客人到店後,自己申請一本菜單。想好自己要點的菜,然後呼叫服務員。服務員站在自己身邊記錄客人點的菜的內容。

在這裡插入圖片描述

  • 到店情況 :並發量
    • 到店情況不理想時,一個服務員一本菜單,就足夠瞭
  • 客人:服務端請求
  • 點餐內容:客服端發送的實際數據
  • 老板:操作系統
  • 人力成本:系統資源
  • 菜單:文件狀態描述符(FD)。操作系統對於一個進程能夠同時持有的文件狀態描述符的個數是有限制的,在linux系統中,$Ulimit -n 查看這個限制值,當然也是可以(並且應該)進行內核參數調整的
  • 服務員:操作系統內核用於IO操作的線程(內核線程)
  • 廚師:應用程序線程(當然廚房就是應用程序進程)
    • 方法A:同步IO
    • 方法B:同步IO
    • 方法C:多路復用IO

總結

本篇文章就到這裡瞭,希望能給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容!

推薦閱讀: