【NIO】Java NIO之通道

leesf發表於2017-04-16

一、前言

  前面學習了緩衝區的相關知識點,接下來學習通道。

二、通道

  2.1 層次結構圖

  對於通道的類層次結構如下圖所示。

  

  其中,Channel是所有類的父類,其定義了通道的基本操作。從 Channel 介面引申出的其他介面都是面向位元組的子介面,包括 WritableByteChannel和ReadableByteChannel。這也意味著通道只能在位元組緩衝區上操作

  2.2 通道基礎

  Channel介面類只定義了兩個方法(isOpen和close),分別表示通道是否開啟和關閉通道,具體細節需要子類實現。   

  IO操作可分為File IO和Stream IO,對應通道也有它們是檔案( file)通道和套接字( socket)通道 。通道可以有多種方式建立。Socket 通道有可以直接建立新 socket 通道的工廠方法。但File通道不能直接建立,只能通過在一個開啟的RandomAccessFile、FileInputStream或FileOutputStream的物件上呼叫getChannel( )方法來獲取。

  通道將資料傳輸給 ByteBuffer 物件或者從 ByteBuffer 物件獲取資料進行傳輸,通道可以是單向( unidirectional)或者雙向的( bidirectional)。一個 channel 類可能實現定義read( )方法的 ReadableByteChannel 介面,而另一個 channel 類也許實現 WritableByteChannel 介面以提供 write( )方法。實現這兩種介面其中之一的類都是單向的,只能在一個方向上傳輸資料。如果一個類同時實現這兩個介面,那麼它是雙向的,可以雙向傳輸資料。如ByteChannel 介面,該介面繼承 ReadableByteChannel 和WritableByteChannel 兩個介面,可雙向傳輸資料。

  值得注意的是,FileInputStream 物件的getChannel( )方法獲取的 FileChannel 物件是隻讀的,不過從介面宣告的角度來看卻是雙向的,因為FileChannel 實現 ByteChannel 介面。在這樣一個通道上呼叫 write( )方法將丟擲未經檢查的NonWritableChannelException 異常,因為 FileInputStream 物件總是以 read-only 的許可權開啟檔案。

  通道會連線一個特定 I/O 服務且通道例項( channel instance)的效能受它所連線的 I/O 服務的特徵限制。如一個連線到只讀檔案的 Channel 例項不能進行寫操作,即使該例項所屬的類可能有 write( )方法。

  通道可以以阻塞( blocking)或非阻塞( nonblocking)模式執行,非阻塞模式的通道永遠不會讓呼叫的執行緒休眠。請求的操作要麼立即完成,要麼返回一個結果表明未進行任何操作。只有面向流的( stream-oriented)的通道,如 sockets 和 pipes 才能使用非阻塞模式。

  通道不能被重複使用,一個開啟的通道即代表與一個特定 I/O 服務的特定連線並封裝該連線的狀態。當通道關閉時,連線會丟失,通道將不再連線任何東西

  2.3 Scatter/Gather

  Scatter/Gather是指在多個緩衝區上實現一個簡單的 I/O 操作。

  對於write操作而言,資料是從幾個緩衝區按順序抽取(稱為 gather)並沿著通道傳送的,該 gather 過程的效果好比將全部緩衝區的內容連結起來,並在傳送資料前存放到一個大的緩衝區中。

  對於read操作而言,從通道讀取的資料會按順序被散佈(稱為 scatter)到多個緩衝區,將每個緩衝區填滿直至通道中的數據或者緩衝區的最大空間被消耗完。

  如下程式碼片段,假定 channel 連線到一個有 48 位元組資料等待讀取的 socket

ByteBuffer header = ByteBuffer.allocateDirect (10);
ByteBuffer body = ByteBuffer.allocateDirect (80);
ByteBuffer [] buffers = { header, body };
int bytesRead = channel.read (buffers);

  此時,bytesRead為48,header 緩衝區將包含前 10 個從通道讀取的位元組而 body 緩衝區則包含接下來的 38 個位元組,緊接著,下面程式碼片段  

body.clear( );
body.put("FOO".getBytes()).flip( ); // "FOO" as bytes
header.clear( );
header.putShort (TYPE_FILE).putLong (body.limit()).flip( );
long bytesWritten = channel.write (buffers);

  則將不同buffer(header、body)中的資料gather起來寫入通道,總共傳送13個位元組(3 + 2 + 8)。

  Scatter和Gather將讀取到的資料分開存放到多個儲存桶( bucket)或者將不同的資料區塊合併成一個整體

  2.4 檔案通道

  FileChannel 類可以實現常用的 read, write 以及 scatter/gather 操作,同時它也提供了很多專用於檔案的新方法。並且檔案通道總是阻塞式的,因此不能被置於非阻塞模式。對於檔案 I/O,最強大之處在於非同步 I/O( asynchronous I/O),它允許一個程式可以從作業系統請求一個或多個 I/O 操作而不必等待這些操作的完成,發起請求的程式之後會收到它請求的 I/O 操作已完成的通知。 

  FileChannel不能直接建立,需要使用getChannel方法獲取,並且其是執行緒安全( thread-safe)的。多個程式可以在同一個例項上併發呼叫方法而不會引起任何問題,不過並非所有的操作都是多執行緒的,如影響通道位置或者影響檔案大小的操作都是單執行緒的,如果一個執行緒在執行會影響通道位置或檔案大小時,那麼其他嘗試進行此類操作的執行緒必須等待。

  每個 FileChannel 物件與檔案描述符是一對一關係,同底層的檔案描述符一樣,每個 FileChannel 都有一個叫"file position"的概念,這個position值決定檔案中哪一處的資料接下來將被讀或者寫,這與緩衝非常相似。

  FileChannel 位置( position)是從底層的檔案描述符獲得的,該 position 同時被作為通道引用獲取來源的檔案物件共享。這也就意味著一個物件對該 position 的更新可以被另一個物件看到,如下程式碼片段  

RandomAccessFile randomAccessFile = new RandomAccessFile ("filename", "r");
// Set the file position
randomAccessFile.seek (1000);
// Create a channel from the file
FileChannel fileChannel = randomAccessFile.getChannel( );
// This will print "1000"
System.out.println ("file pos: " + fileChannel.position( ));
// Change the position using the RandomAccessFile object
randomAccessFile.seek (500);
// This will print "500"
System.out.println ("file pos: " + fileChannel.position( ));
// Change the position using the FileChannel object
fileChannel.position (200);
// This will print "200"
System.out.println ("file pos: " + randomAccessFile.getFilePointer( ));

  可以看到,隨著randomAccessFile設定不同的position,fileChannel的position也會相應的跟著改變。

  類似於緩衝區的 get( ) 和 put( )方法,當位元組被 read( )或 write( )方法傳輸時,檔案 position 會自動更新。如果 position 值達到了檔案大小的值(檔案大小的值可以通過 size( )方法返回), read( )方法會返回一個檔案尾條件值( -1)。可是,不同於緩衝區的是,如果實現 write( )方法時 position前進到超過檔案大小的值,該檔案會擴充套件以容納新寫入的位元組

  同樣類似於緩衝區,也有帶 position 引數的絕對形式的 read( )和 write( )方法。這種絕對形式的方法在返回值時不會改變當前的檔案 position。由於通道的狀態無需更新,因此絕對的讀和寫可能會更加有效率,操作請求可以直接傳到原生程式碼。更妙的是,多個執行緒可以併發訪問同一個檔案而不會相互產生干擾。這是因為每次呼叫都是原子性的( atomic),並不依靠呼叫之間系統所記住的狀態。

  對於FileChannel實現的檔案鎖定模型而言,鎖的物件是檔案而不是通道或執行緒,這意味著檔案鎖不適用於判優同一臺 Java 虛擬機器上的多個執行緒發起的訪問。如果一個執行緒在某個檔案上獲得了一個獨佔鎖,然後第二個執行緒利用一個單獨開啟的通道來請求該檔案的獨佔鎖,那麼第二個執行緒的請求會丟擲OverlappingFileLockException異常。但如果這兩個執行緒執行在不同的 Java 虛擬機器上,那麼第二個執行緒會阻塞,因為鎖最終是由作業系統或檔案系統來判優的並且幾乎總是在程式級而非執行緒級(同一JVM上的執行緒)上判優。鎖都是與一個檔案關聯的,而不是與單個的檔案控制程式碼或通道關聯。如下示例展示了同一JVM上的兩個執行緒使用同一檔案鎖。  

import java.io.FileOutputStream;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;

/**
 * Created by LEESF on 2017/4/16.
 */
public class FileLockDemo {
    public static void main(String[] args) throws Exception {
        FileOutputStream fileOutputStream = new FileOutputStream("F://test.txt");
        FileChannel fileChannel = fileOutputStream.getChannel();

        Thread thread1 = new Thread(new MyRunnalbe(fileChannel));
        Thread thread2 = new Thread(new MyRunnalbe(fileChannel));

        thread1.start();
        thread2.start();
    }

    static class MyRunnalbe implements Runnable {
        private FileChannel fileChannel;

        public MyRunnalbe(FileChannel fileChannel) {
            this.fileChannel = fileChannel;
        }

        @Override
        public void run() {
            try {
                FileLock fileLock= fileChannel.lock();
                System.out.println(fileLock.isValid());
                Thread.sleep(1000);
            } catch (Exception ex) {
                System.out.println(Thread.currentThread().getName() + " " + ex);
            }
        }
    }
}

  輸出結果  

true
Thread-1 java.nio.channels.OverlappingFileLockException

  可以看到,當thread2獲取鎖後,thread1再獲取鎖時,發出現異常。

  鎖與檔案關聯,而不是與通道關聯。我們使用鎖來判優外部程式,而不是判優同一個 Java 虛擬機器上的執行緒

  2.5 記憶體對映檔案

  新的 FileChannel 類提供了一個名為 map( )的方法,該方法可以在一個開啟的檔案和一個特殊型別的 ByteBuffer 之間建立一個虛擬記憶體對映,由 map( )方法返回的 MappedByteBuffer 物件的行為類似與基於記憶體的緩衝區,只不過該物件的資料元素儲存在磁碟上的檔案中。通過記憶體對映機制來訪問一個檔案會比使用常規方法讀寫高效得多,甚至比使用通道的效率都高。

  當需要對映整個檔案時,可使用如下程式碼片段 

buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());

  與檔案鎖的範圍機制不一樣,對映檔案的範圍不應超過檔案的實際大小。如果您請求一個超出檔案大小的對映,檔案會被增大以匹配對映的大小。

  同常規的檔案控制程式碼類似,檔案對映可以是可寫的或只讀的。前兩種對映模式MapMode.READ_ONLY 和 MapMode.READ_WRITE 意義是很明顯的,它們表示你希望獲取的對映只讀還是允許修改對映的檔案。請求的對映模式將受被呼叫 map( )方法的 FileChannel 物件的訪問許可權所限制,如果通道是以只讀的許可權開啟的而您卻請求 MapMode.READ_WRITE 模式,那麼map( )方法會丟擲一個 NonWritableChannelException 異常;如果您在一個沒有讀許可權的通道上請求MapMode.READ_ONLY 對映模式,那麼將產生 NonReadableChannelException 異常。不過在以read/write 許可權開啟的通道上請求一個 MapMode.READ_ONLY 對映卻是允許的。而MapMode.PRIVATE 模式表示一個寫時拷貝( copy-on-write)的對映,這意味著通過 put( )方法所做的任何修改都會導致產生一個私有的資料拷貝並且該拷貝中的資料只有MappedByteBuffer 例項可以看到。該過程不會對底層檔案做任何修改,而且一旦緩衝區被施以垃圾收集動作( garbage collected),那些修改都會丟失。

  FileChannel的transferTo( )和 transferFrom( )方法允許將一個通道交叉連線到另一個通道,而不需要通過一箇中間緩衝區來傳遞資料。

  2.6 Socket通道

  Socket 通道有與檔案通道不同的特徵, 一個或幾個執行緒就可以管理成百上千的活動 socket 連線了並且只有很少甚至可能沒有效能損失。

  DatagramChannel 和 SocketChannel 實現定義讀和寫功能的介面而 ServerSocketChannel不實現。 ServerSocketChannel 負責監聽傳入的連線和建立新的 SocketChannel 物件,它本身從不傳輸資料。socket 通道類( DatagramChannel、SocketChannel和ServerSocketChannel)在被例項化時都會建立一個對等socket物件。

  所有通道可以在非阻塞情況下執行,這依託於SelectableChannel,使用其configureBlocking方法即可配置是否阻塞。

  ServerSocketChannel 是一個基於通道的 socket 監聽器,能夠在非阻塞模式下執行。

  當需要對一個埠進行監聽時,需要獲取通道對應的 socket,然後使用socket繫結到指定埠進行監聽,常用程式碼如下 

ServerSocketChannel ssc = ServerSocketChannel.open( );
ServerSocket serverSocket = ssc.socket( );
// Listen on port 1234
serverSocket.bind (new InetSocketAddress (1234));

  在完成繫結後,可以使用ServerSocketChannel或者ServerSocket的accept方法來接受到達通道的連線,當使用ServerSocketChannel的accept時,會返回 SocketChannel 型別的物件,返回的物件能夠在非阻塞模式下執行。 而當使用ServerSocket時的accept時,總是阻塞並返回一個 java.net.Socket 物件。因此較優的做法是使用ServerSocketChannel的accept方法,下面是監聽1234埠的示例。 

import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.net.InetSocketAddress;

/**
 * Created by LEESF on 2017/4/16.
 */

public class ChannelAccept {
    public static final String GREETING = "Hello I must be going.\r\n";

    public static void main (String [] argv) throws Exception {
        int port = 1234; // default
        if (argv.length > 0) {
            port = Integer.parseInt (argv [0]);
        }
        ByteBuffer buffer = ByteBuffer.wrap (GREETING.getBytes( ));
        ServerSocketChannel ssc = ServerSocketChannel.open( );
        ssc.socket( ).bind (new InetSocketAddress (port));
        ssc.configureBlocking (false);
        while (true) {
            System.out.println ("Waiting for connections");
            SocketChannel sc = ssc.accept( );
            if (sc == null) {
                // no connections, snooze a while
                Thread.sleep (2000);
            } else {
                System.out.println ("Incoming connection from: "
                        + sc.socket().getRemoteSocketAddress( ));
                buffer.rewind( );
                sc.write (buffer);
                sc.close( );
            }
        }
    }
}

   SocketChannel是使用最多的 socket 通道類,Socket 和 SocketChannel 類封裝點對點、有序的網路連線,類似於 TCP/IP網路連線,SocketChannel 扮演客戶端,會發起和一個監聽伺服器的連線。每個 SocketChannel 物件建立時都會同一個對等的 java.net.Socket 物件對應。

  下面程式碼片段會連線指定主機的指定埠。  

SocketChannel socketChannel = SocketChannel.open( );
socketChannel.connect (new InetSocketAddress ("somehost", somePort));

  如果通過Socket的connect方法進行連線,那麼執行緒在連線建立好或超時過期之前都將保持阻塞;而若通過SocketServer的connect方法進行連線,那麼會對發起對請求地址的連線並且立即返回值。如果返回值是 true,說明連線立即建立了(這可能是本地環回連線);如果連線不能立即建立, connect( )方法會返回 false 且併發地繼續連線建立過程。

  Socket 通道是執行緒安全的。併發訪問時無需特別措施來保護髮起訪問的多個執行緒,不過任何時候都只有一個讀操作和一個寫操作在進行中。

  如下示例完成了客戶端向服務端之間的資料傳送,使用ServerSocketChannel充當服務端,SocketChannel充當客戶端,客戶端向服務端傳送資料,服務端接收後響應客戶端,然後服務端關閉,客戶端接收到響應資料後,關閉,程式碼如下

  服務端  

import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;

/**
 * Created by LEESF on 2017/4/16.
 */
public class ServerSocketChannelDemo {
    public static void main(String[] args) throws Exception {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocketChannel.configureBlocking(false);
        serverSocket.bind(new InetSocketAddress("localhost", 1234));

        while (true) {
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (socketChannel != null) {
                ByteBuffer byteBuffer = ByteBuffer.allocate(512);
                socketChannel.read(byteBuffer);
                byteBuffer.flip();
                System.out.println("server received message: " + getString(byteBuffer));
                byteBuffer.clear();
                String message = "server sending message " + System.currentTimeMillis();
                System.out.println("server sends message: " + message);
                byteBuffer.put(message.getBytes());
                byteBuffer.flip();
                socketChannel.write(byteBuffer);
                break;
            }
        }
        try {
            serverSocketChannel.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static String getString(ByteBuffer buffer) {
        Charset charset;
        CharsetDecoder decoder;
        CharBuffer charBuffer;
        try {
            charset = Charset.forName("UTF-8");
            decoder = charset.newDecoder();
            charBuffer = decoder.decode(buffer.asReadOnlyBuffer());
            return charBuffer.toString();
        } catch (Exception ex) {
            ex.printStackTrace();
            return "";
        }
    }
}

  客戶端  

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;

/**
 * Created by LEESF on 2017/4/16.
 */
public class SocketChannelDemo {
    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost", 1234));
        String message = "client sending message " + System.currentTimeMillis();
        ByteBuffer byteBuffer = ByteBuffer.allocate(512);
        byteBuffer.clear();
        System.out.println("client sends message: " + message);
        byteBuffer.put(message.getBytes());
        byteBuffer.flip();
        socketChannel.write(byteBuffer);

        while (true) {
            byteBuffer.clear();
            int readBytes = socketChannel.read(byteBuffer);
            if (readBytes > 0) {
                byteBuffer.flip();
                System.out.println("client receive message: " + getString(byteBuffer));

                break;
            }
        }

        try {
            socketChannel.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static String getString(ByteBuffer buffer) {
        Charset charset;
        CharsetDecoder decoder;
        CharBuffer charBuffer;
        try {
            charset = Charset.forName("UTF-8");
            decoder = charset.newDecoder();
            charBuffer = decoder.decode(buffer.asReadOnlyBuffer());
            return charBuffer.toString();
        } catch (Exception ex) {
            ex.printStackTrace();
            return "";
        }
    }
}

  服務端結果如下 

server received message: client sending message 1492334785236
server sends message: server sending message 1492334785276

  客戶端結果如下  

client sends message: client sending message 1492334785236
client receive message: server sending message 1492334785276

  每個DatagramChannel 物件關聯一個DatagramSocket 物件,DatagramChannel 是模擬無連線協議(如 UDP/IP)。DatagramChannel 物件既可以充當伺服器(監聽者)也可以充當客戶端(傳送者)。如果新建立的通道負責監聽,那麼通道必須首先被繫結到一個埠或地址/埠組合上。  

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

  2.7 管道

  管道就是一個用來在兩個實體之間單向傳輸資料的導管,Pipe 類實現一個管道範例,不過它所建立的管道是程式內(在 Java 虛擬機器程式內部)而非程式間使用的。Pipe 類建立一對提供環回機制的 Channel 物件。這兩個通道的遠端是連線起來的,以便任何寫在 SinkChannel 物件上的資料都能出現在 SourceChannel 物件上。管道可以被用來僅在同一個 Java 虛擬機器內部傳輸資料。

  當向管道中寫入資料時,需要訪問Pipe的sink通道,當從管道中讀取資料時,需要訪問Pipe的source通道,下面示例展示了通道的讀寫操作    

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.Pipe;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;

/**
 * Created by LEESF on 2017/4/16.
 */

public class PipeDemo {
    public static void main (String [] argv) throws Exception {
        Pipe pipe = Pipe.open();
        Pipe.SinkChannel sinkChannel = pipe.sink();
        String newData = "New String to write to file..." + System.currentTimeMillis();
        ByteBuffer buf = ByteBuffer.allocate(48);
        buf.clear();
        System.out.println("writing data: " + newData);
        buf.put(newData.getBytes());
        buf.flip();
        while (buf.hasRemaining()) {
            sinkChannel.write(buf);
        }

        Pipe.SourceChannel sourceChannel = pipe.source();
        ByteBuffer byteBuffer = ByteBuffer.allocate(48);
        sourceChannel.read(byteBuffer);
        byteBuffer.flip();
        String strs = getString(byteBuffer);
        System.out.println("reading data: " + strs);
    }

    public static String getString(ByteBuffer buffer) {
        Charset charset;
        CharsetDecoder decoder;
        CharBuffer charBuffer;
        try {
            charset = Charset.forName("UTF-8");
            decoder = charset.newDecoder();
            charBuffer = decoder.decode(buffer.asReadOnlyBuffer());
            return charBuffer.toString();
        } catch (Exception ex) {
            ex.printStackTrace();
            return "";
        }
    }
}

  執行結果 

writing data: New String to write to file...1492332436279
reading data: New String to write to file...1492332436279

  NIO還提供了通道工具類共使用,即可以通過java.nio.channels.Channels類建立通道等操作。

三、總結

  本篇博文講解了通道,包括檔案通道和套接字通道,以及通道與緩衝之間gather和scatter的操作,更多具體內容,有興趣的讀者可以查閱原始碼進一步學習,也謝謝各位園友的觀看~

相關文章