JAVA基礎之NIO

1、Buffer

1.1 概述

Java NIO中的Buffer用于和NIO通道進行交互,是將數據移進移出通道的唯一方式。緩沖區本質上是一塊可以寫入數據,然后可以從中讀取數據的內存。這塊內存被包裝成NIO Buffer對象,并提供了一組方法,用來方便的訪問該塊內存。

1.png

Java的NIO使用ByteBufferCharBufferDoubleBufferFloatBufferIntBufferLongBufferShortBuffer覆蓋了能通過IO發送的基本數據類型,還有個Mappedyteuffer用于表示內存映射文件。

1.2 實現原理

所有繼承自java.nio.Buffer的緩沖區都有4個屬性:capacitylimitpositionmark,并遵循:

mark <= position <= limit <= capacity

  • capacity:可以容納的最大數據量,創建時被設定并且不能改變
  • limit:能寫入或者讀取的數據上限
  • position: 當前正讀到或者寫到的位置,會隨著讀寫操作而自增
  • mark:一個標志量,可以暫存我們的讀進度(讀-寫-讀)

再來看看Buffer的操作方法,與上面列舉的索引密切相關。

分配一個容量為12的緩沖區,初始化狀態:

2.png

通過put()方法載入數據或從Channel中讀取數據:

3.png

在上圖的基礎上進行flip()操作,由寫模式轉入讀模式,則會進入下面的狀態:

4.png

在上圖基礎上,進行get()操作或向Channel中寫入數據,position會后移,直到position=limit,如下圖:

5.png

在上圖基礎上,進行rewind()的操作,position為0,limit不變,如下圖,如需多次讀取緩沖區數據,可以在兩次讀取之間使用rewind()

6.png

假設新的狀態如下圖:

7.png

在新狀態下進行compact()操作,進入下面狀態:

8.png

在新狀態下進行clear()操作,返回到初始狀態,即position=0limit=capacity

9.png

除此之外,Buffer還有兩個特殊的方法:mark()reset()方法,通過調用mark()方法,可以標記Buffer中的一個特定position,之后可以通過調用reset()方法恢復到這個position

1.3 使用方法

這對Buffer的操作有兩種模式:讀模式寫模式

10.png

讀模式的目標區域為數據填充區,position游標在數據填充區移動,limit為已寫數據的邊界;寫模式的目標區域為數據空白區,position游標在數據空白區移動,limitBuffer的容量邊界。

當向Buffer寫入數據時,Buffer會記錄下寫了多少數據。一旦要讀取數據,需要通過flip()方法將Buffer從寫模式切換到讀模式。在讀模式下,可以讀取之前寫入到buffer的所有數據。

一旦讀完了所有的數據,就需要清空緩沖區,讓它可以再次被寫入。有兩種方式能清空緩沖區:調用clear()compact()方法。clear()方法會清空整個緩沖區compact()方法只會清除已經讀過的數據,任何未讀的數據都被移到緩沖區的起始處,新寫入的數據將放到緩沖區未讀數據的后面。一個典型的例如如下:

    Path file = Paths.get("test.txt");
    try (FileChannel fc = FileChannel.open(file, StandardOpenOption.WRITE, StandardOpenOption.READ)) {
        ByteBuffer buf = ByteBuffer.allocate(128); // 分配一個容量為128字節的Buffer
        while ((fc.read(buf)) != -1) { // 循環載入內容到Buffer
            buf.flip(); // 使Buffer由寫模式轉為讀模式
            while (buf.hasRemaining()) {
                System.out.print((char) buf.get()); // 循環讀取Buffer
            }
            buf.clear(); // 清理Buffer,為下一次寫入做準備
        }
    } catch (Exception e) {
        e.printStackTrace();
    }

clear()這個方法命名給人的感覺就是將數據清空了,但是實際上卻不是的,它并沒有清空緩沖區中的數據,只是重置了對象中的三個索引值。因此,假設此次該Buffer中的數據是滿的,下次讀取的數據不足以填滿緩沖區,那么就會存在上一次遺留下來的的數據,所以在判斷緩沖區中是否還有可用數據時,使用hasRemaining()方法,在JDK中,這個方法的代碼如下:

    public final boolean hasRemaining() {
        return position < limit;
    }

在該方法中,比較了positionlimit的值,用以判斷是否還有可用數據,上次的遺留數據被隔離在limit之外,所以不會干擾本次的數據處理。

2、Channel

2.1 概述

Java NIO的通道類似流,但又有些不同:

  • 既可以從通道中讀取數據,又可以寫數據到通道。但流的讀寫通常是單向的。
  • 通道可以異步地讀寫。
  • 通道中的數據總是要先讀到一個Buffer,或者總是要從一個Buffer中寫入。

這些是Java NIO中最重要的通道的實現:

  • FileChannel:從文件中讀寫數據
  • DatagramChannel:通過UDP讀寫網絡中的數據
  • SocketChannel:通過TCP讀寫網絡中的數據
  • ServerSocketChannel:可以監聽新進來的TCP連接,像Web服務器那樣。對每一個新進來的連接都會創建一個SocketChannel

Java NIO相對于舊的java.io庫來說,并不是要取代,而是提出的三個新的設計思路:

  • 對原始類型的讀/寫緩沖的封裝
  • 基于Channel的讀寫機制,對Stream的進一步抽象。
  • 事件輪詢/反應設計模式(即Selector機制)

按上述思路,而Channel機制是作為Stream的進一步抽象而產生的,那么ChannelStream相比有什么不同呢?按字面理解實際上就可以獲得信息:Stream作為流是有方向的,而Channel則只是通道,并沒有指明方向。因此,讀寫操作都可以在同一個Channel里實現。Channel的命名強調了nio中數據輸入輸出對象的通用性,為非阻塞的實現提供基礎。

Channel的實現里,也存在只讀通道和只寫通道,這兩種通道實際上抽象了Channel的讀寫行為。

至于Channel的IO阻塞狀態讀寫,則和傳統的java.io包類似。但多了一層緩沖而已。因此,按照原來的設計思路來用nio也是可行的,不過nio的設計本質上還是非阻塞輸入輸出控制,把控制權重新交給程序員。

因此,java.nio從設計角度看,就不是替代java.io包,而是為java.io提供更多的控制選擇。

2.2 scatter/gather

Java NIO開始支持scatter/gather,scatter/gather用于描述從Channel中讀取或者寫入到Channel的操作。

11.png

ReadableByteChannelWritableByteChannel接口提供了通道的讀寫功能,而ScatteringByteChannelGatheringByteChannel接口都新增了兩個以緩沖區數組作為參數的相應方法。

scatter / gather經常用于需要將傳輸的數據分開處理的場合,例如傳輸一個由消息頭和消息體組成的消息,你可能會將消息體和消息頭分散到不同的Buffer中,這樣你可以方便的處理消息頭和消息體。

分散(scatter):在讀操作時將讀取的數據寫入多個buffer中。因此,Channel將從Channel中讀取的數據“分散(scatter)”到多個Buffer中。如下圖描述:

12.png

例如:

    ByteBuffer header = ByteBuffer.allocate(128);
    ByteBuffer body = ByteBuffer.allocate(1024);

    ByteBuffer[] bufferArray = { header, body };

    channel.read(bufferArray);

注意Buffer首先被插入到數組,然后再將數組作為channel.read() 的輸入參數。read()方法按照Buffer在數組中的順序將從Channel中讀取的數據寫入到Buffer,當一個Buffer被寫滿后,Channel緊接著向另一個Buffer中寫。

Scattering Reads在移動下一個Buffer前,必須填滿當前的Buffer,這也意味著它不適用于動態消息(消息大小不固定)。換句話說,如果存在消息頭和消息體,消息頭必須完成填充(例如 128byte),Scattering Reads才能正常工作。

聚集(gather):在寫操作時將多個buffer的數據寫入同一個Channel,因此,Channel 將多個Buffer中的數據“聚集(gather)”后發送到Channel。如下圖描述:

13.png

例如:

    ByteBuffer header = ByteBuffer.allocate(128);
    ByteBuffer body = ByteBuffer.allocate(1024);

    ByteBuffer[] bufferArray = { header, body };

    channel.write(bufferArray);

Buffer``數組是write()方法的入參,write()方法會按照Buffer在數組中的順序,將數據寫入到Channel,注意只有positionlimit之間的數據才會被寫入。因此,如果一個Buffer的容量為128byte,但是僅僅包含58byte的數據,那么這58byte的數據將被寫入到Channel`中。因此與Scattering Reads相反,Gathering Writes能較好的處理動態消息。

2.3 FileChannel

Java NIO中的FileChannel是一個連接到文件的通道。可以通過文件通道讀寫文件。

FileChannel無法設置為非阻塞模式,它總是運行在阻塞模式下

在使用FileChannel之前,必須先打開它。有兩種方法,一種是通過File,但是我們無法直接打開一個FileChannel,需要通過使用一個InputStreamOutputStreamRandomAccessFile來獲取一個FileChannel實例。下面是通過RandomAccessFile打開FileChannel的示例:

    RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
    FileChannel inChannel = aFile.getChannel();

FileChannel類的對象既可以通過直接打開文件的方式來創建,也可以從已有的流中得到。

FileChannel類的open方法用來打開一個新的文件通道。調用時的第一個參數是要打開的文件的路徑,第二個參數是打開文件時的選項。不同的選項會對通道的能力產生影響。比如,當一個文件通道以只讀的方式打開時,就不能通過write方法來寫入數據。

        Path file = Paths.get("my.txt");
        FileChannel channel = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);

在打開文件通道時可以選擇的選項有很多,其中最常見的是讀取和寫入模式的選擇,分別通過java.nio.file.StandardOpenOption枚舉類型中的READWRITE來聲明。

另外一種創建文件通道的方式是從已有的FileInputStream類、FileOutputStream類和RandomAccessFile類的對象中得到。這3個類都有一個getChannel方法來獲取對應的FileChannel類的對象,所得到的FileChannel類的對象的能力取決于其來源流的特征。對InputStream類的對象來說,它所得到的FileChannel類的對象是只讀的,而FileOutputStream類的對象所得到的通道是可寫的,RandomAccessFile類的對象所得到的通道的能力則取決于文件打開時的選項。

RandomAccessFile file = new RandomAccessFile("my.txt", "rw");
FileChannel inChannel = file.getChannel();

在Java NIO中,如果兩個通道中有一個是FileChannel,那你可以直接將數據從一個Channel傳輸到另外一個Channel
FileChannel的transferFrom()方法可以將數據從源通道傳輸到FileChannel中這個方法在JDK文檔中的解釋為將字節從給定的可讀取字節通道傳輸到此通道的文件中。下面是一個簡單的例子:

    RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");
    FileChannel fromChannel = fromFile.getChannel();

    RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");
    FileChannel toChannel = toFile.getChannel();

    long position = 0;
    long count = fromChannel.size();

    toChannel.transferFrom(position, count, fromChannel);

方法的輸入參數position表示從position處開始向目標文件寫入數據,count表示最多傳輸的字節數。如果源通道的剩余空間小于 count個字節,則所傳輸的字節數要小于請求的字節數。

此外要注意,在SoketChannel的實現中,SocketChannel只會傳輸此刻準備好的數據(可能不足count字節)。因此,SocketChannel可能不會將請求的所有數據(count個字節)全部傳輸到FileChannel中。

transferTo()方法將數據從FileChannel傳輸到其他的Channel中。下面是一個簡單的例子:

    RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");
    FileChannel fromChannel = fromFile.getChannel();

    RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");
    FileChannel toChannel = toFile.getChannel();

    long position = 0;
    long count = fromChannel.size();

    fromChannel.transferTo(position, count, toChannel);

是不是發現這個例子和前面那個例子特別相似?除了調用方法的FileChannel對象不一樣外,其他的都一樣。

上面所說的關于SocketChannel的問題在transferTo()方法中同樣存在。SocketChannel會一直傳輸數據直到目標buffer被填滿。

有時可能需要在FileChannel的某個特定位置進行數據的讀/寫操作。可以通過調用position()方法獲取FileChannel的當前位置。也可以通過調用position(long pos)方法設置FileChannel的當前位置。

如果將位置設置在文件結束符之后,然后試圖從文件通道中讀取數據,讀方法將返回-1 —— 文件結束標志。

如果將位置設置在文件結束符之后,然后向通道中寫數據,文件將撐大到當前位置并寫入數據。這可能導致“文件空洞”,磁盤上物理文件中寫入的數據間有空隙。

可以使用FileChannel.truncate()方法截取一個文件。截取文件時,文件將中指定長度后面的部分將被刪除。如:

channel.truncate(1024);

這個例子截取文件的前1024個字節。

FileChannel.force()方法將通道里尚未寫入磁盤的數據強制寫到磁盤上。出于性能方面的考慮,操作系統會將數據緩存在內存中,所以無法保證寫入到FileChannel里的數據一定會即時寫到磁盤上。要保證這一點,需要調用force()方法。force()方法有一個boolean類型的參數,指明是否同時將文件元數據(權限信息等)寫到磁盤上。

在對大文件進行操作時,性能問題一直比較難處理。通過操作系統的內存映射文件支持,可以比較快速地對大文件進行操作。內存映射文件的原理在于把系統的內存地址映射到要操作的文件上。讀取這些內存地址就相當于讀取文件的內容,而改變這些內存地址的值就相當于修改文件中的內容。被映射到內存地址上的文件在使用上類似于操作系統中使用的虛擬內存文件。通過內存映射的方式對文件進行操作時,不再需要通過I/O操作來完成,而是直接通過內存地址訪問操作來完成,這就大大提高了操作文件的性能,因為訪問內存地址比I/O操作要快得多。

FileChannel類的map方法可以把一個文件的全部或部分內容映射到內存中,所得到的是一個ByteBuffer類的子類MappedByteBuffer的對象,程序只需要對這個MappedByteBuffer類的對象進行操作即可。對這個MappedByteBuffer類的對象所做的修改會自動同步到文件內容中。

在進行內存映射時需要指定映射的模式,一共有3種可用的模式,由FileChannel.MapMode這個枚舉類型來表示:

  • READ_ONLY:表示只能對映射之后的MappedByteBuffer類的對象進行讀取操作
  • READ_WRITE:表示是可讀可寫的
  • PRIVATE:通過MappedByteBuffer類的對象所做的修改不會被同步到文件中,而是被同步到一個私有的復本中。這些修改對其他同樣映射了該文件的程序是不可見的。

內存映射文件的使用示例:

    public void mapFile() throws IOException {
        try (FileChannel channel = FileChannel.open(Paths.get("src.data"), StandardOpenOption.READ,
                StandardOpenOption.WRITE)) {
            MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());
            byte b = buffer.get(1024 * 1024);
            buffer.put(5 * 1024 * 1024, b);
            buffer.force();
        }
    }

如果希望更加高效地處理映射到內存中的文件,把文件的內容加載到物理內存中是一個好辦法。通過MappedByteBuffer類的load方法可以把該緩沖區所對應的文件內容加載到物理內存中,以提高文件操作時的性能。由于物理內存的容量受限,不太可能直接把一個大文件的全部內容一次性地加載到物理內存中。可以每次只映射文件的部分內容,把這部分內容完全加載到物理內存中進行處理。完成處理之后,再映射其他部分的內容。

由于I/O操作一般比較耗時,出于性能考慮,很多操作在操作系統內部都是使用緩存的。在程序中通過文件通道API所做的修改不一定會立即同步到文件系統中。如果在沒有同步之前發生了程序錯誤,可能導致所做的修改丟失。因此,在執行完某些重要文件內容的更新操作之后,應該調用FileChannel類的force方法來強制要求把這些更新同步到底層文件中。可以強制同步的更新有兩類,一類是文件的數據本身的更新,另一類是文件的元數據的更新。在使用force方法時,可以通過參數來聲明是否在同步數據的更新時也同步元數據的更新。

2.4 SocketChannel與ServerSocketChannel

Java NIO中的SocketChannel是一個連接到TCP網絡套接字的通道。可以通過以下2種方式創建:

  • 打開一個SocketChannel并連接到互聯網上的某臺服務器。
  • 一個新連接到達ServerSocketChannel時,會創建一個SocketChannel
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("192.168.0.1", 80));

SocketChannelread()write()操作與FileChannel類似,不同的是可以設置 SocketChanne 為非阻塞模式。設置之后,就可以在異步模式下調用connect(), read()write()了。非阻塞模式與選擇器搭配會工作的更好,通過將一或多個SocketChannel注冊到Selector,可以詢問選擇器哪個通道已經準備好了讀取,寫入等。

ServerSocketChannel 是一個可以監聽新進來的TCP連接的通道, 就像標準IO中的ServerSocket一樣。通過調用 ServerSocketChannel.open() 方法來打開ServerSocketChannel,通過 ServerSocketChannel.accept() 方法監聽新進來的連接。

下面的代碼使用SocketChannelServerSocketChannel以非阻塞的方式實現客戶端向服務端發送消息:

public class Server {

    ServerSocketChannel serverSocketChannel;

    private void initServer(int port) throws IOException {
        serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(port));
        serverSocketChannel.configureBlocking(false); // 設置成非阻塞模式
        System.out.println("Server初始化成功");
    }

    private void listen() throws IOException {
        while (true) {
            // 如果是阻塞模式,程序會阻塞在這里,直到有連接進來,
            // 現在為非阻塞模式,無論有沒有連接都會返回,socketChannel可能為null
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (socketChannel != null) {
                read(socketChannel);
            }
        }
    }

    private void read(SocketChannel socketChannel) throws IOException {
        ByteBuffer buf = ByteBuffer.allocate(1024);

        // 非阻塞模式下,read()方法可能在尚未讀取到任何數據時就返回了,所以需要判斷
        while (socketChannel.read(buf) != -1) {
            buf.flip();
            System.out.println("收到消息: " + Charset.forName("UTF-8").decode(buf));
            buf.clear();
        }
    }

    public static void main(String[] args) throws IOException {
        Server server = new Server();
        server.initServer(8000);
        server.listen();
    }
}
public class Client {

    SocketChannel socketChannel;

    private void initClient(String ip, int port) throws IOException {
        socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        socketChannel.connect(new InetSocketAddress(ip, port));
    }

    private void sendMsg(String msg) throws IOException {
        ByteBuffer buf = ByteBuffer.wrap(msg.getBytes(Charset.forName("UTF-8")));
        socketChannel.write(buf);
    }

    public static void main(String[] args) throws IOException {
        Client client = new Client();
        client.initClient("127.0.0.1", 8000);

        while (!client.socketChannel.finishConnect()) {
            System.out.println("等待連接...");
        }
        client.sendMsg("今晚暗號:");
        client.sendMsg("天王蓋地虎");
        client.sendMsg("寶塔鎮河妖");
    }
}

2.5 DatagramChannel

Java NIO中的DatagramChannel是一個能收發UDP包的通道。因為UDP是無連接的網絡協議,所以不能像其它通道那樣讀取和寫入。

下面是 DatagramChannel 的打開方式:

DatagramChannel channel = DatagramChannel.open();
channel.socket().bind(new InetSocketAddress(9999));

這個例子打開的 DatagramChannel可以在UDP端口9999上接收數據包。

通過receive()方法從DatagramChannel接收數據,如:

ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
channel.receive(buf);

receive()方法會將接收到的數據包內容復制到指定的Buffer,如果Buffer容不下收到的數據,多出的數據將被丟棄。

通過send()方法從DatagramChannel發送數據,如:

String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
int bytesSent = channel.send(buf, new InetSocketAddress("127.0.0.1", 80));

這個例子發送一串字符到“127.0.0.1”服務器的UDP端口80。 因為服務端并沒有監控這個端口,所以什么也不會發生。也不會通知你發出的數據包是否已收到,因為UDP在數據傳送方面沒有任何保證。

可以將DatagramChannel“連接”到網絡中的特定地址的。由于UDP是無連接的,連接到特定地址并不會像TCP通道那樣創建一個真正的連接。而是鎖住DatagramChannel ,讓其只能從特定地址收發數據。比如:

channel.connect(new InetSocketAddress("127.0.0.1", 80));

當連接后,也可以使用read()write()方法,就像在用傳統的通道一樣。只是在數據傳送方面沒有任何保證。

2.6 Pipe

管道的概念對于Unix和類Unix操作系統的用戶來說早就很熟悉了。Unix系統中,管道被用來連接一個進程的輸出和另一個進程的輸入。Java NIO中的Pipe類實現一個管道范例,不過它所創建的管道是進程內(在Java虛擬機進程內部)而非進程間使用的,也就是說,Pipe通常用于兩個線程之間的通信。

Pipe類定義了兩個嵌套的通道類來實現管路。這兩個類是Pipe.SourceChannel(管道負責讀的一端)和Pipe.SinkChannel(管道負責寫的一端)。

14.png

這兩個通道實例是在Pipe對象創建的同時被創建的,可以通過在Pipe對象上分別調用source( )和sink( )方法來取回。
用法示例如下:

public class PipeTest {
    public static void main(String[] args) throws IOException {
        Pipe pipe = Pipe.open();
        PipeWriter pipeWriter = new PipeWriter(pipe);
        PipeReader pipeReader = new PipeReader(pipe);

        ExecutorService exec = Executors.newFixedThreadPool(2);
        exec.submit(pipeWriter);
        exec.submit(pipeReader);
    }
}

class PipeWriter implements Callable<Boolean> {

    Pipe pipe;

    public PipeWriter(Pipe pipe) {
        this.pipe = pipe;
    }

    @Override
    public Boolean call() {
        try {
            SinkChannel sinkChannel = pipe.sink();
            for (int i = 10; i >= 0; i--) {
                String msg = "嫦娥6號飛船發射倒計時:" + i;
                ByteBuffer buf = ByteBuffer.wrap(msg.getBytes("UTF-8"));
                sinkChannel.write(buf);
                TimeUnit.SECONDS.sleep(1);
            }
            String msg = "嫦娥6號飛船發射成功!";
            ByteBuffer buf = ByteBuffer.wrap(msg.getBytes("UTF-8"));
            sinkChannel.write(buf);
        } catch (Exception e) {
            return false;
        }
        return true;
    }

}

class PipeReader implements Callable<Boolean> {

    Pipe pipe;

    public PipeReader(Pipe pipe) {
        this.pipe = pipe;
    }

    @Override
    public Boolean call() {
        try {
            SourceChannel sourceChannel = pipe.source();
            ByteBuffer buf = ByteBuffer.allocate(128);
            while ((sourceChannel.read(buf)) != -1) {
                buf.flip();
                System.out.println(Charset.forName("UTF-8").decode(buf));
                buf.clear();
            }
        } catch (Exception e) {
            return false;
        }
        return true;
    }

}

3、Selector

3.1 概述

Java NIO的選擇器允許一個單獨的線程來監視多個輸入通道,多個通道可以共用一個選擇器,然后使用一個單獨的線程來“選擇”通道:這些通道里已經有可以處理的輸入,或者選擇已準備寫入的通道。這種選擇機制,使得一個單獨的線程很容易來管理多個通道。

15.png

3.2 register

要使用Selector,得向Selector注冊Channel,然后調用它的select()方法。這個方法會一直阻塞到某個注冊的通道有事件就緒。一旦這個方法返回,線程就可以處理這些事件,事件的例子有如新連接進來,數據接收等。

在通道上可以注冊我們感興趣的事件。一共有以下四種事件:

  • SelectionKey.OP_ACCEPT:服務端接收客戶端連接事件
  • SelectionKey.OP_CONNECT:客戶端連接服務端事件
  • SelectionKey.OP_READ:讀事件
  • SelectionKey.OP_WRITE:寫事件

Selector一起使用時,Channel必須處于非阻塞模式下。這意味著不能將FileChannelSelector一起使用,因為FileChannel不能切換到非阻塞模式,而套接字通道都可以。

    Selector selector = Selector.open();
    channel.configureBlocking(false);
    SelectionKey key = channel.register(selector, Selectionkey.OP_READ);

如果對不止一種事件感興趣,那么可以用“位或”操作符將常量連接起來,如下:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
SelectionKey key = channel.register(selector, interestSet);

可以將一個對象或者更多信息附著到SelectionKey上,這樣就能方便的識別某個給定的通道。例如,可以附加與通道一起使用的Buffer,或是包含聚集數據的某個對象。使用方法如下:

selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment()

還可以在用register()方法向Selector注冊Channel的時候附加對象。如:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

SelectionKey有四個方法連判斷是否為某個事件,與上面的四種事件相對應:

selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();

3.3 select

一旦向Selector注冊了一或多個通道,就可以調用select()方法返回你所感興趣的事件(如連接、接受、讀或寫)已經準備就緒的那些通道。

select()阻塞到至少有一個通道在你注冊的事件上就緒了。

select(long timeout)select()一樣,除了最長會阻塞timeout毫秒(參數)。

selectNow()不會阻塞,不管什么通道就緒都立刻返回;也可能沒有任何通道就緒,則返回零。

select()方法返回的int值表示有多少通道已經就緒。亦即,自上次調用select()方法后有多少通道變成就緒狀態。如果調用select()方法,因為有一個通道變成就緒狀態,返回了1,若再次調用select()方法,如果另一個通道就緒了,它會再次返回1。如果對第一個就緒的channel沒有做任何操作,現在就有兩個就緒的通道,但在每次select()方法調用之間,只有一個通道就緒了。

調用SelectorselectedKeys()方法,可以訪問“已選擇鍵集(selected key set)”中的就緒通道:

    Set selectedKeys = selector.selectedKeys();
    Iterator keyIterator = selectedKeys.iterator();
    while (keyIterator.hasNext()) {
        SelectionKey key = keyIterator.next();
        if (key.isAcceptable()) {
            // a connection was accepted by a ServerSocketChannel.
        } else if (key.isConnectable()) {
            // a connection was established with a remote server.
        } else if (key.isReadable()) {
            // a channel is ready for reading
        } else if (key.isWritable()) {
            // a channel is ready for writing
        }
        keyIterator.remove();
    }

注意每次迭代末尾的keyIterator.remove()調用。Selector不會自己從已選擇鍵集中移除SelectionKey實例。必須在處理完通道時自己移除。下次該通道變成就緒時,Selector會再次將其放入已選擇鍵集中。

SelectionKey.channel()方法返回的通道需要轉型成要處理的類型,如ServerSocketChannelSocketChannel等。

一個完整的例子:

public class NIOServer {
    // 通道管理器
    private Selector selector;

    /**
     * 獲得一個ServerSocket通道,并對該通道做一些初始化的工作
     * 
     * @param port
     *            綁定的端口號
     * @throws IOException
     */
    public void initServer(int port) throws IOException {
        // 獲得一個ServerSocket通道
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        // 設置通道為非阻塞
        serverChannel.configureBlocking(false);
        // 將該通道對應的ServerSocket綁定到port端口
        serverChannel.socket().bind(new InetSocketAddress(port));
        // 獲得一個通道管理器
        this.selector = Selector.open();
        // 將通道管理器和該通道綁定,并為該通道注冊SelectionKey.OP_ACCEPT事件,注冊該事件后,
        // 當該事件到達時,selector.select()會返回,如果該事件沒到達selector.select()會一直阻塞。
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
    }

    /**
     * 采用輪詢的方式監聽selector上是否有需要處理的事件,如果有,則進行處理
     */
    public void listen() throws IOException {
        System.out.println("服務端啟動成功!");
        // 輪詢訪問selector
        while (true) {
            // 當注冊的事件到達時,方法返回;否則,該方法會一直阻塞
            selector.select();
            // 獲得selector中選中的項的迭代器,選中的項為注冊的事件
            Iterator ite = this.selector.selectedKeys().iterator();
            while (ite.hasNext()) {
                SelectionKey key = (SelectionKey) ite.next();
                // 刪除已選的key,以防重復處理
                ite.remove();
                // 客戶端請求連接事件
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    // 獲得和客戶端連接的通道
                    SocketChannel channel = server.accept();
                    // 設置成非阻塞
                    channel.configureBlocking(false);

                    // 在這里可以給客戶端發送信息
                    channel.write(ByteBuffer.wrap(new String("向客戶端發送了一條信息").getBytes()));
                    // 在和客戶端連接成功之后,為了可以接收到客戶端的信息,需要給通道設置讀的權限。
                    channel.register(this.selector, SelectionKey.OP_READ);

                    // 獲得了可讀的事件
                } else if (key.isReadable()) {
                    read(key);
                }

            }

        }
    }

    /**
     * 處理讀取客戶端發來的信息 的事件
     * 
     * @param key
     * @throws IOException
     */
    public void read(SelectionKey key) throws IOException {
        // 服務器可讀取消息:得到事件發生的Socket通道
        SocketChannel channel = (SocketChannel) key.channel();
        // 創建讀取的緩沖區
        ByteBuffer buffer = ByteBuffer.allocate(10);
        channel.read(buffer);
        byte[] data = buffer.array();
        String msg = new String(data).trim();
        System.out.println("服務端收到信息:" + msg);
        ByteBuffer outBuffer = ByteBuffer.wrap(msg.getBytes());
        channel.write(outBuffer);// 將消息回送給客戶端
    }

    /**
     * 啟動服務端測試
     * 
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {
        NIOServer server = new NIOServer();
        server.initServer(8000);
        server.listen();
    }

}

4、AsynchronousChannel

NIO除了提供了非阻塞IO,還提供了異步IO。阻塞/非阻塞、同步/異步是兩對比較容易混淆的概念,在此解釋一下。

4.1 同步/異步

同步/異步, 它們是消息的通知機制。

所謂同步,就是在發出一個功能調用時,在沒有得到結果之前,該調用就不返回。按照這個定義,其實絕大多數函數都是同步調用。但是一般而言,我們在說同步、異步的時候,特指那些需要其他部件協作或者需要一定時間完成的任務。

異步的概念和同步相對,當一個異步過程調用發出后,調用者不會立刻得到結果。實際處理這個調用的部件是在調用發出后,通過狀態、消息、回調函數等來通知調用者來處理結果。

舉個例子,小明他媽(調用方)派小明(被調用方)去車站迎接客人,小明一直在車站等到客人到達,把客人帶回家,交給他媽。這就是同步調用。

小明嫌在車站等著無聊,改為每隔五分鐘就出去看一次,立即回來告訴他媽客人到沒到,這就是異步調用。

4.2阻塞/非阻塞

阻塞/非阻塞, 它們是程序在等待消息(無所謂同步或者異步)時的狀態。

阻塞調用是指調用結果返回之前,當前線程會被掛起。有人也許會把阻塞調用和同步調用等同起來,實際上他是不同的。對于同步調用來說,很多時候當前線程還是激活的,只是從邏輯上當前函數沒有返回而已。

非阻塞和阻塞的概念相對應,指在不能立刻得到結果之前,該函數不會阻塞當前線程,而會立刻返回。

還是小明他媽(調用方)派小明(被調用方)去車站迎接客人,在客人到來之前,小明他媽什么都不干,專心等待客人,這就是阻塞調用。

后來,小明他媽變聰明了,在客人到來之前,她可以洗菜、拖地、聽聽歌,客人來了之后再招待客人,這就是非阻塞調用

同步大部分是阻塞的,異步大部分是非阻塞的,但是它們之間并沒有必然的因果關系

4.3 異步通道

Java NIO中有三種異步通道:AsynchronousFileChannelAsynchronousServerSocketChannelAsynchronousSocketChannel

異步調用主要有兩種方式:將來式回調式

將來用式用java.util.concurrent包下的Future接口來保存異步操作的處理結果。這意味著當前線程不會因為比較慢的IO操作而停止,而是開啟一個單獨的線程發起IO操作,并在操作完成時返回結果。與此同時,主線程可以繼續執行其他需要完成的任務。

從硬盤上的文件里讀取100000字節,將來式可以這么做:

    Path file = Paths.get("/Users/winner/Desktop/foobar.txt");
    try {
        AsynchronousFileChannel channel = AsynchronousFileChannel.open(file);
        ByteBuffer buffer = ByteBuffer.allocate(100_000);
        Future<Integer> result = channel.read(buffer, 0);
        while (!result.isDone()) {
            System.out.println("do someting else");
        }
        buffer.flip();
        System.out.println(Charset.forName("UTF-8").decode(buffer));
    } catch (Exception e) {

    }

AsynchronousFileChannel會關聯線程池,可以在創建時指定,如果沒有指定,JVM會為其分配一個系統默認的線程池(可能會與其他通道共享),默認線程池是由AsynchronousChannelGroup類定義的系統屬性進行配置的。

回調式的基本思想是主線程會派一個CompletionHandler到獨立的線程中執行IO操作,當IO操作完成后,會調用(或失敗)CompletionHandler的completed(failed)方法。

異步事件一成功或失敗就需要馬上采取行動時,一般會采用回調式。

在異步IO活動結束后,接口java.nio.channels.CompletionHandler<V,A>會被調用,其中V是結果類型,A是提供結果的附著對象。

同樣從硬盤上的文件里讀取100000字節,回調式可以這么做:

    try {
        AsynchronousFileChannel channel = AsynchronousFileChannel.open(file);
        ByteBuffer buffer = ByteBuffer.allocate(100_000);

        channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {

            @Override
            public void completed(Integer result, ByteBuffer attachment) {
                attachment.flip();
                System.out.println(Charset.forName("UTF-8").decode(attachment));
            }

            @Override
            public void failed(Throwable exc, ByteBuffer attachment) {
                System.out.println("Parse file failed:");
                exc.printStackTrace();
            }
        });

    } catch (Exception e) {
        e.printStackTrace();
    }
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容