1. 程式人生 > 程式設計 >Java SE基礎鞏固(六):Java IO

Java SE基礎鞏固(六):Java IO

到現在為止,Java IO可分為三類:BIO、NIO、AIO。最早出現的是BIO,然後是NIO,最近的是AIO,BIO即Blocking IO,NIO有的文章說是New NIO,也有的文章說是No Blocking IO,我查了一些資料,官網說的應該是No Blocking IO,提供了Selector,Channle,SelectionKey抽象,AIO即Asynchronous IO(非同步IO),提供了Fauture等非同步操作。

1 BIO

i81QZn.png

上圖是BIO的架構體系圖。可以看到BIO主要分為兩類IO,即字元流IO和位元組流IO,字元流即把輸入輸出資料當做字元來看待,Writer和Reader是其繼承體系的最高層,位元組流即把輸入輸出當做位元組來看待,InputStream和OutputStream是其繼承體系的最高層。下面以檔案操作為例,其他的實現類也非常類似。

順便說一下,整個BIO體系大量使用了裝飾者模式,例如BufferedInputStream包裝了InputStream,使其擁有了緩衝的能力。

1.1 位元組流

public class Main {

    public static void main(String[] args) throws IOException {
		//寫入檔案
        FileOutputStream out = new FileOutputStream("E:\\Java_project\\effective-java\\src\\top\\yeonon\\io\\bio\\test.txt");
        out.write("hello,world"
.getBytes("UTF-8")); out.flush(); out.close(); //讀取檔案 FileInputStream in = new FileInputStream("E:\\Java_project\\effective-java\\src\\top\\yeonon\\io\\bio\\test.txt"); byte[] buffer = new byte[in.available()]; in.read(buffer); System.out.println(new
String(buffer,"UTF-8")); in.close(); } } 複製程式碼

向FileOutputStream建構函式中傳入檔名來建立FileOutputStream物件,即開啟了一個位元組流,之後使用write方法向位元組流中寫入資料,完成之後呼叫flush重新整理緩衝區,最後記得要關閉位元組流。讀取檔案也是類似的,先開啟一個位元組流,然後從位元組流中讀取資料並存入記憶體中(buffer陣列),然後再關閉位元組流。

因為InputStream和OutputStream都繼承了AutoCloseable介面,所以如果使用的是try-resource的語法來進行位元組流的IO操作,可不需要手動顯式呼叫close方法了,這也是非常推薦的做法,在示例中我沒有這樣做只是為了方便。

1.2 字元流

位元組流主要使用的是InputStream和OutputStream,而字元流主要使用的就是與之對應的Reader和Writer。下面來看一個示例,該示例的功能和上述示例的一樣,只不過實現手段不同:

public class Main {

    public static void main(String[] args) throws IOException {
        Writer writer = new FileWriter("E:\\Java_project\\effective-java\\src\\top\\yeonon\\io\\bio\\test.txt");
        writer.write("hello,world\n");
        writer.write("hello,yeonon\n");
        writer.flush();
        writer.close();

        BufferedReader reader = new BufferedReader(new FileReader("E:\\Java_project\\effective-java\\src\\top\\yeonon\\io\\bio\\test.txt"));

        String line = "";
        int lineCount = 0;
        while ((line = reader.readLine()) != null) {
            System.out.println(line);
            lineCount++;
        }
        reader.close();
        System.out.println(lineCount);
    }
}
複製程式碼

Writer非常簡單,無法就是開啟字元流,然後向字元流中寫入字元,然後關閉。關鍵是Reader,示例程式碼中使用了BufferedReader來包裝FileReader,使得原本沒有緩衝功能的FileReader有了緩衝功能,這就是上面提到過的裝飾者模式,BufferedReader還提供了方便使用的API,例如readLine(),這個方法每次呼叫會讀取檔案中的一行。

以上就是BIO的簡單使用,原始碼的話因為涉及太多的底層,所以如果對底層不是很瞭解的話會很難理解原始碼。

2 NIO

BIO是同步阻塞的IO,而NIO是同步非阻塞的IO。NIO中有幾個比較重要的元件:Selector,SelectionKey,Channel,ByteBuffer,其中Selector就是所謂的選擇器,SelectionKey可以簡單理解為選擇鍵,這個鍵將Selector和Channle進行一個繫結(或者所Channle註冊到Selector上),當有資料到達Channel的時候,Selector會從阻塞狀態中恢復過來,並對該Channle進行操作,並且,我們不能直接對Channle進行讀寫操作,只能對ByteBuffer操作。如下圖所示:

i8YB80.png

下面是一個Socket網路程式設計的例子:

//服務端
public class SocketServer {

    private Selector selector;
    private final static int port = 9000;
    private final static int BUF = 10240;

    private void init() throws IOException {
        //獲取一個Selector
        selector = Selector.open();
	    //獲取一個服務端socket Channel
        ServerSocketChannel channel = ServerSocketChannel.open();
        //設定為非阻塞模式
        channel.configureBlocking(false);
        //繫結埠
        channel.socket().bind(new InetSocketAddress(port));
	    //把channle註冊到Selector上,並表示對ACCEPT事件感興趣
        SelectionKey selectionKey = channel.register(selector,SelectionKey.OP_ACCEPT);

        while (true) {
            //該方法會阻塞,直到和其繫結的任何一個channel有資料過來
            selector.select();
            //獲取該Selector繫結的SelectionKey
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                //記得刪除,否則就無限迴圈了
                iterator.remove();
                //如果該事件是一個ACCEPT,那麼就執行doAccept方法,其他的也一樣
                if (key.isAcceptable()) {
                    doAccept(key);
                } else if (key.isReadable()) {
                    doRead(key);
                } else if (key.isWritable()) {
                    doWrite(key);
                } else if (key.isConnectable()) {
                    System.out.println("連線成功!");
                }
            }
        }
    }

    //寫方法,注意不能直接對channle進行讀寫操作,只能對ByteBuffer進行操作
    private void doWrite(SelectionKey key) throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(BUF);
        buffer.flip();
        SocketChannel socketChannel = (SocketChannel) key.channel();
        while (buffer.hasRemaining()) {
            socketChannel.write(buffer);
        }
        buffer.compact();
    }

    //讀取訊息
    private void doRead(SelectionKey key) throws IOException {
        SocketChannel socketChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(BUF);
        long reads = socketChannel.read(buffer);
        while (reads > 0) {
            buffer.flip();
            byte[] data = buffer.array();
            System.out.println("讀取到訊息: " + new String(data,"UTF-8"));
            buffer.clear();
            reads = socketChannel.read(buffer);
        }
        if (reads == -1) {
            socketChannel.close();
        }
    }

    //當有連線過來的時候,獲取連線過來的channle,然後註冊到Selector上,並設定成對讀訊息感興趣,當客戶端有訊息過來的時候,Selector就可以讓其執行doRead方法,然後讀取訊息並列印。
    private void doAccept(SelectionKey key) throws IOException {
        ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
        System.out.println("服務端監聽中...");
        SocketChannel socketChannel = serverChannel.accept();
        socketChannel.configureBlocking(false);
        socketChannel.register(key.selector(),SelectionKey.OP_READ);
    }
    
    public static void main(String[] args) throws IOException {
        SocketServer server = new SocketServer();
        server.init();
    }
}

//客戶端,寫得比較簡單
public class SocketClient {

    private final static int port = 9000;
    private final static int BUF = 10240;


    private void init() throws IOException {
        //獲取channel
        SocketChannel channel = SocketChannel.open();
        //連線到遠端伺服器
        channel.connect(new InetSocketAddress(port));
        //設定非阻塞模式
        channel.configureBlocking(false);
        //往ByteBuffer裡寫訊息
        ByteBuffer buffer = ByteBuffer.allocate(BUF);
        buffer.put("Hello,Server".getBytes("UTF-8"));
        buffer.flip();
        //將ByteBuffer內容寫入Channle,即傳送訊息
        channel.write(buffer);
        channel.close();
    }


    public static void main(String[] args) throws IOException {
        SocketClient client = new SocketClient();
            client.init();
    }
}

複製程式碼

嘗試啟動一個服務端,多個客戶端,結果大致如下所示:

服務端監聽中...
讀取到訊息: Hello,Server                       
服務端監聽中...
讀取到訊息: Hello,Server  
複製程式碼

註釋寫得挺清楚了,我這裡只是簡單使用了NIO,但實際上NIO遠遠不止這些東西,光一個ByteBuffer就能說一天,如果有機會,我會在後面Netty相關的文章中詳細說一下這幾個元件。在此就不再多說了。

吐槽一些,純NIO寫的服務端和客戶端實在是太麻煩了,一不小心就會寫錯,還是使用Netty類似的框架好一些啊。

3 AIO

在JDK7中新增了一些IO相關的API,這些API稱作AIO。因為其提供了一些非同步操作IO的功能,但本質是其實還是NIO,所以可以簡單的理解為是NIO的擴充。AIO中最重要的就是Future了,Future表示將來的意思,即這個操作可能會持續很長時間,但我不會等,而是到將來操作完成的時候,再過來通知我,這就是非同步的意思。下面是兩個使用AIO的例子:

    public static void main(String[] args) throws ExecutionException,InterruptedException,IOException {
        Path path = Paths.get("E:\\Java_project\\effective-java\\src\\top\\yeonon\\io\\aio\\test.txt");
        AsynchronousFileChannel channel = AsynchronousFileChannel.open(path);
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        Future<Integer> future = channel.read(buffer,0);
        Integer readNum = future.get(); //阻塞,如果不呼叫該方法,main方法會繼續執行
        buffer.flip();
        System.out.println(new String(buffer.array(),"UTF-8"));
        System.out.println(readNum);
    }
複製程式碼

第一個例子使用AsynchronousFileChannel來非同步的讀取檔案內容,在程式碼中,我使用了future.get()方法,該方法會阻塞當前執行緒,在例子中即主執行緒,當工作執行緒,即讀取檔案的執行緒執行完畢後才會從阻塞狀態中恢復過來,並將結果返回。之後就可以從ByteBuffer中讀取資料了。這是使用將來時的例子,下面來看看使用回撥的例子:

public class Main {

    public static void main(String[] args) throws ExecutionException,IOException {
        Path path = Paths.get("E:\\Java_project\\effective-java\\src\\top\\yeonon\\io\\aio\\test.txt");
        AsynchronousFileChannel channel = AsynchronousFileChannel.open(path);
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        channel.read(buffer,0,buffer,new CompletionHandler<Integer,ByteBuffer>() {
            @Override
            public void completed(Integer result,ByteBuffer attachment) {
                System.out.println("完成讀取");
                try {
                    System.out.println(new String(attachment.array(),"UTF-8"));
                } catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void failed(Throwable exc,ByteBuffer attachment) {
                System.out.println("讀取失敗");
            }
        });
        System.out.println("繼續執行主執行緒");
        //呼叫完成之後不需要等待任務完成,會直接繼續執行主執行緒
        while (true) {
            Thread.sleep(1000);
        }
    }
}

複製程式碼

輸出的結果大致如下所示,但不一定,這取決於執行緒排程:

繼續執行主執行緒
完成讀取

hello,world
hello,yeonon
複製程式碼

當任務完成,即讀取檔案完畢的時候,會呼叫completed方法,失敗會呼叫failed方法,這就是回撥。詳細接觸過回撥的朋友應該不難理解。

4 BIO、NIO、AIO的區別

  1. BIO是同步阻塞的IO,NIO是同步非阻塞IO,AIO非同步非阻塞IO,這是最基本的區別。阻塞模式會導致其他執行緒被IO執行緒阻塞,必須等待IO執行緒執行完畢才能繼續執行邏輯,非阻塞和非同步並不等同,非阻塞模式下,一般會採用事件輪詢的方式來執行IO,即IO多路複用,雖然仍然是同步的,但執行效率比傳統的BIO要高很多,AIO則是非同步IO,如果把IO工作當做一個任務的話,在當前執行緒中提交一個任務之後,不會有阻塞,會繼續執行當前執行緒的後續邏輯,在任務完成之後,當前執行緒會收到通知,然後再決定如何處理,這種方式的IO,CPU效率是最高的,CPU幾乎沒有發生過停頓,而時一直至於忙狀態,所以效率非常高,但程式設計難度會比較大。
  2. BIO面向的是流,無論是字元流還是位元組流,通俗的講,BIO在讀寫資料的時候會按照一個接一個的方式讀寫,而NIO和AIO(因為AIO實際上是NIO的擴充,所以從這個方面來看,可以把他們放在一塊)讀寫資料的時候是按照一塊一塊的讀取的,讀取到的資料會快取在記憶體中,然後在記憶體中對資料進行處理,這種方式的好處是減少了硬碟或者網路的讀寫次數,從而降低了由於硬碟或網路速度慢帶來的效率影響。
  3. BIO的API雖然比較底層,但如果熟悉之後編寫起來會比較容易,NIO或者AIO的API抽象層次高,一般來說應該更容易使用才是,但實際上卻很難“正確”的編寫,而且DEBUG的難度也較大,這也是為什麼Netty等NIO框架受歡迎的原因之一。

以上就是我理解的BIO、NIO和AIO區別。

5 小結

本文簡單粗略的講了一下BIO、NIO、AIO的使用,並未涉及原始碼,也沒有涉及太多的原理,如果讀者希望瞭解更多關於三者的內容,建議參看一些書籍,例如老外寫的《Java NIO》,該書全面系統的講解了NIO的各種元件和細節,非常推薦。