JAVA 探究NIO

不該相遇在秋天發表於2018-11-30

事情的開始

  1.4版本開始,java提供了另一套IO系統,稱為NIO,(New I/O的意思),NIO支援面向緩衝區的、基於通道的IO操作。

  1.7版本的時候,java對NIO系統進行了極大的擴充套件,增強了對檔案處理和檔案系統特性的支援。

  在不斷的進化迭代之中,IO的很多應用場景應該推薦使用NIO來取代。

  NIO系統構建於兩個基礎術語之上:緩衝區和通道。

緩衝區

Buffer類

  緩衝區是一個固定資料量的指定基本型別的資料容器,可以將它理解成一塊記憶體,java將它封裝成了Buffer類。

  每個非布林基本資料型別都有各自對應的緩衝區操作類,所有緩衝區操作類都是Buffer類的子類。

  除了儲存的內容之外,所有的緩衝區都具有通用的核心功能:當前位置、界限、容量。

  當前位置是要讀寫的下一個元素的索引

  界限是緩衝區中最後一個有效位置之後下一個位置的索引值

  容量是緩衝區能夠容納的元素的數量,一般來說界限等於容量。

  對於標記、位置、限制和容量值遵守以下不變式:0 <= 標記 <= 位置 <= 限制 <= 容量

方法列表:

方法 描述
Object array() 返回此緩衝區的底層實現陣列
int arrayOffset() 返回此緩衝區的底層實現陣列中第一個元素的索引
int capacity() 返回此緩衝區的容量
Buffer clear() 清除此緩衝區並返回緩衝區的引用
Buffer flip() 將緩衝區的界限設定為當前位置,並將當前位置重置為0,即反轉緩衝區
boolean hasArray() 返回緩衝區是否具有可訪問的底層實現陣列。
boolean hasRemaining() 返回緩衝區中是否還有剩餘元素
boolean isDirect() 返回此緩衝區是否是直接緩衝區(直接緩衝區可以直接對緩衝區進行IO)
boolean isReadOnly() 該緩衝區是否只讀
int limit() 返回緩衝區的界限
Buffer limit(int n) 將緩衝區的界限設定為n
Buffer mark() 設定標記
int position() 返回此緩衝區的位置
Buffer position(int n) 將緩衝區的當前位置設定為n
int remaining() 返回當前位置與界限之間的元素數量(即界限減去當前位置的結果值)
Buffer reset() 將緩衝區的位置重置為之前設定標記的位置
Buffer rewind() 將緩衝區的位置設定為0

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 
清除、反轉、和重繞

  這三個詞是在查閱JDK文件看到的,對應Buffer類的三個方法,個人覺得非常有助於理解。

  clear()使緩衝區為一系列新的通道讀取或相對放置 操作做好準備:它將限制設定為容量大小,將位置設定為 0。

  flip()使緩衝區為一系列新的通道寫入或相對獲取 操作做好準備:它將限制設定為當前位置,然後將位置設定為 0。

  rewind()使緩衝區為重新讀取已包含的資料做好準備:它使限制保持不變,將位置設定為 0。

 

資料傳輸

  下面這些特定的緩衝區類派生字Buffer,這些類的名稱暗含了他們所能容納的資料型別:

  ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、MappedByteBuffer、ShortBuffer

  其中 MappedByteBuffer是ByteBuffer的子類,用於將檔案對映到緩衝區。

  所有的緩衝區類都定義的有get()和put()方法,用於存取資料。(當然,如果緩衝區是隻讀的,就不能使用put操作)

 

通道

通道的用處

  通道,表示到實體,如硬體裝置、檔案、網路套接字或可以執行一個或多個不同 I/O 操作(如讀取或寫入)的程式元件的開放的連線,用於 I/O 操作的連線。

  通過通道,可以讀取和寫入資料。拿 NIO與原來的I/O 做個比較,通道就像是流,但它是面向緩衝區的。

  正如前面提到的,所有資料都通過 Buffer 物件來處理。你永遠不會將位元組直接寫入通道中,相反,您是將資料寫入包含一個或者多個位元組的緩衝區。同樣,您不會直接從通道中讀取位元組,而是將資料從通道讀入緩衝區,再從緩衝區獲取這個位元組。

  通道與流的不同之處在於通道是雙向的。而流只是在一個方向上移動(一個流必須是 InputStream 或者 OutputStream 的子類), 而 通道 可以用於讀、寫或者同時用於讀寫。

  通道實現了Channel介面並且擴充套件了Closeable介面和AutoCloseable介面,通過實現AutoCloseable介面,就可以使用帶資源的try語句管理通道,那麼當通道不再需要時會自動關閉。

獲取通道

  獲取通道的一種方式是對支援通道的物件呼叫getChannel()方法。

  例如,以下IO類支援getChannel()方法:

DatagramSocket、FileInputStream、FileOutputStream、RandomAccessFile、ServerSocket、Socket

  根據呼叫getChannel()方法的物件型別返回特定型別的通道,比如對FileInputStream、FileOutputStream或RandomAccessFile物件呼叫getChannel()方法時,會返回FileChannel型別的通道,對Socket物件呼叫getChannel()方法時,會返回SocketChannel型別的通道。

  通道都支援各種read()和write()方法,使用這些方法可以通過通道執行IO操作。

方法如下:

方法 描述
int read(ByteBuffer b) 將位元組讀取到緩衝區,返回實際讀取的位元組數
int read(ByteBuffer b,long start) 從start指定的檔案位置開始,從通道讀取位元組,並寫入緩衝區
int write(ByteBuffer b) 將位元組從緩衝區寫入通道
int write(ByteBuffer b,long start) 從start指定的檔案位置開始,將位元組從緩衝區寫入通道

 

 

 

 

 

 
字符集和選擇器

  NIO使用的另外兩個實體是字符集和選擇器。

  字符集定義了將位元組對映為字元的方法,可以使用編碼器將一系列字元編碼成位元組,使用解碼器將一系列位元組解碼成字元。

  字符集、編碼器和解碼器由java.nio.charset包中定義的類支援,因為提供了預設的編碼器和解碼器,所以通常不需要顯式的使用字符集進行工作。

  選擇器支援基於鍵的,非鎖定的多通道IO,也就是說,它可以通過多個通道執行IO,當然,前提是通道需要呼叫register方法註冊到選擇器中,

  選擇器的應用場景在基於套接字的通道。

 

Path介面

  Path是JDK1.7新增進來的介面,該介面封裝了檔案的路徑。

  因為Path是介面,不是類,所以不能通過建構函式直接建立Path例項,通常會呼叫Paths.get()工廠方法來獲取Path例項。

get()方法有兩種形式:

  Path get(String pathname,String ...more)
  Path get(URI uri)

  建立連結到檔案的Path物件不會導致開啟或建立檔案,理解這一點很重要,這僅僅只是建立了封裝檔案目錄路徑的物件而已。

以下程式碼示例常用用法(1.txt是一個不存在的檔案):

        Path path = Paths.get("./nio/src/1.txt");
        System.out.println("自身路徑:"+path.toString());//輸出.\nio\src\1.txt
        System.out.println("檔案或目錄名稱:"+path.getFileName());//輸出1.txt
        System.out.println("路徑元素數量:"+path.getNameCount());//輸出4
        System.out.println("路徑中第3截:"+path.getName(2));//輸出src
        System.out.println("父目錄的路徑"+path.getParent());//輸出.\nio\src
        System.out.println(path.getRoot());//輸出null
        System.out.println("是否絕對路徑:"+path.isAbsolute());//輸出false

        Path p = path.toAbsolutePath();//返回與該路徑等價的絕對路徑
        System.out.println("看看我這個是不是絕對路徑:"+p.toString());//輸出E:\JAVA\java_learning\.\nio\src\1.txt

        File file = path.toFile();//從該路徑建立一個File物件
        System.out.println("檔案是否存在:"+file.exists());//false

        Path path1 = file.toPath();//再把File物件轉成Path物件
        System.out.println("是不是同一個物件:"+path1.equals(path));//輸出true

 

為基於通道的IO使用NIO

通過通道讀取檔案

手動分配緩衝區

  這是最常用的方式,手動分配一個緩衝區,然後執行顯式的讀取操作,讀取操作使用來自檔案的資料載入緩衝區。

  

        try(FileChannel seekableByteChannel = (FileChannel) Files.newByteChannel(Paths.get("./nio/src/2.txt"))){
            ByteBuffer buffer = ByteBuffer.allocate(5);//指定緩衝區大小
            int count = seekableByteChannel.read(buffer);//將檔案中的資料讀取到緩衝區
            buffer.rewind();
            while (count > 0){
                System.out.println((char)buffer.get());//讀取緩衝區中的資料
                count --;
            }
        }catch (Exception e){
            e.printStackTrace();
        }

  該示例使用了SeekableByteChannel物件,該物件封裝了檔案操作的通道,可以轉成FileChannel(不是預設的檔案系統不能轉)。這裡注意,分配緩衝區大小就代表了最多讀取的資料位元組大小,比如我的示例檔案中位元組數是8個,但是我只分配了5個位元組的緩衝區,因此只能讀出前5個位元組的資料。

  為什麼會有buffer.rewind()這行程式碼呢?因為呼叫了read()方法將檔案內容讀取到緩衝區後,當前位置處於緩衝區的末尾,所以要重繞緩衝區,將指標重置到緩衝區的起始位置。

將檔案對映到緩衝區

  這種方式的優點是緩衝區自動包含檔案的內容,不需要顯式的讀操作。同樣的要先獲取Path物件,再獲取檔案通道。

  用newByteChannel()方法得到的SeekableByteChannel物件轉成FileChannel型別的物件,因為FileChannel物件有map()方法,將通道對映到緩衝區。

map()方法如下所示:

  MappedByteBuffer map(FileChannel.MapMode how,long begin,long size) throws IOException

引數how的值為:MapMode.READ_ONLY、MapMode.READ_WRITE、MapMode.PRIVATE 之一。

  對映的開始位置由begin指定,對映的位元組數由size指定,作為MappedByteBuffer返回指向緩衝區的引用,MappedByteBuffer是ByteBuffer的子類,一旦將檔案對映到緩衝區,就可以從緩衝區讀取檔案了。

        try(FileChannel fileChannel = (FileChannel) Files.newByteChannel(Paths.get("./nio/src/2.txt"))){
            long size = fileChannel.size();//獲取檔案位元組數量
            MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY,0,size);
            for(int i=0;i < size; i ++){
                System.out.println((char)mappedByteBuffer.get());
            }
        }catch (Exception e){
            e.printStackTrace();
        }

 

通過通道寫入檔案

手動分配緩衝區
        try(FileChannel seekableByteChannel = (FileChannel) Files.newByteChannel(Paths.get("./nio/src/2.txt"),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.APPEND)){
            ByteBuffer buffer = ByteBuffer.allocate(5);//指定緩衝區大小
            for(int i=0;i<5;i++){
                buffer.put((byte)('A'+i));
            }
            buffer.rewind();
            seekableByteChannel.write(buffer);
        }catch (Exception e){
            e.printStackTrace();
        }

因為是針對寫操作而開啟檔案,所以引數必須指定為StandardOpenOption.WRITE,如果希望檔案不存在就建立檔案,可以指定StandardOpenOption.CREATE,但是我還希望是以追加的形式寫入內容,所以又指定了StandardOpenOption.APPEND。

  需要注意的是buffer.put()方法每次呼叫都會向前推進當前位置,所以在呼叫write()方法之前,需要將當前位置重置到緩衝區的開頭,如果沒有這麼做,write()方法會認為緩衝區中沒有資料。

將檔案對映到緩衝區
        Path path = Paths.get("./nio/src/4.txt");
        try(FileChannel fileChannel = (FileChannel) Files.newByteChannel(path,StandardOpenOption.WRITE,StandardOpenOption.READ,StandardOpenOption.CREATE)){
            MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_WRITE,0,5);
            for(int i=0;i < 5; i ++){
                buffer.put((byte) ('A'+i));
            }
        }catch (Exception e){
            e.printStackTrace();
        }

可以看出,對於通道自身並沒有顯式的寫操作,因為緩衝區被對映到檔案,所以對緩衝區的修改會自動反映到底層檔案中。

  對映緩衝區要麼是隻讀,要麼是讀/寫,所以這裡必須是READ和WRITE兩個選項都得要。一旦將檔案對映到緩衝區,就可以向緩衝區中寫入資料,並且這些資料會被自動寫入檔案,所以不需要對通道執行顯式的寫入操作。

  另外,寫入的檔案大小不能超過緩衝區的大小,如果超過了之後會丟擲異常,但是已經寫入的資料仍然會成功。比如緩衝區5個位元組,我寫入10個位元組,程式會丟擲異常,但是前5個位元組仍然會寫入檔案中。

使用NIO複製和移動檔案

        Path path = Paths.get("./nio/src/4.txt");
        Path path2 = Paths.get("./nio/src/40.txt");
        try{
            Files.copy(path2,path, StandardCopyOption.REPLACE_EXISTING);
            //Files.move(path,path2, StandardCopyOption.REPLACE_EXISTING);
        }catch (Exception e){
            e.printStackTrace();
        }

StandardCopyOption.REPLACE_EXISTING選項的意思是如果目標檔案存在則替換。

為基於流的IO使用NIO

  如果擁有Path物件,那麼可以通過呼叫Files類的靜態方法newInputStream()或newOutputStream()來得到連線到指定檔案的流。

方法原型如下:

  static InputStream newInputStream(Path path,OpenOption... how) throws IOException

how的引數值必須是一個或多個由StandardOpenOption定義的值,如果沒有指定選項,預設開啟方式為StandardOpenOption.READ。

  一旦開啟檔案,就可以使用InputStream定義的任何方法。

  因為newInputStream()方法返回的是常規流,所以也可以在緩衝流中封裝流。

        try(BufferedInputStream inputStream = new BufferedInputStream(Files.newInputStream(Paths.get("./nio/src/2.txt")))){
            int s = inputStream.available();
            for(int i=0;i<s;i++){
                int c = inputStream.read();
                System.out.print((char) c);
            }
        }catch (Exception e){
            e.printStackTrace();
        }

OutputStream和前面的InputStream類似:

        try(BufferedOutputStream outputStream = new BufferedOutputStream(Files.newOutputStream(Paths.get("./nio/src/2.txt")))){
            for(int i=0;i<26;i++){
                outputStream.write((byte)('A'+i));
            }
        }catch (Exception e){
            e.printStackTrace();
        }

 

為基於檔案系統使用NIO

Files類

  要進行操作的檔案是由Path指定的,但是對檔案執行的許多操作都是由Files類中的靜態方法提供的。

  java.nio.file.Files類就是為了替代java.io.File類而生。

以下列出部分常用方法:

方法 描述
static Path copy(Path from,Path to,CopyOption... opts) 將from複製到to,返回to
static Path move(Path from,Path to,CopyOption... opts) 將from移動到to,返回to
static Path createDirectory(Path path,FileAttribute<?> attribs) 建立一個目錄,目錄屬性是由attribs指定的。
static Path createFile(Path path,FileAttribute<?> attrbs) 建立一個檔案,檔案屬性是由attribs指定的。
static void delete(Path path) 刪除一個檔案
static boolean exists(Path path) path代表的路徑是否存在(無論檔案還是目錄)
static boolean notExists(Path path) path代表的路徑是否不存在(無論檔案還是目錄)
static boolean isRegularFile(Path path) 是否是檔案
static boolean isDirectory(Path path) 是否是目錄
static boolean isExecutable(Path path) 是否是可執行檔案
static boolean isHidden(Path path) 是否是隱藏檔案
static boolean isReadable(Path path) 是否可讀
static boolean isWritable(Path path) 是否可寫
static long size(Path path) 返回檔案大小
static SeekableByteChannel newByteChannel(Path path,OpenOption... opts) 開啟檔案,opts指定開啟方式,返回一個通道物件
static DirectoryStream<Path> newDirectoryStream(Path path) 開啟目錄,返回一個目錄流
static InputStream newInputStream(Path path,OpenOption... opts) 開啟檔案,返回一個輸入流
static OutputStream newOutputStream(Path path,OpenOption... opts) 開啟檔案,返回一個輸出流

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

引數列表中出現的有型別為OpenOption的引數,它是一個介面,真實傳入的引數是StandardOpenOption類中的列舉,這個列舉引數與newBufferedWriter/newInputStream/newOutputStream/write方法一起使用。

StandardOpenOption類中的列舉 描述
READ 用於讀取開啟檔案
WRITE 用於寫入開啟檔案
APPEND 如果是寫入,則內容追加到末尾
CREATE 自動在檔案不存在的情況下建立新檔案
CREATE_NEW 建立新檔案,如果檔案已存在則丟擲異常
DELETE_ON_CLOSE 當檔案被關閉時刪除檔案
DSYNC 對檔案內容的修改被立即寫入物理裝置
SYNC 對檔案內容或後設資料的修改被立即寫入物理裝置
TRUNCATE_EXISTING 如果用於寫入而開啟,那麼移除已有內容

 

 

 

 

 

 

 

 

 

 

 

 

下面演示追加寫入檔案操作:

        try{
            Path path = Paths.get("./nio/src/8.txt");
            String str = "今天天氣不錯哦\n";
            Files.write(path,str.getBytes(),StandardOpenOption.CREATE, StandardOpenOption.APPEND);
        }catch (Exception e){
            e.printStackTrace();
        }

 

目錄流

遍歷目錄

  如果Path中的路徑是目錄,那麼可以使用Files類的靜態方法newDirectoryStream()來獲取目錄流。

方法原型如下:

  static DirectoryStream<Path> newDirectoryStream(Path dir) throw IOException

  呼叫此方法的前提是目標必須是目錄,並且可讀,否則會拋異常。

        try(DirectoryStream<Path> paths = Files.newDirectoryStream(Paths.get("./nio/src"))){
            for(Path path : paths){
                System.out.println(path.getFileName());
            }
        }catch (Exception e){
            e.printStackTrace();
        }

  DirectoryStream<Path>實現了Iterable<Path>,所以可以用foreach迴圈對其進行遍歷,但是它實現的迭代器針對每個例項只能獲取一次,所以只能遍歷一次。

匹配內容

  Files.newDirectoryStream方法還有一種形式,可以傳入匹配規則:

  static DirectoryStream<Path> newDirectoryStream(Path dir,String glob) throws IOException

  第二個引數就是匹配規則,但是它不支援強大的正則,只支援簡單的匹配,如"?"代表任意1個字元,"*"代表任意個任意字元。

使用示例 匹配所有.java結尾的檔案:

        try(DirectoryStream<Path> paths = Files.newDirectoryStream(Paths.get("./nio/src"),"*.java")){
            for(Path path : paths){
                System.out.println(path.getFileName());
            }
        }catch (Exception e){
            e.printStackTrace();
        }
複雜匹配

這種方式的原型為:

  static DirectoryStream<Path> newDirectoryStream(Path dir,DirectoryStream.Filter<? super Path> filter) throws IOException

其中的DirectoryStream.Filter是定義了以下方法的介面:

  boolean accept(T entry) throws IOException

這個方法中如果希望匹配entry就返回true,否則就返回false,這種形式的優點是可以基於檔名之外的其他內容過濾,比如說,可以只匹配目錄、只匹配檔案、匹配檔案大小、建立日期、修改日期等各種屬性。

下面是匹配檔案大小的示例:

        String dirname = "./nio/src";
        DirectoryStream.Filter<Path> filter = (entry)->{
            if(Files.size(entry) > 25){
                return true;
            }
            return false;
        };
        try(DirectoryStream<Path> paths = Files.newDirectoryStream(Paths.get(dirname),filter)){
            for(Path path : paths){
                System.out.println(path.getFileName());
            }
        }catch (Exception e){
            e.printStackTrace();
        }

 

目錄樹

  遍歷目錄下的所有資源以往的做法都是用遞迴來實現,但是在NIO.2的時候提供了walkFileTree方法,使得遍歷目錄變得優雅而簡單,其中涉及4個方法,根據需求選擇重寫。

示例如下:

        String dir = "./nio";
        try{
            Files.walkFileTree(Paths.get(dir), new SimpleFileVisitor<Path>(){
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    System.out.println("正在訪問檔案:"+file);
                    return super.visitFile(file, attrs);
                }

                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                    System.out.println("正在訪問目錄:"+dir);
                    return super.preVisitDirectory(dir, attrs);
                }

                @Override
                public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
                    System.out.println("訪問失敗的檔案:"+file);
                    return super.visitFileFailed(file, exc);
                }

                @Override
                public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
                    System.out.println("這個目錄訪問結束了:"+dir);
                    return super.postVisitDirectory(dir, exc);
                }
            });
        }catch (Exception e){
            e.printStackTrace();
        }

列印結果如圖:

檔案加鎖機制

  JDK1.4引入的檔案加鎖機制,要鎖定一個檔案,可以呼叫FileChannel類的lock或tryLock方法

  FileChannel channel = FileChannel.open(path);
  FileLock lock = channel.lock() 或者 FileLock lock1 = channel.tryLock()

第一個呼叫會阻塞直到獲得鎖,第二個呼叫立刻就會返回 要麼返回鎖 要麼返回Null。

  獲得鎖後這個檔案將保持鎖定狀態,直到這個通道關閉,或者釋放鎖:lock.release(); 點進原始碼可以輕易發現,FileChannel實現了AutoCloseable介面,也就是說,可以通過try語句來自動管理資源,不需要手動釋放鎖。

  還可以鎖定檔案內容的一部分:

  FileLock lock(long start,long size,boolean shared)
  FileLock lock(long start,long size,boolean shared)

  鎖定區域為(從start到start+size),那麼在start+size之外的部分不會被鎖定。shared引數為布林值,代表是否是讀鎖,讀鎖就是共享鎖,寫鎖就是排他鎖。

原始碼分享

https://gitee.com/zhao-baolin/java_learning/tree/master/nio