計算機程序的思惟邏輯 (57) - 二進制文件和字節流

本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連接 html

本節咱們介紹在Java中如何以二進制字節的方式來處理文件,上節咱們提到Java中有流的概念,以二進制方式讀寫的主要流有:java

  • InputStream/OutputStream: 這是基類,它們是抽象類。
  • FileInputStream/FileOutputStream: 輸入源和輸出目標是文件的流。
  • ByteArrayInputStream/ByteArrayOutputStream: 輸入源和輸出目標是字節數組的流。
  • DataInputStream/DataOutputStream: 裝飾類,按基本類型和字符串而非只是字節讀寫流。
  • BufferedInputStream/BufferedOutputStream: 裝飾類,對輸入輸出流提供緩衝功能。

下面,咱們就來介紹這些類的功能、用法、原理和使用場景,最後,咱們總結一些簡單的實用方法。編程

InputStream/OutputStream

InputStream的基本方法

InputStream是抽象類,主要方法是:數組

public abstract int read() throws IOException;
複製代碼

read從流中讀取下一個字節,返回類型爲int,但取值在0到255之間,當讀到流結尾的時候,返回值爲-1,若是流中沒有數據,read方法會阻塞直到數據到來、流關閉、或異常出現,異常出現時,read方法拋出異常,類型爲IOException,這是一個受檢異常,調用者必須進行處理。read是一個抽象方法,具體子類必須實現,FileInputStream會調用本地方法,所謂本地方法,通常不是用Java寫的,大多使用C語言實現,具體實現每每與虛擬機和操做系統有關。微信

InputStream還有以下方法,能夠一次讀取多個字節:網絡

public int read(byte b[]) throws IOException 複製代碼

讀入的字節放入參數數組b中,第一個字節存入b[0],第二個存入b[1],以此類推,一次最多讀入的字節個數爲數組b的長度,但實際讀入的個數可能小於數組長度,返回值爲實際讀入的字節個數。若是剛開始讀取時已到流結尾,則返回-1,不然,只要數組長度大於0,該方法都會盡力至少讀取一個字節,若是流中一個字節都沒有,它會阻塞,異常出現時也是拋出IOException。該方法不是抽象方法,InputStream有一個默認實現,主要就是循環調用讀一個字節的read方法,但子類如FileInputStream每每會提供更爲高效的實現。app

批量讀取還有一個更爲通用的重載方法:性能

public int read(byte b[], int off, int len) throws IOException 複製代碼

讀入的第一個字節放入b[off],最多讀取len個字節,read(byte b[])就是調用了該方法:編碼

public int read(byte b[]) throws IOException {
    return read(b, 0, b.length);
}
複製代碼

流讀取結束後,應該關閉,以釋放相關資源,關閉方法爲:spa

public void close() throws IOException 複製代碼

無論read方法是否拋出了異常,都應該調用close方法,因此close一般應該放在finally語句內。close本身可能也會拋出IOException,但一般能夠捕獲並忽略。

InputStream的高級方法

InputStream還定義了以下方法:

public long skip(long n) throws IOException public int available() throws IOException public synchronized void mark(int readlimit) public boolean markSupported() public synchronized void reset() throws IOException 複製代碼

skip跳過輸入流中n個字節,由於輸入流中剩餘的字節個數可能不到n,因此返回值爲實際略過的字節個數。InputStream的默認實現就是盡力讀取n個字節並扔掉,子類每每會提供更爲高效的實現,FileInputStream會調用本地方法。在處理數據時,對於不感興趣的部分,skip每每比讀取而後扔掉的效率要高。

available返回下一次不須要阻塞就能讀取到的大概字節個數。InputStream的默認實現是返回0,子類會根據具體狀況返回適當的值,FileInputStream會調用本地方法。在文件讀寫中,這個方法通常沒什麼用,但在從網絡讀取數據時,能夠根據該方法的返回值在網絡有足夠數據時纔讀,以免阻塞。

通常的流讀取都是一次性的,且只能往前讀,不能日後讀,但有時可能但願可以先看一下後面的內容,根據狀況,再從新讀取。好比,處理一個未知的二進制文件,咱們不肯定它的類型,但可能能夠經過流的前幾十個字節判斷出來,判讀出來後,再重置到流開頭,交給相應類型的代碼進行處理。

InputStream定義了三個方法,mark/reset/markSupported,用於支持從讀過的流中重複讀取。怎麼重複讀取呢?先使用mark方法將當前位置標記下來,在讀取了一些字節,但願從新從標記位置讀時,調用reset方法。

可以重複讀取不表明可以回到任意的標記位置,mark方法有一個參數readLimit,表示在設置了標記後,可以繼續日後讀的最多字節數,若是超過了,標記會無效。爲何會這樣呢?由於之因此可以重讀,是由於流可以將從標記位置開始的字節保存起來,而保存消耗的內存不能無限大,流只保證不會小於readLimit。

不是全部流都支持mark/reset的,是否支持能夠經過markSupported的返回值進行判斷。InpuStream的默認實現是不支持,FileInputStream也不直接支持,但BufferedInputStream和ByteArrayInputStream能夠。

OutputStream

OutputStream的基本方法是:

public abstract void write(int b) throws IOException;
複製代碼

向流中寫入一個字節,參數類型雖然是int,但其實只會用到最低的8位。這個方法是抽象方法,具體子類必須實現,FileInputStream會調用本地方法。

OutputStream還有兩個批量寫入的方法:

public void write(byte b[]) throws IOException public void write(byte b[], int off, int len) throws IOException 複製代碼

在第二個方法中,第一個寫入的字節是b[off],寫入個數爲len,最後一個是b[off+len-1],第一個方法等同於調用:write(b, 0, b.length);。OutputStream的默認實現是循環調用單字節的write方法,子類每每有更爲高效的實現,FileOutpuStream會調用對應的批量寫本地方法。

OutputStream還有兩個方法:

public void flush() throws IOException public void close() throws IOException 複製代碼

flush將緩衝而未實際寫的數據進行實際寫入,好比,在BufferedOutputStream中,調用flush會將其緩衝區的內容寫到其裝飾的流中,並調用該流的flush方法。基類OutputStream沒有緩衝,flush代碼爲空。

須要說明的是文件輸出流FileOutputStream,你可能會認爲,調用flush會強制確保數據保存到硬盤上,但實際上不是這樣,FileOutputStream沒有緩衝,沒有重寫flush,調用flush沒有任何效果,數據只是傳遞給了操做系統,但操做系統何時保存到硬盤上,這是不必定的。要確保數據保存到了硬盤上,能夠調用FileOutputStream中的特有方法。

close通常會首先調用flush,而後再釋放流佔用的系統資源。同InputStream同樣,close通常應該放在finally語句內。

FileInputStream/FileOutputStream

FileOutputStream

FileOutputStream的主要構造方法有:

public FileOutputStream(File file) throws FileNotFoundException public FileOutputStream(File file, boolean append) throws FileNotFoundException public FileOutputStream(String name) throws FileNotFoundException public FileOutputStream(String name, boolean append) throws FileNotFoundException 複製代碼

有兩類參數,一類是文件路徑,能夠是File對象file,也能夠是文件路徑name,路徑能夠是絕對路徑,也能夠是相對路徑,若是文件已存在,append參數指定是追加仍是覆蓋,true表示追加,沒傳append參數表示覆蓋。new一個FileOutputStream對象會實際打開文件,操做系統會分配相關資源。若是當前用戶沒有寫權限,會拋出異常SecurityException,它是一種RuntimeException。若是指定的文件是一個已存在的目錄,或者因爲其餘緣由不能打開文件,會拋出異常FileNotFoundException,它是IOException的一個子類。

咱們看一段簡單的代碼,將字符串"hello, 123, 老馬"寫到文件hello.txt中:

OutputStream output =  new FileOutputStream("hello.txt");
try{
    String data = "hello, 123, 老馬";
    byte[] bytes = data.getBytes(Charset.forName("UTF-8"));
    output.write(bytes);
}finally{
    output.close();
}
複製代碼

OutputStream只能以byte或byte數組寫文件,爲了寫字符串,咱們調用String的getBytes方法獲得它的UTF-8編碼的字節數組,再調用write方法,寫的過程放在try語句內,在finally語句中調用close方法。

FileOutputStream還有兩個額外的方法:

public FileChannel getChannel() public final FileDescriptor getFD() 複製代碼

FileChannel定義在java.nio中,表示文件通道概念,咱們不會深刻介紹通道,但內存映射文件方法定義在FileChannel中,咱們會在後續章節介紹。FileDescriptor表示文件描述符,它與操做系統的一些文件內存結構相連,在大部分狀況下,咱們不會用到它,不過它有一個方法sync:

public native void sync() throws SyncFailedException;
複製代碼

這是一個本地方法,它會確保將操做系統緩衝的數據寫到硬盤上。注意與OutputStream的flush方法相區別,flush只能將應用程序緩衝的數據寫到操做系統,sync則確保數據寫到硬盤,不過通常狀況下,咱們並不須要手工調用它,只要操做系統和硬件設備沒問題,數據早晚會寫入,但在必定特定狀況下,必定須要確保數據寫入硬盤,則能夠調用該方法。

FileInputStream

FileInputStream的主要構造方法有:

public FileInputStream(String name) throws FileNotFoundException public FileInputStream(File file) throws FileNotFoundException 複製代碼

參數與FileOutputStream相似,能夠是文件路徑或File對象,但必須是一個已存在的文件,不能是目錄。new一個FileInputStream對象也會實際打開文件,操做系統會分配相關資源,若是文件不存在,會拋出異常FileNotFoundException,若是當前用戶沒有讀的權限,會拋出異常SecurityException。

咱們看一段簡單的代碼,將上面寫入的文件"hello.txt"讀到內存並輸出:

InputStream input = new FileInputStream("hello.txt");
try{
    byte[] buf = new byte[1024];
    int bytesRead = input.read(buf);    
    String data = new String(buf, 0, bytesRead, "UTF-8");
    System.out.println(data);
}finally{
    input.close();
}
複製代碼

讀入到的是byte數組,咱們使用String的帶編碼參數的構造方法將其轉換爲了String。這段代碼假定一次read調用就讀到了全部內容,且假定字節長度不超過1024。爲了確保讀到全部內容,能夠逐個字節讀取直到文件結束:

int b = -1;
int bytesRead = 0;
while((b=input.read())!=-1){
    buf[bytesRead++] = (byte)b;
}
複製代碼

在沒有緩衝的狀況下逐個字節讀取性能很低,可使用批量讀入且確保讀到文件結尾,以下所示:

byte[] buf = new byte[1024];
int off = 0;
int bytesRead = 0;
while((bytesRead=input.read(buf, off, 1024-off ))!=-1){
    off += bytesRead;
}    
String data = new String(buf, 0, off, "UTF-8");
複製代碼

不過,這仍是假定文件內容長度不超過一個固定的大小1024。若是不肯定文件內容的長度,不但願一次性分配過大的byte數組,又但願將文件內容所有讀入,怎麼作呢?能夠藉助ByteArrayOutputStream。

ByteArrayInputStream/ByteArrayOutputStream

ByteArrayOutputStream

ByteArrayOutputStream的輸出目標是一個byte數組,這個數組的長度是根據數據內容動態擴展的。它有兩個構造方法:

public ByteArrayOutputStream() public ByteArrayOutputStream(int size) 複製代碼

第二個構造方法中的size指定的就是初始的數組大小,若是沒有指定,長度爲32。在調用write方法的過程當中,若是數組大小不夠,會進行擴展,擴展策略一樣是指數擴展,每次至少增長一倍。

ByteArrayOutputStream有以下方法,能夠方便的將數據轉換爲字節數組或字符串:

public synchronized byte[] toByteArray()
public synchronized String toString() public synchronized String toString(String charsetName) 複製代碼

toString()方法使用系統默認編碼。

ByteArrayOutputStream中的數據也能夠方便的寫到另外一個OutputStream:

public synchronized void writeTo(OutputStream out) throws IOException 複製代碼

ByteArrayOutputStream還有以下額外方法:

public synchronized int size() public synchronized void reset() 複製代碼

size返回當前寫入的字節個數。reset重置字節個數爲0,reset後,能夠重用已分配的數組。

使用ByteArrayOutputStream,咱們能夠改進上面的讀文件代碼,確保將全部文件內容讀入:

InputStream input = new FileInputStream("hello.txt");
try{
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    byte[] buf = new byte[1024];
    int bytesRead = 0;
    while((bytesRead=input.read(buf))!=-1){
        output.write(buf, 0, bytesRead);
    }    
    String data = output.toString("UTF-8");
    System.out.println(data);
}finally{
    input.close();
}
複製代碼

讀入的數據先寫入ByteArrayOutputStream中,讀完後,再調用其toString方法獲取完整數據。

ByteArrayInputStream

ByteArrayInputStream將byte數組包裝爲一個輸入流,是一種適配器模式,它的構造方法有:

public ByteArrayInputStream(byte buf[]) public ByteArrayInputStream(byte buf[], int offset, int length) 複製代碼

第二個構造方法以buf中offset開始length個字節爲背後的數據。ByteArrayInputStream的全部數據都在內存,支持mark/reset重複讀取。

爲何要將byte數組轉換爲InputStream呢?這與容器類中要將數組、單個元素轉換爲容器接口的緣由是相似的,有不少代碼是以InputStream/OutputSteam爲參數構建的,它們構成了一個協做體系,將byte數組轉換爲InputStream能夠方便的參與這種體系,複用代碼。

DataInputStream/DataOutputStream

上面介紹的類都只能以字節爲單位讀寫,如何以其餘類型讀寫呢?好比int, double。可使用DataInputStream/DataOutputStream,它們都是裝飾類。

DataOutputStream

DataOutputStream是裝飾類基類FilterOutputStream的子類,FilterOutputStream是OutputStream的子類,它的構造方法是:

public FilterOutputStream(OutputStream out) 複製代碼

它接受一個已有的OutputStream,基本上將全部操做都代理給了它。

DataOutputStream實現了DataOutput接口,能夠以各類基本類型和字符串寫入數據,部分方法以下:

void writeBoolean(boolean v) throws IOException;
void writeInt(int v) throws IOException;
void writeDouble(double v) throws IOException;
void writeUTF(String s) throws IOException;
複製代碼

在寫入時,DataOutputStream會將這些類型的數據轉換爲其對應的二進制字節,好比:

  • writeBoolean: 寫入一個字節,若是值爲true,則寫入1,不然0
  • writeInt: 寫入四個字節,最高位字節先寫入,最低位最後寫入
  • writeUTF: 將字符串的UTF-8編碼字節寫入,這個編碼格式與標準的UTF-8編碼略有不一樣,不過,咱們不用關心這個細節。

與FilterOutputStream同樣,DataOutputStream的構造方法也是接受一個已有的OutputStream:

public DataOutputStream(OutputStream out) 複製代碼

咱們來看一個例子,保存一個學生列表到文件中,學生類的定義爲:

class Student {
    String name;
    int age;
    double score;
    
    public Student(String name, int age, double score) {
         ...
    }
    ...
}    
複製代碼

咱們省略了構造方法和getter/setter方法,學生列表內容爲:

List<Student> students = Arrays.asList(new Student[]{
        new Student("張三", 18, 80.9d),
        new Student("李四", 17, 67.5d)
});
複製代碼

將該列表內容寫到文件students.dat中的代碼能夠爲:

public static void writeStudents(List<Student> students) throws IOException{
    DataOutputStream output = new DataOutputStream(
            new FileOutputStream("students.dat"));
    try{
        output.writeInt(students.size());
        for(Student s : students){
            output.writeUTF(s.getName());
            output.writeInt(s.getAge());
            output.writeDouble(s.getScore());
        }
    }finally{
        output.close();
    }
}
複製代碼

咱們先寫了列表的長度,而後針對每一個學生、每一個字段,根據其類型調用了相應的write方法。

DataInputStream

DataInputStream是裝飾類基類FilterInputStream的子類,FilterInputStream是InputStream的子類。

DataInputStream實現了DataInput接口,能夠以各類基本類型和字符串讀取數據,部分方法以下:

boolean readBoolean() throws IOException;
int readInt() throws IOException;
double readDouble() throws IOException;
String readUTF() throws IOException;
複製代碼

在讀取時,DataInputStream會先按字節讀進來,而後轉換爲對應的類型。

DataInputStream的構造方法接受一個InputStream:

public DataInputStream(InputStream in) 複製代碼

仍是以上面的學生列表爲例,咱們來看怎麼從文件中讀進來:

public static List<Student> readStudents() throws IOException{
    DataInputStream input = new DataInputStream(
            new FileInputStream("students.dat"));
    try{
        int size = input.readInt();
        List<Student> students = new ArrayList<Student>(size);
        for(int i=0; i<size; i++){
            Student s = new Student();
            s.setName(input.readUTF());
            s.setAge(input.readInt());
            s.setScore(input.readDouble());
            students.add(s);
        }
        return students;
    }finally{
        input.close();
    }
}
複製代碼

基本是寫的逆過程,代碼比較簡單,就不贅述了。

使用DataInputStream/DataOutputStream讀寫對象,很是靈活,但比較麻煩,因此Java提供了序列化機制,咱們在後續章節介紹。

BufferedInputStream/BufferedOutputStream

FileInputStream/FileOutputStream是沒有緩衝的,按單個字節讀寫時性能比較低,雖然能夠按字節數組讀取以提升性能,但有時必需要按字節讀寫,好比上面的DataInputStream/DataOutputStream,它們包裝了文件流,內部會調用文件流的單字節讀寫方法。怎麼解決這個問題呢?方法是將文件流包裝到緩衝流中。

BufferedInputStream內部有個字節數組做爲緩衝區,讀取時,先從這個緩衝區讀,緩衝區讀完了再調用包裝的流讀,它的構造方法有兩個:

public BufferedInputStream(InputStream in) public BufferedInputStream(InputStream in, int size) 複製代碼

size表示緩衝區大小,若是沒有,默認值爲8192。

除了提升性能,BufferedInputStream也支持mark/reset,能夠重複讀取。

與BufferedInputStream相似,BufferedOutputStream的構造方法也有兩個,默認的緩衝區大小也是8192,它的flush方法會將緩衝區的內容寫到包裝的流中。

在使用FileInputStream/FileOutputStream時,應該幾乎老是在它的外面包上對應的緩衝類,以下所示:

InputStream input = new BufferedInputStream(new FileInputStream("hello.txt"));
OutputStream output =  new BufferedOutputStream(new FileOutputStream("hello.txt"));
複製代碼

再好比:

DataOutputStream output = new DataOutputStream(
        new BufferedOutputStream(new FileOutputStream("students.dat")));
DataInputStream input = new DataInputStream(
        new BufferedInputStream(new FileInputStream("students.dat")));    
複製代碼

實用方法

能夠看出,即便只是按二進制字節讀寫流,Java也包括了不少的類,雖然很靈活,但對於一些簡單的需求,卻須要寫不少代碼,實際開發中,常常須要將一些經常使用功能進行封裝,提供更爲簡單的接口。下面咱們提供一些實用方法,以供參考。

拷貝

拷貝輸入流的內容到輸出流,代碼爲:

public static void copy(InputStream input, OutputStream output) throws IOException{
    byte[] buf = new byte[4096];
    int bytesRead = 0;
    while((bytesRead = input.read(buf))!=-1){
        output.write(buf, 0, bytesRead);
    }
}    
複製代碼

將文件讀入字節數組

代碼爲:

public static byte[] readFileToByteArray(String fileName) throws IOException{
    InputStream input = new FileInputStream(fileName);
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    try{
        copy(input, output);
        return output.toByteArray();
    }finally{
        input.close();
    }
}
複製代碼

這個方法調用了上面的拷貝方法。

將字節數組寫到文件

public static void writeByteArrayToFile(String fileName, byte[] data) throws IOException{
    OutputStream output = new FileOutputStream(fileName);
    try{
        output.write(data);
    }finally{
        output.close();    
    }
}
複製代碼

Apache有一個類庫Commons IO,裏面提供了不少簡單易用的方法,實際開發中,能夠考慮使用。

小結

本節咱們介紹瞭如何在Java中以二進制字節的方式讀寫文件,介紹了主要的流。

  • InputStream/OutputStream:是抽象基類,有不少面向流的代碼,以它們爲參數,好比本節介紹的copy方法。
  • FileInputStream/FileOutputStream:流的源和目的地是文件。
  • ByteArrayInputStream/ByteArrayOutputStream:源和目的地是字節數組,做爲輸入至關因而適配器,做爲輸出封裝了動態數組,便於使用。
  • DataInputStream/DataOutputStream:裝飾類,按基本類型和字符串讀寫流。
  • BufferedInputStream/BufferedOutputStream:裝飾類,提供緩衝,FileInputStream/FileOutputStream通常老是應該用該類裝飾。

最後,咱們提供了一些實用方法,以方便常見的操做,在實際開發中,能夠考慮使用專門的類庫如Apache Commons IO。

本節介紹的流不適用於處理文本文件,好比,不能按行處理,沒有編碼的概念,下一節,就讓咱們來看文本文件和字符流。


未完待續,查看最新文章,敬請關注微信公衆號「老馬說編程」(掃描下方二維碼),深刻淺出,老馬和你一塊兒探索Java編程及計算機技術的本質。用心原創,保留全部版權。

相關文章
相關標籤/搜索