JAVA Blocking I/O

背景

JAVA I/O(Input/Output)是JAVA中一个非常重要的组件,因为文件读写、字节数组读写的应用场景非常多,例如,从配置文件中读取配置,将内存中的内容dump到数据文件中,图片文件、视频文件的读写与解析,网络I/O。因此对于JAVA I/O组件的了解是非常必要的。JAVA I/O分为BIO(Blocking IO)、NIO(Non-blocking IO)和AIO(Asynchronous IO),本文作为JAVA I/O系列中的一篇文章,主要介绍BIO。

字节流与字符流

在解释何为字节流何为字符流之前,我们需要先弄清楚什么是“流”。

在JAVA中,将input和output抽象为流,就像管道一样,只不过其中流动的是数据。这些数据按照不同的最小单元进行划分,可分为两种,一种以字节(Byte,8个bit)作为最小单元,即字节流;另一种则是以字符(根据不同的编码方式,占用不同的字节数)作为最小单元,即字符流。

字节流

字节流可分为InputStream(输入流)和OutputStream(输出流)。

我们集中精力在常见的字节流相关类与接口上,可得到如下所示的类继承关系图。

在我的工作中,遇到比较多的InputStream主要有FileInputStream,BufferedInputStream和ByteArrayInputStream;遇到比较多的OutputStream主要有FileOutputStream,BufferedOutputStream和ByteArrayOutputStream。在下边的篇幅中,我们将一一加以介绍。

FileInputStream

FileInputStream主要用于对文件的读取。

首先看构造函数。FileInputStream有三种构造函数:

  1. 根据filePath进行创建:内部转成2
  2. 根据File类进行创建:检查是否可读、文件路径是否合法,创建FileDescriptor,并通过JNI调用操作系统API打开文件
  3. 根据FileDescriptor进行创建:检查FD(FileDescriptor是否可读,然后做attach)

作为InputStream的子类,read方法是非常重要的,FileInputStream的read方法主要分为以下三种:

  1. 无参数read:从流中读取一个字节
  2. 传入byte[]的read:从流中读取数组长度的数据到数组中
  3. 传入byte[]以及offset和length:从流中读取数据到数组中,从offset开始读取,读取len长度的数据

看完FileInputStream的源码,很多人会比较敏锐地发现,由于在读取的过程中,需要通过JNI调用操作系统API对文件进行读取,读取的速度上肯定不如直接从内存中读取要快。但是如果文件很大,把全部文件都装载到内存中,也会对内存资源造成浪费,并给GC带来负担。那么有没有一种机制能够“聪明”地将文件中的内容按需加载一部分到内存中,从而有效地提升读取的效率呢?下边对BufferedInputStream的分析,将会对这个问题带来更加全面的阐述。

ByteArrayInputStream

首先看构造函数。与FileInputStream主要用于读取从文件中读取数据的使用场景不同,ByteArrayInputStream主要用于从byte数组中读取数据,而byte数组可能是其他函数已经读取或已经处理过的数据。ByteArrayInputStream有两种构造函数:

  1. 入参为byte数组的构造函数:将传入的byte数组赋值给ByteArrayInputStream内部的byte数组
  2. 入参为byte数组、offset和len的构造函数:除1的操作之外,还需要将当前位置设置为offset。

ByteArrayInputStream的read方法主要分为以下两种:

  1. 无参数read:从输入流(byte数组)中读取一个字节
  2. 传入byte[]以及offset和length:从流中读取数据到输入的数组中,注意ByteArrayInputStream内部是采用buf数组来存储数据的,因此此方法会从buf数组的offset开始读取,读取len长度的数据,使用System.arraycopy,本质上是通过JNI调用操作系统API进行数组间的数据拷贝。

BufferInputStream

刚才在介绍FileInputStream的时候已经了解到,如果每次都通过JNI调用操作系统API获取文件内容,相对于在内存中对文件内容进行预读取并建立缓存的方案相比,效率是低下的。而BufferedInputStream就是应对这样的使用场景的。它内置了缓存byte数组buf[],通过count,pos,markPos等参数对缓存byte数组进行控制。

首先看构造函数,BufferedInputStream有两种构造函数:

  1. 入参为InputStream的构造函数:基于InputStream创建,缓存byte数组的size为8192
  2. 入参为InputStream和size的构造函数:与1类似,缓存byte数组的size等于传入的size值,相对于1,能够根据业务场景定义缓存byte数组的大小,内存管理更加有效率。

下边我们来看一下其read方法。先看无入参的read方法:

 /**
     * See
     * the general contract of the <code>read</code>
     * method of <code>InputStream</code>.
     *
     * @return     the next byte of data, or <code>-1</code> if the end of the
     *             stream is reached.
     * @exception  IOException  if this input stream has been closed by
     *                          invoking its {@link #close()} method,
     *                          or an I/O error occurs.
     * @see        java.io.FilterInputStream#in
     */
    public synchronized int read() throws IOException {
        // 判断当前位置是否大于等于count,初始情况下,二者均为0
        // 二次进入时,缓存已经填充完毕,因此直接跳过该语句块,直接从缓存中读取数据返回,大大加快读取速度
        if (pos >= count) {
            // 填充缓存,详见下面方法
            fill();
            if (pos >= count)
                return -1;
        }
        // 缓存填充完毕之后,从缓存中读取数据,将pos+1
        return getBufIfOpen()[pos++] & 0xff;
    }
 /**
     * Fills the buffer with more data, taking into account
     * shuffling and other tricks for dealing with marks.
     * Assumes that it is being called by a synchronized method.
     * This method also assumes that all data has already been read in,
     * hence pos > count.
     */
    private void fill() throws IOException {
        byte[] buffer = getBufIfOpen();
        // 首次执行的时候,该值为-1,通过mark方法可以将改值设置为当时的pos
        if (markpos < 0)
            pos = 0;            /* no mark: throw away the buffer */
        // 如果设置了markpos,则markpos为当时的pos,走入下述分支
        // 如果当前的pos已经大于了buffer的长度,即需要重新填充buffer了
        else if (pos >= buffer.length)  /* no room left in buffer */
            if (markpos > 0) {  /* can throw away early part of the buffer */
                int sz = pos - markpos;
                System.arraycopy(buffer, markpos, buffer, 0, sz);
                pos = sz;
                markpos = 0;
            } else if (buffer.length >= marklimit) {
                // buffer数组长度超读取的限制(mark函数设置),从0位置开始重新填充
                markpos = -1;   /* buffer got too big, invalidate mark */
                pos = 0;        /* drop buffer contents */
            } else if (buffer.length >= MAX_BUFFER_SIZE) {
                // buffer数组长度超限,抛Error
                throw new OutOfMemoryError("Required array size too large");
            } else {            /* grow buffer */
                // buffer扩容
                int nsz = (pos <= MAX_BUFFER_SIZE - pos) ?
                        pos * 2 : MAX_BUFFER_SIZE;
                if (nsz > marklimit)
                    nsz = marklimit;
                // 构造更大的buffer,将原始buffer中的内容拷贝过来
                byte nbuf[] = new byte[nsz];
                System.arraycopy(buffer, 0, nbuf, 0, pos);
                if (!bufUpdater.compareAndSet(this, buffer, nbuf)) {
                    // Can't replace buf if there was an async close.
                    // Note: This would need to be changed if fill()
                    // is ever made accessible to multiple threads.
                    // But for now, the only way CAS can fail is via close.
                    // assert buf == null;
                    throw new IOException("Stream closed");
                }
                buffer = nbuf;
            }
        count = pos;
        // 从流中读取数据,填充到buffer缓存中
        int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
        if (n > 0)
            // 设置count
            count = n + pos;
    }

其大致流程为:

  1. 如果pos大于等于count,则说明是首次执行(二者初始值都为0),或多次读取之后,buffer中的数据均已读取完毕,或本身请求的位置就大于了buffer的count,此时需要重新填充buffer,即调用fill方法,从内置的InputStream中读取数据,填充缓存buffer;
  2. 如果pos值并没有超出buffer缓存的范围,则直接从buffer缓存中取数据,并且将pos游标+1。

通过这样的机制,可以做到仅当首次读取或超出缓存buffer范围之外的时候,才会从底层InputStream中读取数据(),其余场景均可直接从缓存buffer中读取数据。由于缓存buffer是在内存中,因此读取速度能够大大加快。

接下来我们看一下,根据offset和len批量获取数据的read方法,具体实现代码如下:

 /**
     * Reads bytes from this byte-input stream into the specified byte array,
     * starting at the given offset.
     *
     * <p> This method implements the general contract of the corresponding
     * <code>{@link InputStream#read(byte[], int, int) read}</code> method of
     * the <code>{@link InputStream}</code> class.  As an additional
     * convenience, it attempts to read as many bytes as possible by repeatedly
     * invoking the <code>read</code> method of the underlying stream.  This
     * iterated <code>read</code> continues until one of the following
     * conditions becomes true: <ul>
     *
     *   <li> The specified number of bytes have been read,
     *
     *   <li> The <code>read</code> method of the underlying stream returns
     *   <code>-1</code>, indicating end-of-file, or
     *
     *   <li> The <code>available</code> method of the underlying stream
     *   returns zero, indicating that further input requests would block.
     *
     * </ul> If the first <code>read</code> on the underlying stream returns
     * <code>-1</code> to indicate end-of-file then this method returns
     * <code>-1</code>.  Otherwise this method returns the number of bytes
     * actually read.
     *
     * <p> Subclasses of this class are encouraged, but not required, to
     * attempt to read as many bytes as possible in the same fashion.
     *
     * @param      b     destination buffer.
     * @param      off   offset at which to start storing bytes.
     * @param      len   maximum number of bytes to read.
     * @return     the number of bytes read, or <code>-1</code> if the end of
     *             the stream has been reached.
     * @exception  IOException  if this input stream has been closed by
     *                          invoking its {@link #close()} method,
     *                          or an I/O error occurs.
     */
    public synchronized int read(byte b[], int off, int len)
        throws IOException
    {
        getBufIfOpen(); // Check for closed stream
        if ((off | len | (off + len) | (b.length - (off + len))) < 0) {
            throw new IndexOutOfBoundsException();
        } else if (len == 0) {
            return 0;
        }

        int n = 0;
        for (;;) {
            // 调用read1方法
            int nread = read1(b, off + n, len - n);
            if (nread <= 0)
                return (n == 0) ? nread : n;
            n += nread;
            // 如果累计的已读取数据大于等于len,即读取到了全部所需的数据了,返回
            if (n >= len)
                return n;
            // 否则,检查InputStream是否还有效,有效就进行下一轮读取,无效则说明读取完成,返回
            // if not closed but no bytes available, return
            InputStream input = in;
            if (input != null && input.available() <= 0)
                return n;
        }
    }
 /**
     * Read characters into a portion of an array, reading from the underlying
     * stream at most once if necessary.
     */
    private int read1(byte[] b, int off, int len) throws IOException {
        // 获取buffer缓存中目前仍可读取的数据个数
        int avail = count - pos;
        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. */
            if (len >= getBufIfOpen().length && markpos < 0) {
                return getInIfOpen().read(b, off, len);
            }
            // 填充buffer缓存
            fill();
            avail = count - pos;
            if (avail <= 0) return -1;
        }
        int cnt = (avail < len) ? avail : len;
        // 从缓存buffer中获取数据,填充到入参byte数组中
        System.arraycopy(getBufIfOpen(), pos, b, off, cnt);
        pos += cnt;
        return cnt;
    }

从中不难发现,其实现方式与上一个方法比较类似,依然是先check缓存buffer中数据是否已经全部读完,如果已经读完则从InputStream中获取,并填充缓存buffer,反之则从缓存buffer中获取数据。

FileOutputStream

与FileInputStream相对应的用于写入文件的OutputStream为FileOutputStream,其主要的使用场景是向文件中写入内容。

首先看构造函数,其构造函数的主要流程就是确定要写入的文件(无论是以文件名作为入参还是直接将File对象作为入参),check文件是否合法,通过JNI调用操作系统API打开文件(可以指定是否采用append模式,即指定是向文件中追加还是覆盖该文件)。

接下来看write方法,即向文件中写入内容的方法,其本质都是通过JNI调用操作系统API向文件中写入数据。

ByteArrayOutputStream

与ByteInputStream相对应的用于向byte数组中写入数据的OutputStream为ByteArrayOutputStream。

首先看构造函数,其构造函数的主要流程就是构建数据写入的载体--byte数组,可根据业务场景指定byte数组的size,也可以采用默认值--32。

接下来看write方法,其实现的大致流程均为先check数组容量是否足够写入该数据,如果不够的话需要做数组扩容,然后向数组中写入数据。

BufferedOutputStream

从上述FileOutputStream的write方法实现中不难发现这样的问题,如果每次写入(即使是写入一个byte)都是通过JNI调用操作系统API操作文件,在性能上是存在问题的。在计算机科学领域,解决这个问题的办法很简单,那就是尽可能地批量向文件中做写入,BufferedOutputStream就这样应运而生了。

其write方法实现方式大致如下:

  1. 在内部保有缓存数组buf,当外部调用BufferedOutputStream的write方法时,首先检查buf是否已满,如果已满则执行flushBuffer,在该方法内部调用BufferedOutputStream内部的OutputStream,做写入操作;
  2. 如果buf没有满,则向缓存数据buf中添加数据,不真正做写入操作。

字符流

在讲解字符流之前,我们先来回顾一下什么是字符,以及字节与字符的关系。

字节是与字符编码方式无关的,而字符是与字符编码方式有关的。以发电报为例举个形象点的例子,字节流就像莫尔斯电码,每个人都可以获取到,但是字符流就像翻译好的电报,如果没有采用正确的字符编码方式进行编解码,那么得到的将是乱码。

几种常见的字符编码方式为:ASCII,UTF-8(UTF-16,UTF-32)。

与字节流类似,字符流也分为输入字符流(Reader)和输出字符流(Writer)。

我们集中在主要的字符输入流和输出字符流上,可以得到如下类图。

FileReader

FileReader继承了InputStreamReader,内置了StreamDecoder,用于将字节解析成字符。

在FileReader的构造函数中,无论是传入文件名还是直接传入File对象,FileReader都会基于文件创建FileInputStream。

当调用read方法的时候,无论是无入参read(读取单个字符)还是根据offset、length来读取,本质上都是通过内置的StreamDecoder进行读取,在其中进行字节到字符的解码工作。

BufferedReader

BufferedReader,是内置有缓存char数组和InputStream的Reader。

其构造函数中,可指定缓存char数组的size,如果不指定的话就采用默认值:8192。不过出于对内存资源高效管理的目标,建议根据业务场景对缓存char数组的size进行指定。

重点在其read方法,BufferedReader共有三种read方法:

  1. 无入参的read方法,读取一个字符;

  2. 给出offset和len的read方法,批量读取一定数目的字符;

  3. readLine方法,一次读取一行的内容。

read方法内部的大致逻辑均为,首先check当前要读的位置是否已经超出了内置缓存char数组的size,如果超出了,则从内置的Reader中,读取一定量的数据填充到缓存char数组中,方便后续的持续读取。

FileWriter

FileWriter继承了OutputStreamWriter,内置了StreamEncoder,用于将字符解析成字节,从而写入到OutputStream中去(OutputStream是字节流)。

在FileWriter的构造函数中,无论是传入文件名还是直接传入File对象,FileWriter都会基于文件创建FileOutputStream。

当调用write方法的时候,无论是无入参write(写单个字符)还是根据offset、length来write,本质上都是通过内置的StreamEncoder进行字符到字节的转换,然后写入到OutputStream中去。

BufferedWriter

BufferedWriter,是内置有缓存char数组和OutputStream的Writer。

其构造函数中,可指定缓存char数组的size,如果不指定的话就采用默认值:8192。不过出于对内存资源高效管理的目标,建议根据业务场景对缓存char数组的size进行指定。

重点在其write方法,BufferedWriter共有三种常用的write方法:

  1. write(int c),向输出流中写入单个字符;

  2. write(String s, int off, int len),向输出流中写入字符串;

  3. write(char cbuf[], int off, int len),向输出流中批量写入字符数组。

其逻辑在思路上均大同小异,首先check要写入的位置是否超出了缓存char数组的范围,如果超出了,则flushBuffer,将缓存char数组中的内容一次性写入到输出流中,否则,将数据存储到缓存char数组中。这样可以有效地做到批量写入。

JAVA BIO的局限性

本文中介绍的输入输出流均为JAVA BIO中的范畴(JAVA blocking I/O),其在性能上是存在一定的局限性的。例如,对于BufferedInputStream和BufferedOutputStream,其read方法和write方法中,均加了synchronized关键字,即在方法层级上是不能支持多线程并发执行的,因此在现今主流的需要支撑大流量、高并发、低延迟的系统中应用并不是很多。但因其足够简单,并初步给予开发者有关I/O的实现思路,因此会在一些不是很care性能的场景,比如容器启动的时候单线程读取size不大的配置文件、监控程序单线程地dump执行中的trace等场景仍保有一席之地。后续的文章中,我们会针对能够解决BIO性能不高、并发程度不高的JAVA NIO和AIO进行介绍,并深入到多线程网络通信框架中。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值