Java 的 char 是几个字节?为什么会出现乱码?Unicode 和 UTF-8 是什么关系?
简化版
Java 的 char 是 2 个字节(16 位),因为 Java 内部用 UTF-16 编码字符(一个 char 存一个 UTF-16 码元)。要分清几个概念:Unicode 是「字符集」——给世界上每个字符分配一个唯一编号(码点,如 ‘中’ = U+4E2D);UTF-8/UTF-16 是「编码方式」——把码点转成实际字节(UTF-8 是变长 1~4 字节、UTF-16 是 2 或 4 字节)。乱码的根本原因是「编码和解码用了不同的字符编码」——比如用 UTF-8 编码存的字节,用 GBK 去解码,就乱了。所以处理字符串、读写文件、网络传输时要统一编码(推荐 UTF-8)。另外一个坑:char 只有 2 字节,存不下 emoji 等「增补字符」(码点 > U+FFFF),它们要用两个 char(代理对) 表示。
详细版
三个核心概念的区别:
| 概念 | 是什么 | 例子 |
|---|---|---|
| 字符集(Unicode) | 给每个字符分配唯一编号(码点) | ‘中’ → U+4E2D、‘A’ → U+0041、’😀’ → U+1F600 |
| 编码(UTF-8/16/32) | 把码点转成实际字节的规则 | UTF-8 变长 1~4 字节、UTF-16 定长 2 或 4 字节 |
| 字节流 | 实际存储/传输的 0/1 | ’中’ 的 UTF-8 编码是 3 字节 E4 B8 AD |
为什么 Java 的 char 是 2 字节:
Java 内部用 UTF-16 存字符串(char[] / 早期)
UTF-16:基本平面字符(U+0000~U+FFFF,覆盖常用字符)用 1 个 char(2字节)表示
所以 char = 2 字节 = 一个 UTF-16 码元
但:char 只有 2 字节,存不下码点 > U+FFFF 的字符(emoji、生僻字)
→ 这些"增补字符"要用 2 个 char(代理对 surrogate pair)表示
乱码的原因与解决:
// 乱码的本质:编码和解码用了不同的字符集
byte[] bytes = "中文".getBytes("UTF-8"); // 用 UTF-8 编码成字节
String s = new String(bytes, "GBK"); // 用 GBK 解码 → 乱码!
// 正确:编码和解码用同一个字符集
String s2 = new String("中文".getBytes("UTF-8"), "UTF-8"); // ✓ 不乱
代理对(emoji 的坑):
String emoji = "😀"; // 码点 U+1F600,超出 U+FFFF
System.out.println(emoji.length()); // 2!不是 1——用了 2 个 char(代理对)
System.out.println(emoji.codePointCount(0, emoji.length())); // 1——按码点算才是 1 个字符
⚠️
String.length()返回的是 char 的个数(UTF-16 码元数)不是「字符数」——对于 emoji、部分生僻字(增补字符,用代理对表示),一个字符占 2 个 char,length()会返回 2。所以「统计字符个数」不能直接用length(),要用codePointCount();截取字符串也要小心别把代理对从中间截断(截断后是无效字符)。
完整版教学
一、先分清字符集和编码:两个不同的层次
字符处理里最容易混的是「字符集」和「编码」,它们是两个不同的层次:
字符集(Character Set):给字符编号
问题:"中" 这个字符,用什么数字代表它?
Unicode 的答案:'中' = U+4E2D(这个编号叫"码点 code point")
→ 字符集只管"字符 ↔ 编号"的映射,不管怎么存
编码(Encoding):把编号变成字节
问题:U+4E2D 这个编号,实际存成哪些字节?
UTF-8 的答案:3 个字节 E4 B8 AD
UTF-16 的答案:2 个字节 4E 2D
→ 编码只管"编号 ↔ 字节"的转换
关键区别:Unicode 是「字符集」(字符→编号),UTF-8/UTF-16 是「编码」(编号→字节)。Unicode 只回答「这个字符是几号」,UTF-8/16 回答「这个号码怎么存成字节」。很多人把「Unicode」和「UTF-8」混为一谈——其实 Unicode 是一套「字符编号表」,UTF-8/16/32 是这套编号的不同「存储方案」。理解「字符集给编号、编码把编号变字节」这两层,就理解了后面所有问题——乱码是编码层的错、char 大小是编码层的选择。
二、为什么 char 是 2 字节:UTF-16
Java 的 char 固定 2 字节(16 位),这是因为 Java 内部用 UTF-16 表示字符:
Java 诞生时(1995),Unicode 只有 65536 个字符(U+0000~U+FFFF,2 字节够用)
→ Java 就设计 char = 2 字节 = 一个 UTF-16 码元,能存一个字符
→ 当时"一个 char = 一个字符"是成立的
后来 Unicode 扩展到 100 多万个字符(增补字符,码点 > U+FFFF)
→ 2 字节的 char 存不下了!
→ 这些字符用"代理对"(2 个 char)表示(下面详述)
→ 于是"一个 char = 一个字符"不再总成立
所以 char 是 2 字节是「历史设计 + UTF-16」的结果——Java 早期假设「2 字节能存所有字符」,char 就定成 2 字节。这个假设在 Unicode 扩展后失效了(emoji 等存不下),但 char 的大小已经无法改(兼容性),只能用「代理对」打补丁。理解「char = 2 字节是因为 Java 内部用 UTF-16、早期够用」,就理解了这个大小的来历,也为「emoji 的坑」埋下了伏笔。
三、UTF-8 vs UTF-16 vs UTF-32:编码方案对比
同一套 Unicode 编号,有多种编码方案,各有取舍:
| 编码 | 字节数 | 特点 | 适用 |
|---|---|---|---|
| UTF-8 | 变长 1~4 字节 | ASCII 兼容(英文 1 字节)、无字节序问题 | 网络/文件传输主流 |
| UTF-16 | 2 或 4 字节 | 常用字符 2 字节、有字节序(BOM) | Java/Windows 内部 |
| UTF-32 | 定长 4 字节 | 定长好处理但费空间 | 很少用 |
UTF-8 变长的巧妙:
ASCII 字符(英文、数字)→ 1 字节(和 ASCII 完全兼容,省空间)
中文等常用字符 → 3 字节
emoji 等增补字符 → 4 字节
→ 英文为主的场景省空间、且兼容老的 ASCII 系统
为什么网络/文件用 UTF-8:
① ASCII 兼容(老系统能认)② 无字节序问题(UTF-16 有大小端 BOM 麻烦)
③ 省空间(英文场景)→ 成了事实标准
选型:UTF-8 是网络传输、文件存储的主流(兼容、无字节序、省空间);UTF-16 是 Java/Windows 的内部表示(Java 字符串内部用它)。UTF-32 定长但费空间、很少用。理解「UTF-8 变长兼容 ASCII 是网络/文件主流、UTF-16 是 Java 内部」,就知道该用哪个——对外统一 UTF-8,Java 内部是 UTF-16 你不用管。
四、乱码的根本原因:编解码不一致
乱码是字符处理最常见的问题,它的根本原因只有一个——编码和解码用了不同的字符编码:
字符串在计算机里经历:"字符" ⇄ "字节" 的转换:
编码(encode):字符 → 字节(存文件、发网络时)
解码(decode):字节 → 字符(读文件、收网络时)
乱码 = 编码用 A、解码用 B(A ≠ B):
"中文" --UTF-8 编码--> 字节 [E4 B8 AD E6 96 87]
--GBK 解码--> 用 GBK 规则去解释这些字节 → 解释成别的字符 → 乱码
正确 = 编码解码用同一个字符集:
"中文" --UTF-8 编码--> 字节 --UTF-8 解码--> "中文" ✓
关键认知:乱码不是「字节坏了」,而是「用错了解码规则」——同一串字节,用不同编码规则解读会得到不同字符。所以避免乱码的唯一办法是「全链路统一编码」:文件用什么编码存、就用什么编码读;网络请求声明什么编码、就用什么编码解。常见乱码场景:① 文件读写没指定编码(用了平台默认编码,不同机器不一样);② HTTP 请求响应编码不一致(Content-Type 的 charset);③ 数据库连接编码不匹配。解决方案统一是「处处显式指定 UTF-8」。理解「乱码是编解码不一致、解决靠全链路统一 UTF-8」,就掌握了乱码问题的根治方法。
五、代理对:emoji 和生僻字的坑
char 只有 2 字节,存不下码点 > U+FFFF 的「增补字符」(emoji、生僻字),Java 用 代理对(surrogate pair) 解决——用 2 个 char 表示一个增补字符:
String emoji = "😀"; // U+1F600,超出 U+FFFF
emoji.length(); // 2!——用了 2 个 char(一个代理对)
emoji.charAt(0); // 高代理项(无意义的半个字符)
emoji.codePointCount(0, emoji.length()); // 1——按码点算才是 1 个字符
// 遍历 emoji 字符串要按码点,不能按 char
emoji.codePoints().forEach(cp -> ...); // 正确按码点遍历
代理对机制:UTF-16 用「一对特殊的 char(高代理 + 低代理)」组合表示一个增补字符——增补字符占 2 个 char。这带来一系列坑:① length() 返回 char 数不是字符数(emoji 算 2);② charAt(i) 可能取到半个代理项(无意义);③ 截取字符串可能从代理对中间截断(得到无效字符);④ 反转字符串会破坏代理对。所以处理「可能含 emoji/生僻字」的字符串时,要用码点相关的 API(codePointCount、codePoints()、offsetByCodePoints)而非 char 相关的(length、charAt)。理解「代理对用 2 个 char 表示增补字符、导致 length ≠ 字符数」,就避开了 emoji 处理的坑(现在 emoji 无处不在,这个坑越来越常见)。
六、工程实践:统一编码
字符编码问题的工程解法就一句话——全链路统一 UTF-8、处处显式指定:
文件读写:显式指定编码,别用平台默认
new InputStreamReader(in, StandardCharsets.UTF_8) // ✓ 显式 UTF-8
new FileReader(file) // ✗ 用平台默认编码,隐患
字符串 ⇄ 字节:显式指定编码
str.getBytes(StandardCharsets.UTF_8) // ✓
str.getBytes() // ✗ 平台默认
HTTP:Content-Type 声明 charset=UTF-8
数据库:连接串和表都用 utf8mb4(MySQL 的 utf8mb4 才能存 emoji!)
JVM:-Dfile.encoding=UTF-8 兜底
核心原则:① 处处显式指定 UTF-8(别依赖「平台默认编码」——不同操作系统默认不同,是乱码的常见根源);② 用 StandardCharsets.UTF_8 常量(避免拼错编码名字符串);③ MySQL 存 emoji 要用 utf8mb4(MySQL 的 utf8 其实是 3 字节、存不下 4 字节的 emoji,utf8mb4 才是真正的 UTF-8)。这些实践的共同点是「消除编码的不确定性」——把「用哪个编码」明确定死为 UTF-8,全链路一致,就不会乱码。理解「工程上处处显式 UTF-8、注意 MySQL 的 utf8mb4」,就能在真实项目里根治编码问题。
记忆钩子:「char 是 2 字节(Java 内部 UTF-16);Unicode 是字符集(给字符编号码点)、UTF-8/16 是编码(编号→字节,UTF-8 变长兼容 ASCII 是网络主流);乱码=编解码用了不同编码,解决靠全链路统一 UTF-8 显式指定;char 存不下 emoji(>U+FFFF),用 2 个 char 代理对表示,导致 length ≠ 字符数,要用 codePoint API;MySQL 存 emoji 用 utf8mb4」。
七、常见误区与追问
- 误区:Unicode 就是 UTF-8。 不是——Unicode 是字符集(给字符分配编号/码点),UTF-8 是编码(把码点转成字节);Unicode 是「编号表」、UTF-8/16/32 是它的不同「存储方案」。
- 误区:一个 char 就是一个字符。 不总是——char 是 2 字节的 UTF-16 码元,emoji 等增补字符(码点 > U+FFFF)要用 2 个 char(代理对)表示,此时一个字符占 2 个 char。
- 误区:
String.length()返回字符个数。 返回 char 个数(UTF-16 码元数)——含 emoji 时一个字符算 2,要数字符个数用 codePointCount()。 - 误区:乱码是字节损坏了。 不是——字节没坏,是「解码用了和编码不同的字符集」(如 UTF-8 编码用 GBK 解码),同一串字节用不同规则解读得到不同字符。
- 追问:为什么 Java 的 char 是 2 字节? Java 内部用 UTF-16,char = 一个 UTF-16 码元 = 2 字节;早期 Unicode 只有 65536 个字符(2 字节够),后来扩展了但 char 大小无法改,增补字符改用代理对。
- 追问:MySQL 存 emoji 为什么要用 utf8mb4 不用 utf8? MySQL 的 utf8 其实是最多 3 字节的阉割版,存不下 4 字节的 emoji(增补字符);utf8mb4 才是真正的 UTF-8(最多 4 字节),能存 emoji。
- 追问:怎么正确处理含 emoji 的字符串? 用码点相关 API——codePointCount() 数字符、codePoints() 遍历、offsetByCodePoints() 定位,别用 length()/charAt()(会把代理对当 2 个 char,且可能取到半个代理项)。
八、加强记忆
Java 的 char 是 2 字节(16 位),因为 Java 内部用 UTF-16 编码(char = 一个 UTF-16 码元)。要分清三层:Unicode 是「字符集」(给每个字符分配唯一编号/码点,如 ‘中’=U+4E2D)、UTF-8/UTF-16 是「编码」(把码点转成实际字节,UTF-8 变长 1~4 字节兼容 ASCII、是网络/文件主流,UTF-16 是 Java 内部)、字节流是实际存储。乱码的根本原因是「编码和解码用了不同的字符编码」(如 UTF-8 编码的字节用 GBK 解码),字节没坏、是解读规则错了,解决靠全链路统一 UTF-8、处处显式指定(别依赖平台默认编码、用 StandardCharsets.UTF_8)。一个大坑:char 只 2 字节,存不下 emoji 等增补字符(码点 > U+FFFF),它们用 2 个 char(代理对)表示,导致 length() 返回 char 数 ≠ 字符数(emoji 算 2)、charAt 可能取半个代理项、截取可能截断代理对——处理含 emoji 的字符串要用 码点 API(codePointCount/codePoints)。工程实践:处处显式 UTF-8、MySQL 存 emoji 用 utf8mb4(普通 utf8 是 3 字节存不下)。一句话「char 2 字节 UTF-16、Unicode 是字符集 UTF-8 是编码、乱码是编解码不一致靠统一 UTF-8、emoji 用代理对导致 length≠字符数、MySQL 用 utf8mb4」。