NIO 五 文件通道

一 通道和流的區別

  前一篇介紹了NIO的通道接口,十餘個接口看起來極爲複雜,但實際上NIO提供的實現類,且應用場景覆蓋較廣泛的並不多。

  從目前瞭解到的信息來看,通道僅僅是用於將緩衝區數據寫入,或者從其中讀取數據到緩衝區,咋一看和流類似,但從接口設定上來看還是有一些區別的:

  1. Java提供的流多爲單向操作,或讀或寫,而通道的實現多爲雙向,可讀可寫;
  2. 通道實現類提供的API基本上都是將緩衝區數據寫入通道,或從通道中讀取數據到緩衝區,二者聯繫緊密;
  3. NIO提供的通道可支持異步讀寫,這是最重要的特性。

二 已實現的主要通道

  NIO提供了非常多的通道實現,但是常用的無非以下四個:

  1. FileChannel
  2. DatagramChannel
  3. SocketChannel
  4. SocketServerChannel

  其中FileChannel支持從文件中讀寫數據;DatagramChannel則支持UDP協議的網絡通信數據讀寫;SocketChannel支持TCP協議的網絡通信數據讀寫,SocketServerChannel和SocketChannel有關聯,是服務端監聽客戶端TCP請求的用的,一旦建立請求會創建一個SocketChannel來支持此連接通道中的數據讀寫。

  這裏先鋪墊一下,NIO的主要應用場景是網絡通信,文件讀寫方面未必比傳統流式方便,但是爲了介紹通道的使用的方法,本文以FileChannel爲例,介紹與其相關的主要API。SocketChannel則在後續的網絡通信方面介紹中細說。

三 FileChannel

  先看FileChannel的定義:

/**
 *
 * @see java.io.FileInputStream#getChannel()
 * @see java.io.FileOutputStream#getChannel()
 * @see java.io.RandomAccessFile#getChannel()
 *
 * @author Mark Reinhold
 * @author Mike McCloskey
 * @author JSR-51 Expert Group
 * @since 1.4
 */
public abstract class FileChannel extends AbstractInterruptibleChannel implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel {
...
}

  首先FileChannel是一個抽象類,繼承自AbstractInterruptibleChannel,從註釋中可以很明顯的看出來,io包的FileInputStream、FileOutputStream以及功能更爲全面的RandomAccessFile類都提供了返回FileChannel實例的方法。

  其次FileChannel要求實現類必須實現SeekableByteChannel、GatheringByteChannel以及ScatteringByteChannel接口,算上基類AbstractInterruptibleChannel的Channel和InterruptibleChannel接口,可以斷定FileChannel必然支持中斷機制,支持數據讀寫,以及對position的維護操作,從這幾個方面着手,再看FileChannel提供的抽象方法定義,基本上就把FileChannel喫透了。

  上面的學習方法適用所有的Java技術體系。

四 API介紹

  FileChannel在其內部維護一個與文件相關聯的position參數,基於此參數可支持對文件數據的讀寫,且FileChannel是阻塞的。

  除常見的文件讀寫、關閉等操作,FileChannel還支持將文件映射到內存中,常見於大文件的讀寫操作,內存映射後的讀寫更爲高效。如果在寫文件時爲了防止意外情況導致的文件數據丟失,FileChannel還支持實時更新——強制更新存儲設備,甚至可以對文件中的部分數據進行加鎖操作,以防止其他應用程序對文件內容進行訪問。

  因通道的阻塞特性,FileChannel是線程安全的,比如說任意執行線程可執行通道的關閉操作,那麼其他線程也發起關閉操作,則會被阻塞。

  需要注意的是,多個線程同時操作文件數據時,可操作的數據未必一致,取決於操作系統對數據寫入文件的執行策略。

  由於FileChannel提供的文件操作方法較多,後文會根據FileChannel接口的實現來介紹不同的文件操作API,以此來加深對接口的理解,冗餘的說明則儘量掠過以節省篇幅,所以讀者需要格外注意接口的特性。

4.1 獲取FileChannel對象

  FileChannel類沒有提供任何打開文件的方法(我本機使用的JDK1.8,未來的版本中可能會提供),正如前文中介紹的,如果想獲得FileChannel對象,可通過FileInputStream、FileOutputStream、RandomAccessFile對象的getChannel()方法實現。

  這裏需要注意的是,通過FileChannel對象對文件進行數據讀寫後,會影響提供getChannel方法的對象——稱之爲源文件操作對象,此時通過源文件操作對象訪問文件數據和FileChannel對象操作後的數據一致,比如說通過FileChannel來改變文件大小,那麼通過源文件操作對象訪問到的文件大小是改變後的。

  另一個需要注意的點是不同的源文件操作對象提供的FileChannel實例,對文件的操作權限是不一樣的:

  1. FileInputStream提供的FileChannel可讀文件
  2. FileOutputStream提供的FileChannel可寫文件
  3. RandomAccessFile提供的FileChannel,創建RandomAccessFile對象的模式不同則操作權限不同,“r”模式可讀,“w”模式可寫,“rw”模式可讀可寫
public class FileChannelTest {
    public static void main(String[] args) throws Exception {
        FileInputStream fis = new FileInputStream(new File("TestFile"));
        FileChannel fileChannel = fis.getChannel();
        System.out.println("文件是否已經打開:" + fileChannel.isOpen() + " 文件大小:" + fileChannel.size());
        fileChannel.close();

        RandomAccessFile raf = new RandomAccessFile(new File("TestFile"), "rw");
        fileChannel = raf.getChannel();
        System.out.println("文件是否已經打開:" + fileChannel.isOpen() + " 文件大小:" + fileChannel.size());
        fileChannel.close();

        FileOutputStream fos = new FileOutputStream(new File("TestFile"));
        fileChannel = fos.getChannel();
        System.out.println("文件是否已經打開:" + fileChannel.isOpen() + " 文件大小:" + fileChannel.size());
        fileChannel.close();
    }
}

輸出結果:

文件是否已經打開:true 文件大小:45
文件是否已經打開:true 文件大小:45
文件是否已經打開:true 文件大小:0

4.2 數據寫入

  通過IDE工具查看和write相關的API,可以看到下述四個API:

writeAPI

4.2.1 write(ByteBuffer src)

  write(ByteBuffer src)方法是實現的是WritableByteChannel接口,實現的是將參數src的剩餘可操作字節(字節長度爲ByteBuffer的remaining方法返回值)寫入FileChannel,再細緻點說:

  1. 這是個阻塞方法(前文說了FileChannel的方法都是阻塞的,即當前線程寫入動作未結束時,其他線程的寫入動作均被阻塞,其他的IO操作是否允許併發的處理,取決於FileChannel的實際類型後文不再贅述);
  2. 將src緩衝區的數據寫入通道
  3. 從通道的當前位置position開始寫入(此position不是緩衝區的position)
  4. 寫入長度爲src的剩餘可操作字節數(即remaining方法返回值)

  此方法的應用請參考下面的示例:

public class FileChannelTest {
    public static void main(String[] args) throws Exception {
        FileOutputStream fos = new FileOutputStream(new File("TestFile"));
        FileChannel fileChannel = fos.getChannel();
        // 設置通道的position爲3
        fileChannel.position(3);
        ByteBuffer byteBuffer = ByteBuffer.wrap("abcdefg".getBytes(Charset.defaultCharset()));
        System.out.println("初始的緩衝區大小爲:" + byteBuffer.limit());
        // 設置緩衝區的position爲2
        byteBuffer.position(2);
        System.out.println("緩衝區remaining長度爲:" + byteBuffer.remaining() + " 通道的position爲:" + fileChannel.position() + " 通道的長度爲:" + fileChannel.size());
        fileChannel.write(byteBuffer);
        System.out.println("寫入數據後通道的position爲:" + fileChannel.position() + " 通道的長度爲:" + fileChannel.size());
    }
}

輸出結果:

初始的緩衝區大小爲:7
緩衝區remaining長度爲:5 通道的position爲:3 通道的長度爲:0
寫入數據後通道的position爲:8 通道的長度爲:8

  從示例中很明顯的看出來寫入通道的數據長度爲緩衝去區的remaining長度5,加上預先移動的3字節,寫入後通道的數據長度爲8。

  接下來在驗證下阻塞特性,這裏只需要啓動多個線程,同時向通道中寫入數據,然後我們看看數據是否會出現交叉亂序:

public class FileChannelTest {
    public static void main(String[] args) throws Exception {
        FileOutputStream fos = new FileOutputStream(new File("TestFile"));
        FileChannel fileChannel = fos.getChannel();
        for (int i = 0; i < 5; i++) {
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        ByteBuffer byteBuffer = ByteBuffer.wrap("abcdefg".getBytes(Charset.defaultCharset()));
                        fileChannel.write(byteBuffer);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        ByteBuffer byteBuffer = ByteBuffer.wrap("1234567".getBytes(Charset.defaultCharset()));
                        fileChannel.write(byteBuffer);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
            thread1.start();
            thread2.start();
        }
    }
}

文件內容:

12345671234567abcdefg1234567abcdefg1234567abcdefg1234567abcdefgabcdefg

  一目瞭然,沒有出現數字和字母交叉的排序,出現數字或字母重複追加的情況是因爲線程的調度順序是非線性的,但數字和字母沒有交叉則證明了API的阻塞特性是確實存在的,在數字或字母沒有完全寫入前,其他線程無法寫入任何數據。

4.2.2 write(ByteBuffer[] srcs)

  此API實現的是GatheringByteChannel接口,GatheringByteChannel派生自WritableByteChannel接口,因此包含章節4.2.1中介紹的特性,此外從參數中也可以看出它支持將多個緩衝區的remaining字節寫入通道。

  需要注意的是緩衝區數組的元素順序決定了寫入數據的順序,因阻塞特性多個緩衝區數組同時寫入時,不會出現數據交叉錯亂的場景。

4.2.3 write(ByteBuffer[] srcs, int offset, int length)

  此API實現的接口依然是GatheringByteChannel,所以和write(ByteBuffer[] srcs)幾乎一致,唯一不同的是,此API多了兩個參數:

  1. offset指參數srcs的偏移量,即從第幾個字節緩衝區纔開始需要向通道寫入數據
  2. length指從offset位置開始有length長度個字節緩衝區需要向通道寫入數據

  如果還覺得比較難理解,讀者可認爲write(ByteBuffer[] srcs)等價於write(ByteBuffer[] srcs, 0, srcs.length),這樣是不是就清楚了。

4.2.4 write(ByteBuffer src, long position)

  這個API和write(ByteBuffer src)單純的區別在於不是從通道的position開始寫入數據,而是可以指定通道位置了,其他的特性完全一致。

  唯一需要說明的是如果指定的position大於了通道關聯的文件大小,不會報錯,而是對文件進行擴容,在參數position之前的被擴容的部分,寫入的是未指定的字節數據。另外調用此方法,不會影響通道的position值,這些特性讀者可自行編寫測試函數驗證。

  補充一個不需要解釋的細節,參數position不能爲負。

4.3 數據讀取

  同樣的通過IDE查看read相關的API:

readAPI

  這四個API對應前文中的4個寫入API,下面僅介紹下對應的實現接口。

4.3.1 read(ByteBuffer dst)

  這個API實現的是ReadableByteChannel接口,以阻塞方式從通道中將數據讀到緩衝區dst中,需要注意以下幾點:

  1. 從通道的position開始讀;
  2. 讀到的數據會寫入dst中,從dst的position位置開始寫入;
  3. 寫入的數據長度爲dsc的remaining方法返回值

  另外需要注意的是此API的返回值,返回值類型爲int,可能出現的結果有以下三種場景:

  1. 正整數,表示讀取到的字節數
  2. 0,沒有讀到任何數據,不排除dst.remaining返回的值爲0
  3. -1,讀到了通道末尾

4.3.2 read(ByteBuffer[] dsts)

  這個API實現的是ScatteringByteChannel接口,ScatteringByteChanne派生自ReadableByteChannel,因此具備章節4.3.1中介紹的特性,此外它還支持將通道當前位置開始的數據寫入多個字節緩衝區中,每個緩衝區寫入數據的多少取決於緩衝區的remaining。

4.3.3 read(ByteBuffer[] dsts, int offset, int length)

  對應章節4.2.3的寫操作,實現的接口是ScatteringByteChanne,這意味着和read(ByteBuffer[] dsts)方法的行爲一致,差異在於兩個參數,不再贅述。

4.3.4 read(ByteBuffer dst, long position)

  對應章節4.2.4的寫方法,用於從指定的通道的位置,將通道數據讀入到緩衝區的當前位置。除了可指定文件位置外,其他特性同read(ByteBuffer dst)方法。

  需要注意的是,首先參數不能爲負,不解釋。另外如果參數position大於文件的大小,那麼則不會讀取任何數據。同write(ByteBuffer dst, long position),調用此方法不會改變通道的position值。

4.4 設置通道位置

  前文中的讀寫API和通道position是緊密相關的,那麼在讀寫數據時時刻掌握通道position值就顯得尤爲重要,必要時還需要對通道位置進行設置。

  FileChannel提供了position(long newPosition)方法來設置通道位置,參數newPosition有些特殊,它的值可以大於通道關聯的文件大小,但方法本身卻不會更改文件的大小,而是在後續的讀寫過程中發生作用:

  1. 後續讀數據時直接返回已讀到文件末尾
  2. 後續寫數據時會對文件進行擴容,擴容大小可滿足寫入數據,在原文件數據和新寫入數據之間的部分是未指定的字節數據。

  這部分需要結合章節4.2、4.3中的數據讀寫來理解。

4.5 獲取文件大小

  沒啥好介紹的,size()方法。

4.6 切分文件數據

  這個API和前幾篇介紹緩衝區的切分是一樣的道理,truncate(long size)方法會將通道關聯的文件按參數大小進行切分,這裏參數值可能出現以下幾種情況:

  1. 如果參數值大於文件大小,沒有任何影響
  2. 參數值小於文件大小,切分文件,並且丟掉size後面的數據,注意!切分後可以認爲是一個新文件

  另一個需要注意的點是,如果切分的時候通道position值已經大於了參數size值,那麼切分後position被重置爲size值,比如說原文件內容“1234567”:

public class FileChannelTest {
    public static void main(String[] args) throws Exception {
        FileOutputStream fos = new FileOutputStream(new File("TestFile"));
        FileChannel fileChannel = fos.getChannel();
        fileChannel.write(ByteBuffer.wrap("1234567".getBytes(Charset.defaultCharset())));
        fileChannel.position(6);
        System.out.println("原文件大小:" + fileChannel.size() + " 通道position:" + fileChannel.position());
        FileChannel newFileChannel = fileChannel.truncate(3);
        System.out.println("切分文件大小:" + newFileChannel.size() + " 通道position:" + newFileChannel.position());
    }
}

輸出結果:

原文件大小:7 通道position:6
切分文件大小:3 通道position:3

4.7 通道傳輸

  前文中介紹的數據讀寫都是通道和緩衝區之間進行數據的傳輸,本小節則介紹通道間進行數據傳輸的API。

4.7.1 從當前通道傳輸給其他可寫通道

  第一個API是transferTo(long position, long count, WritableByteChannel dest),我們可以認爲此API等同於章節4.2介紹的數據寫入API,只不過目標變成了其他可寫通道(第三個參數已經明確告知,目標通道必須是WritableByteChannel接口類型)。

  此API用於從當前通道的position位置開始,長count個字節的數據寫入目標通道dest,因涉及兩個通道,那麼實際上數據傳輸能夠成功執行就出現了多種可能:

  1. 如果當前通道position後的數據長度不足count值,又或者目標通道可接收的數據長度不足count值,則傳輸的實際字節數小於count值;
  2. 如果參數position值大於當前通道關聯的文件大小,則不傳輸任何數據;
  3. 如果數據可寫入,那麼目標通道的position值會增加實際寫入數據的長度;

  此外還有其他的可能,這裏不一一列舉,讀者可在實際應用中進行函數測試驗證,但是需要注意的是,調用此方法不會改變當前通道的position。

4.7.2 從其他可讀通道傳輸給當前通道

  transferFrom(ReadableByteChannel src, long position, long count),此API和章節4.7.1剛好相反,各參數含義互換位置即可,這裏不再贅述。

4.8 鎖文件區域

  這裏和前文一開始的介紹遙相呼應,FileChannel支持將關聯的文件的部分區域進行鎖定,以防止其他其他線程對此區域數據進行操作。

  鎖文件區域方法爲lock(long position, long size, boolean shared),需要注意的是參數position和size可以和文件的數據不一致 ,即此方法僅針對通道從position位置開始,size長度的數據區域鎖定,且參數shared可以指定鎖類型爲獨佔鎖或是共享鎖,至於最終採用何種類型的鎖,還要看實際的操作系統支不支持(有些操作系統不支持共享鎖,那麼此方法會自動將鎖類型轉爲獨佔鎖 )。

  鎖定文件區域是非常複雜的,有諸多場景上的差異,甚至和操作系統有關,所以這裏不過多的介紹,如果有機會,後面會單獨開一個章節來介紹,有興趣的朋友可以自行搜索相關信息 。

  那麼既然有部分區域鎖定,就必然支持全通道鎖定,無參數lock方法實現了此功能,我們完全可以認爲lock()等價於lock(0L, Long.MAV_VALUE, false),實際上源碼中也的確是這樣實現的。

  因爲篇幅原因,不再介紹和鎖相關的內容(嘗試獲取通道鎖)。

4.9 內存映射

  這是一個比較重量級的應用,方法map(FileChannel.MapMode mode, long position, long size)支持將通道關聯的文件,在參數區域內的數據映射到內存中,以此來實現更高效率的數據訪問。

  此方法返回一個MappedByteBuffer對象,可認爲是一個文件數據緩衝區副本。

  其中參數mode提供了三種枚舉定義:

  1. MapMode.READ_ONLY,只讀,如果對此區域內數據進行修改會拋出異常;
  2. MapMode.READ_WRITE,讀寫,操作MappedByteBuffer對象會直接同步到文件,但是需要注意的是其他關聯了此文件的通道、緩衝區等未必能及時看到;
  3. MapMode.PRIVATE,私有,一個完全獨立的部分,操作MappedByteBuffer對象不會同步到文件,其他關聯了此文件的通道、緩衝區也不可見。

  和章節4.8類型,內存映射也涉及操作系統的支持與否,並且用法較多,這裏不再詳細介紹每一個點,後面如果有時間我會單獨寫一個章節來介紹相關內容。

五 結語

  如果想關注更多硬技能的分享,可以參考積少成多系列傳送門,未來每一篇關於硬技能的分享都會在傳送門中更新鏈接。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章