javaIO学习总结篇

2020年1月15日
javaIO学习总结篇插图

本文出自明月工作室:https://www.freebytes.net/it/java/java-io-zongjie.html

简介

本文对javaIO的描述不涉及NIO,仅讲述IO的设计架构、设计思想、部分源码、常用的API及某些重要的细节等。

基础知识

回顾一下相关的基础知识,是很有必要的。

bit:

位。位是电子计算机中最小的数据单位。每一位的状态只能是0或1。比尔盖茨曾经说过,计算机世界都是由0和1组成的。计算机也只能读懂0和1这种二进制数据。

byte

字节。8个二进制位构成1个”字节(Byte)”,它是存储空间的基本计量单位。即:1byte=8bit。

编码:

编码的知识量太大,这里只是简单提一下开发中常用到的utf-8编码。在utf-8编码中,一个英文字符等于一个字节,一个中文(含繁体)等于三个字节。无论是中文还是英文,都叫字符。中文标点占三个字节,英文标点占一个字节。也就是说一个“千”字,它要被计算机识别,首先转化成对应它的三个byte字节(对应规则在相关的映射表中),再转化成3*8=24(bit)。‭最终结果是11100101 10001101 10000011‬ ,这个二进制数字才能被计算机读懂。而英文字母“Q”的转化简单些,它只需要一个字节去表示 ‭10000001‬。

原码:

正数的源码是其二级制数,负数的原码是其符号位为1的二进制数。

[+1]原 = 0000 0001

[-1] 原 = 1000 0001

反码:

正数的反码是其本身,负数的反码是符号位不变其余位取反。

[+1] = [00000001]原 = [00000001]反
[-1] = [10000001]原 = [11111110]反

补码:

正数的补码就是其本身,负数的补码是其反码+1。

[+1] = [00000001]原 = [00000001]反 = [00000001]补
[-1] = [10000001]原 = [11111110]反 = [11111111]补

IO的设计思想

一套程序,读取数据的功能和写入数据的功能往往是必不可少的。读取的数据可能来自于各种数据源,包括文件、控制台、网络链接等;而读取的方式,包括按字节读取、按字符读取、缓冲读取等;输出数据时亦然。

对于编写这么一套功能,我们需要解决的问题,抽象来说就是——以何种方式输入数据,以何种方式输出数据的问题。

IO 顾名思义,是input与output的意思,即是输入与输出。它实际上就是一套解决上述问题的方案。在上述问题中,涉及到了两个关键点:数据,何种方式。

在javaIO的实现中,用InputStream、OutputStream、Reader、Writer表示对何种方式的顶层抽象,有什么样的数据,它就用什么样的方式去处理输入输出。如是File数据,就用FileInputStream和FileOutputStream去输入输出;如是byte类型,就用ByteArrayInputStream和ByteArrayOutputStream输入输出…

IO的设计架构

javaIO学习总结篇插图


在javaIO中,根据对数据的不同处理方式,又细分出了字节流和字符流。
InputStream表示字节输入流,用OutputStream表示字节输出流。
用Reader表示字符输入流,用Writer表示字符输出流。

而“流”本身又被定义了两层概念,分为节点流和处理流,节点流是指与数据源直接相连的流,处理流是指与节点流相连间接控制数据源的流,处理流大体上是节点流的上层封装,对节点流的数据做一些处理(例如缓冲处理、过滤处理),IO使用装饰器模式实现这种关系。

装饰器模式

装饰模式是在不必改变原类文件和使用继承的情况下,动态的扩展一个对象的功能。它是通过创建一个包装对象,也就是装饰来包裹真实的对象。

new BufferedInputStream(new FileInputStream(file));

这样一段代码,表示使用BufferedInputStream处理流,装饰FileInputStream节点流,而BufferedInputStream也具备了同FileInputStream的部分核心方法:read(),available(),close()等。这些方法会比FileInputStream原来对应的方法更加强大。

深入源码

1. 字节流顶层抽象 InputStream、OutputStream

InputStream和OutputStream是IO字节流的顶层抽象类之一, InputStream的核心api如下:

//读取输入流中的下一个字节,并返回它
public abstract int read() throws IOException

//指定偏移量和长度,批量读取字节,并返回已读取的数目
public int read(byte b[], int off, int len) throws IOException

//在无阻塞的情况下,返回输入流中剩余的可读取的字节总数,但是并不保证一定精确, 
//主要取决于实现类
public int available() throws IOException

//关闭流,释放系统资源
public void close() throws IOException {}

//标记在读取输入流时,读到的内存空间的位置,参数是标记之后,可被读取的最大字节 
//数
public synchronized void mark(int readlimit) {}

//恢复到标记的位置,重新从标记位置起读取输入流的数据
public synchronized void reset() throws IOException 

OutputStream的核心api如下:

//写一个特定的字节到输出流中,这个字节虽然是32位的整数,但是只有低8位能被写 
//入,高24位会被忽略。
public abstract void write(int b) throws IOException

//批量写入字节
public void write(byte b[]) throws IOException

//强制所有缓冲字节被输出
public void flush() throws IOException

//关闭流,释放系统资源
public void close() throws IOException

可以看到,read()方法返回的是int类型的数据,但其实返回的只是一个字节,只有8位数据,按理说没必要用一个32位的int变量接收。这是为什么呢?

为什么流的read()方法一般都是返回int类型的数据,而不是byte类型数据?

首先,read方法大多是c++的本地方法,由它返回的数据是unsigned byte类型 ,范围是【0,255】,这个范围已经超出了byte能表示的范围,byte的范围是【-128,127】。

针对这个问题,可以设想使用强转的方式去解决,即是:将unsigned byte强转为byte,那么超出的部分【 128,255 】就会用byte的负数部分【-128,-1】来表示。如 :(byte)128=-128 , (byte)129=-127,… (byte)255=-1。

可以看到, 使用强转的方式, unsigned byte 类型的255将被byte类型的-1表示,但是-1是用来表示流结束符的,所以这种使用强转的方法明显不能用来解决问题。

强转不行,那就向上转型,用int去接收 【0,255】。int数据的范围很大,接收完全没有问题。但是这又会引发另一个问题—— jvm做byte向int转型处理时,会根据符号位补全高位。如:

byte类型的数据10,其补码是:00001010,符号位是0,向上转型为int时补全高位,则变成 00000000,00000000,00000000,00001010。跟原来的补码一致,没有问题。但是有负数的情况——

byte类型的数据-10,其补码是:11110110,符号位是1,向上转型为int时补全高位,则变成 11111111,11111111 ,11111111 ,11110110 。跟原来的补码不一致,有问题!

计算机底层是使用补码来存储数据的,因此需要保证数据的补码在转型前后的一致性。上面的10和-10的补码,其实在低8位是完全相同的,不同的只是高24位。 为此,只要将高24位全部换成0,就可以实现补码的一致性。所以inputstream的实现类的read方法,在将读取到的字节值返回时,一般都会写一段“& 0xff”的代码,表示将该值和11111111进行与运算,即可将高24位全部变成0,如:

    //ByteArrayInputStream
    public synchronized int read() {
        return (pos < count) ? (buf[pos++] & 0xff) : -1;
    }


    //BufferedInputStream
    public synchronized int read() throws IOException {
        if (pos >= count) {
            fill();
            if (pos >= count)
                return -1;
        }
        return getBufIfOpen()[pos++] & 0xff;
    }

2. 最基本的节点流ByteArrayInputStream、ByteArrayOutputStream

这两个实现类分别继承了InputStream和OutputStream,主要是用于处理内存中的字节数组。 它们各自维护一个byte数组——

 protected byte buf[];

ByteArrayInputStream初始化时,将byte数组赋予buf [],使用read()方法读取数据时,会依次返回每个元素的值。

javaIO学习总结篇插图(1)

ByteArrayInputStream 的read方法——

public synchronized int read() {    
     return (pos < count) ? (buf[pos++] & 0xff) : -1;
 }

ByteArrayInputStream的基本使用——

public void testByteArrayInputStream()throws IOException{
    byte[] buf = new byte[]{1,2};
    ByteArrayInputStream inputStream = new ByteArrayInputStream(buf);
    int b = inputStream.read();
    System.out.println(b);        //1
    int b1 = inputStream.read();
    System.out.println(b1);       //2
}

ByteArrayOutputStream的write方法——

public void write(int b) {
    this.ensureCapacity(1);
    this.buf[this.count] = (byte)b;
    ++this.count;
}
//实际上只是将数据b写入到buf数组中。

ByteArrayOutputStream的基本使用——

 public void testByteArrayOutputStream() throws IOException{
        byte[] buf = "渣男".getBytes("utf-8");
        //初始化一个长度为5的byte数组,用于存储写入的数据。
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(5);
        for (byte b : buf) {
            byteArrayOutputStream.write(b);
        }
        String s = byteArrayOutputStream.toString("utf-8");
        System.out.println(s);  //渣男
  }

3. 文件节点流 FileInputStream、FileOutputStream

这是专门处理文件数据的两个节点流,维护一个文件对象。其读取文件的方法有两种,按字节读取和批量读取。基本使用如下——

public static File file = new File("F:\\FFOutput\\我为自己而战.mp4");

//按字节读取
@org.junit.Test
public void testRead() throws IOException {
    FileInputStream inputStream = new FileInputStream(file);
    long begin = System.currentTimeMillis();
    int read = inputStream.read();
    while (read != -1) {
        read = inputStream.read();
    }
    System.out.println("总输入时间==" + (System.currentTimeMillis() - begin));
    //总输入时间==51469
    inputStream.close();
}

//批量读取
@org.junit.Test
public void testReadBath() throws IOException {
    FileInputStream inputStream = new FileInputStream(file);
    long begin = System.currentTimeMillis();
    byte[] buf = new byte[8192];
    int read = inputStream.read(buf);
    while (read != -1) {
        read = inputStream.read(buf);
    }
    System.out.println("总输入时间==" + (System.currentTimeMillis() - begin));
    //总输入时间==20
    inputStream.close();
}

可以看到,批量读取文件会比按字节读取更加快速。

4. 缓冲处理流 BufferInputStream、BufferOutputStream

BufferInputStream和BufferOutputStream可以说是封装了一个节点流的处理流。它主要维护了一个缓冲区和一个inputStream节点流,使得在读取数据或写入数据时,经过一层缓冲的处理,以提升读取效率。相比FileInputStream的批量读取来说,他很好的维护了缓冲区,还兼顾了标记重置功能。

BufferInputStream的构造器——

public BufferedInputStream(InputStream in, int size) {
    super(in);
    if (size <= 0) {
        throw new IllegalArgumentException("Buffer size <= 0");
    }
    buf = new byte[size];
}

这里就说明,初始化对象时,需要传入一个InputStream,这个InputStream一般都是节点流,当然也可以是处理流。同时它会初始化一个byte数组,作为缓冲区。

通过read()方法源码读懂缓冲逻辑——

public synchronized int read() throws IOException {
    if (pos >= count) {
        //如果缓冲区数据已经读完,就从InputStream中读取数据,将数据填充到缓冲区
        fill();
        if (pos >= count)
          //数据已经全部读完,那么就返回结束符-1
            return -1;
    }
    //返回缓冲池的数据
    return getBufIfOpen()[pos++] & 0xff;
}

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

//将从inputstream读取到的数据填充入缓冲区。
private void fill() throws IOException {
    byte[] buffer = getBufIfOpen();
    if (markpos < 0)
        pos = 0;            /* no mark: throw away the 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) {
            markpos = -1;   /* buffer got too big, invalidate mark */
            pos = 0;        /* drop buffer contents */
        } else if (buffer.length >= MAX_BUFFER_SIZE) {
            throw new OutOfMemoryError("Required array size too large");
        } else {            /* grow buffer */
            int nsz = (pos <= MAX_BUFFER_SIZE - pos) ?
                    pos * 2 : MAX_BUFFER_SIZE;
            if (nsz > marklimit)
                nsz = marklimit;
            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;

   //这一句比较重要,意思是调用inputstream的批量读取方法,读取到buffer缓冲区中
    int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
    if (n > 0)
        count = n + pos;
}

这里仔细说一下,BufferInputStream对缓冲区的处理,已知缓冲区的大小是有初始值的,初始为8192字节。BufferInputstream在读取数据时,首先调用传入的inputstream的read方法的,将数据批量读取到缓冲区中,再直接返回缓冲区中的数据给调用者。但是当数据远不止8192字节时,这个缓冲区又该怎么办呢?

这里采取的方案是,重复利用缓冲区+扩容的方式来处理的。可以将缓冲区看做是一个队列,队列的初始大小为8192。假设队列已被填满,程序已经读到队列的最后一个值,那么就可以将流中的数据覆写到队列前面的位置,因为前面的值已经被读取过了,没有意义继续存储着。

javaIO学习总结篇插图(2)

这就是缓冲区的重复利用,本质上就是对循环队列的应用。那在什么时候会使用到扩容呢?

bufferInputStream有方法mark()、reset(),意在标记读取到缓冲区中的哪一个位置,在调用mark方法标记完之后,继续读取数据,然后可以调用reset方法复位,将队列游标重新指向mark标记过的位置,继续从这个位置开始读取。

public synchronized void mark(int readlimit) {
    marklimit = readlimit;
    markpos = pos;
}
public synchronized void reset() throws IOException {
    getBufIfOpen(); // Cause exception if closed
    if (markpos < 0)
        throw new IOException("Resetting to invalid mark");
    pos = markpos;
}

正是因为有这个功能的存在,使得BufferInputStream对缓冲区的维护变得复杂了许多,简单的循环队列应用也变得麻烦起来。

它会造成一个问题,那就是已标记过的位置的数据,是不能被覆写的,因为还需要复位。这种情况下循环队列的方案就不可用了。但是这种情况也不是经常发生,所以BufferInputStream应对这种情况的解决方案并不考虑性能问题,简单而粗暴——扩容。

javaIO学习总结篇插图(3)

至于BufferOutputStream,原理是一样的,只不过操作相反,它是在写入数据时先将数据写入缓冲区,再批量将缓冲区数据写入到流中。

5. 随机访问文件类 RandomAccessFile

要访问一个文件的时候,不想把文件从头读到尾,而是希望像访问一个数据库一样地访问一个文本文件,使用RandomAccessFile类是最佳选择。常用于断点续传、分段下载。基本使用如下——

    public static File file = new File("D:\\random.txt");

    /**
     * 用于断点续传、分段下载
     * @throws IOException
     */
    @Test
    public void testSeek() throws IOException {
        //文件中数据为 0123456789
        RandomAccessFile randomAccessFile = new RandomAccessFile(file,"rw");
        //跳过三个字节
        randomAccessFile.skipBytes(3);
        //读取第四个字节
        int read = randomAccessFile.read();
        System.out.println((char) read);    //   3
        //回到第一个字节
        randomAccessFile.seek(0);
        System.out.println((char)randomAccessFile.readByte());  // 0
    }

6. 字符节点流 Reader

对于字符流,这里并不想多说,因为对字符的处理,归根到底还是对字节的处理。有些字符是由1个字节组成,有些是两个,有些是三个,具体依照编码规则而定。所以字符流的实现类,通常会维护一个编码或者解码对象或者做一些编解码的工作。字符流读取到的数据其实开始也是字节数据,只是通过解码操作来解码成字符,再返回给调用方。