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)就是编码中所包含字符的集合。
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 确定的默认字符集,例如:
InputStreamReader
和FileReader
OutputStreamWriter
和FileWriter
Formatter
和Scanner
URLEncoder
和URLDecoder
如果未显式指定字符集,这些 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();
}
✅ 注意:编码参数应同时传递给 InputStreamReader
和 getBytes()
方法。
7. 其他需要注意编码的地方
字符编码不仅在编程中重要,在以下场景中也需注意:
7.1. 文本编辑器
文本编辑器是文本的源头,选择正确的编码至关重要。
7.2. 文件系统
不同操作系统对字符编码的支持不同,可能导致数据丢失。
7.3. 网络传输
使用 FTP 等协议传输文本时,编码转换可能引发问题。建议以二进制方式传输 Unicode 文本。
7.4. 数据库
主流数据库如 Oracle 和 MySQL 支持多种字符编码。选择不当会导致数据损坏。
7.5. 浏览器
浏览器也需正确设置字符编码以正确显示文本。
8. 结论
本文讨论了字符编码在编程中的重要性,介绍了编码基础、常见编码方案及 Java 中的处理方法。
通过示例演示了错误使用编码的后果及解决方案,并列举了其他常见问题场景。
✅ 代码示例可在 GitHub 获取。