在《java编程思想》这本书中,初学者很难理解IO这一篇章,各种类各种用法记起来让人头疼。究其根本,还是对IO不够了解。笔者在工作中也遇到了一些关于IO的的问题,现在就来谈一谈笔者自己的理解。

什么是IO

硬件角度来讲,IO就是从外部接入数据到程序,把程序的数据输出到外部。拿单片机来讲,就是从引脚读取高低电平,向引脚写入高低电平,至于引脚怎么维护高低电平这里就不详细展开了。

现在大多数程序员都不直接与硬件打交道,而是与操作系统打交道。操作系统封装了对所有硬件的读写,提供了逻辑上的IO。例如在linux上把所有硬件封装成了文件,读写硬件就像读写读写内存中的数据一样简单。例如硬盘上的数据或网卡上的数据都被封装成了文件描述符,程序只要告诉操作系统要读哪个文件描述符对应的数据,操作系统会及时地把数据返回给程序所占用内存的指定位置。

如果你理解了上边描述的逻辑IO,套接字编程和文件编程就是一样简单的事了,无非套接字编程需要指定IP和端口才能读写数据而已。

小节一下:IO就是在程序操作下内存和硬件之间的数据交换

InputStream和OutputStream

当你对IO有了基本的概念,接下来要谈的就是java语言对IO的封装。在java中分别有InputStreamOutputStream对应从硬件读取数据,向硬件写数据。为了和操作系统的数据保持一致,IO操作的都是字节,在x86和x64处理器中,一个字节一般都是8位。笔者在这篇文章中为什么把InputStream和OutputStream放到一起来谈,因为他们从根本上来讲没多大区别,都是内存与硬件之间的数据交换。

看一看InputStream和OutputStream的源码,我们发现他们都是抽象类,而不是接口。这个问题值得思考,在这段讲完后笔者会给出自己的看法。

InputStream源码阅读

public abstract class InputStream implements Closeable {
    private static final int MAX_SKIP_BUFFER_SIZE = 2048;

    public abstract int read() throws IOException;

    public int read(byte b[]) throws IOException {
        return read(b, 0, b.length);
    }

    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 {
            for (; i < len ; i++) {
                c = read();
                if (c == -1) {
                    break;
                }
                b[off + i] = (byte)c;
            }
        } catch (IOException ee) {
        }
        return i;
    }

    public long skip(long n) throws IOException {

        long remaining = n;
        int nr;

        if (n <= 0) {
            return 0;
        }

        int size = (int)Math.min(MAX_SKIP_BUFFER_SIZE, remaining);
        byte[] skipBuffer = new byte[size];
        while (remaining > 0) {
            nr = read(skipBuffer, 0, (int)Math.min(size, remaining));
            if (nr < 0) {
                break;
            }
            remaining -= nr;
        }

        return n - remaining;
    }

    public int available() throws IOException {
        return 0;
    }

    public void close() throws IOException {}

    public synchronized void mark(int readlimit) {}

    public synchronized void reset() throws IOException {
        throw new IOException("mark/reset not supported");
    }

    public boolean markSupported() {
        return false;
    }
}

read()

在InputStream中只有read()方法是抽象的,其返回值是一个int值,如果你认为读取的数据范围是从0x80000000到0x7fffffff,那你就理解错误了。

read()方法一次只能读取一个字节,在java中一个字节的数值范围是从-128到+127,对应的正是8位数据。那如果返回值是byte,那有一个问题就被摆在大家面前,那怎么标记文件读取完毕呢?这种文件的结尾是以19EM媒介结束符标记,别的文件有可能以换行符为结束标志。

为了避免这种尴尬又难处理的事情发生,写这个类的大牛把返回值类型定义为int,正常使用时,数据都是处于0-255之间。也就是说如果你自己有一个字节数组,你想把它模拟成一个InputStream,切记在返回一个字节前判断它是不是小于0,如果小于0,就要加上255再返回,这样才是一个正常的int值。而在文件读取结束的时候,返回值被定义为-1,切记只有在read()方法中返回值有可能是-1

read(byte b[], int off, int len)

read(byte b[], int off, int len)read(byte b[])方法是一回事,所以就放一起来看。

这个方法也超级简单,就是调用自身的read()方法向字节数组中填充字节,而填充的起始位置和填出个数都是外部来提供。在填充前也会判断能不能给字节数组中这样填充数据。

skip(long n)

skip方法,你可以把他想象成read(byte b[])方法,只不过不会返回字节数组而已,并且限制最多只能读MAX_SKIP_BUFFER_SIZE个字节。

available()

available()方法是用来判断接下来能够从流中不间断地读取多少个字节数据,这个方法不一定可靠,因为你看到他默认返回的是0,也就是说继承类如果不支持这个方法,那你每次调用这个方法获得的值永远是0。

close()

这个很好理解,当你调起一个硬件并读完它的数据,结束时应该关闭它,让操作系统知道这个硬件当前已经使用完毕。

mark(int readlimit),reset()和markSupported()

这三个方法通常是一块使用的,它们是做什么的呢?来想象一下这个场景,当你读取一个数据流,其中有一个字符意思是接下来有十个字节可能需要可能不需要,这需要根据第十一个字节来判断。这时候我们需要先调用markSupported()方法判断当前数据流是否支持标记,在这个场景中,当然是需要支持的了。判断是支持标记了,接下来调用mark(int readlimit)方法,入参是11,意思是我们在读取完十一个字节后可能会调用reset()方法返回到当前定位的地方重新开始读取数据。接下来你就放心大胆地去读取接下来的十一个字节,读到第十一个字节发现我们需要前面十个字节,那就调用reset()方法,重新开始读取这十个字节。

需要注意的是该抽象类默认是不支持标记的,也不支持重置。需要根据具体实现类的情况来决定到底需要不需要或者能不能支持。

OutputStream源码阅读

public abstract class OutputStream implements Closeable, Flushable {
    public abstract void write(int b) throws IOException;

    public void write(byte b[]) throws IOException {
        write(b, 0, b.length);
    }

    public void write(byte b[], int off, int len) throws IOException {
        if (b == null) {
            throw new NullPointerException();
        } else if ((off < 0) || (off > b.length) || (len < 0) ||
                   ((off + len) > b.length) || ((off + len) < 0)) {
            throw new IndexOutOfBoundsException();
        } else if (len == 0) {
            return;
        }
        for (int i = 0 ; i < len ; i++) {
            write(b[off + i]);
        }
    }

    public void flush() throws IOException {
    }

    public void close() throws IOException {
    }
}

上边部分方法和InputStream的内容是相当的,这里就不具体展开了,只谈一下其中的两个方法。

write(int b)

这个方法和InputStream的read()方法刚好是相反的,写一个字节,虽然入参是int,但是写还是会写一个字节。

如果你要模拟一个OutputStream给一个字节数组写字节,虽然不需要你把大于127的值改写成负数,但是你要知道这是jvm内部帮你做了这件事。

flush()

这个方法是OutputStream独有的,为什么需要呢?你可以理解为我们不可能为了一个字节就麻烦操作系统帮我们接入硬件,如果你要写1GB的数据,那操作系统还干不干别的事情了?

所以很多实现类都是先把数据写到内部的字节数组中,当字节数组满了就会调用这个方法把字节数组的数据连续写出内存。或者当程序需要强制写出数据的时候可以调用这个方法。

小节

如果你能把握住InputStream和OutputStream,那IO基本上你已经算是入门了。凡是与硬件打交道,那就绕不开这两个抽象类。

前面笔者提了一个问题,为什么是抽象类而不是接口,想必你可能有想法了。如果定义成接口,那实现类就必须实现其中的所有方法,但是像read(byte b[], int off, int len),markSupported(),flush()等方法。那有必要让实现类都实现这些方法吗?我认为没必要,作为工程师有时候疏忽大意可能写的实现方法都没有抽象类字节实现的好,又或者工程师自己定义的子类就是不支持标记或关闭,那就没有必要再把这两个方法写到实现类当中。

特殊的实现类FilterInputStream和FilterOutputStream

当你能够理解抽象类InputStream和OutputStream,那接下来一定不能错过这两个特殊的实现类,看起来好像什么事都没有做,再看看你工程里的一些框架和工具包,又好像大家都在用。那到底是怎么回事呢?

装饰器

在谈java的时候,必定要谈到设计模式,装饰器模式打个比喻就是给人套上一层衣服,而衣服能给人带来什么,那装饰器就能给被装饰的类带来什么。

什么时候用装饰器呢?

  • 需要在不影响其他对象的情况下,以动态、透明的方式给对象添加职责。
  • 如果不适合使用子类来进行扩展的时候,可以考虑使用装饰器模式。

装饰器的应用

去看一看FilterInputStream和FilterOutputStream的源码,你会发现,这两个类真的什么事都没做,需要你去给这两个类在构造的时候传入真正的InputStream和OutputStream实现类,在执行方法时还是去执行你传入的实现类的对应方法。

这真是让人苦笑不得,如果需要我自己提供真正的输入输出流,那要这俩类做什么?

实则不然,这两个类给工程师们提供了一个思路,怎么能够在读写字节的基础上做点别的事。

例如你想在文件中读写int,short,utf8编码的字符,怎么搞?int是四个字节,short是两个字节,utf8可能是一个,两个或三个字节。那么请你继承FilterInputStream和FilterOutputStream,再添加上额外的读写int,short,utf8的方法。如果是读int,那就连续读四个字节,再把它们转成int,这个应该比较简单吧。如果是写int,那就把int拆分成四个字节再用write(int b)方法将这四个字节写出。short和utf8也是一样。

令人愉悦的是,java提供了这样的实现类,分别是DataInputStream和DataOutputStream。具体源码大家可以自己翻看翻看,还是比较简单的。

还有工程师提供了了一个带计数的FilterInputStream和FilterOutputStream的读写实现类,并提供了获取计数的方法,这样就不用你维护一个变量去记录读写数据的量。

还有工程师提供了带读写限制的FilterInputStream的读实现类,例如我们只希望读取到最多5MB的数据,而InputStream可能提供超过5MB的数据。

还有工程师在httpResponse的InputStream基础上实现了带contentLength检查的FilterInputStream的读实现类,在发现输入流读到结尾了但是总长度不符合在header中属性contentLength所对应的长度的时候,抛出一个长度不符合的异常。

总而言之,在FilterInputStream和FilterOutputStream给基本的IO流提供了无限的想象性,可以实现很多本来正能在IO流外做的事情。

识别FilterInputStream和FilterOutputStream

如何识别这两个特殊的类的实现类呢?如果你读懂了上边的两个小节,你就会发现他们是需要你传入一个真实的IO流。

总结

顺着对IO的理解,抽象类InputStream和OutputStream源码的阅读,特殊实现类FilterInputStream和FilterOutputStream的应用。我相信你已经能够正确地理解各种框架工具包以及SDK中InputStream和OutputStream子类的调用关系。只需要在你遇到IO相关异常时,从中debug,解决你的业务问题,而不用再来死磕IO。如果希望继续与我交流,请在首页通过email与我联系。

标签: java, Java编程思想

添加新评论