[原]bug笔记 - Java --- 网络编程使用BufferedInputStream从缓冲区中读不到数据(浅析BufferedInputStram和BufferedOutputStream的工作机制)

祝一迪 18/02/01 12:30:34

本文主要是从我写Java网络编程时使用BufferedInputStream和BufferedOutputStream的时候遇到的bug, 来分析BufferedInputStream和BufferedOutputStream的工作机制和简单的源码分析.


1. bug描述

最近在写一个Java网络编程的程序, 其中涉及文件的传输问题, 选择使用BufferedInputStream和BufferedOutputStream来作为网络通信读取数据的方式.
在进行测试的时候, 大文件(几百兆甚至上G)是没有问题的, 但是偶然间测试小文件(十几B)总是失败.
大文件传输成功, 小文件传输不成功, 这说明并不是代码逻辑上的问题, 而应该是某个细节的问题.

经过一番仔细考虑, 去看了看BufferedOutputStream和BufferedInputStream的源码, 终于发现是缓冲区的问题.


小伙伴在看了这博客之后说, 只描述bug对一个陌生人来说不具有太大的参考意义. 所以在这里我在”bug描述”中将我的服务端(sender)和客户端(receiver)关于读写数据的核心代码贴出来, 以供大家参考.
因为代码逻辑较为麻烦, 并且牵扯的方法的调用也比较多, 所以对部分代码做一个大概的解释.

代码的出错部分节选:

sender.java代码节选

    public void sendSection() throws IOException {
        // RecieverSectionInfo是一个POJO, 用于存储分片文件的基本信息
        for (RecieverSectionInfo sectionInfo : sectionInfoList) {
            // 发送分片文件正式内容之前先发送一个包头(含有该分片的基本信息)
            String sectionInfoStr = PackageUtil.packageSectionInfo(sectionInfo);
            byte[] sendHeader = PackageUtil.addHeader(sectionInfoStr);
            recieveOutputStream.write(sendHeader);

            // 使用文件随机读写流读取文件内容
            String fileName = sendPath + sectionInfo.getTargetFileName();
            RandomAccessFile randomAccessFile = new RandomAccessFile(fileName, "rw");
            randomAccessFile.seek(sectionInfo.getOffset());

            // 传输分片文件内容
            long overLen = sectionInfo.getSectionLen();
            while (overLen > 0) {
                int size = overLen > bufferSize ? bufferSize : (int) overLen;
                int temp = randomAccessFile.read(buffer, 0, size);
                recieveOutputStream.write(buffer, 0, size);
                // 下面这句代码就是改掉bug的关键, 加上这句话, 代码就被改掉了, 具体原因请看"bug解决"
                // recieveOutputStream.flush();
                overLen -= size;
            }
        }
    }

receiver.java代码节选

     public void receiveSection(RecieverSectionInfo sectionInfo) throws IOException {
        String filePath = targetPath + sectionInfo.getTempFileName();
        RandomAccessFile file = new RandomAccessFile(filePath, "rw");

        // 开始接收文件
        int haveLen = 0;
        while (haveLen != sectionInfo.getSectionLen()) {
            // 传输小文件的时候这句代码抛异常, temp返回值为-1
            int temp = inputStream.read(buffer, 0, bufferSize);
            file.write(buffer, 0, temp);
            // 每读成功一次, 就使用观察着模式通知view一次
            sendOnReceiving(temp);
            haveLen += temp;
        }
        file.close();
    }

2. bug解决

我在每一次使用BufferedOutput的write()输出数据之后, 调用了flush()方法刷新缓冲区, 这样bug就被改掉了.

那么这样为什么可以成功, 这得从BufferedOutputStream和BufferedInputStream的工作机制和源码层面上进行解释.

BufferedInputStram和BufferedOutputStream的工作机制

BufferedInputStram和BufferedOutputStream是带有缓冲区的输入输出流.

(1) BufferedOutputStream

对于BufferedOutputStream来说, 它在进行流的数据的write时, 并不是立即就将数据写入内核态的缓冲区中, 而是先将数据写入它本身的缓冲区(一个字节数组 : byte[] buf)中, 只有当这个数组写满了之后才会将它本身的缓冲区里面的数据写到内核态的缓冲区中. 关于这个说法, 我们在源码里面来验证一下:

先来看一下BufferedOutput的属性和构造方法:

protected byte buf[]; // BufferedOutputStream的缓冲区

protected int count;  // 此时缓冲区的有效字节数

// BUfferedOutputStream是对OutputStream进行的包装流(在此之下还包装了一层filterOutputStream)
public BufferedOutputStream(OutputStream out) {
    this(out, 8192);
}

// size是指定缓冲区buf的大小
public BufferedOutputStream(OutputStream out, int size) {
    super(out);
    if (size <= 0) {
        throw new IllegalArgumentException("Buffer size <= 0");
    }
    buf = new byte[size];
}

下面是BufferedOutputStream中的两种write()方法:

// 第一种write()方法
 public synchronized void write(byte b[], int off, int len) throws IOException {
    if (len >= buf.length) {
        // 只有当当前需要输出的数据的字节数大于缓冲区buf的大小的时候, 刷新缓冲区, 
        // 并使用out(OutputStream的实例, OutputStream是无缓冲的输出流, 它的write()方法是直接将数据写进内核态缓冲区中)进行数据的输出
        flushBuffer();
        out.write(b, off, len);
        return;
    }

    // 如果当前需要写出的数据小于缓冲区的剩余可写大小, 那么就先将缓冲区清空, 再将数据写进缓冲区中
    if (len > buf.length - count) {
        flushBuffer();
    }
    System.arraycopy(b, off, buf, count, len);
    count += len;
}

我们已经知道, BufferedOutputStream在用户级别提供了一个清空缓冲区的方法flush(), 我也是通过调用这个方法改掉了我的bug. 那么我们来看一下这个方法的源码:

// 用户级别的flush()方法
// 调用内部的清空缓冲区方法
public synchronized void flush() throws IOException {
    flushBuffer();
    // 这句代码的含义是也清空OutputStream的缓冲区, 
    // 但是实质上它什么也没有做!因为OutputStream的flush()方法是空~
    out.flush();
}

// 仅供BufferedOutputStream内部调用的flushBuffer()方法
// flushBuffer():只要当前缓冲区数组里面有数据, 就将数据全部使用OutputStream对象的write()方法写进内核态缓冲区,
// 然后将记录缓冲区有效数组的count值清零
private void flushBuffer() throws IOException {
    if (count > 0) {
        out.write(buf, 0, count);
        count = 0;
    }
}

(2) BufferedInputStream

相比于BufferedOutputStream而言, BufferedInputStream的逻辑就比较复杂了, 而且它提供的用户级别的方法也比BufferedOutputStream多更多, 所以它的源代码也更加复杂一些(我上面列出来的那些代码已经是BufferedOutputStream的全部源码了), 所以在这里我只说一下BufferedInputStream的工作原理, 就不进行源码分析了.

跟BufferedOutputStream一样, BufferedInputStream同样也有一个缓冲区数组.
当使用BufferedInputStream的read()从输入流中读取数据的时候, BufferedInputStream首先会将输入流中的数据分批读进缓冲区数组, 我们所调用的read()方法实质上是从缓冲区中读取数据. 当缓冲区的数据被读完之后, BufferedIputStream再从输入流中(内核态缓冲区)读取一部分数据到自己的缓冲区数组中.
这样做, 可以有效地提高IO读写效率, 因为从内存中读取数据地速度要比从硬盘中读取数据快的多. 但是缓冲区地数组地大小也是需要有限制的, 因为内存的容量始终有限, 不可能将所有的数据都读进内存, 而且一次性将大量数据读进内存也是非常耗时的.

关于BufferedInputStream的源码中比较重要的一个方法就是fill(), 它里面定义了如何从内核态缓冲区读取数据的多种处理, 感兴趣的小伙伴可以去看看这个方法.

最后附上一篇对BufferedInputStream的工作机制和源码分析的十分到位的博客: BufferedInputStream(缓冲输入流)的认知、源码和示例

作者:dela_ 发表于 2018/02/01 12:30:34 原文链接 http://blog.csdn.net/dela_/article/details/79226656
阅读:55 评论:2 查看评论