一文看懂java io系統

我是樹懶發表於2019-07-02

一文看懂java io系統

原文:chenmingyu.top/nio/

學習java IO系統,重點是學會IO模型,瞭解了各種IO模型之後就可以更好的理解java IO

Java IO 是一套Java用來讀寫資料(輸入和輸出)的API。大部分程式都要處理一些輸入,並由輸入產生一些輸出。Java為此提供了java.io包

java中io系統可以分為Bio,Nio,Aio三種io模型

  1. 關於Bio,我們需要知道什麼是同步阻塞IO模型,Bio操作的物件:流,以及如何使用Bio進行網路程式設計,使用Bio進行網路程式設計的問題
  2. 關於Nio,我們需要知道什麼是同步非阻塞IO模型,什麼是多路複用Io模型,以及Nio中的Buffer,Channel,Selector的概念,以及如何使用Nio進行網路程式設計
  3. 關於Aio,我們需要知道什麼是非同步非阻塞IO模型,Aio可以使用幾種方式實現非同步操作,以及如何使用Aio進行網路程式設計

BIO

BIO是同步阻塞IO,JDK1.4之前只有這一個IO模型,BIO操作的物件是流,一個執行緒只能處理一個流的IO請求,如果想要同時處理多個流就需要使用多執行緒

流包括字元流和位元組流,流從概念上來說是一個連續的資料流。當程式需要讀資料的時候就需要使用輸入流讀取資料,當需要往外寫資料的時候就需要輸出流

阻塞IO模型

一文看懂java io系統

在Linux中,當應用程式呼叫recvfrom方法呼叫資料的時候,如果核心沒有把資料準備好不會立刻返回,而是會經歷等待資料準備就緒,資料從核心複製到使用者空間之後再返回,這期間應用程式一直阻塞直到返回,所以被稱為阻塞IO模型

BIO中操作的流主要有兩大類,位元組流和字元流,兩類根據流的方向都可以分為輸入流和輸出流

按照型別和輸入輸出方向可分為:

  1. 輸入位元組流:InputStream
  2. 輸出位元組流:OutputStream
  3. 輸入字元流:Reader
  4. 輸出字元流:Writer

位元組流主要用來處理位元組或二進位制物件,字元流用來處理字元文字或字串

使用InputStreamReader可以將輸入位元組流轉化為輸入字元流

Reader reader  =  new InputStreamReader(inputStream);
複製程式碼

使用OutputStreamWriter可以將輸出位元組流轉化為輸出字元流

Writer writer = new OutputStreamWriter(outputStream)
複製程式碼

我們可以在程式中通過InputStream和Reader從資料來源中讀取資料,然後也可以在程式中將資料通過OutputStream和Writer輸出到目標媒介中

一文看懂java io系統

在使用位元組流的時候,InputStream和OutputStream都是抽象類,我們例項化的都是他們的子類,每一個子類都有自己的作用範圍

一文看懂java io系統

在使用字元流的時候也是,Reader和Writer都是抽象類,我們例項化的都是他們的子類,每一個子類都有自己的作用範圍

一文看懂java io系統

以讀寫檔案為例

從資料來源中讀取資料

輸入位元組流:InputStream

public static void main(String[] args) throws Exception{
    File file = new File("D:/a.txt");
    InputStream inputStream = new FileInputStream(file);
    byte[] bytes = new byte[(int) file.length()];
    inputStream.read(bytes);
    System.out.println(new String(bytes));
    inputStream.close();
}
複製程式碼

輸入字元流:Reader

public static void main(String[] args) throws Exception{
    File file = new File("D:/a.txt");
    Reader reader = new FileReader(file);
    char[] bytes = new char[(int) file.length()];
    reader.read(bytes);
    System.out.println(new String(bytes));
    reader.close();
}
複製程式碼

輸出到目標媒介

輸出位元組流:OutputStream

public static void main(String[] args) throws Exception{
    String var = "hai this is a test";
    File file = new File("D:/b.txt");
    OutputStream outputStream = new FileOutputStream(file);
    outputStream.write(var.getBytes());
    outputStream.close();
}
複製程式碼

輸出字元流:Writer

public static void main(String[] args) throws Exception{
    String var = "hai this is a test";
    File file = new File("D:/b.txt");
    Writer writer = new FileWriter(file);
    writer.write(var);
    writer.close();
}
複製程式碼

BufferedInputStream

在使用InputStream的時候,都是一個位元組一個位元組的讀或寫,而BufferedInputStream為輸入位元組流提供了緩衝區,讀資料的時候會一次讀取一塊資料放到緩衝區裡,當緩衝區裡的資料被讀完之後,輸入流會再次填充資料緩衝區,直到輸入流被讀完,有了緩衝區就能夠提高很多io速度

使用方式將輸入流包裝到BufferedInputStream中

/**
 * inputStream 輸入流
 * 1024 內部緩衝區大小為1024byte
 */
BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream,1024);
複製程式碼

BufferedOutputStream

BufferedOutputStream可以為輸出位元組流提供緩衝區,作用與BufferedInputStream類似

使用方式將輸出流包裝到BufferedOutputStream中

/**
 * outputStream 輸出流
 * 1024 內部緩衝區大小為1024byte
 */
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream,1024);
複製程式碼

位元組流提供了帶緩衝區的,那字元流肯定也提供了BufferedReader和BufferedWriter

BufferedReader

為輸入字元流提供緩衝區,使用方式如下

BufferedReader bufferedReader = new BufferedReader(reader,1024);
複製程式碼

BufferedWriter

為輸出字元流提供緩衝區,使用方式如下

BufferedWriter bufferedWriter = new BufferedWriter(writer,1024);
複製程式碼

BIO模型 網路程式設計

當使用BIO模型進行Socket程式設計的時候,服務端通常使用while迴圈中呼叫accept方法,在沒有客戶端請求時,accept方法會一直阻塞,直到接收到請求並返回處理的相應,這個過程都是線性的,只有處理完當前的請求之後才會接受處理後面的請求,這樣通常會導致通訊執行緒被長時間阻塞

BIO模型處理多個連線:

一文看懂java io系統

在這種模式中我們通常用一個執行緒去接受請求,然後用一個執行緒池去處理請求,用這種方式併發管理多個Socket客戶端連線,像這樣:

一文看懂java io系統

使用BIO模型進行網路程式設計的問題在於缺乏彈性伸縮能力,客戶端併發訪問數量和伺服器執行緒數量是1:1的關係,而且平時由於阻塞會有大量的執行緒處於等待狀態,等待輸入或者輸出資料就緒,造成資源浪費,在面對大量併發的情況下,如果不使用執行緒池直接new執行緒的話,就會大致執行緒膨脹,系統效能下降,有可能導致堆疊的記憶體溢位,而且頻繁的建立銷燬執行緒,更浪費資源

使用執行緒池可能是更優一點的方案,但是無法解決阻塞IO的阻塞問題,而且還需要考慮如果執行緒池的數量設定較小就會拒絕大量的Socket客戶端的連線,如果執行緒池數量設定較大的時候,會導致大量的上下文切換,而且程式要為每個執行緒的呼叫棧都分配記憶體,其預設值大小區間為 64 KB 到 1 MB,浪費虛擬機器記憶體

BIO模型適用於連結數目固定而且比較少的架構,但是使用這種模型寫的程式碼更直觀簡單易於理解

NIO

JDK 1.4版本以來,JDK釋出了全新的I/O類庫,簡稱NIO,是一種同步非阻塞IO模型

非阻塞IO模型

同步非阻塞IO模型實現:

非阻塞IO模型

一文看懂java io系統

應用程式呼叫recvfrom系統呼叫,如果核心資料沒有準備好,會直接返回一個EWOULDBLOCK錯誤,應用程式不會阻塞,但是需要應用程式不斷的輪詢呼叫recvfrom,直到核心資料準備就緒,之後等待資料從核心複製到使用者空間(這段時間會阻塞,但是耗時極小),複製完成後返回

IO複用模型

一文看懂java io系統

IO複用模型,利用Linux系統提供的select,poll系統呼叫,將一個或者多個檔案控制程式碼(網路程式設計中的客戶端連結)傳遞給select或者poll系統呼叫,應用程式阻塞在select上,這樣就形成了一個程式對應多個Socket連結,然後select/poll會線性掃描這個Socket連結的集合,當只有少數socket有資料的時候,會導致效率下降,而且select/poll受限於所持有的檔案控制程式碼數量,預設值是1024個

訊號驅動 IO模型

一文看懂java io系統

系統呼叫sigaction執行一個訊號處理函式,這個系統呼叫不會阻塞應用程式,當資料準備就緒的時候,就為該程式生成一個SIGIO訊號,通過訊號回撥通知應用程式呼叫recvfrom來讀取資料

NIO的核心概念

Buffer(緩衝區)

Buffer是一個物件,它包含一些要寫入或者讀出的資料,在NIO中所有資料都是用快取區處理的,在讀資料的時候要從緩衝區中讀,寫資料的時候會先寫到緩衝區中,緩衝區本質上是一塊可以寫入資料,然後可以從中讀取資料的一個陣列,提供了對資料的結構化訪問以及在內部維護了讀寫位置等資訊

例項化一個ByteBuffer

//建立一個容量為1024個byte的緩衝區
ByteBuffer buffer=ByteBuffer.allocate(1024);
複製程式碼

如何使用Buffer:

  1. 寫入資料到Buffer
  2. 呼叫flip()方法將Buffer從寫模式切換到讀模式
  3. 從Buffer中讀取資料
  4. 呼叫clear()方法或者compact()方法清空緩衝區,讓它可以再次被寫入

更多詳細資訊看這個:ifeve.com/buffers/

Channel(通道)

Channel(通道)資料總是從通道讀取到緩衝區,或者從緩衝區寫入到通道中,Channel只負責運輸資料,而運算元據是Buffer

通道與流類似,不同地方:

  1. 在於條通道是雙向的,可以同時進行讀,寫操作,而流是單向流動的,只能寫入或者讀取
  2. 流的讀寫是阻塞的,通道可以非同步讀寫

一文看懂java io系統

資料從Channel讀到Buffer

inChannel.read(buffer);
複製程式碼

資料從Buffer寫到Channel

outChannel.write(buffer);
複製程式碼

更多詳細資訊看這個:<ifeve.com/channels/>

以複製檔案為例

FileInputStream fileInputStream=new FileInputStream(new File(src));
FileOutputStream fileOutputStream=new FileOutputStream(new File(dst));
//獲取輸入輸出channel通道
FileChannel inChannel=fileInputStream.getChannel();
FileChannel outChannel=fileOutputStream.getChannel();
//建立容量為1024個byte的buffer
ByteBuffer buffer=ByteBuffer.allocate(1024);
while(true){
    //從inChannel裡讀資料,如果讀不到位元組了就返回-1,檔案就讀完了
    int eof =inChannel.read(buffer);
    if(eof==-1){
        break;
    }
    //將Buffer從寫模式切換到讀模式
    buffer.flip();
    //開始往outChannel寫資料
    outChannel.write(buffer);
    //清空buffer
    buffer.clear();
}
inChannel.close();
outChannel.close();
fileInputStream.close();
fileOutputStream.close();
複製程式碼

Selector(多路複用選擇器)

Selector是NIO程式設計的基礎,主要作用就是將多個Channel註冊到Selector上,如果Channel上發生讀或寫事件,Channel就處於就緒狀態,就會被Selector輪詢出來,然後通過SelectionKey就可以獲取到已經就緒的Channel集合,進行IO操作了

Selector與Channel,Buffer之間的關係

一文看懂java io系統

更多詳細資訊看這個:<ifeve.com/selectors/

NIO模型 網路程式設計

JDK中NIO使用多路複用的IO模型,通過把多個IO阻塞複用到一個select的阻塞上,實現系統在單執行緒中可以同時處理多個客戶端請求,節省系統開銷,在JDK1.4和1.5 update10版本之前,JDK的Selector基於select/poll模型實現,在JDK 1.5 update10以上的版本,底層使用epoll代替了select/poll

epoll較select/poll的優點在於:

  1. epoll支援開啟的檔案描述符數量不在受限制,select/poll可以開啟的檔案描述符數量有限
  2. select/poll使用輪詢方式遍歷整個檔案描述符的集合,epoll基於每個檔案描述符的callback函式回撥

select,poll,epoll都是IO多路複用的機制。I/O多路複用就是通過一種機制,一個程式可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程式進行相應的讀寫操作。但select,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而非同步I/O則無需自己負責進行讀寫

NIO提供了兩套不同的套接字通道實現網路程式設計,服務端:ServerSocketChannel和客戶端SocketChannel,兩種通道都支援阻塞和非阻塞模式

服務端程式碼

服務端接受客戶端傳送的訊息輸出,並給客戶端傳送一個訊息

		//建立多路複用選擇器Selector
        Selector selector=Selector.open();
        //建立一個通道物件Channel,監聽9001埠
        ServerSocketChannel channel = ServerSocketChannel.open().bind(new InetSocketAddress(9001));
        //設定channel為非阻塞
        channel.configureBlocking(false);
        //
        /**
         * 1.SelectionKey.OP_CONNECT:連線事件
         * 2.SelectionKey.OP_ACCEPT:接收事件
         * 3.SelectionKey.OP_READ:讀事件
         * 4.SelectionKey.OP_WRITE:寫事件
         *
         * 將channel繫結到selector上並註冊OP_ACCEPT事件
         */
        channel.register(selector,SelectionKey.OP_ACCEPT);

        while (true){
            //只有當OP_ACCEPT事件到達時,selector.select()會返回(一個key),如果該事件沒到達會一直阻塞
            selector.select();
            //當有事件到達了,select()不在阻塞,然後selector.selectedKeys()會取到已經到達事件的SelectionKey集合
            Set keys = selector.selectedKeys();
            Iterator iterator = keys.iterator();
            while (iterator.hasNext()){
                SelectionKey key = (SelectionKey) iterator.next();
                //刪除這個SelectionKey,防止下次select方法返回已處理過的通道
                iterator.remove();
                //根據SelectionKey狀態判斷
                if (key.isConnectable()){
                    //連線成功
                } else if (key.isAcceptable()){
                    /**
                     * 接受客戶端請求
                     *
                     * 因為我們只註冊了OP_ACCEPT事件,所以有客戶端連結上,只會走到這
                     * 我們要做的就是去讀取客戶端的資料,所以我們需要根據SelectionKey獲取到serverChannel
                     * 根據serverChannel獲取到客戶端Channel,然後為其再註冊一個OP_READ事件
                     */
                    // 1,獲取到ServerSocketChannel
                    ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
                    // 2,因為已經確定有事件到達,所以accept()方法不會阻塞
                    SocketChannel clientChannel = serverChannel.accept();
                    // 3,設定channel為非阻塞
                    clientChannel.configureBlocking(false);
                    // 4,註冊OP_READ事件
                    clientChannel.register(key.selector(),SelectionKey.OP_READ);
                } else if (key.isReadable()){
                    // 通道可以讀資料
                    /**
                     * 因為客戶端連上伺服器之後,註冊了一個OP_READ事件傳送了一些資料
                     * 所以首先還是需要先獲取到clientChannel
                     * 然後通過Buffer讀取clientChannel的資料
                     */
                    SocketChannel clientChannel = (SocketChannel) key.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(BUF_SIZE);
                    long bytesRead = clientChannel.read(byteBuffer);
                    while (bytesRead>0){
                        byteBuffer.flip();
                        System.out.println("client data :"+new String(byteBuffer.array()));
                        byteBuffer.clear();
                        bytesRead = clientChannel.read(byteBuffer);
                    }

                    /**
                     * 我們服務端收到資訊之後,我們再給客戶端傳送一個資料
                     */
                    byteBuffer.clear();
                    byteBuffer.put("客戶端你好,我是服務端,你看這NIO多難".getBytes("UTF-8"));
                    byteBuffer.flip();
                    clientChannel.write(byteBuffer);
                } else if (key.isWritable() && key.isValid()){
                    //通道可以寫資料
                }

            }
        }
複製程式碼

客戶端程式碼

客戶端連線上服務端後,先給服務端傳送一個訊息,並接受服務端傳送的訊息

Selector selector = Selector.open();
SocketChannel clientChannel = SocketChannel.open();
//將channel設定為非阻塞
clientChannel.configureBlocking(false);
//連線伺服器
clientChannel.connect(new InetSocketAddress(9001));
//註冊OP_CONNECT事件
clientChannel.register(selector, SelectionKey.OP_CONNECT);
while (true){
    //如果事件沒到達就一直阻塞著
    selector.select();
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
    while (iterator.hasNext()){
        SelectionKey key = iterator.next();
        iterator.remove();
        if (key.isConnectable()){
            /**
             * 連線伺服器端成功
             *
             * 首先獲取到clientChannel,然後通過Buffer寫入資料,然後為clientChannel註冊OP_READ時間
             */
            clientChannel = (SocketChannel) key.channel();
            if (clientChannel.isConnectionPending()){
                clientChannel.finishConnect();
            }
            clientChannel.configureBlocking(false);
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
            byteBuffer.clear();
            byteBuffer.put("服務端你好,我是客戶端,你看這NIO難嗎".getBytes("UTF-8"));
            byteBuffer.flip();
            clientChannel.write(byteBuffer);
            clientChannel.register(key.selector(),SelectionKey.OP_READ);
        } else if (key.isReadable()){
            //通道可以讀資料
            clientChannel = (SocketChannel) key.channel();
            ByteBuffer byteBuffer = ByteBuffer.allocate(BUF_SIZE);
            long bytesRead = clientChannel.read(byteBuffer);
            while (bytesRead>0){
                byteBuffer.flip();
                System.out.println("server data :"+new String(byteBuffer.array()));
                byteBuffer.clear();
                bytesRead = clientChannel.read(byteBuffer);
            }
        } else if (key.isWritable() && key.isValid()){
            //通道可以寫資料
        }
    }
}
複製程式碼

使用原生NIO類庫十分複雜,NIO的類庫和Api繁雜,使用麻煩,需要對網路程式設計十分熟悉,才能編寫出高質量的NIO程式,所以並不建議直接使用原生NIO進行網路程式設計,而是使用一些成熟的框架,比如Netty

AIO

JDK1.7升級了Nio類庫,成為Nio2.0,最主要的是提供了非同步檔案的IO操作,以及事件驅動IO,AIO的非同步套接字通道是真正的非同步非阻塞IO

非同步IO模型

一文看懂java io系統

在Linux系統中,應用程式發起read操作,立刻可以去做其他的事,核心會將資料準備好並且複製到用空間後告訴應用程式,資料已經複製完成read操作

aio模型 網路程式設計

非同步操作

aio不需要通過多路複用器對註冊的通道進行輪詢操作就可以實現非同步讀寫,從而簡化了NIO的程式設計模型

aio通過非同步通道實現非同步操作,非同步通道提供了兩種方式獲取操作結果:

  1. 通過Future類來獲取非同步操作的結果,不過要注意的是future.get()是阻塞方法,會阻塞執行緒
  2. 通過回撥的方式進行非同步,通過傳入一個CompletionHandler的實現類進行回撥,CompletionHandler定義了兩個方法,completed和failed兩方法分別對應成功和失敗

Aio中的Channel都支援以上兩種方式

AIO提供了對應的非同步套接字通道實現網路程式設計,服務端:AsynchronousServerSocketChannel和客戶端AsynchronousSocketChannel

服務端

服務端向客戶端傳送訊息,並接受客戶端傳送的訊息

AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("127.0.0.1", 9001));
//非同步接受請求
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
    //成功時
    @Override
    public void completed(AsynchronousSocketChannel result, Void attachment) {
        try {
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            buffer.put("我是服務端,客戶端你好".getBytes());
            buffer.flip();
            result.write(buffer, null, new CompletionHandler<Integer, Void>(){
                @Override
                public void completed(Integer result, Void attachment) {
                    System.out.println("服務端傳送訊息成功");
                }

                @Override
                public void failed(Throwable exc, Void attachment) {
                    System.out.println("傳送失敗");
                }
            });

            ByteBuffer readBuffer = ByteBuffer.allocate(1024);
            result.read(readBuffer, null, new CompletionHandler<Integer, Void>() {
                //成功時呼叫
                @Override
                public void completed(Integer result, Void attachment) {
                    System.out.println(new String(readBuffer.array()));
                }
                //失敗時呼叫
                @Override
                public void failed(Throwable exc, Void attachment) {
                    System.out.println("讀取失敗");
                }
            });

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    //失敗時
    @Override
    public void failed(Throwable exc, Void attachment) {
        exc.printStackTrace();
    }
});
//防止執行緒執行完
TimeUnit.SECONDS.sleep(1000L);
複製程式碼

客戶端

客戶端向服務端傳送訊息,並接受服務端傳送的訊息

AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
Future<Void> future = client.connect(new InetSocketAddress("127.0.0.1", 9001));
//阻塞,獲取連線
future.get();

ByteBuffer buffer = ByteBuffer.allocate(1024);
//讀資料
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) {
        System.out.println("客戶端接收訊息失敗");
    }
});

ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
writeBuffer.put("我是客戶端,服務端你好".getBytes());
writeBuffer.flip();
//阻塞方法
Future<Integer> write = client.write(writeBuffer);
Integer r = write.get();
if(r>0){
    System.out.println("客戶端訊息傳送成功");
}
//休眠執行緒
TimeUnit.SECONDS.sleep(1000L);
複製程式碼

總結

各IO模型對比:

一文看懂java io系統

偽非同步IO是指使用執行緒池處理請求的Bio模型

參考:

netty權威指南 第二版

ifeve.com/java-nio-al… 併發程式設計網

tech.meituan.com/2016/11/04/… 美團技術團隊

文中圖片如有侵權,聯絡我刪除

相關文章