所謂的 IO 即 Input(輸入)/Output(輸出) ,當軟件與外部資源(例如:網絡,數據庫,磁盤文件)交互的時候,就會用到 IO 操作。而在IO操作中,最常用的一種方式就是流,也被稱為IO流。IO操作比較復雜,涉及到的系統調用比較多,相對操作內存而言性能很低。然而值得興奮的是,Java提供了很多實現類以滿足不同的使用場景,這樣的實現類有很多,我只挑選些在日常編碼中經常用到的類進行說明,本節主要介紹和文件操作相關的流,下面一起來瞧瞧吧。
File
File是Java在整個文件IO體系中比較基礎的類,它可以實現對文件,文件夾以及路徑的操作,譬如:創建文件或文件夾,獲取絕對路徑,判斷是否存在,重命名,刪除,獲取當前目錄下的文件或文件夾等操作。
File file = new File("example"); //相對路徑
System.out.println(file.getAbsolutePath()); //獲取絕對路徑
System.out.println(file.getName()); //獲取名稱
System.out.println(file.exists()); //判斷文件或文件夾是否存在
boolean result = file.mkdirs();// 把 example 當成文件夾來創建,mkdirs()為級聯創建
System.out.println(result);
result = file.createNewFile();// 把 example 當成文件夾來創建
System.out.println(result);
在使用File的時候有幾點需要注意:
- 傳入File中的參數路徑可以存在也可以不存在。
- 傳入File中的參數路徑如果是相對路徑,那么這個路徑是相對於當前Java Project根目錄的。
- 當傳入的路徑不存在的時候,是無法根據 isDirectory() 或 isFile() 來判斷是文件夾還是文件
當有需求進行遍歷指定目錄下所有指定后綴名或是指定名稱文件或文件夾時,需要在ListFile的參數中提供一個名為filter的過濾器來幫助實現過濾功能,這個過濾器Java是不進行提供的,要根據自己的需求來實現。如果要使用這個方法需要實現FileFilter 類。如下實現了一個過濾指定文件的后綴名的過濾器。
class ExtendNameFilter implements FileFilter {
private String extendName;
public ExtendNameFilter(String extendName) {
this.extendName = extendName;
}
public boolean accept(File dir){
if(dir.isDirectory())
return true;
return dir.getName().endsWith(this.extendName);
}
}
// 篩選指定文件夾下文件以.java結尾的文件
File[] files = file.listFiles(new ExtendNameFilter(".java"));
for(File f : files) {
System.out.println(f.getName());
}
File還有很多常用的操作,由於篇幅有限這里就不逐個演示,更多操作的使用方式和如上示例在調用方法上沒有任何區別,主要注意參數和返回值即可。
字節,字符和編碼格式
對於字節,字符和編碼格式這里不做概念性的描述,詳細的釋義網上有很多,請自行查閱。但從表現形式上對於它們可以大致這樣理解:字節和字符對於系統數據而言表現形式是不同的,可以通過打開一些文件來觀察,如果打開的是圖片或者是可執行程序文件,那么就會看到一些類似於亂碼的東西;而如果是文本文件,基本上會看到明文數據,例如“你好”,“Hello World”等。對於前一種看不懂的就是使用字節來表示的,能看的懂得就是使用字符來表示的。而字符也是通過字節來存儲的,只不過,在不同的編碼格式中所使用的字節數是不一樣的,具體哪些字符需要多少個字節表示需要對應的編碼表。例如:使用GBK編碼存儲漢字字符,則用2個字節來表示,但在UTF8中則使用3個字節來表示
FileOutputStream & FileInputStream 字節流
File只是能操作文件或文件夾,但是並不能操作文件中的內容,要想操作文件的內容就需要使用文件IO流,其操作文件的內容主要有兩種方式:以字節的方式和以字符的方式。而該小節主要講以字節文件流的形式操作文件內容,以字符文件流的方式操作我留到下一小節進行說明。
在Java中以字節流的形式操作文件內容的類主要是FileOutputStream 和 FileInputStream。 分別是 OutputStream(字節輸出流) 和 InputStream(字節輸入流) 抽象基類的子類。下面以圖片的復制來展示下該流的用法。
File sourceFile = new File("sourceFile.jpg");
File destFile = new File("destFile.jpg");
FileInputStream fis=null; // 讀取源文件的流
FileOutputStream fos = null; // 輸出到目標文件的流
try {
fis = new FileInputStream(sourceFile);
fos = new FileOutputStream(destFile);
byte[] bytes= new byte[1024];
int len = 0;
while((len=fis.read(bytes))!=-1) {
fos.write(bytes, 0, len);
}
}
catch(IOException ex) {}
finally {
try { fis.close();} catch(IOException ex) {}
try { fos.close();} catch(IOException ex) {}
}
在使用 FileOutputStream 和 FileInputStream 的過程中需要注意的地方:
- FileInputStream 所要操作的文件必須存在,否則就會拋出異常。而 FileOutputStream 寫入的目的文件則不需要存在,當不存在時會被創建,存在的時候會被覆蓋,也可以使用 FileOutputStream 造函數的第二個參數,來實現追加文件內容。
- 在使用 FileInputStream 讀取字節的時候,當讀取到字節的末尾,再繼續讀取,無論多少次都會返回 -1,而返回值len表示本次讀取了多少個字節。通常情況下每次讀取1024個字節,可以達到空間和時間的平衡。但是具體情況也是需要具體分析的。
- 字節流是不存在緩沖區的,所以不需要使用flush操作刷新緩沖區,字節的讀取和寫入都是通過操作系統來實現的。
- 只要是流就是需要關閉的,無論是否在異常情況下都需要關閉流,防止占用系統資源,導致其他程序無法對該文件進行操作。但是在關閉流的時候也有可能會報異常,所以也需要 try...catch。
FileOutputStream 和 FileInputStream主要用來操作字節表現形式的文件,例如圖片,可執行程序等。當然操作字符表現形式的文件也是沒有問題的,只不過這么干不規范。
OutputStreamWriter & InputStreamReader
這小節主要講以字符流的形式操作文件,在Java中對應操作的主要類為 OutputStreamWriter 和 InputStreamReader 。有時候又稱它們為轉換流,具體原因一會在說,先看一個例子。
File sourceFile = new File("sourceFile.txt");
File destFile = new File("destFile.txt");
FileInputStream fis= new FileInputStream(sourceFile);
FileOutputStream fos = new FileOutputStream(destFile);
InputStreamReader reader=null;
OutputStreamWriter writer=null;
try {
reader= new InputStreamReader(fis,"utf-8");
writer =new OutputStreamWriter(fos,"gbk");
char[] cbuf =new char[1024];
int len=0;
while((len=reader.read(cbuf))!=-1) {
System.out.println(String.copyValueOf(cbuf,0,len));
writer.write(cbuf, 0, len);
}
}
catch(IOException ex) {
try{reader.close();}catch(IOException ex) { }
try{writer.close();}catch(IOException ex) { }
}
上述示例主要實現了一個文件的復制,與字節流的使用方式不同的是,字符流的構造函數需要傳遞字節流和編碼格式。這是因為操作文件內容都是以字節的形式來操作的。字符輸入流根據編碼表對字節流讀取的字節轉義成字符,同時也說明了傳遞編碼表格式參數的重要性。如果被讀取文件編碼格式是UTF-8且不傳遞這個參數,那么這個參數為操作系統的默認編碼表(對於Windows而言是GBK),如果默認的編碼表與UTF-8不同(與系統編碼表格式相同,可不傳遞此參數),在轉義為字符的過程中就會出現問題。假如文件內容為“好”,在UTF-8中對應的字節為-10-20-30。那么就以系統的默認編碼表來轉義,假如默認為GBK,“好”字的編碼為-50-60,由原來3個字節表示漢字,現在變成了2個字節表示漢字,又由於編碼表不兼容,所以導致出現亂碼。而在使用字符輸出流的時候,將字符按照編碼表參數轉化為字節后再寫入對應編碼格式的文件中去。如果輸出的內容是以追加的方式,那么需要保證前后兩個輸出文件內容的編碼格式一樣,否則也會出現亂碼。假如之前的輸出文件是GBK格式,你使用字符輸出流輸出的字符格式為UTF8並追加到文件中去,這個時候亂碼就產生了。綜上過程,也就知道大家為什么又稱 FileOutputStream 和 InputStreamReader 為轉換流了。
傳遞給字符流的字節流不需要單獨的進行關系,在字符流關閉的時候會調用字節流的close()方法。
FileWriter & FileReader
FileWriter 和 FileReader 分別是 OutputStreamWriter 和 InputStreamReader 的子類,只不過他們是只能操作系統默認編碼表的字符流。也可以這么簡單的理解: OutputStreamWriter 和 InputStreamReader 的構造函數不支持傳遞第二個參數,就是操作系統默認的編碼表。所以在使用上只需要注意操作的文件編碼格式是否與系統默認的編碼格式一致即可。既然不傳遞第二個參數就可以達到相同的效果,為什么還會有這個兩個類呢?因為這兩個類操作簡單。下面還是以復制文件為例。
File sourceFile = new File("sourceFile.txt");
File destFile = new File("destFile.txt");
FileReader reader=null;
FileWriter writer=null;
try {
reader= new FileReader(sourceFile);
writer =new FileWriter(destFile);
char[] cbuf =new char[1024];
int len=0;
while((len=reader.read(cbuf))!=-1) {
System.out.println(String.copyValueOf(cbuf,0,len));
writer.write(cbuf, 0,len);
}
}
catch(IOException ex) { }
finally {
try{reader.close();}catch(IOException ex) { }
try{writer.close();}catch(IOException ex) { }
}
無論是使用 FileWriter & FileReader 還是 OutputStreamWriter & InputStreamReader ,在他們的內部都會存在緩沖區的,默認大小為8192字節。如果不對流進行關閉的話,數據會繼續存在緩沖區,不會存儲到文件上,除非手動調用flush方法或者是在緩沖區中寫入的數據超過了緩沖區的大小,數據才會刷新到文件上。而調用close方法的內部會先調用flush刷新緩沖區。
BufferedOutputStream & BufferedInputStream & BufferedWriter & BufferedReader
這四個Buffered開頭的類分別是為字節流和字符流提供一個合適的緩沖區來提高讀寫性能,尤其是在讀寫數據量很大的時候效果更佳顯著。其用法和不帶Buffered的流沒有任何區別,只不過在不帶Buffered流的基礎上提供了一些更加便利的方法,例如newLine(),ReadLine()和ReadAllBytes(),他們會根據操作系統的不同添加合適的換行符,根據合適的換行符來讀取一行數據和讀取所有字節。來看一下用法以緩沖字符流為例
File sourceFile = new File("sourceFile.txt");
File destFile = new File("destFile.txt");
BufferedWriter bw =null;
BufferedReader br =null;
try {
FileReader reader= new FileReader(sourceFile);
FileWriter writer=new FileWriter(destFile);
bw =new BufferedWriter(writer);
br =new BufferedReader(reader);
String line =null;
while((line=br.readLine())!=null) {
bw.write(line);
bw.newLine();
}
}
catch(IOException ex) {}
finally {
try { bw.close();} catch(IOException ex) {}
try { br.close();} catch(IOException ex) {}
}
上述的代碼中有兩點需要注意:
- 當按照行來讀取字符的時候,當下一行沒有內容,繼續讀取下一行的內容,結果會返回 null,可以此來判斷文件中是否還有字符。
- 當讀取的文件行返回為null后,仍然會執行一次循環,此時調用newLine() 會在寫入的文件中多添加一個換行符,這個換行符無關緊要,可以不用考慮處理掉。
ObjectOutputStream & ObjectInputStream
在編寫程序的過程中,難免會遇到和外部程序進行數據交流的需求,例如調用外部服務,並傳輸一個對象給對方,此時需要把傳輸對象序列化為流才能和外部程序進行交互。又比如需要對一個對象進行深拷貝,也可以將對象序列化為流之后再反序列化為一個新的對象。Java提供了ObjectOutputStream 和 ObjectInputStream 來實現對對象的序列化和反序列化。序列化后的流為字節流,為了清晰的看到序列化后的結果,以下將序列化后的流輸出到文件中然后在反序列化為一個對象,具體來看一看吧。
Student stu =new Student("vitamin",20,1);
File destFile = new File("destFile.txt");
// 序列化對象到文件中
ObjectOutputStream oos= null;
try {
FileOutputStream fos = new FileOutputStream(destFile);
oos =new ObjectOutputStream(fos);
oos.writeObject(stu);
}
catch(IOException ex) {}
finally {
try {oos.close();}catch(IOException ex) {}
}
// 反序列化文件中的流為對象
ObjectInputStream ois= null;
try {
FileInputStream fis = new FileInputStream(destFile);
ois =new ObjectInputStream(fis);
Student newStu = (Student)ois.readObject();
System.out.println(newStu.toString());
}
catch(Exception ex) {}
finally {
try {ois.close();}catch(IOException ex) {}
}
// Student 類定義
class Student implements Serializable{
private String Name;
public int Age;
public transient int Sex;
public static String ClassName;
private final static long serialVersionUID= -123123612836L;
public Student(String name,int age,int sex) {
this.Name =name;
this.Age = age;
this.Sex = sex;
}
@Override
public String toString() {
return String.format("Name=%s,Age=%d,Sex=%d", this.Name,this.Age,this.Sex);
}
}
對象要想成功實現序列化和反序列化需要注意以下幾點:
- 對象要想實現序列化,被序列化的對象要實現標記接口 Serializable。
- 無論屬性訪問權限如何,都可以進行序列化和反序列化,但靜態屬性無法被序列化和反序列化。
- 如果在對象序列化的過程中,不想讓某個屬性參與其中,可以使用關鍵字
transient
進行標記。 - 序列化到文件后是不要進行flush操作的,同字節流一樣也不存在緩沖區。
- 如果對象在序列化后,對對象的屬性的修改(比如訪問屬性的變更,字段類型的變更)都會導致在反序列后出現類似錯誤 :
Student; local class incompatible: stream classdesc serialVersionUID = -123123612836, local class serialVersionUID = -1225000535040348600
這是由於對象在編譯成class文件過程中會對屬性生成一個serialVersionUID
,這個屬性也會存儲到序列化后的對象中,每次屬性的變更都會導致它進行修改,如果出現前后不一致,則導致出現以上錯誤。如果想避免這個問題,需要在對象內指定 serialVersionUID ,具體數值什么都可以。但是屬性的定義一定要是 final static long。 - 反序列化后的對象是Object類型,不是Student。如果需要使用Student對象的屬性或方法,需要進行強制類型轉化。
- 對象在序列化和反序列化的過程中,拋出的不只有IOException。如果刪除Student類定義或是Student.class文件,然后對序列化后的流調用toString()方法
System.out.println(ois.readObject());
,就會拋出異常:java.lang.ClassNotFoundException: Student
, 如果反序列化后的對象轉為非Student對象,也會報其他的非IOException異常。所以在處理異常的時候,需要考慮到這些情況。
Properties
在學習Java的過程中肯定會接觸到用Map結構來存儲Key/Value關系的數據,在我之前的博客 Java中關於泛型集合類存儲的總結 中講到過它的一個實現類 HashMap。 但是除了HashMap外還有一個實現類HashTable ,它可以實現和HashMap一樣的功能,但是由於是線程安全的(同步的)並且存儲的對象是Object類型,這就導致它的性能對於線程不安全(非同步)HashMap會有所降低,所以不是很常用。但是HashTable有一個子類 Properties 卻很常用,它可以在文件中存儲 Key=Value 形式的數據,可以用其來讀取配置。
Properties prop =new Properties();
prop.setProperty("Name", "vitamin");
prop.setProperty("Age", "20");
File file =new File("destFile.txt");
FileWriter writer =new FileWriter(file);
prop.store(writer, "this is a test conf"); // 存儲到文件中並設置備注,如果備注是中文則會被轉碼
FileReader reader =new FileReader(file);
prop.load(reader);
System.out.println(prop.getProperty("Name")); // vitamin
System.out.println(prop.getProperty("Sex")); // null
System.out.println(prop.getProperty(" Name")); // null
Properties雖然繼承自HashTable,但是它的Key和Value只能是String類型,然而實現內部仍然調用的是put(Object,Object)方法。Properties是允許你直接調用put(Object,Object)方法的,畢竟都是Map的實現類,但是這樣調用了之后,在運行時會報錯並警告你只能設置String類型的數據。
Properties通過load和store方法將Key=Value的對應關系從文件中加載並轉化為Properties對象和將Properties對象轉化為Key=Value對應關系存在到文件中。注意:在文件中存儲的Key=Value關系形式,在等號兩側是否有空格很重要,如果有空格,雖然看上去是沒什么問題,但是對於Properties對象而言卻不是你想要的結果,可以自己嘗試一下。如果需要在被Load的文件中添加注釋的話,則在行首添加 #
即可。
#this is a test conf
#Sat Sep 21 15:03:54 CST 2019
Age=20
Name=vitamin
PrintStream & PrintWriter
最后再來說一下Java提供的打印流 PrintStream 和 PrintWriter,可以在輸出的數據上做一些格式化操作。 提起 PrintStream 你可能會感到很陌生,但你是否留意過經常使用的System.out.print() 方法的內部實現,它的底層就是使用 PrintStream 來操作的,PrintStream 繼承自文件字節流 FileOutputStream。對於后者 PrintWriter 更加常用,因為它實現了前者的所有方法,並且可以實現對字符流的打印,這是PrintStream所沒有的。所以 PrintWriter 也更加靈活。下面通過示例來感受下 PrintWriter吧
File file =new File("destFile.txt");
PrintWriter pw =null;
try{
pw = new PrintWriter(file);
pw.printf("Name=%s", "vitamin");
pw.flush();
}
catch(IOException ex) {}
finally {
pw.close();
}
值得注意的一點是 PrintWriter 的 close() 方法不會拋出IOException,因為在底層這個異常已經被捕捉並處理了。
PrintWriter的內部是有緩沖區的(當構造函數傳入的是File類型時,內部使用的是BefferedWriter來實現的),所以需要手動調用flush()方法。但是PrintWriter的構造函數支持第二個參數:是否啟用自動刷新緩沖。當設置為true后,僅當調用 println , printf , format 方法時才會生效。
IO流的選擇
上面說了這么多的IO流,到底什么場景下需要該使用什么流呢?來看一張圖
除了上面的圖之外還需要在額外問自己幾個問題:
- 是否需要進行序列化和反序列化操作?如果是則選擇 ObjectInputStream 或 ObjectOutputStream。
- 是否需要讀取Key=Value形式或者是想要存儲成Key=Value形式的配置?如果是可以選擇 Properties 操作起來更加方便。
- 是否需要打印指定格式的數據到輸出文件?可以考慮使用 PrintWriter,其實它就是在流的基礎上提供了一些更加簡潔的操作。
第三方工具包
在開發中覺得寫起來比較繁瑣,如果不想自己封裝的話,推薦一個好用的第三方工具包:commons-io。