1. 概述

在本教程中,我们将讨论字符编码的基础知识,以及如何在 Java 中正确处理字符编码。

2. 字符编码的重要性

我们经常需要处理多种语言的文本,比如拉丁文、阿拉伯文等。每种语言中的每个字符都需要被映射为一组二进制数字(0 和 1)。令人惊讶的是,计算机竟然能准确处理全球各种语言的文本。

要做到这一点,我们必须关注字符编码。忽视字符编码往往会导致数据丢失,甚至引发安全漏洞。

为了更好地理解这个问题,我们先定义一个 Java 方法用于解码文本:

String decodeText(String input, String encoding) throws IOException {
    return 
      new BufferedReader(
        new InputStreamReader(
          new ByteArrayInputStream(input.getBytes()), 
          Charset.forName(encoding)))
        .readLine();
}

注意,这里的输入文本使用的是平台默认编码。

如果我们将 input 设置为 “The façade pattern is a software design pattern.”,并将 encoding 设置为 “US-ASCII”,输出结果如下:

The faade pattern is a software design pattern.

显然,这不是我们期望的结果。

到底哪里出了问题?我们将在后续内容中逐步分析并解决这个问题。

3. 编码基础概念

在深入之前,我们先快速回顾三个术语:encoding(编码)、charsets(字符集)和 code point(码点)。

3.1. 编码

计算机只能理解二进制(0 和 1),因此要处理任何文本,都需要一种将真实世界字符映射为二进制的方式。这种映射就是我们所说的字符编码

例如,消息中的第一个字母 “T” 在 US-ASCII 中编码为 “01010100”。

3.2. 字符集

字符到二进制的映射范围可以非常广泛,从仅包含少量字符到涵盖所有实际使用的字符。字符集(charset)就是编码中所包含字符的集合

例如,ASCII 字符集包含 128 个字符

3.3. 码点

码点(code point)是一种抽象概念,用于将字符与其编码形式分离。码点是一个整数,用于唯一标识某个字符

我们可以用十进制或十六进制等进制来表示这个整数。例如,Unicode 中字符 “T” 的码点是 “U+0054”(十进制为 84)。

4. 编码方案解析

字符编码的形式取决于它所支持的字符数量。

字符数量越多,所需的二进制表示就越长,通常以字节为单位。字符数量增加意味着需要更长的二进制表示

下面介绍几种常见的编码方案。

4.1. 单字节编码

最早的编码方案之一是 ASCII(美国信息交换标准代码),它采用单字节编码。这意味着 ASCII 中的每个字符都用 7 位二进制数表示,每字节还剩一位未使用!

ASCII 的 128 个字符包括大小写英文字母、数字和一些特殊字符及控制字符。

我们定义一个简单的 Java 方法,用于显示字符在特定编码下的二进制表示:

String convertToBinary(String input, String encoding) 
      throws UnsupportedEncodingException {
    byte[] encoded_input = Charset.forName(encoding)
      .encode(input)
      .array();  
    return IntStream.range(0, encoded_input.length)
        .map(i -> encoded_input[i])
        .mapToObj(e -> Integer.toBinaryString(e ^ 255))
        .map(e -> String.format("%1$" + Byte.SIZE + "s", e).replace(" ", "0"))
        .collect(Collectors.joining(" "));
}

字符 “T” 在 US-ASCII 中的码点是 84(Java 中 ASCII 被称为 US-ASCII)。

使用上述方法,我们可以看到它的二进制表示:

assertEquals(convertToBinary("T", "US-ASCII"), "01010100");

这正是字符 “T” 的 7 位二进制表示。

原始 ASCII 未使用每个字节的最高位。同时,ASCII 未能表示很多非英文字符。

为了解决这个问题,人们开始利用未使用的最高位,增加额外的 128 个字符。

ASCII 有多种扩展版本,统称为 “ASCII 扩展”。

其中最流行的是 ISO-8859-1,也称为 “ISO Latin 1”。

4.2. 多字节编码

随着需要支持的字符数量增加,单字节编码如 ASCII 已无法满足需求。

多字节编码应运而生,虽然占用更多空间,但支持更大的字符集。

BIG5 和 SHIFT-JIS 是典型的多字节编码方案,它们使用 1 或 2 个字节来表示更广泛的字符集,主要用于中文等字符数量庞大的语言。

我们使用 convertToBinary 方法,输入中文字符 “語”,编码为 “Big5”:

assertEquals(convertToBinary("語", "Big5"), "10111011 01111001");

输出显示 Big5 使用两个字节表示字符 “語”。

国际数字分配机构(IANA)维护了一个 字符编码列表

5. Unicode

虽然编码很重要,但解码同样关键,否则就无法正确理解字符。这只有在广泛使用一致或兼容的编码方案时才能实现

不同地区独立开发的编码方案带来了兼容性问题。

因此,Unicode 应运而生,它是一个统一的编码标准,能够表示世界上所有字符,包括已废弃的字符。

Unicode 的码点数量庞大,需要多个字节来存储。为此,Unicode 提供了多种编码方案。

5.1. UTF-32

UTF-32 是一种固定使用 4 个字节表示每个 Unicode 码点的编码方案。显然,它空间效率较低。

使用 convertToBinary 方法查看字符 “T” 在 UTF-32 中的表示:

assertEquals(convertToBinary("T", "UTF-32"), "00000000 00000000 00000000 01010100");

可以看到,前三个字节完全是浪费的空间。

5.2. UTF-8

UTF-8 是一种变长编码方案,通常使用 1 个字节,必要时可扩展到更多字节,从而节省空间。

再次使用 convertToBinary 方法查看字符 “T” 在 UTF-8 中的表示:

assertEquals(convertToBinary("T", "UTF-8"), "01010100");

结果与 ASCII 完全一致。实际上,UTF-8 与 ASCII 完全向后兼容。

查看字符 “語” 在 UTF-8 中的表示:

assertEquals(convertToBinary("語", "UTF-8"), "11101000 10101010 10011110");

UTF-8 使用 3 个字节表示字符 “語”,这就是所谓的 变长编码

由于其空间效率,UTF-8 是网络上最常见的编码

5.3. UTF-8 与 UTF-16 的区别

UTF-8 和 UTF-16 是两种常见的编码方案,它们的主要区别在于使用的字节数量:

  • UTF-8 最少使用 1 个字节,UTF-16 最少使用 2 个字节。
  • 仅使用 ASCII 字符时,UTF-16 文件大小是 UTF-8 的两倍。
  • UTF-8 的字符长度不固定,索引和计数较慢;UTF-16 对 BMP 字符处理更快。
  • UTF-8 无字节序问题,不需要 BOM;UTF-16 通常需要 BOM 来标识字节序。
  • UTF-8 保证不会出现 NULL 字节(除非编码空字符),兼容性更好。

✅ 总结:UTF-16 更适合内存中的表示,UTF-8 更适合文件和网络传输。

6. Java 中的字符编码支持

Java 支持多种字符编码及其相互转换。Charset 类定义了 Java 平台必须支持的标准编码,包括 US-ASCII、ISO-8859-1、UTF-8 和 UTF-16。

6.1. Java 18 之前的默认字符集

Java 平台依赖一个称为 默认字符集 的属性。JVM 在启动时确定默认字符集

它依赖于运行 JVM 的操作系统的区域设置和字符集。例如,在 macOS 上,默认字符集是 UTF-8。

查看默认字符集的方法:

Charset.defaultCharset().displayName();

在 Windows 上运行该代码,输出可能是:

windows-1252

6.2. 默认字符集的使用场景

许多 Java API 使用 JVM 确定的默认字符集,例如:

  • InputStreamReaderFileReader
  • OutputStreamWriterFileWriter
  • FormatterScanner
  • URLEncoderURLDecoder

如果未显式指定字符集,这些 API 会使用默认字符集。

6.3. 默认字符集的问题

默认字符集在不同操作系统上可能不同,导致跨平台时出现问题。

例如,同一段代码在 macOS 上使用 UTF-8,在 Windows 上使用 Windows-1252,可能导致数据损坏。

6.4. 可以覆盖默认字符集吗?

可以通过以下系统属性尝试覆盖默认字符集:

-Dfile.encoding="UTF-8"
-Dsun.jnu.encoding="UTF-8"

⚠️ 但这些属性是只读的,覆盖可能导致不可预测的行为。

6.5. 解决方案

显式指定字符集,而不是依赖默认设置。

修改我们的示例代码,使用 UTF-8 编码:

new BufferedReader(new InputStreamReader(new ByteArrayInputStream(input.getBytes()), "UTF-8")).readLine();

再次运行 decodeText 方法,编码设置为 “UTF-8”:

The façade pattern is a software-design pattern.

✅ 成功显示正确结果!

6.6. Java 18 及以后的默认字符集

JEP 400 解决了默认字符集问题。Java 18 将 UTF-8 设为默认字符集。

✅ 推荐使用以下命令检查旧版本中的字符集问题:

java -Dfile.encoding=UTF-8

6.7. MalformedInputException

解码字节序列时,如果遇到非法字符,可能抛出 MalformedInputException

Java 提供三种处理策略:

  • IGNORE:忽略非法字符
  • REPLACE:替换为默认字符(如 )
  • REPORT:抛出异常

示例代码:

String decodeText(String input, Charset charset, 
  CodingErrorAction codingErrorAction) throws IOException {
    CharsetDecoder charsetDecoder = charset.newDecoder();
    charsetDecoder.onMalformedInput(codingErrorAction);
    return new BufferedReader(
      new InputStreamReader(
        new ByteArrayInputStream(input.getBytes(charset)), charsetDecoder)).readLine();
}

✅ 注意:编码参数应同时传递给 InputStreamReadergetBytes() 方法。

7. 其他需要注意编码的地方

字符编码不仅在编程中重要,在以下场景中也需注意:

7.1. 文本编辑器

文本编辑器是文本的源头,选择正确的编码至关重要。

7.2. 文件系统

不同操作系统对字符编码的支持不同,可能导致数据丢失。

7.3. 网络传输

使用 FTP 等协议传输文本时,编码转换可能引发问题。建议以二进制方式传输 Unicode 文本。

7.4. 数据库

主流数据库如 Oracle 和 MySQL 支持多种字符编码。选择不当会导致数据损坏。

7.5. 浏览器

浏览器也需正确设置字符编码以正确显示文本。

8. 结论

本文讨论了字符编码在编程中的重要性,介绍了编码基础、常见编码方案及 Java 中的处理方法。

通过示例演示了错误使用编码的后果及解决方案,并列举了其他常见问题场景。

✅ 代码示例可在 GitHub 获取。


原始标题:Guide to Character Encoding | Baeldung