深入浅出 对象序列化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)

©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页