本節我們介紹在Java中如何以二進制字節的方式來處理文件,上節我們提到Java中有流的概念,以二進制方式讀寫的主要流有:
下面,我們就來介紹這些類的功能、用法、原理和使用場景,最後,我們總結一些簡單的實用方法。
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往往會提供更為高效的實現。
批量讀取還有一個更為通用的重載方法:
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); }
流讀取結束後,應該關閉,以釋放相關資源,關閉方法為:
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會將這些類型的數據轉換為其對應的二進制字節,比如:
與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中以二進制字節的方式讀寫文件,介紹了主要的流。
最後,我們提供了一些實用方法,以方便常見的操作,在實際開發中,可以考慮使用專門的類庫如Apache Commons IO。
本節介紹的流不適用於處理文本文件,比如,不能按行處理,沒有編碼的概念,下一節,就讓我們來看文本文件和字符流。
----------------
未完待續,查看最新文章,敬請關注微信公眾號“老馬說編程”(掃描下方二維碼),從入門到高級,深入淺出,老馬和你一起探索Java編程及計算機技術的本質。用心原創,保留所有版權。