深入淺出 對象序列化EOF異常(java.io.EOFException)

1. 說明

本文有一些個人觀點,如果有異議/更好的建議,可以在下面評論,或者聯繫我canliture#outlook.com(#改爲@)

  • 如果你對象流不是很明白的,可以先看看The Java™ Tutorials——(2)Essential Classes——Basic I/O 之 7. 對象流(Object Streams)的講述,鏈接中給出了一些程序例子,很容易理解。

  • 這裏描述的java.io.EOFException異常是在對象流(也就是ObjectInputStream,ObjectOutputStream)的使用過程中,拋出的。

  • 對象流中引發的EOF異常可以嘗試着本文尋找解決方案。當然其它環境下的EOF異常或許也能夠從本文中找到解決方法的思路

2. 一個簡單問題的引發的深入思考

下面給出一個有EOF異常問題的程序,本文就嘗試着以探索的方式來解決此問題。

public static void main(String[] args) throws IOException {
 File f0 = new File("kkk.out");
 FileInputStream fis = null;
 FileOutputStream fos = null;
 ObjectInputStream dis = null;
 ObjectOutputStream dos = null;
 try{
     if(!f0.exists())f0.createNewFile();

     fos = new FileOutputStream(f0);
     fis = new FileInputStream(f0);

     // 1. 初始化Object流語句
     dis = new ObjectInputStream(fis);
     dos = new ObjectOutputStream(fos);

     // 2. 寫"對象"語句
     dos.writeInt(1);
     dos.writeObject(new Integer(3));

     // 3. 讀取,輸出語句
     System.out.println(dis.readInt() + ","+ dis.readInt());
 } catch (Exception e){
     e.printStackTrace();
     if(fos != null) fos.close();
     if(fis != null) fis.close();
     if(dos != null) dos.close();
     if(dis != null) dis.close();
 }
}

上面代碼想傳達的意思很簡單:向使用對象流 向文件kkk.out 寫入1,3兩個數據,然後使用對象流讀取出來這些數據並打印。現在運行這段代碼,發現報如下錯誤:java.io.EOFException,它提示我們報錯的那一行在這:

// 1. 初始化Object流語句
dis = new ObjectInputStream(fis); // 報錯的就是這一行,第xx行
dos = new ObjectOutputStream(fos);

java.io.EOFException
	at java.io.ObjectInputStream$PeekInputStream.readFully(ObjectInputStream.java:2681)
	at java.io.ObjectInputStream$BlockDataInputStream.readShort(ObjectInputStream.java:3156)
	at java.io.ObjectInputStream.readStreamHeader(ObjectInputStream.java:862)
	at java.io.ObjectInputStream.<init>(ObjectInputStream.java:358)
	at Test.main(Test.java:xx行) // 第xx行報錯

現在爲了看清這個問題,我們先看看一下下面的例子:

3. FileInputStream和ObjectInputStream對讀取空文件的應對策略

首先運行程序前,保證項目目錄下,沒有kkk.out這個文件。

public static void main(String[] args) throws IOException {

   File file = new File("kkk.out");

   FileInputStream is = null;
   try {
       if(!file.exists()) file.createNewFile();

       is = new FileInputStream(file);

       int i = is.read();
       System.out.println(i);
   } catch (IOException e) {
       e.printStackTrace();
       if(is != null) is.close();
   }
}

運行程序,通過平常的學習,很容易知道,輸出爲-1,因爲沒有數據在新創建的文件裏面,FileInputStream的read()函數返回-1。

那麼我們再看看下面的例子。
同理, 在運行前,需要保證項目目錄下,沒有kkk.out這個文件。

public static void main(String[] args) throws IOException, ClassNotFoundException {

   File file = new File("kkk.out");

   FileInputStream is = null;
   ObjectInputStream ois = null;
   try {
       if(!file.exists()) file.createNewFile();

       is = new FileInputStream(file);
       ois = new ObjectInputStream(is);

       int i = (Integer) ois.readObject();
       System.out.println(i);
   } catch (IOException e) {
       e.printStackTrace();
       if(is != null) is.close();
   }
}

現在,我們運行程序,發現報錯:java.io.EOFException
由此可見, 對象流不同於普通的字節流,當對象流中沒有數據時,程序卻嘗試讀取異常,會報EOF錯誤;而字節流就不會出現這種情況,字節流會返回-1

3. 初步查找錯誤

我們現在回到最初的程序,它的目的無非就是使用對象流 向文件kkk.out 寫入1,3兩個數據,然後使用對象流讀取出來這些數據並打印。這兩個動作在同一個程序中發生,現在,我們將兩個行爲放到兩個程序中看會不會出錯?

先運行寫入對象程序K1

public static void main(String[] args) throws IOException {

 File f0 = new File("kkk.out");
 FileOutputStream fos = null;
 ObjectOutputStream dos = null;
 try {
     if (!f0.exists()) f0.createNewFile();

     fos = new FileOutputStream(f0);
     // 1. 初始化Object流語句
     dos = new ObjectOutputStream(fos);

     // 2. 寫"對象"語句
     dos.writeInt(1);
     dos.writeObject(new Integer(3));
 } catch (Exception e) {
     e.printStackTrace();
     if (dos != null) dos.close();
 }
}

再運行讀出對象程序K2

public static void main(String[] args) throws IOException {

 File f0 = new File("kkk.out");
 FileInputStream fis = null;
 ObjectInputStream dis = null;
 try {
     if (!f0.exists()) f0.createNewFile();

     fis = new FileInputStream(f0);

     dis = new ObjectInputStream(fis);

     // 2. 讀取,輸出語句
     System.out.println(dis.readInt() + "," + dis.readInt());
 } catch (Exception e) {
     e.printStackTrace();
     if (dis != null) dis.close();
 }
}

我們發現,第一個寫入程序無任何異常,第二個程序報錯java.io.EOFException,錯誤提示爲這一行代碼: System.out.println(dis.readInt() + "," + dis.readInt());

顯然,我們寫入的是Integer,而讀出來用readInt()肯定會出錯;我們修改上面程序爲readObject()發現沒有任何錯誤

// 2. 讀取,輸出語句
System.out.println(dis.readInt() + "," + dis.readObject());

// 正常輸出:
1,3

現在我們把最開始的程序也改爲dis.readObject(),我們發現仍然是和最初一樣的錯誤。因爲我們改的只是後面的錯誤,最開始的錯誤仍然沒有解決:

4. 深入調用棧/JDK源碼查找問題根源

4.1 ObjectInputStream構造函數解析

現在我們找到最初錯誤的地方,找到程序異常的調用棧

// 1. 初始化Object流語句
dis = new ObjectInputStream(fis); // 報錯的就是這一行,第xx行
dos = new ObjectOutputStream(fos);

java.io.EOFException
	at java.io.ObjectInputStream$PeekInputStream.readFully(ObjectInputStream.java:2681)
	at java.io.ObjectInputStream$BlockDataInputStream.readShort(ObjectInputStream.java:3156)
	at java.io.ObjectInputStream.readStreamHeader(ObjectInputStream.java:862)
	at java.io.ObjectInputStream.<init>(ObjectInputStream.java:358)
	at Test.main(Test.java:xx行) // 第xx行報錯

現在我們也不知道怎麼解決這個問題,我們看看錯誤到底怎麼出現的吧。我們按照異常調用棧來研究一下。我們首先看看錯誤中的at java.io.ObjectInputStream.<init>(ObjectInputStream.java:358),發現拋出錯誤的地方是一個函數readStreamHeader();

我們等會兒再研究readStreamHeader();函數,現在我們研究一下ObjectInputStream構造函數:

/**
 * Creates an ObjectInputStream that reads from the specified InputStream.
 * A serialization stream header is read from the stream and verified.
 * This constructor will block until the corresponding ObjectOutputStream
 * has written and flushed the header.
 * ...// 第二段就不列出來了,對問題的討論沒啥影響
 */
public ObjectInputStream(InputStream in) throws IOException {
     verifySubclass();
     bin = new BlockDataInputStream(in);
     handles = new HandleTable(10);
     vlist = new ValidationList();
     serialFilter = ObjectInputFilter.Config.getSerialFilter();
     enableOverride = false;
     readStreamHeader(); // 這一行在異常調用棧中,
     bin.setBlockDataMode(true);
 }

上面的函數看不懂沒啥關係,對問題的討論沒啥影響。 我們只需要弄清楚函數的註釋和找到readStreamHeader();函數即可

ObjectInputStream構造函數的註釋中有這麼一段話,是非常重要的!:

ObjectInputStream構造函數會從傳入的InputStream來讀取數據。首先會讀取序列化流的頭部(serialization stream header)並驗證頭部。此構造器會一直地"阻塞",直到與之對應的ObjectOutputStream寫入或者了序列化頭部。

文檔註釋中的所說的"阻塞"並不是完全正確的!!!,這個我們最後會提到。

fos = new FileOutputStream(f0);這句代碼,我們看看FileOutputStream的構造函數,構造函數調用的是this(file, false);而false的意思是append追加的意思,也就是說,默認是不追加的。那麼:使用FileOutputStream(File file)實例化一個FileOutputStream導致的結果就是此文件首先被清空。

也就是說, 在實例化ObjectInputStream之前,我們就已經把文件清空了(不管文件之前是否存在,是否有數據)

public FileOutputStream(File file) throws FileNotFoundException {
   this(file, false);
}
public FileOutputStream(File file, boolean append){
	// ... 省略
}

現在我們可以做一個小實驗來驗證我們的猜想,首先我們寫入對象程序K1,先把數據寫進去,然後我們把程序代碼順序稍作修改:

// 1. 初始化Object流語句
dis = new ObjectInputStream(fis);

System.out.println("Sleep Start...");
TimeUnit.SECONDS.sleep(3);
System.out.println("Sleep Exit...");

// 注意這裏,我們把FileOutputStream和ObjectOutputStream的
// 初始化放在ObjectInputStream初始化後面
fos = new FileOutputStream(f0);
dos = new ObjectOutputStream(fos);

// 2. 寫"對象"語句
dos.writeInt(1);
dos.writeObject(new Integer(3));

//2. 讀取,輸出語句
System.out.println(dis.readInt() + "," + dis.readObject());

運行結果,發現程序能夠正常運行輸出:

Sleep Start...
Sleep Exit...
1,3

上面的程序先構造ObjectInputStream,而我們已經運行過寫入對象程序K1,文件裏面已經有數據了,那麼序列化頭部也一定在裏面,所有,初始化沒任何問題。接下來實例化FileOutputStream,ObjectOutputStream清空文件,之後開始寫入數據,最後讀取出來,非常順利地運行。

4.2. readStreamHeader()源碼解析

下面我們再分析一下上面提到的readStreamHeader();,我們研究研究它的代碼:

/**
* The readStreamHeader method is provided to allow subclasses to read and
* verify their own stream headers. It reads and verifies the magic number
* and version number.
* // 其它註釋信息省略
*/
protected void readStreamHeader() throws IOException, StreamCorruptedException {
	short s0 = bin.readShort(); // 分析異常拋出調用棧,這裏是程序出錯的那一行
	short s1 = bin.readShort();
	if (s0 != STREAM_MAGIC || s1 != STREAM_VERSION) {
	    throw new StreamCorruptedException(
	        String.format("invalid stream header: %04X%04X", s0, s1));
	}
}

首先看註釋,註釋很重要!!

readStreamHeader函數用來給子類讀取並驗證流的頭部。頭部有兩個字段: magic number version number

通過看源碼我們直到這兩個字段就是s0和s1:

short s0 = bin.readShort(); // 分析異常拋出調用棧,這裏是程序出錯的那一行
short s1 = bin.readShort();

好了,我們現在再往更深層次的異常調用棧進一步吧——研究一下readShort()

public short readShort() throws IOException {
  if (!blkmode) {
      pos = 0;
      in.readFully(buf, 0, 2);// 分析異常拋出調用棧,這裏是程序出錯的那一行
  } else if (end - pos < 2) {
      return din.readShort();
  }
  short v = Bits.getShort(buf, pos);
  pos += 2;
  return v;
}

這裏沒有啥好研究的,就是通過readFully讀取兩個字節(short),我們再看看更深層次的異常拋出調用棧——readFully():

void readFully(byte[] b, int off, int len) throws IOException {
   int n = 0;
   while (n < len) {
       int count = read(b, off + n, len - n);
       if (count < 0) {
           throw new EOFException();
       }
       n += count;
   }
}

這裏的read(b, off + n, len - n);最終是通過底層的InputStream也就是最初傳入ObjectInputStream構造函數的InputStream調用read()函數來讀取數據的。

5. ObjectInputStream問題解讀彙總

好了,通過遞歸調用棧,我們已經找到了最終錯誤異常拋出的地方了。

// 1. 初始化Object流語句
dis = new ObjectInputStream(fis); // 報錯的就是這一行,第xx行
dos = new ObjectOutputStream(fos);

java.io.EOFException
	at java.io.ObjectInputStream$PeekInputStream.readFully(ObjectInputStream.java:2681)
	at java.io.ObjectInputStream$BlockDataInputStream.readShort(ObjectInputStream.java:3156)
	at java.io.ObjectInputStream.readStreamHeader(ObjectInputStream.java:862)
	at java.io.ObjectInputStream.<init>(ObjectInputStream.java:358)
	at Test.main(Test.java:xx行) // 第xx行報錯

通過遞歸調用棧的分析,我們能夠找到錯誤的底層原因了:

實例化ObjectInputStream(InputStream)時,會首先從傳入的InputStream中讀取兩個short字節的序列化頭部字段:magic number和version number這兩個字段並驗證。如果與之對應的ObjectOutputStream還沒將序列化頭部字段寫入,那麼ObjectInputStream構造函數會一直"阻塞"。

文檔註釋中的阻塞並不是完全正確的!!!

爲啥不完全正確?從實驗結果來看,根本沒有任何阻塞的跡象,只有異常現象。 這裏我們需要知道,對於Socket對應的InputStream來說,它的read()函數是一個阻塞函數,必須等"服務端"發送數據過來read()才能返回。然而對於FileInputStream來說,我們上面的例子講過,它是非阻塞,如果流中存在數據,則返回讀取的數據,沒有則返回-1,而這正是拋出EOFException的根本原因:

// readFully源碼中拋出EOFException
int count = read(b, off + n, len - n);
if (count < 0) {
    throw new EOFException();
}

6. ObjectOutputStream問題解讀

看懂了ObjectOutputStream,還不算真正地理解。最後,我們來看看ObjectOutputStream,懂了這個,我們才能真正地知道EOF問題怎麼發生的,並且改正程序使程序避免出這樣的錯誤。

我們看看ObjectOutputStream構造函數:

/**
* Creates an ObjectOutputStream that writes to the specified OutputStream.
* This constructor writes the serialization stream header to the
* underlying stream; callers may wish to flush the stream immediately to
* ensure that constructors for receiving ObjectInputStreams will not block
* when reading the header.
* // 省略對問題討論來說並不重要的註釋
*/
public ObjectOutputStream(OutputStream out) throws IOException {
   // ... 省略部分代碼
   writeStreamHeader();
   // ... 省略部分代碼
}

先看註釋!

ObjectOutputStream(OutputStream)構造函數創建一個ObjectOutputStream,此對象流寫數據到傳入的OutputStream流中。構造函數會首先立即寫序列化頭部到OutputStream中,確保構造函數的用戶(調用者)不會因爲讀不到序列化頭部而“阻塞”
再次說明,這裏的“阻塞”一詞並不完全正確

顯然,通過這個註釋,就暗示了我們: 最好在實際使用的過程中,我們先實例化ObjectOutputStream,再實例化 ObjectInputStream,保證在在同一資源的對象流ObjectInputStream能夠及時讀取到序列化頭而不至於阻塞或者引發EOF異常(阻塞對應於Socket IO,EOF異常對應於文件IO)

我們再看看ObjectOutputStream的writeStreamHeader();這個從名字來看,與ObjectInputStream中的readStreamHeader();是配套的。

由此我們不得不讚嘆類的設計者,這不就跟跟網絡協議類似嘛?協議標準制定者規定雙方需要遵循一定的數據交流協議,而此協議的精髓主要就體現在協議頭部,在這裏就是序列化流頭部(serialization stream header)。

廢話少說,繼續看writeStreamHeader()源碼

/**
* The writeStreamHeader method is provided so subclasses can append or
* prepend their own header to the stream.  It writes the magic number and
* version to the stream.
*
* @throws  IOException if I/O errors occur while writing to the underlying
*          stream
*/
protected void writeStreamHeader() throws IOException {
   bout.writeShort(STREAM_MAGIC);
   bout.writeShort(STREAM_VERSION);
}

既然"協議"是配套的,那麼這裏writeStreamHeader也就很容易理解了,寫兩個頭部字段magic numberversion底層的OutputStream流中

好了,我們最終的問題就解決了。

  • dis.readInt() 改爲 dis.readObject()
  • 按ObjectOutputStream,ObjectInputStream的先後順序,實例化對象流

7. 對象流 EOFException 必坑指南

  • 對象流不同於普通的字節流,當對象流中沒有數據時,程序卻嘗試讀取數據,會報EOFException;而字節流就不會出現這種情況,字節流會返回-1

  • ObjectInputStream寫入的數據,在ObjectOutputStream上讀取時,應該按照相同的數據類型依次讀取,否則數據類型不等會拋出EOFException

  • 最好在實際使用的過程中,我們先實例化ObjectOutputStream,再實例化 ObjectInputStream,這是由這兩個類的設計思想所決定的。如此能保證在同一資源的對象流ObjectInputStream能夠及時讀取到序列化頭而不至於阻塞或者引發EOF異常(阻塞對應於Socket IO,EOF異常對應於文件IO)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章