Java-io学习总结:输入

0.233字数 7511阅读 1071

  • tags:io
  • categories:总结
  • date: 2017-03-28 22:49:50

不仅仅在JAVA领域中,还有其他编程语言中都离不开对IO的操作。io是操作系统与机器硬件与外界进行交互的通道,计算机是个处理器,要处理数据,那当然要有信息数据流了。那么大多数据信息都是需要外界输入,在进行特别处理,得到我们想要的数据。整个IPO(input-process-ouput)过程也就是io处理过程的抽象描述。

不仅仅是计算机中有IPO,整个现代社会不都是这种模式么,就像工厂流水线搬,将原材料(输入信息流)通过指定机器材料(input通道地址)进行加工处理(cpu),最后得到产品(output输出)。所以,io其实是程序处理非常非常重要的模块,没有数据,其他无论硬件资源,程序的意义都不大了。

所以,就像好好总结学习java中IO模板内容,想知道是如何查找资源的?资源路径获取途径?在查找到资源,如何读取,是字节流,还是字符流?读取信息流,如何保存?如何处理资源文件的编码问题等等问题,都是值得去思考思考的。对于我们java应用程序,举个最简单的例子,我们应用程序中创建java.lang.Class对象加载类的时候,就是要在路径中查找对应class字节码文件,这就是个输入过程,当读取完字节码后,就加载到内存中进行处理;或者还有在使用框架时候,总会遇到xml和properties配置文件,在应用启动后,重要通过io对这些配置文件进行读取.......

IO流分类

在总体上可以知道,io就是数据流的传递过程,那必然是两端节点的操作。一端读取数据,另一端可以将处理好的数据回写。那么先来看看对于java中的IO流是如何分类的呢?

A. 根据处理的流的数据单位不同,可以分为:字符流,字节流。
B. 根据数据流动方向不同,可以分为:输入流,输出流。(数据流的方向是相对而言的)
C. 根据读取流的功能不同,可以分为:节点流,处理流。

            字节流         字符流
输入流     InputStream         Reader
输出流     OutputStream            Writer

流的大致处理过程可以抽象成如下图所示:(图片来源于网络)
[图片上传失败...(image-9be6c6-1511063649569)]

另外关于节点流和处理流概念也是可以对应到具体的io操作过程中:[图片来自java中的IO流介绍]
节点流: 该流指的是从一个特定的数据源读写数据。即节点流是直接操作实体资源的(eg:文件,网络流等)。就如javaio中的FileInputStream和FileOutputStream两个类这般,内部有个FileDescriptor文件句柄类,就是直接连接文件数据源,对文件字节流进行读写。

image

处理流: "连接"在已存在的流(字节流或处理流)之上,通过对流数据的处理,为程序提供更为强大的读写功能(eg:处理文件字节流,那么可以增加缓冲区,在读写固定长度的字节后,一次性全部转换成字符数据,供程序使用)。过滤流是使用一个已经存在的输入流或者输出流连接创建的,过滤流就是对字节流进行一系列的包装。

image

eg: BufferedInputStream和BufferedOutputStream,就是使用了已经存在的字节流来构造一个提供带缓冲的读写流,大大提高的程序读写文件资源的效率。

那么,java中的io流除了知道是有字节字符io流几大类,其实内部还有很多根据不同的场景实现的其他流,这都是因为抽象IO基类无法高效的完整资源数据流的输入输出,所以java又自定义了几种用于不同场景的流。java-io流类体系如下:(图片来自:IO流体系)

image

可以看到,针对不同的使用需求,访问文件,数组,字符串,管道,缓冲流,转换流等等,都有合适的类去完整任务。我们的目的就是在理解基础IO操作流程,底层结构,常用api的情况下,可以因地制宜的选择合适类去处理业务。

字节字符与编码

因为java io在读取或者写入字节字符的时候,都会进行字符字节编码解码的工作。在学习IO之前,弄懂字节字符的编码解码是什么?在字节转换成字符的时候,是如何根据字节解码成字符串的?当字符转换成字节输出的时候,内部是如何对字符进行编码成字节输出的?编码是开发中不可避免的部分。所以,这里可以参照自己写得关于字符字节编码解码的文章: 字节字符编码解析ASCII编码汉字转换

Input输入

首先,可以通过javaio中input输入流的类图结构大致了解java中输入流有哪些,并且可以从类图中清楚的了解这些流的层级关系。当然了,输入流中根据数据源读取是字节还是字符又分为字节流(InputStream)和字符流(Reader),下面可以分别了解学习输入流内容。

InputStream字节输入流

InputStream对应的流,表示的是从程序外部资源(远程/本地文件,网络数据字节流..)读取资源内部字节数据,保存到一个byte字节数组中,在程序中,就能对该字节数组内的数据进行读取,修改等处理操作。那么,代表着字符输入流的Inputstream抽象类中,针对资源文件和数据规定了哪些操作呢?比如如何读取字节数据,保存在哪?如何一次读取多个字节?在读取流过程中,使用什么来记录读取状态?如何标记字节流中读取的位置等都是值得思考的。


inputstream.png
//InputStream.java
public abstract int read() throws IOException;/*一次读取一个字节,返回读取字节值0~255*/
public int read(byte b[]) throws IOException;/*一次性将字节流中字节数据保存到b数组中*/
/*一次性读取len(或者更少)个字节放置到b[off]~b[b.length-1]中*/ 
public int read(byte b[], int off, int len) throws IOException;
public long skip(long n);/*在读取字节流中,跳过n个字节继续读取*/
public int available() throws IOException; /*返回字节流中可以读取的字节数量*/
public void close() throws IOException;/*关闭输入字节流*/
public synchronized void mark(int readlimit);/*标记读取流字节位置标记*/
public synchronized void reset() throws IOException;/*重置字节流读取位置*/

可以从方法中看到,顶层的字节数入流中是通过一个字节数组byte[]来保存流中数据信息的。当外部资源文件句柄与该流连接上之后,可以每次读取文件中一个字节,也可以读取文件中的指定字节数,最后都是保存在byte[]字节数组中。当然还有提供一些在读取字节数据过程中的标记,重置操作,可以读取流中任意位置的字节数据。当读取字节都保存在byte数组中后,还能通过available方法获取已经读取字节的数量。

之后,所有的字节输入流InputStream的子类就可以再次基础上进行重写覆盖或者定义一些其他的针对字节流的操作。目的当然是通过更加合理,合适的底层容器如字节数组,更加高效的读取资源字节流。提高IO使用效率,节省资源占用时间。

其实,从InputStream类图中可以发现,每个子类都会或多或少的自定义方法去实现或者重写InputStream字节输入流基本方法。
底层多数都是使用一个byte[]字节数组字段去存储从文件或者其他外部资源读取到的字节数据。
然后所有read方法都是将字节数据在byte[]中进行处理操作,以及像mark,reset方法都是基于保存了字节数据的字节数组字段,然后移动posistion指针,修改byte[]中实际字节数量的count字段等等.....只是每种子类处理的场景是不同的,有些如PipedInputStream类中有Thread类型的readSide,WriteSide字段,是用于两个线程进行管道通讯的输入字节流;FileInputStream类内部则有一个FileDescriptor文件指针,是直接指向文件的输入流。

总的来说,基于InputStream的实现子类都有这样特性:该类字节流都是将外部资源读取成字节数据流,通过read方法,将这些字节流保存到底层的byte[]数组中,然后就会有如pos,count,mark等变量用于保存读取指针位置,字节数组中字节数量,标记位置等变量来维护字节流于字节数组的关系。目的当然都是将输入源中的字节根据需求读取到某个容器进行保存,程序中可以根据容器获取字节数据。

那么具体的这个字节数组长什么样,里面的数据具体是什么样子的呢?下面可以通过一个文件输入流的例子,将桌面的一个text文件中的数据,以字节流方法读取到程序中,在程序中的字节数组变量调试查看这些字节数组,然后再可以根据这些字节数组数据与text文件进行对比:(注意当使用不同的编码方式保存文件信息,就会得到不同数量的字节数,当读取文件成字节数组也就不同。同一个文件,分别使用gbk与utf8编码后,就会得到不同的字节数

    @Test
    public void InputStreamTest() throws Exception{
        InputStream fis = new FileInputStream("C:/Users/_Fan/Desktop/demo.txt");
        byte[] buf = new byte[fis.available()];
        fis.read(buf,0,buf.length);
//      System.out.println(new String(buf,"gbk"));
        System.out.println("字节数量:>>"+buf.length);
        fis.close();
    }
fis_byte.png

demo.txt文件中保存的只是一些网站链接,使用的是系统gbk编码。从上图可以看到,我通过FileInputStream输出流,将demo.txt中的数据以字节流的进行读取出来,保存到buf字节数组中。
然后通过调试,可以看到buf内的所有字节数组,每个字节是8位,所以都是对应着一个-128~+127整数数值。
其实在计算机内存中,demo.txt中的所有字符串都是二进制字节,可以看到该文件一共有221个字节

当我们通过文本工具打开文件时候,就会根据这些二进制字节数据解码(因为都是英文字母,所以按照gbk解码也不会出现乱码),这些每个字节其实对应着一个英文字母。(若是文本中有负数,那么就不是对应一个字节码了,就会通过补码方式保存成多个字节)打开后,就可以看到这些由字节数据解码成的英文字符串了。

反过来,该text文件就是字节数据集合,当我们通过FileInputStream读取该文件时候,这些字节就会被读取保存到字节数组buf中,我们就可以看到如上图绿色框中的整数字节数组了。因为demo.txt文件中第一个英文字母是h,对应一个8位的二进制字节就是104(0110 1000)
所以当我们读取这个文件字节流完时候,buf中的第一个字节就是h(104)了。 ASCII编码转换

那么为什么java中的有符号的byte的范围是-128~127呢因为一个byte是8位,简单的说就是2^8=256,那么正负数对半分,正数与负数应该都是128个,正数从0~+127就是128个数,那么负数呢,因为计算机在内存中存储负数是用补码表示,按照正常逻辑负数应该是-0~-127对吧,但是有个特殊的-0,对应的二进制表示为1000 0000,就把他当做一个负数,对他求补码(取反+1),最后就得到了-128,所以,在负数这边的128个取值是从-1~-128。(1111 1111 ~ 1000 0000),这样就是128个对半分啦~~

在了解了字节输入流如何读取资源数据,并通过代码调试看到读取到字节流内的字节数据具体形态,那么这些不同的字节输入流分别适合在什么情况下使用呢?

FileInputStream: 该类适合在知道要读取的数据源是文件,程序要获取文件中的字节数据情况下,可以通过传递文件绝对路径,或者文件File对象到FileInputStream构造器中与文件资源连接,创建字节输入流通道。
[文件资源 --> FileInputStream输入字节流 ---> 程序/内存(字节数组)]

FilterInputStream: 过滤功能的字节输入流,在该类中会有一个底层的InputStream类型的输入流作为基础数据流,FilterInputStream类就是在该基础数据流上,提供额外的其他处理,给基础流增加新的功能,进行封装。最常用的该处理流就是BufferedInputStream类。
该类是在基础字节流基础上,提供了字节数据缓冲功能,字节流在操作的时候本身是不会用到缓冲区(内存)的,是与文件本身直接操作的。所以使用缓冲字节输入流时候,会在内存中创建一个byte[]作为缓冲区,将基础字节流中的字节数据分批读取到这个缓冲区中,每当缓冲区数据读取完之后,基础输入流会再次填充缓冲区,如此反复,直到我们读取完输入流所有的字节数据
这样,就能对该缓冲区进行mark标记,reset重置等方法,快速的获取字节数据,增加了每次读取的字节数量,大大提高了字节读取效率,而不是每次读取字节流都要与外部文件等资源进行IO交互,消耗额外时间与资源。(该缓冲输入流中最重要的两个方法个人认为是fill()read1()方法
前者是将基础字节流数据读取到缓冲区中,后者是说明了每次读取字节都是从缓冲区中去取的....)

//BufferedInputStream.java
public
class BufferedInputStream extends FilterInputStream {

    private static int defaultBufferSize = 8192;//,默认的缓冲区大小
    protected volatile byte buf[];//缓冲数组

    /*
    * 当前缓冲区的有效字节数
    * 注意,这里是指缓冲区的有效字节数,而不是输入流中的有效字节数。
    */
    protected int count;

    // 当前缓冲区的位置索引
    // 注意,这里是指缓冲区的位置索引,而不是输入流中的位置索引。
    protected int pos;

    /*
    * 当前缓冲区的标记位置
    * markpos与reset()方法配合使用才有意义:
    * (1) 通过mark()函数,保存当前pos位置索引到markpos中。
    * (2) 通过reset()函数,会将pos的值重置为markpos。当再次使用read()读取数据时候,
    * 就会从上面的mark()标记的位置开始读取数据。
    */
    protected int markpos = -1;

    /*
    * marklimit是缓冲区可标记位置的最大值。
    */
    protected int marklimit;
...
    //得到基础字节输入流in
    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;
    }

    /**
    * 从基础字节输入流中读取一部分数据,更新到内存缓冲区中。
    */
    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 {            /* grow buffer */
                int nsz = pos * 2;
                if (nsz > marklimit)
                    nsz = marklimit;
                byte nbuf[] = new byte[nsz];
                System.arraycopy(buffer, 0, nbuf, 0, pos);
                if (!bufUpdater.compareAndSet(this, buffer, nbuf)) {
                    throw new IOException("Stream closed");
                }
                buffer = nbuf;
            }
        count = pos;
        int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
        if (n > 0)
            count = n + pos;
    }

    /*
    * 从缓冲区中读取一个字节,因为字节范围是-128+127,
    * 所以可以经过 & 0xff得到一个字节对应的整数值。
    */
    public synchronized int read() throws IOException {
        if (pos >= count) {
            fill();
            if (pos >= count)
                return -1;
        }
        //根据当前索引位置pos得到缓冲数组对应byte字节,并将pos向后移动一位
        return getBufIfOpen()[pos++] & 0xff;
    }

    /*
    * 从缓冲区中读取len个字节,并将读取的字节拷贝到入参b中
    */
    private int read1(byte[] b, int off, int len) throws IOException {
        int avail = count - pos;    //得到缓冲区中还能读取到的字节数量
        if (avail <= 0) {
            if (len >= getBufIfOpen().length && markpos < 0) {
                return getInIfOpen().read(b, off, len);
            }
            //若已经读完缓冲区中的数据,则调用fill()从输入流读取下一部分数据来填充缓冲区
            fill();
            avail = count - pos;
            if (avail <= 0) return -1;
        }
        int cnt = (avail < len) ? avail : len;
        //将buf[pos] ~ buf[pos + cnt]字节数组拷贝到b中
        System.arraycopy(getBufIfOpen(), pos, b, off, cnt); 
        pos += cnt;
        return cnt;
    }
...

}

可以从源代码大致看出,程序对字节的读取都是从内存中的字节缓冲区中获取数据的,在缓冲区中又会根据缓冲区状态标记如pos(当前读取位置指针),count(缓冲区中有效字节数),markpos(pos标记)等等对缓冲区进行移动读取,若是缓冲区数据读取完后,又通过fill()方法,将基础字节流的数据读取部分更新到缓冲区中,直到基础字节流中数据被读取完。

简单看看BufferedInputStream的使用,并使用JUNIT调试看看内部如何执行?根据代码断点查看程序执行流程:

    @Test
    public void BufferedInputStreamTest() throws Exception{
        InputStream fis = new FileInputStream("C:/Users/_Fan/Desktop/tt.txt");
        byte[] buf = new byte[fis.available()];//保存读取到的文件字节数组
    (断点)BufferedInputStream bis = new BufferedInputStream(fis); //在文件字节流基础上,添加字节缓冲功能
    (断点)bis.read(buf,0,buf.length); //将缓冲区中的数据拷贝到buf字节数组中
        System.out.println(new String(buf,"utf8"));
        System.out.println("字节数量:>>"+buf.length);
        fis.close();
    }

调试第一步:在BufferedInputStream构造器处后:
通过文件基础输入流available可知buf长度为6,此时buf = [0,0,0,0,0]。然后在此基础流上,添加缓冲功能。此时,BufferedInputStream对象bis属性值: buf=[8192个0],count =0, in = FileInputStream(fis),marklimit=0, markpos =-1,pos=0。向下,跳到第二个断点。

第二个断点:BufferedIntputStream对象bis创建成功,所有内部属性都进行了初始化。进入read方法:
进入BufferedInptuStream.read(b,off,len)方法,做一些基础判断后,调用私有read1(b,off+n,len -n)方法,此时,bis对象状态没有改变。
进入read1方法,通过首次判断int avail = count - pos;,因为count=pos=0,所以avail<0,说明此刻缓冲区中没有可读字节数据。
之后,就会调用非常重要的方法fill(),目的将基础流fis中字节数据更新到缓冲区中,进入fill方法:
重要的来了,调用getInIfOpen().read(buffer, pos, buffer.length - pos);方法,实质上getInIfOpen()方法就是返回FileInputStream类型fis对象,也就是bis对象的in属性,底层调用的就是fis.read(byte[],int,int)方法,将文件字节流中的数据读取到缓冲区buf中,
此刻,bis属性中的buf缓冲区的数据有变动了,从buf=[8192个0],读取数据之后,变成了buf=[-17, -69, -65, -28, -67, -96,(8192-6)个0]。因为fis文件中有6个字节。然后更新count=6。
之后又回调到前面的read1方法,后面的自己数组操作都是用bis中的buf缓冲区数据进行操作了。在操作中就不断更新count,pos,markpos等属性....
[read() ---> read1() ---> fill()[将基础字节流数据转移到缓冲区buf] ---> read1() ---> read()]
结束调试后输出:(因为中文用UTF8编码,有三个字节是用于标识UTF8编码的字节:ef bb bf )

你
字节数量:>>6

那为什么不一次性将所有的基础输入字节流数据保存到内存中的缓冲区byte[]中呢?
首先,因为默认的缓冲区大小是8kb,若是要读取的外部资源文件很大呢?几兆?几十兆?那这样一次读写数据会占用IO资源,消耗时间。何不每次读取部分,程序先使用缓冲区中字节数据,若是用完,每次再从基础输入流中读取字节呢。

第二当然是希望尽可能不占用内存资源了,若是多线程,多个IO任务在执行,每个线程要读取1M,20M的字节流,那么积累起来,也占用相当大的内存空间,因为缓冲区都是建立在内存中的。(这里有篇说BufferedInputStream挺好的文章,可学习参考:BufferedInputStream(缓冲输入流)的认知、源码和示例)

ByteArrayInputStream: 该类是字节数组输入流,其实与BufferInputStream类似,该字节数组输入流内部也有一个byte[]字节数组缓冲流,所以使用该类来读取数据或者程序使用,都是通过字节数组来实现的。

那通常这些带有缓冲区的字节输入流适合什么时候用?当我们要处理一个内部或者外部的字节数组资源的时候(字节输入流来源是字节数组),就可以通过构造器将这个字节数组传入ByteArrayInptuStream中,内部底层就把这个字节数组作为缓冲区数组,后续的针对字节的操作就可以使用这个缓冲区来完成。

public
class ByteArrayInputStream extends InputStream {

    //保存输入流数据的字节数组缓冲区
    protected byte buf[];

    //下一个会被读取的字节位置索引(缓冲区读取指针)
    protected int pos;

    //缓冲区标记索引
    protected int mark = 0;

    //缓冲区字节流的长度
    protected int count;

    //通过构造器将输入字节数组传入内部,创建一个内容为buf的缓冲区字节流
    public ByteArrayInputStream(byte buf[]) {
        this.buf = buf;
        this.pos = 0;
        this.count = buf.length;
    }
    //通过构造器将输入字节数组传入内部,创建一个内容为buf的缓冲区字节流
    //offset为读取指针起始位置,length为读取偏移量长度
    public ByteArrayInputStream(byte buf[], int offset, int length) {
        this.buf = buf;
        this.pos = offset;
        this.count = Math.min(offset + length, buf.length);
        this.mark = offset;
    }
...
}

有时候需要想想,在我们读取一些小文件,字节数少的资源,其实不使用带有缓冲功能的输入流也是可以的。但是,当输入源字节数量大的时候,为了提高IO效率,就可以使用缓冲区机制,将输入源大量的字节数据,一批批,一次次的将数据拷贝到内存缓冲区,程序从内存缓冲区获取速度那就当然快了,效率也高了。

也来看看字节数入流的简单操作:主要目的就是处理传入的字节数组。

    @Test
    public void byteArrayInputStreamTest() throws Exception{
        InputStream fis = new FileInputStream("C:/Users/_Fan/Desktop/tt.txt");
        byte[] buf = new byte[fis.available()];//保存读取到的文件字节数组
        fis.read(buf,0,buf.length);//通过文件字节流将内容保存到buf字节数组中
        byte[] dest = new byte[buf.length];//创建一个被字节数组输入流使用的数组
        ByteArrayInputStream bais = new ByteArrayInputStream(buf);
        bais.read(dest,0,dest.length);//通过bais将输入的字节数组拷贝到dest中
        System.out.println(new String(dest,"utf8"));
        System.out.println("字节数量:>>"+dest.length);
        fis.close();
    }
  • ObjectInputStream: 是对象序列化是读取对象字节信息的类。序列化问题需要找时间在总结总结。
  • PipedInputStream: 该类是用于两个线程通讯的通道输入流,一个线程的输出作为另外一个线程的输入,组成字节流通道。
  • SequenceInputStream: 用于逻辑上关联多个输入流的读取顺序。从输入流A-->B--->C。

这里呢,还多说一点,关于java系统中的标准输入流:System.in。可以看到,这个流连接着键盘字节流输入或者其他主机环境或者用户指定的输入源。(常用的场景就是我们要通过控制台键盘输入数据,就可以通过System.in流得到输入数据)

//System.java
public final class System {

    /**
     * The "standard" input stream. This stream is already
     * open and ready to supply input data. Typically this stream
     * corresponds to keyboard input or another input source specified by
     * the host environment or user.
     */
    public final static InputStream in = null;
...
}

Reader字符输入流

与InputStream都是输入流,只是流中基础数据类型不一样。InputStream中的是字节数据(raw bytes),Reader则是字符数据。在java中,最简单区别两者方式可以从两者存储数据使用的字节数,byte类型是一个字节,字符char则是使用两个字节存储。因为我们也会经常想直接读取某个外部资源内的字符数据,而不是更底层的字节。当从资源中读取到的字符流,一般就是我们可以直接看懂的,就不用再次通过字节数据转换。无论是英文字符串还是中文内容,都可以用char来存储表示。这样对于我们程序处理也更方便。通常字节流读取是使用缓冲流进行保存字节和程序处理的。

reader.png

下面通过FileReader来做个简单例子,再通过源代码来查看内部数据是如何流动的:

    @Test
    public void FileReaderTest() throws Exception{
        Reader reader = new FileReader("C:/Users/_Fan/Desktop/tt.txt");
        char[] cbuf = new char[10];
//      reader.read(cbuf,0,cbuf.length);
        int len = 0;
        while((len = reader.read(cbuf)) != -1){
            System.out.println(new String(cbuf,0,len));
        }
        System.out.println("java中的char字节大小:>>"+Character.SIZE/8);
        byte[] bytes = getBytes(cbuf);
        System.out.println("转换成字节数大小:>>"+bytes.length);
        reader.close();
    }
================================
//输出
hello哈
java中的char字节大小:>>2
转换成字节数大小:>>23 
(5个字母(5个字节)+ 1个中文(3个utf8标识字节+3个存储中文汉字的字节)) = 5+6 = 11个字节,
剩下的12字节就是剩下(10 - 6) = 4个字符得到的字节数。
================================
    /**
     * getBytes : 将字符数组转换成字节数组
     * @param chars
     * @return
     */
    private byte[] getBytes(char[] chars){
        Charset cs = Charset.forName("utf-8");
        CharBuffer cb = CharBuffer.allocate(chars.length);
        cb.put(chars);
        cb.flip();
        ByteBuffer bb = cs.encode(cb);//字符串-->字节(编码)
        return bb.array();
    }

那么,需要通过断点调试看看这个字符数组是什么样的?将字符数组转换成字节数组又会变成什么样子?通过下面的程序调试截图:


reader_char.png

从上面可以看到,当使用字符输入流Reader以及相关子类来获取输入源内的字符时候,会将字符保存到char[]字符数组中。每个数组索引位置处,都保存着输入源中的一个字符,而不是字节。因为在java中,一个char在内存中最大可以开辟两个字节来保存字符,大多数字符都可以在内存中保存,包括汉字(这里的"哈"对应Unicode编码为"\u54c8")

因为文件是用utf编码,按照utf存储规则,汉字"哈"需要用三个字节来存储。[更进一步说明,当在磁盘文件中按照utf存储汉字的规则,需要将54c8两个字节进行拆分成三个字节(-27,-109,-120)保存在磁盘上,当程序读取文件的时候,根据utf8编码标识得知文件使用utf8编码存储,就会按照特定规则取出字节码,又解码拼装成两个字节的54c8存储在char中,程序就能正确看到中文汉字]

char ha = '\u54c8';
System.out.println(new String("\u54c8"));//哈
System.out.println(ha);//哈

那么,FileReader内部是如何实现字符读取的呢?与前面的直接读取字节流有什么联系么?为什么可以直接将字节文件读取成字符数组呢,通过源码来看看啦:

//通过构造器创建文件字符流输入:
//Reader reader = new FileReader("C:/Users/_Fan/Desktop/tt.txt");
//FileReader.java
/*
* FileReader继承自InputStreamReader类,
* 所以实际FileReader.read()调用的是InputStreamReader的read方法。
*/
public class FileReader extends InputStreamReader {

    //调用父类InputStreamReader,在FileInputStream文件字节流基础上处理
    public FileReader(String fileName) throws FileNotFoundException {
        super(new FileInputStream(fileName));

...
}

所以,当调用reader.read(cbuf,0,cbuf.length);方法,实际上是通过委托给父类InputStreamReader类来处理。因为InputStreamReader是个处理流,也是转换流把,作用是将字节转换为字符。所以,FileReader文件字符输入流,其实底层还是由FileInputStream文件字节输入流来连接外部的文件资源,然后在字节流基础上,InputStreamReader类在再将这些字节流转换为字符流,最后可以保存到char[]字符数组中。
关于这个InputStream转换流,放到下一节进行详说。

由Reaer系列类图也可以看出,这些常规的字节输入流是每次进行一次数据读取,都会进行一次物理IO操作,当然费时费资源了,所以,与InputStream系列中的BufferedInputStream带有缓冲功能的字节输入一样,这里的带有缓冲功能的字符输入流BufferedReader也要来看看了,看看内部是不是与FileReader依赖FileInputStream一样,这个BufferedReader底层也是依赖于带有缓冲功能的字节输入流BufferedInputStream,以及内部外部文件字节资源与缓冲区数据的更新机制是怎么的?

    @Test
    public void FileReaderTest() throws Exception{
        Reader reader = new FileReader("C:/Users/_Fan/Desktop/tt.txt");
        char[] cbuf = new char[10];
//      reader.read(cbuf,0,cbuf.length);
        /*当基础流已经使用read读取过后,再次读取数据到缓冲区就没有数据了
         * eg: 若在br.read()之前,reader已经调用了read(cbuf,0,cbuf.length)
         * 方法,一次性将文件中的字节流数据读取完了,那么此时br.read()将会读取不到数据,
         * 因为原始基础流已经没有数据了。
         */
        BufferedReader br = new BufferedReader(reader);//在FileReader流上添加字符缓冲
        br.read(cbuf, 0, cbuf.length);
//      String line = br.readLine();
        System.out.println(new String(cbuf));
        br.close();
        reader.close();
    }

可以从用法与源码中可知:
字符缓冲流是在字符流Reader基础上,底层通过在内存中创建char[]字符数组当做缓冲区来使用
而字符输入流Reader又是在字节流InputStream基础上来实现字符数组数据的读取

所以,相当于BufferedReader字符缓冲流是在InputStream字节输入流上套了两层过滤与处理功能,才能达到字符缓冲区功能,他底层方法调用过程:
[BufferedReader.read() --->read1()--->fill(in.read(cb, dst, cb.length - dst)通过基础字符流读取字符,更新到缓冲区中) --->read1() --> read() ],差不多就是这么个过程,其中,基础字符流读取字符过程,也是有字节流支持的。至于如何将字节流转换成字符流,就要看InputStreamReader如何实现的了。
所以,总的来说,无论是字节输入流,字符输入流都是需要先将外部资源文件通过字节输入流读取到程序中,然后在此基础上,可以将字节流转成字符流或者添加什么其他的缓冲等功能都是看需求与各自类中定义的实现

BufferedReader对于读取文件内容字符输入流是个不错的选择,其内部还有一个readLine方法,可以按照文件每行字符数据进行读取,底层是通过StringBuffer对象,通过循环读取文件字符,通过判断是否是换行符号来读取每行字符。也是很有用的方法。结合char[]缓冲,按行读取文件字符内容,效率也高,但是也要注意文件的编码设置。

还有一个字符数组输入流也是常见的常用的,就是CharArrayReader类。可以通过传入一个char[]字符数组到构造器,之后,可以通过CharArrayReader的char[] buf缓冲区来对这些字符数据流进行操作,也是给某些字符数组添加缓冲,和一些其他的处理,更高效的使用这些字符数组。

    @Test
    public void CharArrayReaderTest() throws Exception{
        Reader reader = new FileReader("C:/Users/_Fan/Desktop/tt.txt");
        char[] cbuf = new char[10];
        reader.read(cbuf,0,cbuf.length);//将文件中字符保存到cbuf字符数组中
        //将cbuf传入CharArrayReader,通过缓冲对字符数组处理    
        CharArrayReader car = new CharArrayReader(cbuf);
        char[] temp = new char[cbuf.length];
        car.read(temp,0,temp.length);//将字符缓冲区的字符拷贝到temp中
        System.out.println(new String(temp));
        reader.close();
    }

根据类图,字符输入流系列的一些处理,数据源可以是文件,字符数组等等,目的就是程序要高效处理字符数组。还有一些其他的字符输入流就做简单说明,因为每个使用场景都是可以单独来细说的。知道有这么个玩意就行啦..嘿嘿...

Reader系列的子类,多数都是可以与InputStream中分别对应,包括这些使用流处理场景。这里可以省略过。但是,有个非常重要的子类InputStreamReader这个处理输入流,主要作用是将字节流转换成字符流。Reader中挺多子类都是要依赖此类进行输入流处理。更详细说明,可以放到下一节中。

输入字节流与字符流的转换

InputStreamReader:输入处理流,主要是包装字节流,将这些字节流转换成字符流。在将字节转换成字符,需要StreamDecoder类来处理。所以,就主要来看看StreamDecoder内部实现,就能大致清除了解字节是如何转换成字符的?数据流是如何流动传输的等等。就取上面的FileReader的列子进行说明:

  1. 看看FileReader构造器调用:
  public FileReader(String fileName) throws FileNotFoundException {
        super(new FileInputStream(fileName));//调用InputStreamReader()
    }
  1. 可以看到,实际是通过调用父类InputStreamReader转换流的构造器:
    (重要的是,还新建了个FileInputStream文件字节流传参,说明字符流的读取与字节流密切相关,或者说是依赖字节输入流)
    private final StreamDecoder sd;

    public InputStreamReader(InputStream in) {
        super(in);
        try {
            sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
        } catch (UnsupportedEncodingException e) {
            // The default encoding should always be available
            throw new Error(e);
        }
    }
  1. 可以看到,将FileInputStream作为in参数传递给内部的StreamDecoder类的构造器:
    public static StreamDecoder forInputStreamReader(InputStream in,
                                                     Object lock,
                                                     String charsetName)
        throws UnsupportedEncodingException
    {
        String csn = charsetName;   //传递的charsetName == null
        if (csn == null)
            csn = Charset.defaultCharset().name(); //查询系统默认字符编码集
        try {
            if (Charset.isSupported(csn))//支持编码
                return new StreamDecoder(in, lock, Charset.forName(csn));//调用构造函数
        } catch (IllegalCharsetNameException x) { }
        throw new UnsupportedEncodingException (csn);
    }
...
    //字符集对象
    private Charset cs;
    //字符解码:字节-->字符
    private CharsetDecoder decoder;
    private ByteBuffer bb;

    // Exactly one of these is non-null
    private InputStream in;//字节输入流,这里通过构造器传递的是FileInputStream in.
    private ReadableByteChannel ch;//nio,字节通道对象

    StreamDecoder(InputStream in, Object lock, Charset cs) {
        this(in, lock,
         cs.newDecoder()
         .onMalformedInput(CodingErrorAction.REPLACE)
         .onUnmappableCharacter(CodingErrorAction.REPLACE));
    }

    /*
    * 初始化好字节读取通道,字节缓冲区,字符解码器等等对象。
    * 为后续将字节流转换成字符流做铺垫
    */
    StreamDecoder(InputStream in, Object lock, CharsetDecoder dec) {
        super(lock);
        this.cs = dec.charset();
        this.decoder = dec;

        // This path disabled until direct buffers are faster
        if (false && in instanceof FileInputStream) {
        ch = getChannel((FileInputStream)in); //返回in.getChannel();默认支持通道
        if (ch != null) //若是支持通道,那么直接从堆外内存开辟地址给缓冲字节流
            bb = ByteBuffer.allocateDirect(DEFAULT_BYTE_BUFFER_SIZE);
        }
        if (ch == null) {
        this.in = in;
        this.ch = null;
        //堆内内存
        bb = ByteBuffer.allocate(DEFAULT_BYTE_BUFFER_SIZE);
        }
        bb.flip();                      // So that bb is initially empty
    }
  1. 在StreamDecoder对象等默认配置初始化好后,看看FileReader.read是如何读取字符数组的:
        Reader r = new FileReader("C:/Users/XianSky/Desktop/readme.txt");
        char[] cbuf = new char[20];
        r.read(cbuf, 0, cbuf.length);
  1. 然后,调用的是InputStreamReader.read方法:
    public int read(char cbuf[], int offset, int length) throws IOException {
        return sd.read(cbuf, offset, length);
    }
  1. 可以看到是代理给StreamDecoder对象sd进行读取:
    public int read(char cbuf[], int offset, int length) throws IOException {
        int off = offset;
        int len = length;
        synchronized (lock) {
            ensureOpen();
            ...
            return n + implRead(cbuf, off, off + len);
        }
    }

    /*
    * 可以看到,StreamDecoder实际上就是使用CharBuffer,ByteBuffer的互相转换来对
    * FileInputStream读取到的字节流转换成字符流。
    */
    int implRead(char[] cbuf, int off, int end) throws IOException {
        ...
        //先将cbuf使用缓冲区包装起来
        CharBuffer cb = CharBuffer.wrap(cbuf, off, end - off);
        if (cb.position() != 0)
        // Ensure that cb[0] == cbuf[off]
        cb = cb.slice();

        boolean eof = false;
        for (;;) {
        //将ByteBuffer bb字节缓冲区内字节进行解码成字符
        CoderResult cr = decoder.decode(bb, cb, eof);
        if (cr.isUnderflow()) {
            if (eof)
                break;
            if (!cb.hasRemaining())
                break;
            if ((cb.position() > 0) && !inReady())
                break;          // Block at most once
            int n = readBytes();//实际将字节数组转换成字符数组方法
            if (n < 0) {
                eof = true;
                if ((cb.position() == 0) && (!bb.hasRemaining()))
                    break;
                decoder.reset();
            }
            continue;
        }
    ...
        cr.throwException();
        }

    ....
        return cb.position();
    }
  1. 当sd.read方法调用,返回的时候,char[] cbuf字符数组已经把文件的字符都读取出来了。具体的StreamDecoder的read时序图如下:


    StreamDecoder.png

综上呢,InputStreamReader转换流呢,主要就是利用StreamDecoder将StreamDecoder内部的InputStream对象读取到的字节流,利用CharBuffer,ByteBuffer,ReableByteChannel等NEW IO的算法来对字节流进行解码处理,转换成字符流数组返回到最开始读取字符的方法处,并且,此时参数中的char[]也已经被StreamDecoder解码的字节流填充完了。。。


参考:
JAVA IO - 白大虾
BufferedInputStream(缓冲输入流)的认知、源码和示例
JAVA常用类库/JAVA IO

推荐阅读更多精彩内容