前文讲了字节流,今天我们看一下字符流。

对于字节流,一切皆是byte,读写的是原始数据。

但是遇到中文、emoji、标点这些人类语言,只看byte就不够了。

我们会碰到编码、解码、字符边界、BOM、乱码这些问题。

本文把视角从字节流提升到字符流,看一看以字符为最小单位的Reader/Writer体系。

粗暴一点,我们可以把字符流看成字节流 + 编码规则的组合。

InputStreamReader/OutputStreamWriter负责把byte和char互相翻译,BufferedReader/BufferedWriter则是提高性能的。

一、前置知识

在正式讲字符流之前,我们先理清楚一些前置的概念和知识点。

1.1 字符集和字符编码

字符集可以看成是字典。Unicode是目前最权威的字符集。他尝试把世界上所有语言、符号都编排到他这个字典里,给每一个字符都赋予一个唯一的编号。这个编号术语上称为码点。

比如'A'的Unicode码点是U+0041。

'你'的Unicode码点是U+4F60。

'😊'的Unicode码点是U+1F60A。

U+就是表示这是个Unicode码点。

Unicode只规定了编号,比如'你'对应4F60。但是没有规定这个编号4F60该怎么在磁盘上用字节存储。

那到底是怎么存储的呢?

这时候就要引入字符编码,他类似一个编码规则,定义了怎么把Unicode码点翻译成实际的字节序列。

我们经常接触的编码有GBK、UTF-8等。

GBK主要用于简体中文,是国家标准。通常用两个字节表示一个汉字。

当我们以GBK的编码保存'懒'字时,这个字的实际存储是0xC0C1。

从历史演进看,可以把GB2312、GBK、GB18030看成一条升级线:GB2312解决"有一个简体中文编码"的问题,GBK解决"汉字不够用"的问题,而GB18030则在国家标准层面,彻底跟Unicode对齐,成为官方统一方案。在工程实践里,GBK更多以Windows的CP936实现存在,而GB18030则是后来所有中文软件理论上必须支持的终极编码标准。

接下来我们看一下UTF-8的规则。

这是UTF-8的编码规则表:

Unicode 码点范围

UTF-8 编码模式(二进制)

字节数

U+0000 ~ U+007F

0xxxxxxx

1 字节

U+0080 ~ U+07FF

110xxxxx 10xxxxxx

2 字节

U+0800 ~ U+FFFF

1110xxxx 10xxxxxx 10xxxxxx

3 字节

U+10000 ~ U+10FFFF

11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

4 字节

我们还是以'懒'字为示例:

'懒'的Unicode码点是61D2,他在U+0800 ~ U+FFFF这个范围内,根据上面的规则,需要使用三字节模式。

也就是使用1110xxxx 10xxxxxx 10xxxxxx这个模版。

61D2这个码点转换成二进制的形式是:0110 0001 1101 0010

把这个二进制按照模版的顺序替换'x',就能得到最终的编码结果。

这是我们得到的最终结果:11100110 10000111 10010010。这也是'懒'字以UTF-8形式在磁盘上存储的数据。

转换成16进制:E6 87 92

跟我们使用记事本保存后结果一致。

1.2 乱码

我们在编程的过程中,经常都会碰到乱码问题。

从上一节可以看出,其实乱码的原因很简单。我们用A编码对内容进行了编码存储,但是用B编码进行解码,自然就会乱码。

1.3 BOM

BOM的全称是Byte Order Mark,他是一个隐藏在文件开头的特殊字节序列。Unicode码点是U+FEFF。

是用来声明这个文件的编码方式的。

我们打开Windows的记事本,写入内容进行保存,选择编码方式时可以看到一种UTF-8 BOM。

左侧是带BOM的,右侧是不带BOM的。使用UltraEdit打开显示是一样的。

当我们切换到16进制时,就能看出带有BOM的多出了三个字节。

EF BB BF表示的就是该文件是UTF-8编码的。

对于我们写代码而言,基本上都是选择不带BOM的。我们的源代码本身就只应该包含文本内容本身。编码信息应该是文件的元数据,类似文件创建日期等。而不应该把他作为文件内容的一部分。

比如在IDEA中,我们对源码文件的编码设置,一般都是with NO BOM的。

在编程领域基本上已经摒弃了BOM。现代的IDE或者编辑器都能通过文件元数据或者字节序列的规则统计等方式智能的识别出文件的编码格式。再不济也还有提示我们手动选择编码的方式。

1.4 LF vs CRLF

在I/O里,换行符也不是一个统一的概念。

Windows中使用CRLF(\r\n)表示换行,占两个字节。

在Linux系统里,使用LF(\n)表示换行,占用一个字节。

我们在Windows打开记事本,输入两行内容。(中间敲了一次回车进行换行)。

在文件的右下角能看出,换行符使用的是Windows的CRLF。

当我们使用Notepad++打开文件,点击显示所有字符,就能看到CRLF。

为什么类似空格、制表符、换行符这些内容需要额外的显示操作,才显示出来? 因为这些符号主要起到的是格式化的作用,算是文本的元数据。默认情况下,编辑器只渲染内容字符,而通过视觉布局(文本之间的间隙、行的折行)来暗示空白/控制字符的存在。

\r的Unicode码点是U+000D

\n的unicode码点是U+000A

可以去这个网站查:https://symbl.cc/cn/unicode-table/

对于Unicode码点在U+0000到U+007F范围内的字符(上面提到的\r\n都在这个范围),经过UTF-8编码后,还是会保持单字节表示,二进制值不变。

所以我们用UltraEdit打开之前的文件,选择16进制显示,直接去找有没有连续的0D 0A。

找到了就说明文件内容中确实使用CRLF换行符。

二、字符流

之前我们说过,字符流 = 字节流 + 编码规则的组合。

单纯的操作字节流,是非常繁琐的,需要处理的问题很多,比如编码、字符边界、BOM、换行符等。

引入字符流之后,就方便了很多,他可以帮我们按照正确的编码规则读取字节。

能够正确的处理字符边界、自动处理BOM等。

在java.io体系里,Reader和Writer是所有字符流的抽象基类。

Reader是所有字符输入流鼻祖,Writer是所有字符输出流的鼻祖。

他们定义了一些以字符(char)为单位的读写API。

我们知道FileInputStream他是一个字节流(InputStream),我们想要的是字符流(Reader)。

也就是说怎么把字节按照规则拼接起来,转换成字符,或者把字符拆分成字节,才能让我们可以直接操作字符流呢?

这就引入了字节流与字符流之间的桥梁,InputStreamReader和OutputStreamWriter,其实他们负责的就是字节流与字符流之间的转换翻译工作。

我们来看看InputStreamReader和OutputStreamWriter的工作原理。

2.1 InputStreamReader

首先直接使用InputStreamReader读取文件。


package com.lazy.snail.day60;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

/**
 * @ClassName InputStreamReaderDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/13 16:02
 * @Version 1.0
 */
public class InputStreamReaderDemo {
    public static void main(String[] args) {
        try (FileInputStream fis = new FileInputStream("C:\\Users\\lazysnail\\Desktop\\懒惰蜗牛.txt");
             InputStreamReader reader = new InputStreamReader(fis, StandardCharsets.UTF_8)) {

            System.out.println("=== 读取文件内容 ===");
            int charCode;
            while ((charCode = reader.read()) != -1) {
                System.out.print((char) charCode);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

示例代码中首先还是使用字节流FileInputStream建立与文件的连接,然后将字节流引用传递给字符流 InputStreamReader,同时指定了解码规则是 UTF-8(我们的"懒惰蜗牛.txt"是记事本以UTF-8编码格式保存的),reader.read()会让字符流中的StreamDecoder按照编码格式对字节流进行解码,逐字符的返回。

2.2 OutputStreamWriter

接下来看一下OutputStreamWriter。


package com.lazy.snail.day60;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;

/**
 * @ClassName OutputStreamWriterDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/13 16:26
 * @Version 1.0
 */
public class OutputStreamWriterDemo {
    public static void main(String[] args) {
        try (FileOutputStream fos = new FileOutputStream("C:\\Users\\lazysnail\\Desktop\\懒惰蜗牛.txt");
             OutputStreamWriter writer = new OutputStreamWriter(fos, StandardCharsets.UTF_8)) {

            System.out.println("=== 写入文件内容 ===");
            writer.write("懒惰蜗牛\n");
            writer.write("懒惰蜗牛工坊");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

其实跟InputStreamReader类似,示例代码中首先使用字节流FileOutputStream建立与文件的连接,然后将字节流引用传递给字符流OutputStreamWriter,同时指定了编码规则为UTF-8。

当调用writer.write()时,字符流内部的StreamEncoder会按照UTF-8编码格式对字符进行编码。

StreamEncoder会将这些编码后的字节序列写入底层连接的FileOutputStream,最终保存到"懒惰蜗牛.txt"文件里。

OutputStreamWriter和InputStreamReader核心依赖分别是StreamEncoder、StreamDecoder。二者负责按照给定的规则进行字节与字符之间的转换。

三、字符缓冲流

之前我们讲字节流的时候,讲过为了增强字节流的功能,提高读写性能,采用装饰器模式,给基础的字节流引入了缓冲区。

字符流也采用了同样的思想,引入了字符缓冲区,提高性能。

BufferedReader和BufferedWriter就是字符流中的装饰器,跟BufferedInputStream一样,内部带了一个缓冲区,只不过是字符类型。

3.1 BufferedReader

我们看一下BufferedReader怎么使用:

package com.lazy.snail.day60;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

/**
 * @ClassName BufferedReaderDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/13 16:51
 * @Version 1.0
 */
public class BufferedReaderDemo {
    public static void main(String[] args) {
        try (FileInputStream fis = new FileInputStream("C:\\Users\\lazysnail\\Desktop\\懒惰蜗牛.txt");
             InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_8);
             BufferedReader reader = new BufferedReader(isr)) {

            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

先是用FileInputStream创建字节流,然后通过InputStreamReader这个桥梁将字节流转换成字符流。

接着把字符流增强成BufferedReader,我们就可以直接操作字符缓冲流了。

BufferedReader的readLine会一行一行的读取数据,,他会自动处理换行符。

3.2 BufferedWriter

BufferedWriter相对于BufferedReader就是一个反向的操作。

package com.lazy.snail.day60;

import java.io.BufferedWriter;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;

/**
 * @ClassName BufferedWriterDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/13 17:00
 * @Version 1.0
 */
public class BufferedWriterDemo {
    public static void main(String[] args) {
        try (FileOutputStream fos = new FileOutputStream("C:\\Users\\lazysnail\\Desktop\\懒惰蜗牛.txt");
             OutputStreamWriter osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8);
             BufferedWriter bw = new BufferedWriter(osw)) {

            bw.write("懒惰蜗牛");

            bw.newLine();

            bw.write("懒惰蜗牛工坊");

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

newLine方法会自动根据平台处理换行符。

结语

从直接操作原始的字节流到以字符为单位处理文本。

字符流体系通过InputStreamReader和OutputStreamWriter进行编解码工作。

通过BufferedReader和BufferedWriter提高读写性能。

这套字符流的体系让我们从繁琐的字节操作、编码猜测和边界处理中解放出来。

以一种更符合人类感官的形式操作数据。

下一篇预告

Day61 | Java IO之序列化

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐