java io里的BufferedInputStream为什么性能高

java io流的分类

  • 从读写角度拆分

    • 读取流(InputStream、Reader的子类)
    • 写入流(OutputStream、Writer的子类)
  • 按操作数据类型可分为

    • 字节流:所有InputStream、OutputStream的子类都是字节进行操作。
    • 字符流:所有Reader、Writer的子类都是针对字符进行操作。
  • 按处理类型可分为

    • 终端流(节点流):个人理解,觉得终端流更形象贴切一些。如果某个流是直接和磁盘、内存、网络等物理终端联通来进行读写操作的,这种流就可以称为终端流或者节点流。
    • 包装流(处理流):包装流不直接和物理终端连接,所以他需要依赖终端流,因为没有终端流就没有读写目的地,包装流可以对数据读写进行过滤控制,比如类型转换、数据缓冲等。

如果我们要读取磁盘上的一个文件,那么可以使用FileInputStream,他是一个读取流、字节流、终端流。我们也可以在FileInputStream的外层包一个BufferedInputStream,他也是一个读取流,字节流,包装流,他可以实现对数据读取的缓冲。具体这个缓冲是什么意思,我们下面再具体分析。先来看一个实际的例子。

测试用例

我们先通过一个文件读取的测试程序来观察一下BufferedInputStream和FileInputStream的读性能。

public class TestBuffered01 {
    public static void main(String[] args) throws Exception {
        int b = 0;
        long start = System.currentTimeMillis();
        FileInputStream fis = new FileInputStream("/home/syl/Downloads/ubuntu.iso");
        while ((b = fis.read()) != -1) { // 每次读取一个字节
            // 什么也不做,就是看看多久能读完
        }
        System.out.println("FileInputStream read cost: " + (System.currentTimeMillis() - start));
        fis.close();

        // 重置开始时间,以下用 buffered方式读取
        start = System.currentTimeMillis();
        /* 
        读取ubuntu-c1.iso文件,这个文件是ubuntu.iso的拷贝。让两个流读取同样大小但是不同的文件主要是为了比较同时从磁盘读取的性能。如果此处也读取ubuntu.iso文件的话,因为FileInputStream在之前已经读取过,一部分数据已经缓存在了内存中,后续再读取会直接从内存返回,性能比较就不公平了。
        */
        fis = new FileInputStream("/home/syl/Downloads/ubuntu-c1.iso");
        BufferedInputStream bis = new BufferedInputStream(fis);
        while ((b = bis.read()) != -1) { // 每次读取一个字节
            // 什么也不做,就是看看多久能读完
        }
        System.out.println("BufferedInputStream read cost: " + (System.currentTimeMillis() - start));
        bis.close();
    }
}

代码很简单,分别采用FileInputStream和BufferedInputStream两个不同的类但是同样的api方法读取同样大小的不同文件(Ubuntu的安装文件,大小912M)。执行结果如下:

FileInputStream read cost: 853355

BufferedInputStream read cost: 20549

通过执行结果可见,直接采用FileInputStream的read()读取耗时将近15分钟,而采用BufferedInputStream的read()读取耗时只有20秒,性能差异巨大。接下来我们从源码角度来分析下威慑么会有这么大的差异。

源码分析

通过上面的测试程序,我们可以看到无论是基于FileInputStream读取,还是基于BufferedInputStream读取,我们都是调用的read()方法来是实现字节读取的。因为他们都是InputStream的子类,所以我们先来分析下InputStream这个抽象类中的read()方法是如何定义的。

InputStream的read()方法
/**
     * Reads the next byte of data from the input stream. The value byte is
     * returned as an <code>int</code> in the range <code>0</code> to
     * <code>255</code>. If no byte is available because the end of the stream
     * has been reached, the value <code>-1</code> is returned. This method
     * blocks until input data is available, the end of the stream is detected,
     * or an exception is thrown.
     *
     * <p> A subclass must provide an implementation of this method.
     *
     * @return     the next byte of data, or <code>-1</code> if the end of the
     *             stream is reached.
     * @exception  IOException  if an I/O error occurs.
     */
public abstract int read() throws IOException;

这是个抽象方法,只有声明,没有实现,是需要子类去实现的。注释很多,所以我们接下来再来看他的子类FileInputStream对于read的实现。方法注释的也很容易理解:

从输入流中读取下一个字节的数据,这个数据以int方式返回,这个int的范围是在0-255之间(因为一个字节是8个二进制位,2的8次方能表示的无符号整数范围就是0-255),如果读不到可用数据了,就说明已经读到了流的末尾了,那么就返回-1(这也就是while循环里的判断依据)。这个方法在如下情况下会接触阻塞:

  • 输入数据可用
  • 读到了流的末尾
  • 产生了异常

换句话说,在等待数据输入(例如:System.in)或者没有读到数据的时候,会一直阻塞。

FileInputStream的read()方法
/**
     * Reads a byte of data from this input stream. This method blocks
     * if no input is yet available.
     *
     * @return     the next byte of data, or <code>-1</code> if the end of the
     *             file is reached.
     * @exception  IOException  if an I/O error occurs.
     */
// 方法注释就是InputStream的精简版
public int read() throws IOException {
    return read0(); // 调用本地方法read0
}

// 这是个本地方法,不同的平台有不同的实现
private native int read0() throws IOException;
BufferedInputStream的部分源码
private static int DEFAULT_BUFFER_SIZE = 8192; // 默认的缓冲字节数组长度

private static int MAX_BUFFER_SIZE = Integer.MAX_VALUE - 8; // 缓冲字节数据的最大长度

protected volatile byte buf[]; // 缓冲字节数组

protected int count; // 缓冲字节数组中已缓冲的数据长度,他应该小于等于buf.length

protected int pos; // 指的是一个读取数据的位置(应该从缓冲字节数组的哪个位置读取数据了)

/**
* 构造方法,传入被包装的终端流(对应我们例子中的就是FileInputStream实例)
*/
public BufferedInputStream(InputStream in) {
    this(in, DEFAULT_BUFFER_SIZE); // 调用下面的构造方法,传入默认的缓冲数组长度
}

/**
* 构造方法,传入被包装的终端流以及缓冲数组的长度,最后就会按照给定长度初始化缓冲数组。
*/
public BufferedInputStream(InputStream in, int size) {
    super(in);
    if (size <= 0) {
        throw new IllegalArgumentException("Buffer size <= 0");
    }
    buf = new byte[size];
}

/**
* 获取被包装的终端流
*/
private InputStream getInIfOpen() throws IOException {
    InputStream input = in;
    if (input == null)
        throw new IOException("Stream closed");
    return input;
}

/**
* 获取缓冲字节数组
*/
private byte[] getBufIfOpen() throws IOException {
    byte[] buffer = buf;
    if (buffer == null)
        throw new IOException("Stream closed");
    return buffer;
}

/**
* 从缓冲区读取数据。
* 首先你要知道read方法是一个一个字节的读。当缓冲区为空的时候,就先把缓冲区填满(也可能一次填不满,假设你想要读100字节,但受限于内核以及网络通信等各种配置参数,可能一次返回的不足100字节),然后一个一个的从缓冲区读。缓冲区读完了之后,需要 再接着填 或者 清空缓冲区再重新填,然后再一个一个的从缓冲区读。这其中的接着填 和 清空重新填 的动作里,pos 和 count就两个变量全程参与了。
*/
public synchronized int read() throws IOException {
    /*
    	pos 和 count都是 BufferedInputStream对象的实例属性。初始值都是0.
        pos的作用就是标识要从缓冲的byte数组中的哪个位置读取下一个字节,每次成功读取一个,pos都要加1。
    	count的作用就是标识缓冲区有多少数据,缓冲区默认长度8192,但如果输入流只有100个字节,那么count就是100。
    	1、如果是第一次读,那么pos=0,count=0,缓冲区长度8192,假设读取回来100个字节,那么pos还是0,count=0 + 100 = 100。
    	2、这之后的100次read()调用,都从buf数组直接返回。然后pos = 100 了。
    	3、这时候 pos >= count了,因为缓冲的都读完了,所以要接着读数据然后往缓冲区填(fill),假设又读取回来500个字节,那么pos还是100,count= 100 + 500 = 600。
    	4、这之后的500次read()调用,都从buf数组直接返回。以此类推。
    	5、当count= buf.length的时候,再fill的时候,pos会从0开始,count = 0 + 读回来的字节数。
    
    	所以一旦pos >= count 就说明要么缓冲区还没有数据,要么缓冲区的数据都已经都读完了,总之需要重新填数据了(在后面接着填、或者清空从头填)。
    */
    if (pos >= count) {
        fill(); // 重点是这个方法,填充数据,下面有单独解析
        if (pos >= count) //  如果执行了fill填充数据方法之后,pos还大于等于count,就说没有读取到数据,所以可以直接返回-1,表示已经读完了。
            return -1;
    }
    // 走到这一步,就说缓冲区里有未读取的数据,那么从pos位置读一个字节返回,然后pos加1,下次从pos加1的位置接着读一个字节
    return getBufIfOpen()[pos++] & 0xff;
}

/**
* 往缓冲区填充数据。
* 数据从哪来?从终端流读取而来。
*/
private void fill() throws IOException {
    byte[] buffer = getBufIfOpen(); // 获取缓冲区字节数组(构造方法里已经初始化过),默认长度8192
    // 省略了很多代码
    count = pos;
    // 重点是这一行代码,首先会通过getInIfOpen方法获取到被自己包装的终端流,对应我们例子中的也就是FileInputStream,然后调用FileInputStream的read(byte b[], int off, int len) 方法,读取出(buffer.length - pos)长度个数据,填充到buffer数组中(也就是把数据填充到缓冲区数组buf中的pos位置后面)
    int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
    if (n > 0)
        count = n + pos; // 如果n大于0,说明读取到了数据(但不一定就正好是buffer.length - pos个,可能不足这些个,但是没关系,总之数组能装下,然后更新数据的长度也就是count的值为pos+n)
    // 如果n小于等于0,说明没读取到数据,以为上面设置了count = pos,所有从这个方法返回到上层方法后,还有if (pos >= count)的判断,这个判断后面就会直接return -1了。
}
小结

通过上面的源码分析,我们可以得出如下结论:

  • FileInputStream的read()方法调用了一个本地方法read0(),从文件一个一个字节的读取数据。

  • BufferedInputStream的read()方法是从自己的一个缓冲字节数组里一个一个的读数据。

  • 但是BufferedInputStream的缓冲数组需要通过终端流FileInputStream的read(byte b[], int off, int len) 来填充数据。

如果是这样,那我们就会发现,这BufferedInputStream的read()方法和FileInputStream的read()方法的性能差异,就变成了FileInputStream的read()方法和FileInputStream的read(byte b[], int off, int len) 方法的性能差异了。所以我们再看下FileInputStream的read(byte b[], int off, int len) 方法是如何实现的。

FileInputStream的read(byte b[], int off, int len)
/**
* 一次性尽可能多的读取数据,当然读取数据的长度最多就会len个,读取回来的数据,从字节数组b的off位置往后写入到字节数组b中
*/
public int read(byte b[], int off, int len) throws IOException {
	return readBytes(b, off, len);
}

private native int readBytes(byte b[], int off, int len) throws IOException;

我们会发现,FileInputStream的read(byte b[], int off, int len)方法的本质就是一次性读取多个字节,减少IO次数,他最终还是调用了一个本地方法readBytes。

到这里,关于read(byte b[], int off, int len)方法有必要多说几句:

  • 该方法在抽象类InputStream中已经有定义且有默认实现。
  • FileInputStream作为InputStream的子类用他认为性能更高效的方式重新重写了该方法。

用他认为更高效的方式重写是什么意思?那就是说父类的实现很低效么?

所以我们接下来再看看InputStream的对于read(byte b[], int off, int len)方法的默认实现

InputStream的read(byte b[], int off, int len)
//  Subclasses are encouraged to provide a more efficient implementation of this method
public int read(byte b[], int off, int len) throws IOException {
        if (b == null) {
            throw new NullPointerException();
        } else if (off < 0 || len < 0 || len > b.length - off) {
            throw new IndexOutOfBoundsException();
        } else if (len == 0) {
            return 0;
        }

        int c = read();
        if (c == -1) {
            return -1;
        }
        b[off] = (byte)c;

        int i = 1;
        try {
            // 重点就看这里,这就是在一个循环里调用read()方法
            for (; i < len ; i++) {
                c = read(); // 读取一个字节
                if (c == -1) {
                    break;
                }
                b[off + i] = (byte)c; // 把读取到的字节填充到数组里
            }
        } catch (IOException ee) {
        }
        return i;
    }

所以我们可以看到InputStream的read(byte b[], int off, int len) 方法的实现是很低效的,他只是循环调用read()方法而已,还记得么?read()方法是抽象方法,是需要子类实现了,所以这个地方是一个钩子方法,父类方法逻辑中依赖子类的方法实现。所以我们的FileInputStream类一定要实现read()方法,但是可以不实现read(byte b[], int off, int len) 方法,如果FileInputStream真的没有自己重写read(byte b[], int off, int len) 方法的话,那么即便是用BufferInputStream将FileInputStream包装起来,也达不到提升性能的效果,反而还会下降一些(因为逻辑复杂了,多了一层数组的中转)。所以InputStream的read(byte b[], int off, int len) 方法的注释上说“鼓励子类用性能更好的方式重写该方法”。所以FileInputStream重写了该方法(虽然看不到本地代码是如何实现,但这不是我们要关心的重点),总是他的实现一定是批量读取一批字节,而不是一个一个读。

再探性能

经过上面的分析,我们再看下面的测试用例。

public class TestBuffered02 {
    public static void main(String[] args) throws Exception {
        byte[] bytes = new byte[8192]; // 定义个接受数据的自己数组
        long start = System.currentTimeMillis();
        FileInputStream fis = new FileInputStream("/home/syl/Downloads/ubuntu-c2.iso");
        while (fis.read(bytes) != -1) { // 一次读取一批,最多能读取8192个字节
            // 什么也不做,就是看看多久能读完
        }
        System.out.println("FileInputStream batch read bytes cost: " + (System.currentTimeMillis() - start));
        fis.close();

        // 重置开始时间,以下用 buffered方式读取
        start = System.currentTimeMillis();
        fis = new FileInputStream("/home/syl/Downloads/ubuntu-c3.iso");
        bis = new BufferedInputStream(fis);
        while (bis.read(bytes) != -1) {// 一次读取一批,最多能读取8192个字节
            // 什么也不做,就是看看多久能读完
        }
        System.out.println("BufferedInputStream read bytes cost: " + (System.currentTimeMillis() - start));
        bis.close();        
    }
}

输出结果如下:

FileInputStream batch read bytes cost: 2731
BufferedInputStream read bytes cost: 3948

该测试用例,对比的还是FileInputStream和BufferedInputStream,还是读取同样大小的文件(Ubuntu的安装文件,912M)。但是和上一次测试用力不同的是,上一次测试用例,调用的单字节的读取的方法read()。这个测试用例调用的是两个流中的批量读取字节的方法read(byte[] bytes)。

从运行时间看,二者差异不大了,而且性能都很高了。多执行几次的话(中间要穿插着读取其他文件,以便让内存中的文件缓存新陈代谢【LRU】),二者的时间会你高我低的交替,并不存在谁一直比谁快。

再继续分析源码之前,我们来思考这样一个问题:BufferedInputStream一定比FileInputStream性能高么? 或者说bufered缓冲流一定比终端流性能高么?

答案肯定是否定的,你自己就可以对比出来了,如果你用BufferedInputStream的read()方式来和FileInputStream的read(byte[])方式来对比,就会发现后者的性能更高。所以性能对比也要有背景和前提的,要基于同样的方法才有参考意义,而且就算是同样的方法也并不是用了缓冲流就一定能提高性能。接下来我们再来分析我们的测试用例:

  • 直接调用终端流FileInputStream的read(byte[] bytes)方法,设置读取的字节数组大小为8192,read(bytes)方法最终还是会调用readBytes(bytes, 0, 8192)方法。这就等于说我们直接用了FileInputStream中最高效的读取方法了。

    public int read(byte b[]) throws IOException {
        return readBytes(b, 0, b.length);
    }
    
  • 调用BufferedInputStream的read(byte[] bytes)方法,如果你打开BufferedInputStream类的源码会发现,他的内部和read相关的两个个重载方法分别是:

    • read() 这个是单字节读取,我们上一次测试用例已经使用并分析过。
    • read(byte[], int, int) 这个是批量读取。

    可是没有我们用到的read(byte[])方法,但是我们还正常编译并执行通过了。这是因为read(byte[])方法是在BufferedInputStream的父类FilterInputStream中定义的。

    // FilterInputStream类中的read(byte b[])方法的实现
    
    public int read(byte b[]) throws IOException {
        return read(b, 0, b.length); // 这里调用的是下面的方法么?并不是,因为子类BufferedInputStream中自己重写了read(byte[], int, int)方法,那么执行的就肯定是BufferedInputStream中的实现。所以我们下面再看BufferedInputStream的read(byte[], int, int)实现。
    }
    
    // 这是FilterInputStream的默认实现,但是这个方法是可以被子类重写的。
    public int read(byte b[], int off, int len) throws IOException {
    	return in.read(b, off, len);
    }
    
BufferedInputStream的read(byte b[], int off, int len)
/**
* 《下面是对源码中的注释的一个翻译和理解》
* 这个方法干的事情就是,从一个字节输入流(终端流)里读取出多个字节(len个)然后放到一个字节数组(b)中。返回值就是读取到的字节个数,读到的字节个数应该是 <= len的。
* 这个方法实际上也是对InputStream的read(byte b[], int off, int len)方法的一个重定义的实现。
* 因为这是个包装流,他的所有数据读取还是要依赖底层的终端流,但是他会循环的调用底层终端流的对应的read方法来尽可能多的读取字节然后填满自己的缓冲区。那么到什么时候这个循环迭代会终止呢?
* 1、指定的字节已经读取过了
* 2、底层的终端流返回-1了,说明读到流的末尾了。
* 3、底层流的状态处于不可用了(available方法返回false),说明被阻塞了。
*/
public synchronized int read(byte b[], int off, int len)
        throws IOException
{
    getBufIfOpen(); // Check for closed stream 这个意思是检查当前的缓冲流是否被关闭了,因为close方法里会把buf数组置为null。如果流关闭了,那这直接抛异常了就,下面不会执行。
    
    // 下面的判断属于入参有效性校验。这里面用了位运算的 或 运算。如果off/len/(off+len)/b.length-(off+len)当中有一个是负数,那么他的二进制首位一定是1,所有和任何的数值再去或运算之后的首位还是1,所以最终还是个负数,一定小于0。
    if ((off | len | (off + len) | (b.length - (off + len))) < 0) { 
        throw new IndexOutOfBoundsException();
    } else if (len == 0) {
        return 0;
    }

    int n = 0; // 用来存储一共读了多少个字节了
    for (;;) { // 这就是方法注释里描述的循环读
        int nread = read1(b, off + n, len - n); // 调用了自己的read1方法
        if (nread <= 0) // 如果nread为0说明没读到,如果为-1说明读到末尾了
            return (n == 0) ? nread : n; // 如果n为0,说明之前的循环也啥都没读到 或者 这就是第一次循环,可以直接返回nread;如果n不等于0,说明这不是第一次循环,之前还是读到了的,只是这次没读到或者读到末尾了,那就应该返回之前读到的字节数n。
        n += nread; // 走到这一步,说明是正常读到了数据,那么累加n。
        if (n >= len) // 如果n现在已经大于等于要读的长度了,说明已经填满目标字节数组了。
            return n; // 返回实际读取到的字节数n
        // if not closed but no bytes available, return
        InputStream input = in; // 走到这一步,说明正常读取到了,但是还没有填满目标字节数组,所以要继续循环,但是先做了一个判断,判断我依赖的底层终端流还是否处于可读状态,如果是那就继续循环接着读,如果不是那也只能就这样返回了。
        if (input != null && input.available() <= 0)
            return n;
    }
}

// 注意:这里的byte[] b 以及上面参数里的byte[] b,都是调用方传入的,这叫目标字节数组。他并不是BufferedInputStream对象的内置缓冲区,BufferedInputStream的内置缓冲区默认是8192,属性名叫buf。所以这个传入的b可能比buf大,也可能比buf小。
private int read1(byte[] b, int off, int len) throws IOException {
    // 这个count和pos我们在上面的read()源码里分析过,是实例属性,表示buf数组的有效数值个数和下一个要读取的位置,count-pos <= 0 的意思就是缓冲的数据已经都读取过了,需要再往里填了。
    int avail = count - pos; // avail代表的就是缓冲区里可读取的字节数
    if (avail <= 0) {
        /* If the requested length is at least as large as the buffer, and
               if there is no mark/reset activity, do not bother to copy the
               bytes into the local buffer.  In this way buffered streams will
               cascade harmlessly. */
        // 如果 要读取的字节数,比内置的缓冲区的长度还要大 或者相等 ,并且不用关心mark位置(mark、reset会单独再分析,这里先不关注)。
        if (len >= getBufIfOpen().length && markpos < 0) {
            // 调用终端流的批量读取方法,读取数据填充到目标字节数组b中。直接返回,也就是说这绕过了缓冲区。
            return getInIfOpen().read(b, off, len);
        }
   		
        // 走到这里,说明要读取的字节数小于内置缓冲区的长度,那么调用fill方法,之前分析过fill的源码,里面也还是会调用getInIfOpen().read(b, off, len)方法读取,填充缓冲区。
        fill(); 
        avail = count - pos; // fill内部逻辑会更新count的值。最终得到缓冲区中可被读取的字节个数。
        //这里再次判定是否读取到了流末尾。
        if (avail <= 0) return -1;
    }
    
    int cnt = (avail < len) ? avail : len; // 上面说过avail代表的就是缓冲区里可被读取的字节数。即使大于要读取的len个了,因为调用方只要读取len个,所以就只截取len个。所以cnt的意思就是从缓冲区里拿出多少个给目标字节数组。
    
    // 这里从缓冲区字节数据中的pos位置开始拿出cnt个字节,拷贝到目标字节数组b。
    System.arraycopy(getBufIfOpen(), pos, b, off, cnt);
    pos += cnt; // 因为已经拷贝给目标数组,也就意味着读取过了,所以需要更新pos的值,加cnt。
    return cnt; // 返回读取到的字节个数。
}

总结与思考

批处理

从java底层、以及操作系统层面来分析,批量读取要比循环单个读取的性能高很多(回想一下我们的FileInputStrean的read()和read(byte[])的方法,最终都是调用了本地方法),虽然总的读取总量不变,但是因为减少了用户态和内核态的交互次数,也就是减少了最耗性能的系统调用的次数,所以性能才会有明显提高很多。与之类比,你也一定清楚,一次性从数据库里读取100条记录,要比循环100次每次读一条快的多,这中间可能即涉及到网络io,又涉及到磁盘io,无论是网络IO,还是磁盘IO,都是需要经过操作系统内核的系统调用才能完成。所以批量读写已经是一个性能优化方面的最佳实践了,但是所谓批量到底定位多少合适,就需要基于自己场景、压测的数据、或者经验了。

性能高低不绝对

通过我们文中的测试用例中,你会发现如果参数调整合适、方法调用得当,FileInputStream这个终端流的性能未必就比BufferedInputStream差。所以还是要多发问,多求真,多分析源码,多上手实操。才能做出相对靠谱的判断和结论。

思考

回头来看我们第二个测试用例中,目标字节数组的大小为8192

byte[] bytes = new byte[8192];

也就是和默认缓冲区的大小相同。如果我们调整为

byte[] bytes = new byte[128];

那么最终的响应时间又会有什么差异呢?

  • 9
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值