那些你学了又忘的Java IO(五):字符流

这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战

人生苦短,不如养狗

作者:Brucebat.Sun

一、概要

1. 什么是字符

  在计算机中将字母、数字以及符号(包含运算符号、标点符号和其他的一些符号)称为字符(Character) 。需要注意的是,字符是一个信息单位,而字节才是计算机中数据结构存储的基本单位。字符在进行存储时,会根据程序使用的字符编码集将字符转换成一个或者多个字节进行存储。

2. 使用场景

  从上面的概念中不难看出,引入字符主要是为了处理文本类数据。由于世界上的语言种类繁杂,为了让计算机能够存储和处理不同的语言,通过编码集来对这些语言中的符号进行类似自然数序列的映射处理。通过这样的映射处理,不同语言的字符会被表示成不同的二进制数,在存储时需要使用一个或者多个字节进行存储,这就造成了上一篇文章中谈到的字节流处理文本数据时的乱码问题。下面我们通过中文字符来看一下字节流在处理过程中的问题。

中文字符乱码问题

chinese-messy.png

  上图中代码逻辑为通过字节流逐个读取数据然后转成字符打印到控制台,具体结果如下:

chinese-messy-1.png

  从上面的结果可以看到,使用这种方式进行中文字符读取时会出现乱码情况。这主要是由于在进行字符转换时,中文字符使用了多个字节进行存储(在使用UTF-8编码时,中文字符需要使用3个字节存储,而在使用Unicode时,中文字符需要使用2个字节存储),在使用单个字节进行读取时文本数据本身遭到了破坏,导致最终读取的数据再经过字符集映射后得到和预期目标不符的字符,也就出现上面图片中展示乱码问题。

  为了避免这一问题,在进行文本类数据读取时需要使用字符流来进行处理。

二、输入/输出字符流及使用

  在Java IO类库中,所有字符流都是Reader/Writer的子类,并且无论是输入流或是输出流在其类名都会以Reader/Writer结尾,这是一个默认的规范,开发者在实现自定义子类时也需要遵守这一规范。下面我们按照输入流和输出流两部分来分别说明。

1. 输入流

1.1 Reader类浅析

  和上一篇文章一样,在具体使用输入字符流之前,我们先来看一下“输入字符流之父”Reader。在Reader中提供四种读取数据的方式,这里主要了解以下常用的三种读取数据的方法:

  • int read() : 该方法从目标数据源读取一个字符的数据,这里返回的内容为按照字节存储的字符数据(按照不同编码集可能是多个字节),当读取到流的末尾或者发生I/O异常时,返回值为-1。为了能够实现连续读取字符数组,其实现类中大多会使用一个指针对象来标识当前读取数据的位置;
  • int read(char cbuf[]) : 该方法从指定字符数组中一次性读取全部字符数组数据;
  • int read(char cbuf[], int off, int len) : 该方法是用于进行字符数组指定范围的数据读取,也是以上两个方法进行读取操作时实际使用的方法(部分子类实现中上述两种方法也可能不会使用该方法)。需要说明的是,该方法为一个抽象方法,需要子类自行实现。

  这里我们使用第一个方法进行Reader类的编程范式总结:

        // 创建一个字符输入流,其中XXXReader为Reader的子类,data为需要读取的数据。这里使用try-with-resources来避免显示的关闭流
        try (Reader reader = new XXXReader(data)) {
            // 将字符输入流中的第一个字符读取到缓冲区中
            int read = reader.read();
            // 如果read返回的值小于0,表示读取完毕
            while (read != -1) {
                // 对读取到的数据进行处理
                System.out.print((char) read);
                // 继续读取下一个字符
                read = reader.read();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
复制代码

  除了上面的读取方法,在阅读Reader源码中会发现一个在InputStream当中没有出现的东西——锁对象(lock)

  结合上面读取方法中子类的实现方式,相信大家应该能够理解为什么这里会出现一个锁对象,因为在多个线程同时使用一个Reader对象进行数据时,其内部用于标识读取位置的指针在进行pos++操作时会发生并发问题,即会发生重复读取的问题。那么聪明的同学可能会问InptStream难道不会有相同的问题吗?当然会有,但是在InputSteram当中使用synchronized关键字来修饰了read()方法。

  新的问题来了,为什么在Reader当中不使用相同的方式进行处理,而是引入了一个lock成员变量?官方文档给出了如下解释:

The object used to synchronize operations on this stream. For efficiency, a character-stream object may use an object other than itself to protect critical sections. A subclass should therefore use the object in this field rather than this or a synchronized method.
复制代码

  简单理解,就是将并发控制转让给外部对象,而不是单纯依赖于当前的Reader对象来控制。

1.2 使用案例

  下面我们通过CharArrayReaderFileReader来了解一下Reader使用过程。

a. CharArrayReader
        String text = "蝙蝠侠";
        // 创建一个字符输入流,这里使用try-with-resources来避免显示的关闭流
        try (Reader reader = new CharArrayReader(text.toCharArray())) {
            // 将字符输入流中的第一个字符读取到缓冲区中
            int read = reader.read();
            // 如果read返回的值小于0,表示读取完毕
            while (read != -1) {
                // 对读取到的数据进行处理
                System.out.print((char) read);
                // 继续读取下一个字符
                read = reader.read();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
复制代码
b. FileReader
        // 创建一个字符输入流,这里使用try-with-resources来避免显示的关闭流
        try (Reader reader = new FileReader("/Users/suntianyu/Desktop/test.json")) {
            // 将字符输入流中的第一个字符读取到缓冲区中
            int read = reader.read();
            // 如果read返回的值小于0,表示读取完毕
            while (read != -1) {
                // 对读取到的数据进行处理
                System.out.print((char) read);
                // 继续读取下一个字符
                read = reader.read();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
复制代码

2. 输出流

2.1 Writer类浅析

  同上,我们先来看一下“字符输出流之父”Writer。在Writer当中提供了如下的写入方法:

  • void write(int c) : 该方法提供了写入单个字符的能力。注意这里入参虽然是一个int类型数据,但是在进行实际写入操作时,会读取该整型数据的低16位;
  • void write(char cbuf[]) : 该方法提供了一次性写入整个字符数组的能力;
  • void write(char cbuf[], int off, int len) : 该方法提供了写入字符数组指定范围数据的能力,也是上一个方法在进行实际写入操作时调用的方法;
  • void write(String str) : 该方法提供了直接读取字符串的能力;
  • void write(String str, int off, int len) : 该方法提供读取字符串指定范围数据的能力,也是上一个方法在进行实际写入操作时调用的方法;

  由于在工程中我们大多使用String来进行文本数据操作,所以这里我们声依永第四种方法来进行Writer的编程范式总结:

        String text = "蝙蝠侠";
        // 创建一个字符输出流,其中XXXWriter为Writer的子类,这里使用try-with-resources来避免显示的关闭流
        try (Writer writer = new XXXWriter()) {
            // 将字符输入流中的第一个字符读取到缓冲区中
            writer.write(text);
            // 将缓冲区中的数据写入到输出流中
            writer.flush();
        } catch (Exception e) {
            e.printStackTrace();
        }
复制代码

  除了上述的写入方法外,在阅读Writer类的源码时我们再次发现了一个在OutputStream中不存在的东西——缓冲区。官方注释给出了如下解释:

Temporary buffer used to hold writes of strings and single characters
复制代码

  简单翻译一下,缓冲区就是用于临时存储需要写入的字符串数据或者单个字符。虽然这里给出了writeBuffer变量的说明,但是感觉好像啥也没有说,啥也没弄懂。

  那么为什么字符流当中需要使用缓冲区呢?这主要是因为Writer及其子类实现在进行字符或者字符串数据写入时,每个字符串的写入都需要先将对应的字符按照编码集转换成对应的字节数据,然后再发起写入请求。如果每个字符写入都需要单独调用一次IO写入,那么多次叠加之后的性能消耗是非常巨大的(编码映射耗时 + IO操作耗时)。因此Writer类使用缓冲区来将数据缓冲,当缓冲区满了之后一次性发送一整块缓冲区的数据进行写入,减少IO调用次数,此时编码映射耗时没有变化,但是由于IO次数减少,IO操作耗时也大幅减少,写入的总耗时大大降低。

2.2 使用案例

  下面我们通过FileWriter来了解一下Writer的使用过程。

        String text = "蝙蝠侠";
        // 创建一个字符输出流,这里使用try-with-resources来避免显示的关闭流
        try (Writer writer = new FileWriter("/Users/suntianyu/Desktop/test.json")) {
            // 将字符输入流中的第一个字符读取到缓冲区中
            writer.write(text);
            // 将缓冲区中的数据写入到输出流中
            writer.flush();
        } catch (Exception e) {
            e.printStackTrace();
        }
复制代码

  和FileOutputStream类似,FileWriter封装了类似的较为简单的文件写入能力,将文件操作相关API细节进行了屏蔽。

三、总结

  简而言之,字符流的诞生主要是为了处理由于语言不同而带来的不同字符操作问题。在明白了这一点之后,进行数据的读写操作时遇到应该使用字节流还是字符流这样的问题,相信大家会做出更好的判断。这里不得不感慨秦始皇当前统一文字是一件多么明智的举措,现在我只希望汉字能够早日成为世界官方文字。哈哈哈哈~~

おすすめ

転載: juejin.im/post/7067114094448345118