本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
本節我們介紹在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往往會提供更為高效的實現。
批量讀取還有一個更為通用的過載方法:
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會將這些型別的資料轉換為其對應的二進位制位元組,比如:
- 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程式設計及計算機技術的本質。用心原創,保留所有版權。