Java NIO(一)

約定

有很多人會將Java NIO分為Java NIO和Java NIO2,分別指jdk1.4引入的新IO和jdk1.7引入的新IO,在本人IO系列的文章里,將Java IO編程模型分為三種,Java BIO即Java IO標準庫、Java NIO即jdk1.4引入的新IO包、Java AIO即jdk1.7引入的新IO包,盡管這樣的描述不是非常的精確,但是這樣方便在概念上將其區分開來。

此IO系列文章計劃討論NIO和AIO,本文聚焦NIO。

引言

Java NIO 并不是什么新技術,但對java程序員來說,NIO的概念可能了解了但并沒有機會深入研究,因為多數程序員都疲于編寫應用層的業務代碼,很少觸及高效的IO編程,除非你有機會涉及分布式應用且要自己處理通信,比如Hadoop的遠程調用,或者編寫如jetty這樣的http server。但除了分布式應用,我們依然可以在文件處理的相關應用中使用它,因此,好好掌握它是有實際意義的。

NIO在JDK1.4中引進,其本意是new IO,當然,因為其提供了非阻塞IO編程模型,也有人稱之為非阻塞IO。
和我們經常用的java 標準IO相比主要有兩個區別:

  1. 標準IO面向流處理數據,NIO面向塊處理數據

    利用標準IO編程需要和字節流以及字符串流打交道,對于NIO編程需要和管道(channel) 和 數據塊(buffer)打交道,數據經常會從channel讀到buffer,或者從buffer寫到channel;面向流的處理方式是一次處理一個字節,NIO的每次操作將創建或消費一個塊,因此處理起來更加快速,但是也失去了面向流編程的簡單直接的編程體驗。

  2. 標準IO提供的是阻塞的編程模型,而NIO是非阻塞編程模型

    在以前的文章里解釋過阻塞和非阻塞的區別,標準IO在讀寫數據時,在讀寫未完成前,線程一直無法做其他事情。NIO編程,在將數據從channel讀取到buffer里時,線程可以做其他事情,一旦數據被讀入buffer,線程可以去處理它。

盡管如此,標準IO和NIO間做了些融合,一些IO包的api可以讀寫塊,NIO也可以按字節操作。另外,這兩種IO編程模型并不存在誰取代誰的論調,它們在實際應用中有著各自的特色。BIO和NIO之間最典型的應用區別在于網絡編程的應用上,他們之間就是殺雞刀和宰牛刀的區別。

  1. 標準IO模型概念簡單直接,編程方式優雅,但因其是阻塞的IO模型,所以其適合處理并發量較小的場景。如java程序員經常會用到的java遠程調試,目標虛擬機只允許一個調試客戶端對其進行調試,調試客戶端和目標虛擬機的通信完全就是獨占模式,這個場景用個小巧的殺雞刀就可以了。

  2. NIO是非阻塞的IO模型,一個線程就可以同時處理多個請求,提高了單位時間內創建連接數能力,適合高訪問量的在線服務。

管道和數據塊

小時候鄰居家有口壓水井,那里承載著無數泛黃的記憶,左鄰右舍常常提著桶去打水,用來洗衣做飯,而每到此時,小伙伴們便去爭先恐后的壓手柄,伴隨著賣力的喘氣聲,井里的水在氣壓的作用下涌出,看見清澈的涼水流入水桶有種莫名的成就感,那種簡簡單單的歡呼雀躍簡直比復雜的編程舒服多了。
誰都沒有想到,若干年后其中一個孩子干起了編程,并把壓水井的管道以及水桶比作NIO的管道和數據塊了。

NIO中有兩個核心概念,即管道(channel)和數據塊(buffer),它們將用在NIO的每個IO操作中。

在NIO的世界里,數據流轉必須通過channel進行,它代表一個連接對象,連接目標可以是某個硬件設備、一個文件、一個socket、或者一個能進行IO操作的程序組件。NIO中主要的channel包括:FileChannel DatagramChannel SocketChannel ServerSocketChannel,主要涉及UDP TCP網絡IO和文件IO。channel類似標準IO的stream,但channel不同于stream,它是雙向的,可以通知支持讀寫,而stream是單向的,要么只能讀要么只能寫。我們可以讀取channel里的數據,也可以往channel里寫入數據,但是channel不提供具體的數據操作能力,對channel的讀寫都必須通過buffer來操作。

buffer是一個數據容器,所有要寫入到channel的數據必須先存入buffer,然后告訴channel打算寫入管道的數據在哪個buffer里,從channel讀取的任務數據時,也要告訴channel,將讀取的數據放到哪個buffer里。因此,buffer是從channel讀取數據或將數據寫入channel的中間載體,其本質是一個數組,提供了結構化的數據訪問能力,它會跟蹤系統的讀寫過程。

NIO為每種java基本類型提供了buffer類:ByteBuffer CharBuffer ShortBuffer IntBuffer LongBuffer FloatBuffer DoubleBuffer 它們都繼承與抽象的Buffer類。

在本文里我們將熟悉常用的文件管道FileChannel和字節數據塊ByteBuffer。

直觀的感受

讀取文件

首先,從標準IO的文件輸入流打開一個文件管道,創建一個容量為1024字節的buffer,然后將文件管道里的數據讀取到buffer。最終讀取到多少是不確定的,取決于文件管道所剩的數據量和buffer的容量,但是如果read方法一旦返回-1,代表讀到了文件末尾。

FileInputStream in = new FileInputStream("/xxx.txt");
FileChannel c = in.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
c.read(buffer);

寫文件

首先,從文件輸出流打開一個文件管道,創建一個容量為1024字節的buffer,將字符串內容存入buffer,再將buffer內容寫入管道。

FileOutputStream out = new FileOutputStream("/xxx.txt");
FileChannel c = in.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
buffer.put('something you like'.getBytes());
buffer.flip();
c.write(buffer);

拷貝文件

拷貝一個文件到另一個文件,其大致過程是不斷的讀取源管道的內容到buffer,再將buffer的內容寫入目標管道。buffer的clear和flip操作將在后面細說。

FileInputStream in = new FileInputStream("/src");
FileChannel fcin = in.getChannel();
FileOutputStream out = new FileOutputStream("/target");
FileChannel fcout = in.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
while(true){
    buffer.clear();
    int rl = fcin.read(buffer);
    if(rl==-1){
        break;
    }
    buffer.flip();
    fcout.write(buffer);
}

ByteBuffer

buffer的讀寫狀態

buffer本質上是一個序號從0開始計數的數組,在每次讀寫操作后其讀寫狀態都會發生變化,主要體現在position,limit,capacity三個變量上。

positon指向下次應該讀取或寫入的位置。對于從管道讀取內容放到buffer里的場景,position指向下次從管道讀取數據時應該寫入buffer的位置,如目前從管道里一共讀取了10個字節,那么position就指向位置10,它是下一個數據存放位置。對于將buffer里的內容寫入到管道的場景,position代表下次向管道寫入的數據位于buffer的哪個位置,如目前寫入了20個字節,那么position就指向位置20,它是下一個數據讀取位置。

limit是buffer的讀寫邊界。對于從管道讀取內容放到buffer里的場景,limit代表能夠寫入buffer的空間長度,它指向buffer最大允許寫入位置的下一個位置;對于將buffer里的內容寫入到管道的場景,limit是決定了buffer里有多少數據可被寫入到管道,具體來說,它指向buffer最大允許讀取位置的下一個位置。

capacity是buffer的最大容量,就是buffer底層數組的size。

任何情況下,position<=limit<=capacity。

創建ByteBuffer

通過靜態方法ByteBuffer.allocate,或wrap包裝方法,通過wrap包裝的方式其包裝的數組的數據內容會和buffer的數據內容一致。

clear和flip

如上所說,buffer其實就是一塊連續的內存,那么申請到一塊內存是有系統開銷的,對應用程序而言應該充分利用好這塊內存,不要頻繁創建。例如上面給出的拷貝文件的例子就充分利用了臨時申請的buffer。

特別是經常處于讀寫切換的場景,如從一個管道讀內容到buffer,再將buffer寫入到其他管道完成數據拷貝。更應該充分利用clear和flip。

clear操作:使limit=capacity position=0,和新創建一個buffer時的狀態一致。

flip操作:limit=position position=0。

使用buffer來讀寫管道內容遵循四個步驟

  1. 從管道讀取數據,此時數據寫入buffer
  2. 調用buffer.clip方法,將buffer轉為待讀模式
  3. 讀取buffer的內容,將其寫入另一個管道
  4. 調用buffer.clear方法,將buffer轉為待寫模式

讀寫

get和put操作是讀取和寫入方法,分絕對位置和相對位置操作,和相對位置操作相比,絕對位置的讀寫操作不影響buffer的狀態。

ByteBuffer.get(int index)可以指定位置讀取。ByteBuffer.get()按著當前position指定的位置讀取,讀取完后position自動后移一位;ByteBuffer.put(int index,byte b)指定具體的寫入位置,ByteBuffer.put(byte b)position指定的位置寫入,寫入后position后移一位。

分片

slice可以創建子buffer,子buffer的內容是從當前position截取到limit-1,子buffer和父buffer各自的讀寫狀態獨立,但是數據是共享的。改變了子buffer的數據內容,父buffer對應的數據內容也會改變。

控制只讀

asReadOnlyBuffer可以將buffer轉為只讀buffer,在有些時候你防止其他邏輯修改buffer內容時可以使用

FileChannel

FileChannel對象是一個連接文件的管道,通過該管道可以讀寫目標文件。另外,要強調的是FileChannel雖然屬于NIO范疇,但其并不是非阻塞模式的管道,因此,在讀寫文件管道時其和被java程序員所熟悉的文件輸入輸出流一樣依然屬于阻塞模式,這也是為什么其用在文件讀寫的概率不高的原因。

創建文件管道

創建文件管道可以通過IO標準庫的FileInputStream和FileOutputStream,以及RandomAccessFile的getChannel方法打開一個文件管道。如下代碼:

public final FileChannel getChannel()

也可以通過FileChannel提供的靜態的open方法

public static FileChannel open(Path path, OpenOption... options)
public static FileChannel open(Path path,
                                   Set<? extends OpenOption> options,
                                   FileAttribute<?>... attrs)

讀寫管道

通過read方法可以將文件管道中的數據讀取到buffer中,如下代碼:

ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = channel.read(buffer);

read方法返回int類型的返回值,表示從管道中讀取了多少字節的數據并存入buffer內,如果返回值為-1,表示已經讀到了文件末尾。

同樣的,通過write方法可以將buffer中的數據寫入文件管道,如下代碼:

String s = "something to write";
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put(s.getBytes());
buffer.flip();
channel.write(buffer);

分散讀和聚集寫

可以將channel的數據讀到多個buffer中,如channel.read(ByteBuffer[])方法,稱之為分散讀,當管道填滿了第一個buffer,它會自動向下個buffer寫入數據。分散讀通常用在對管道中的數據進行固定分片,每個片段的數據具有固定意義的場景,這樣通過一次讀管道即可取出有整體意義的分片數據,如下代碼:

ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body   = ByteBuffer.allocate(1024);
ByteBuffer[] bufferArray = { header, body };
channel.read(bufferArray);

同樣的,也可以將多個buffer的數據寫入到管道,如channel.write(ByteBuffer[]),管道會按順序將buffer內的數據寫入管道,稱之為聚集寫。當然,寫入管道的數據是每個buffer的postion至limit之間的數據。如下代碼:

ByteBuffer[] bufferArray = { header, body };
channel.write(bufferArray);

管道間數據轉移

FileChannel提供了文件管道和其他管道間數據轉移的方法,其他管道指的是實現了ReadableByteChannel或WritableByteChannel的管道,主要包括常用的文件管道,和涉及網絡編程的socket管道以及數據報管道。因此,如果要真的實現一個文件拷貝的功能,用管道間的數據轉移功能是相當簡便的。

public abstract long transferFrom(ReadableByteChannel src,long position, long count)
public abstract long transferTo(long position, long count, WritableByteChannel target)

position和size

文件管道提供了獲取和設置position的方法,如果設置新的position超過文件末尾,讀取動作將返回-1,寫入動作會導致先前的文件末尾和新的position之間形成空缺。

long position()
FileChannel position(long newPosition)

size方法可以返回文件的大小

long size()

對于可寫的文件管道,通過truncate方法可以以任意大小截取管道,否則會拋出NonWritableChannelException異常。指定的size小于當前文件大小才會重建,否則不會發生變化,另外,重建是從位置0開始截取,截取size個字節。

FileChannel truncate(long size)

強制寫入

為了提高性能,操作系統可能會緩存一些數據在內存里且并未寫入磁盤,文件管道提供了force方法,強迫所有修改寫入磁盤,其boolean類型的參數表示文件的元數據是否一起強制寫入。這個方法在防止系統崩潰導致數據丟失時很有用。

void force(boolean metaData)

文件鎖

文件管道提供了對文件進行加鎖的方法:

FileLock lock()
FileLock lock(long position, long size, boolean shared)
FileLock tryLock()
FileLock tryLock(long position, long size, boolean shared)

lock和tryLock的區別在于前者以阻塞的方式請求文件鎖,而第二種方式是嘗試獲取文件鎖,無論是否成功都會立即返回。

帶參數的方法給予了文件區域加鎖的能力,以及指定是否為共享鎖。但是,區域加鎖和共享鎖的能力取決于操作系統的實現,因此,一般建議使用文件鎖時只考慮整個文件的排它鎖。另外,文件鎖對象的所屬者屬于java虛擬機進程,并非線程,因此絕對不要用它來實現多線程控制。

另外,用文件鎖的典型場景是一個java進程希望獨占該文件,不希望其他進程干擾。如一個java應用程序啟動時通過tryLock對某文件加排它鎖,如果沒有獲取到鎖,則退出進程,通過這樣的方式可以控制某java應用程序只會運行一個進程。

小結

本文屬于java NIO系列的第一篇,講述了Java NIO的相關概念,著重理解管道Channel和數據塊Buffer的作用,并對文件管道FileChannel和字節數據塊ByteBuffer進行較深入的介紹,在此基礎上讀者深入其他管道和Buffer將不在話下。下期我們將進入NIO的網絡編程。

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

推薦閱讀更多精彩內容