转换流 | JavaSE

转换流

一、学习目标

完成本章后,你应该能够:

  • 能够解释为什么字节流和字符流之间需要转换。
  • 能够理解 InputStreamReader 的作用:字节 → 字符。
  • 能够理解 OutputStreamWriter 的作用:字符 → 字节。
  • 能够指定正确字符集读取文本,解决编码不匹配导致的乱码问题。
  • 能够指定字符集写出文本。
  • 能够组合 FileInputStreamInputStreamReaderBufferedReader 等流形成完整 IO 管道。
  • 能够理解 JDK 21 中转换流与 FileReader/FileWriter + Charset 的关系。

二、核心知识

2.1 先回忆一个最重要的问题:文本文件本质是什么

假设文件中有:

你好Java

磁盘里真正保存的不是:

char

而是:

byte

也就是说:

字符
  ↓ 编码
字节
  ↓
文件

读取时反过来:

文件
 ↓
字节
 ↓ 解码
字符

因此文本 IO 的本质离不开两个过程:

编码:char/String → byte

解码:byte → char/String

2.2 什么是转换流

Java 提供两个非常经典的转换流:

InputStreamReader
OutputStreamWriter

它们分别负责:

InputStreamReader
byte → char
解码

OutputStreamWriter
char → byte
编码

整个关系:

                   解码
InputStream(byte) ───────→ Reader(char)
                InputStreamReader


                    编码
Writer(char) ─────────────→ OutputStream(byte)
               OutputStreamWriter

因此转换流可以理解为:

字节流与字符流之间的桥梁。


2.3 InputStreamReader

InputStreamReader

public class InputStreamReader extends Reader

所以它本质属于:

字符输入流

但是它需要接收:

InputStream

然后把底层读取到的:

byte

按照指定字符集解码为:

char

结构:

文件 / 网络 / 其他字节源
        ↓
InputStream
        ↓
InputStreamReader
        ↓
Reader
        ↓
字符

2.4 OutputStreamWriter

OutputStreamWriter

public class OutputStreamWriter extends Writer

所以它属于:

字符输出流

它接收字符:

char / String

按照指定字符集编码成:

byte

然后交给底层:

OutputStream

结构:

字符
 ↓
OutputStreamWriter
 ↓
OutputStream
 ↓
文件 / 网络 / 其他字节目标

2.5 为什么会乱码

假设文件原来采用:

GBK

保存:

中国

磁盘中存在的是按照 GBK 规则生成的字节。

如果读取时却说:

这些字节按照 UTF-8 解码

那么就发生:

GBK 编码
    ↓
GBK 字节
    ↓
UTF-8 解码
    ↓
错误字符 / 乱码

所以真正必须记住的规则是:

编码时使用什么字符集,解码时原则上就必须按照对应字符集解释这些字节。

例如:

UTF-8 编码 → UTF-8 解码

GBK 编码 → GBK 解码

而不是:

GBK 编码 → UTF-8 解码

三、使用方法

3.1 InputStreamReader 默认字符集

最基本构造器:

InputStreamReader(InputStream in)

例如:

InputStreamReader reader =
        new InputStreamReader(
                new FileInputStream("data.txt")
        );

这里:

FileInputStream

负责:

读取 byte

而:

InputStreamReader

负责:

byte → char

如果没有指定字符集:

new InputStreamReader(inputStream)

就使用 Java 默认字符集。


3.2 指定字符集

更加重要的形式:

InputStreamReader(
        InputStream in,
        Charset charset
)

JDK 21 中建议优先使用:

Charset

而不是手写字符集字符串。

例如读取 UTF-8:

import java.io.*;
import java.nio.charset.StandardCharsets;

public class InputStreamReaderDemo {

    public static void main(String[] args) throws IOException {

        try (
                InputStreamReader reader =
                        new InputStreamReader(
                                new FileInputStream("data.txt"),
                                StandardCharsets.UTF_8
                        )
        ) {

            char[] buffer = new char[1024];

            int len;

            while ((len = reader.read(buffer)) != -1) {
                System.out.print(
                        new String(buffer, 0, len)
                );
            }
        }
    }
}

注意:

StandardCharsets.UTF_8

比:

"UTF-8"

更加类型安全,也避免字符集名称写错。


3.3 读取 GBK 文件

StandardCharsets 中没有:

GBK

常量。

可以使用:

Charset.forName("GBK")

例如:

import java.io.*;
import java.nio.charset.Charset;

public class ReadGbkDemo {

    public static void main(String[] args) throws IOException {

        Charset gbk = Charset.forName("GBK");

        try (
                InputStreamReader reader =
                        new InputStreamReader(
                                new FileInputStream("gbk.txt"),
                                gbk
                        )
        ) {

            char[] buffer = new char[1024];

            int len;

            while ((len = reader.read(buffer)) != -1) {
                System.out.print(
                        new String(buffer, 0, len)
                );
            }
        }
    }
}

数据过程:

gbk.txt
 ↓
FileInputStream
 ↓
GBK 字节
 ↓
InputStreamReader
 ↓ 指定 GBK 解码
Unicode 字符
 ↓
Java 程序

只要:

文件真实编码 = 解码使用的字符集

文本就可以被正确解释。


3.4 InputStreamReader + BufferedReader

实际开发中,更常见的不是直接:

InputStreamReader.read()

而是继续包装:

BufferedReader

例如:

import java.io.*;
import java.nio.charset.Charset;

public class GbkBufferedReaderDemo {

    public static void main(String[] args) throws IOException {

        try (
                BufferedReader br =
                        new BufferedReader(
                                new InputStreamReader(
                                        new FileInputStream("gbk.txt"),
                                        Charset.forName("GBK")
                                )
                        )
        ) {

            String line;

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

现在出现三层流:

FileInputStream
       ↓
InputStreamReader
       ↓
BufferedReader

每一层职责都非常清晰。

FileInputStream

负责:

从文件读取字节

InputStreamReader

负责:

按照指定字符集
byte → char

BufferedReader

负责:

缓冲
+
readLine()

因此不要把下面这一串代码看成“套娃”:

new BufferedReader(
        new InputStreamReader(
                new FileInputStream("data.txt"),
                charset
        )
);

应该读成:

我要从文件读 byte
        ↓
我要按指定编码把 byte 解成 char
        ↓
我要增加缓冲并按行读取

这样 Java IO 就会突然变得很好理解。


3.5 OutputStreamWriter 基础

OutputStreamWriter 用来:

把字符按照某种字符集编码为字节,然后写到底层字节流。

UTF-8 写出:

import java.io.*;
import java.nio.charset.StandardCharsets;

public class OutputStreamWriterDemo {

    public static void main(String[] args) throws IOException {

        try (
                OutputStreamWriter writer =
                        new OutputStreamWriter(
                                new FileOutputStream("data.txt"),
                                StandardCharsets.UTF_8
                        )
        ) {

            writer.write("你好 Java");
            writer.write("\n");
            writer.write("你好 IO");
        }
    }
}

数据过程:

"你好 Java"
      ↓
Java 字符
      ↓
OutputStreamWriter
      ↓ UTF-8 编码
byte
      ↓
FileOutputStream
      ↓
data.txt

3.6 使用 GBK 写出文本

import java.io.*;
import java.nio.charset.Charset;

public class WriteGbkDemo {

    public static void main(String[] args) throws IOException {

        Charset gbk = Charset.forName("GBK");

        try (
                Writer writer =
                        new OutputStreamWriter(
                                new FileOutputStream("gbk.txt"),
                                gbk
                        )
        ) {

            writer.write("你好 Java");
        }
    }
}

此时:

Java 字符
  ↓
GBK 编码
  ↓
GBK 字节
  ↓
gbk.txt

如果之后读取:

GBK 解码

就能恢复正确字符。


3.7 OutputStreamWriter + BufferedWriter

和输入端一样,输出端也可以继续包装缓冲流:

BufferedWriter bw =
        new BufferedWriter(
                new OutputStreamWriter(
                        new FileOutputStream("result.txt"),
                        StandardCharsets.UTF_8
                )
        );

结构:

BufferedWriter
      ↓
OutputStreamWriter
      ↓
FileOutputStream
      ↓
文件

职责:

BufferedWriter
缓冲 + newLine()

OutputStreamWriter
char → byte,负责编码

FileOutputStream
把 byte 写入文件

例如:

import java.io.*;
import java.nio.charset.StandardCharsets;

public class BufferedUtf8WriterDemo {

    public static void main(String[] args) throws IOException {

        try (
                BufferedWriter bw =
                        new BufferedWriter(
                                new OutputStreamWriter(
                                        new FileOutputStream("students.txt"),
                                        StandardCharsets.UTF_8
                                )
                        )
        ) {

            bw.write("1001,张三,Java");
            bw.newLine();

            bw.write("1002,李四,MySQL");
            bw.newLine();

            bw.write("1003,王五,Spring Boot");
        }
    }
}

四、原理与进阶

4.1 转换流为什么叫“桥梁”

Java IO 最根本的两大数据体系:

InputStream / OutputStream
              ↓
             byte


Reader / Writer
              ↓
             char

两套体系中间怎么办?

就是:

InputStreamReader

OutputStreamWriter

所以可以画出完整关系:

                    解码
InputStream ─────────────────→ Reader
             InputStreamReader


                    编码
Writer ─────────────────────→ OutputStream
             OutputStreamWriter

一句话记忆:

Reader 方向的转换流负责解码,Writer 方向的转换流负责编码。


4.2 Java 内部字符与文件编码不要混为一谈

Java 程序里面:

String name = "中国";

你日常操作的是:

Java 字符 / String

但是一旦:

写入文件

最终就必须变成:

byte

因此需要:

编码

读取文件则反过来:

byte
 ↓
解码
 ↓
Java 字符

所以理解转换流以后,可以把字符 IO 看成:

字符输入:

外部 byte
→ 解码
→ Java char/String


字符输出:

Java char/String
→ 编码
→ 外部 byte

这才是字符流真正的本质。


4.3 默认字符集为什么不能乱依赖

如果写:

new InputStreamReader(inputStream)

就使用默认字符集。

在 JDK 21 环境中,Java 默认字符集通常按 UTF-8 统一。

但工程实践中,只要:

外部数据的编码是明确的

最好也明确写:

StandardCharsets.UTF_8

原因不是代码写不出来,而是:

让编码契约显式化。

别人看到:

new InputStreamReader(
        in,
        StandardCharsets.UTF_8
)

立刻知道:

这个输入必须按照 UTF-8 解码。

比隐式依赖默认环境更加清楚。


4.4 JDK 21 中 FileReader 已经可以指定 Charset

这里需要补充一个现代 Java 很重要的变化。

以前很多教程解决:

指定编码读取文件

时会重点写:

new InputStreamReader(
        new FileInputStream("data.txt"),
        StandardCharsets.UTF_8
);

这种写法当然仍然完全成立。

但是现代 JDK 中:

FileReader

已经提供:

FileReader(String fileName, Charset charset)

因此 JDK 21 可以直接:

Reader reader =
        new FileReader(
                "data.txt",
                StandardCharsets.UTF_8
        );

写文件也一样:

Writer writer =
        new FileWriter(
                "data.txt",
                StandardCharsets.UTF_8
        );

所以如果只是:

文件 + 指定字符集

可以直接:

FileReader + Charset
FileWriter + Charset

代码更简单。

那么转换流是不是没用了?

当然不是。


4.5 转换流真正无法替代的价值

InputStreamReader 接收的是:

InputStream

而不是:

FileInputStream

也就是说:

任何字节输入流

都可以转换成:

字符输入流

以后网络编程会看到:

Socket socket = ...;

InputStream inputStream =
        socket.getInputStream();

如果网络上传输的是文本:

BufferedReader br =
        new BufferedReader(
                new InputStreamReader(
                        inputStream,
                        StandardCharsets.UTF_8
                )
        );

这时:

FileReader

就无能为力了。

因为数据来源根本不是文件。

所以转换流真正应该理解成:

通用 byte ↔ char 桥梁。

而不是:

“专门解决文件乱码的一个工具。”


4.6 FileReader 本身和 InputStreamReader 的关系

实际上:

FileReader

本身就是:

InputStreamReader

的子类。

关系:

Reader
   ↑
InputStreamReader
   ↑
FileReader

而:

FileWriter

的关系是:

Writer
   ↑
OutputStreamWriter
   ↑
FileWriter

所以你现在应该看到一个非常漂亮的体系:

InputStreamReader
解决通用 byte → char

FileReader
专门解决文件 byte → char


OutputStreamWriter
解决通用 char → byte

FileWriter
专门解决文件 char → byte

这就是继承与 IO 抽象体系真正结合起来的地方。


五、实践应用

5.1 GBK 文件转 UTF-8 文件

这是转换流非常经典的综合案例。

需求:

source-gbk.txt
      ↓
GBK 解码
      ↓
Java 字符
      ↓
UTF-8 编码
      ↓
target-utf8.txt

完整数据流:

source-gbk.txt
       ↓
FileInputStream
       ↓
InputStreamReader(GBK)
       ↓
BufferedReader
       ↓
String
       ↓
BufferedWriter
       ↓
OutputStreamWriter(UTF-8)
       ↓
FileOutputStream
       ↓
target-utf8.txt

代码:

import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class CharsetConvertDemo {

    public static void main(String[] args) throws IOException {

        Charset gbk = Charset.forName("GBK");

        try (
                BufferedReader br =
                        new BufferedReader(
                                new InputStreamReader(
                                        new FileInputStream("source-gbk.txt"),
                                        gbk
                                )
                        );

                BufferedWriter bw =
                        new BufferedWriter(
                                new OutputStreamWriter(
                                        new FileOutputStream("target-utf8.txt"),
                                        StandardCharsets.UTF_8
                                )
                        )
        ) {

            String line;

            while ((line = br.readLine()) != null) {

                bw.write(line);
                bw.newLine();
            }
        }
    }
}

这个程序真正做的是:

GBK byte
 ↓
GBK 解码
 ↓
Java 字符
 ↓
UTF-8 编码
 ↓
UTF-8 byte

这就是:

字符集转码。


5.2 网络文本通信

未来学习 Socket 时:

socket.getInputStream()

得到的是:

InputStream

如果协议传输文本,就可以:

BufferedReader br =
        new BufferedReader(
                new InputStreamReader(
                        socket.getInputStream(),
                        StandardCharsets.UTF_8
                )
        );

发送文本:

BufferedWriter bw =
        new BufferedWriter(
                new OutputStreamWriter(
                        socket.getOutputStream(),
                        StandardCharsets.UTF_8
                )
        );

因此今天学习转换流,其实已经在为:

Java 网络编程

铺路。


六、常见问题

6.1 InputStreamReader 是输入流还是输出流?

输入流。

继承关系:

Reader
 ↑
InputStreamReader

虽然名字里出现:

InputStream

但那表示:

它包装 InputStream。

最终得到的是:

Reader

所以它属于:

字符输入流

6.2 OutputStreamWriter 是字节流还是字符流?

字符输出流。

因为:

Writer
 ↑
OutputStreamWriter

它接收:

char/String

然后内部编码成:

byte

交给 OutputStream


6.3 为什么 FileInputStream 不能直接指定 UTF-8?

因为:

FileInputStream

只负责:

byte

在字节层面根本不存在:

这是中文
这是英文
这是 UTF-8 字符

这些概念。

只有开始:

byte → char

解码时,字符集才真正参与。

所以:

FileInputStream

不负责解码。


6.4 UTF-8 文件使用 GBK 读取为什么乱码?

因为发生:

UTF-8 byte
       ↓
按照 GBK 规则解释
       ↓
错误字符

错误不是:

Java 不认识中文

而是:

解码规则和编码规则不一致。


6.5 InputStreamReader 和 FileReader 到底怎么选?

如果数据源就是文件,并且使用现代 JDK:

new FileReader(
        file,
        StandardCharsets.UTF_8
)

更加直接。

如果底层已经是:

InputStream

例如:

Socket InputStream
HTTP 输入流
进程输入流
其他字节流

那么使用:

InputStreamReader

更加通用。

因此不要机械背:

指定编码 = InputStreamReader

应该理解为:

byte → char = InputStreamReader

文件 → char = FileReader 也可以

6.6 Charset 和 String 字符集名称怎么选?

两种形式都存在:

new InputStreamReader(in, "UTF-8");

以及:

new InputStreamReader(
        in,
        StandardCharsets.UTF_8
);

现代 Java 更推荐:

Charset

例如:

StandardCharsets.UTF_8

因为:

  • 不容易拼写错误;
  • 语义更加清晰;
  • 避免依赖字符集名称字符串;
  • API 类型更加明确。

6.7 转换流能解决所有乱码吗?

不能。

它只能解决:

你已经知道真实字符集,并且使用正确字符集进行解码的问题。

如果:

文件本身已经被错误编码

或者:

字节数据已经损坏

单纯改变:

InputStreamReader

不能神奇恢复数据。

所以乱码问题必须沿着:

原始字符
 ↓
编码
 ↓
字节
 ↓
存储/传输
 ↓
解码
 ↓
目标字符

整个链路排查。


七、练习与验收

7.1 知识问答

  • [ ] 什么是转换流?
  • [ ] Java 中两个核心转换流分别是什么?
  • [ ] InputStreamReader 的作用是什么?
  • [ ] OutputStreamWriter 的作用是什么?
  • [ ] InputStreamReader 属于字节流还是字符流?
  • [ ] OutputStreamWriter 属于字节流还是字符流?
  • [ ] 什么是编码?
  • [ ] 什么是解码?
  • [ ] 为什么编码与解码字符集不一致可能产生乱码?
  • [ ] 为什么转换流被称为字节流与字符流之间的桥梁?
  • [ ] JDK 21 中 FileReader 能否直接指定 Charset
  • [ ] 既然 FileReader 可以指定 Charset,为什么还需要 InputStreamReader
  • [ ] StandardCharsets.UTF_8"UTF-8" 两种写法有什么区别?
  • [ ] GBK 文件转换成 UTF-8 文件本质经历了哪些步骤?

7.2 代码阅读

不运行下面程序:

BufferedReader br =
        new BufferedReader(
                new InputStreamReader(
                        new FileInputStream("data.txt"),
                        StandardCharsets.UTF_8
                )
        );

回答:

  • [ ] FileInputStream 负责什么?
  • [ ] InputStreamReader 负责什么?
  • [ ] BufferedReader 负责什么?
  • [ ] 数据从文件进入 Java 程序一共经历了哪些转换?

阅读:

BufferedWriter bw =
        new BufferedWriter(
                new OutputStreamWriter(
                        new FileOutputStream("data.txt"),
                        StandardCharsets.UTF_8
                )
        );

回答:

  • [ ] 数据最终以字符还是字节形式保存在磁盘?
  • [ ] UTF-8 编码在哪一层发生?
  • [ ] BufferedWriter 的主要职责是什么?
  • [ ] FileOutputStream 的主要职责是什么?

7.3 手写代码

关闭 AI 自动补全,独立完成:

  • [ ] 使用 InputStreamReader 以 UTF-8 读取文本文件。
  • [ ] 使用 InputStreamReader 以 GBK 读取文本文件。
  • [ ] 使用 OutputStreamWriter 以 UTF-8 写出中文文本。
  • [ ] 使用 OutputStreamWriter 以 GBK 写出中文文本。
  • [ ] 使用 BufferedReader + InputStreamReader 按行读取 GBK 文件。
  • [ ] 使用 BufferedWriter + OutputStreamWriter 写出 UTF-8 文件。
  • [ ] 使用 JDK 21 的 FileReader + Charset 重写其中一个案例。

7.4 Debug

假设:

data.txt

真实编码为 GBK。

下面代码:

BufferedReader br =
        new BufferedReader(
                new InputStreamReader(
                        new FileInputStream("data.txt"),
                        StandardCharsets.UTF_8
                )
        );

要求:

  • [ ] 判断中文是否可能乱码。
  • [ ] 找出根本原因。
  • [ ] 修复代码。
  • [ ] 画出错误情况下“编码 → 解码”的数据链。

分析:

InputStreamReader reader =
        new InputStreamReader(
                new FileInputStream("a.jpg"),
                StandardCharsets.UTF_8
        );

要求:

  • [ ] 从语法层面是否可以建立该对象?
  • [ ] 从业务语义上是否合理?
  • [ ] 为什么图片等二进制文件不应该使用字符流处理?

7.5 综合训练

完成:

GBK 文本文件 → UTF-8 文本文件转换器

要求:

输入:
source-gbk.txt

输出:
target-utf8.txt

技术要求:

FileInputStream
InputStreamReader
BufferedReader

BufferedWriter
OutputStreamWriter
FileOutputStream

流程必须能够解释为:

GBK 字节
 ↓
GBK 解码
 ↓
Java 字符
 ↓
UTF-8 编码
 ↓
UTF-8 字节

完成代码后,再思考:

如果输入数据不是文件,而是 Socket.getInputStream(),整个程序需要修改哪一层?

7.6 本章验收

不查看资料,画出:

             InputStreamReader
InputStream ───────────────────→ Reader
          byte       解码       char


             OutputStreamWriter
Writer ────────────────────────→ OutputStream
       char        编码          byte

并能够口述:

编码是什么
解码是什么
为什么会乱码
InputStreamReader 做什么
OutputStreamWriter 做什么
BufferedReader 又做什么
为什么各种流可以嵌套组合

最后独立完成:

读取一个 GBK 文件,并将其转换成 UTF-8 文件。

如果这段代码不仅能写出来,而且能从:

字节 → 解码 → 字符 → 编码 → 字节

解释整个过程,那么转换流就已经真正掌握。