“一个 char 就是一个字符”——这个直觉在 Java 中是错误的。一个 emoji 👨‍👩‍👧‍👦 在 Java 中占 11 个 char,但在用户眼中只是一个字符。本文将从 Unicode 的基础概念出发,深入剖析 Java 的字符编码机制,揭示 char、码点、代码单元、字形簇之间的层次关系。


Part 1: Unicode 基础——字符集的大一统

从 ASCII 到 Unicode 的演进

编码 年份 字符数 位宽 覆盖范围
ASCII 1963 128 7 位 英文字母、数字、控制字符
ISO 8859-1 1987 256 8 位 西欧语言
GB2312 1980 6,763 汉字 双字节 简体中文
GBK 1995 21,886 汉字 双字节 简繁中文
Shift_JIS 1982 ~7,000 变长 日文
Unicode 1.0 1991 7,161 16 位(最初设想) 多语言
Unicode 16.0 2024 154,998 21 位(实际) 全球所有文字 + emoji

Unicode 之前的世界是编码丛林:每种语言有自己的编码标准,同一个字节序列在不同编码下表示不同的字符,导致**乱码(Mojibake)**无处不在。

Unicode 的目标:为世界上每一个字符分配一个唯一的编号(码点)

码点(Code Point)

码点是 Unicode 为每个字符分配的唯一编号,用 U+ 前缀加十六进制表示:

字符 码点 名称
A U+0041 LATIN CAPITAL LETTER A
U+4E2D CJK UNIFIED IDEOGRAPH-4E2D
😀 U+1F600 GRINNING FACE
𝄞 U+1D11E MUSICAL SYMBOL G CLEF

码点范围:U+0000 到 U+10FFFF,共 1,114,112 个位置(21 位)。

Unicode 平面

Unicode 将码点空间划分为 17 个平面(Plane),每个平面 65,536(2^16)个码点:

代码平面(Code Plane)

Unicode 码点空间被分为 17 个平面(Plane 0-16),每个平面容纳 65,536 个码点。这 17 个平面共能表示 1,114,112 个字符。

平面 名称 码点范围 说明 包含字符示例
Plane 0 BMP U+0000 - U+FFFF 基本多文种平面 绝大多数常用字符(中文、英文、emoji 等)
Plane 1 SMP U+10000 - U+1FFFF 补充多文种平面 历史文字、emoji、音乐符号等
Plane 2 SIP U+20000 - U+2FFFF 补充表意平面 扩展汉字
Plane 3-13 - U+30000 - U+DFFFF 保留 未使用
Plane 14 SSP U+E0000 - U+EFFFF 补充专用平面 用于私人自定义字符
Plane 15-16 PUA U+F0000 - U+10FFFF 私人使用区 用于私人自定义字符

BMP 之外的字符(U+10000 及以上)被称为增补字符(Supplementary Characters),它们在 Java 中需要两个 char 来表示。


Part 2: UTF-16 与 Java 的 char

为什么 Java 选择了 UTF-16

Java 诞生于 1995 年,当时 Unicode 还只有 65,536 个字符(BMP),设计者认为 16 位足以容纳所有字符。因此 Java 的 char 被设计为 16 位无符号整数,直接对应一个 Unicode 码点。

但 Unicode 2.0(1996 年)引入了增补平面,码点范围扩展到 U+10FFFF,超出了 16 位的表示能力。Java 不得不引入**代理对(Surrogate Pair)**机制来处理增补字符。

代理对(Surrogate Pair)

对于 BMP 之外的字符(U+10000 ~ U+10FFFF),UTF-16 使用两个 16 位代码单元来表示:

编码算法

  1. 将码点减去 0x10000,得到一个 20 位的值
  2. 高 10 位加上 0xD800,得到高代理(High Surrogate):范围 0xD800 ~ 0xDBFF
  3. 低 10 位加上 0xDC00,得到低代理(Low Surrogate):范围 0xDC00 ~ 0xDFFF

示例:😀(U+1F600)的编码

1
2
3
4
5
6
码点:0x1F600
减去 0x100000xF600 = 0000 1111 0110 0000 000020 位)
10 位:0000 1111 01 = 0x3D → 高代理 = 0xD800 + 0x3D = 0xD83D
10 位:10 0000 0000 = 0x200 → 低代理 = 0xDC00 + 0x200 = 0xDE00

UTF-16 编码:0xD83D 0xDE00

代码单元 vs 码点 vs 字形簇

这是理解 Java 字符处理的关键层次模型:

1
2
3
4
5
6
7
8
9
10
11
层次 4: 字形簇(Grapheme Cluster)—— 用户看到的"一个字符"
👨‍👩‍👧‍👦 = 1 个字形簇

层次 3: 码点(Code Point)—— Unicode 分配的编号
👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦 = 7 个码点

层次 2: 代码单元(Code Unit)—— Java 的 char
每个 emoji 码点 = 2char,ZWJ = 1char → 共 11char

层次 1: 字节(Byte)—— 存储单位
UTF-8 编码下每个 emoji = 4 字节 → 共 25 字节
概念 Java 类型 大小 示例
字节 byte 8 位 UTF-8 的一个字节
代码单元 char 16 位 UTF-16 的一个单元
码点 int 21 位(存在 32 位 int 中) U+1F600
字形簇 无内置类型 可变 👨‍👩‍👧‍👦

Java 中的陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String emoji = "😀";

// ❌ 错误的长度
System.out.println(emoji.length()); // 2(代码单元数)
System.out.println(emoji.charAt(0)); // ?(高代理,无意义的半个字符)
System.out.println(emoji.charAt(1)); // ?(低代理,无意义的半个字符)

// ✅ 正确的码点操作
System.out.println(emoji.codePointCount(0, emoji.length())); // 1
System.out.println(emoji.codePointAt(0)); // 128512 (0x1F600)

// ✅ 遍历码点
emoji.codePoints().forEach(cp -> {
System.out.println("U+" + Integer.toHexString(cp).toUpperCase());
});
// 输出:U+1F600

Part 3: UTF-8、UTF-16、UTF-32 对比

UTF-8 编码规则

UTF-8 是变长编码,使用 1-4 个字节表示一个码点:

码点范围 字节数 编码格式 示例
U+0000 ~ U+007F 1 0xxxxxxx A → 0x41
U+0080 ~ U+07FF 2 110xxxxx 10xxxxxx é → 0xC3 0xA9
U+0800 ~ U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx 中 → 0xE4 0xB8 0xAD
U+10000 ~ U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 😀 → 0xF0 0x9F 0x98 0x80

UTF-8 的优势

  • ASCII 兼容:ASCII 字符只需 1 字节,与 ASCII 编码完全相同
  • 无字节序问题:不需要 BOM(Byte Order Mark)
  • 自同步:UTF-8是一种变长编码方式,其特点在于能够通过前导字节和后续字节的不同模式来识别字符边界,从而实现对不同长度字符的有效解析与处理。

三种 UTF 编码对比

维度 UTF-8 UTF-16 UTF-32
代码单元大小 8 位 16 位 32 位
变长/定长 变长(1-4 字节) 变长(2 或 4 字节) 定长(4 字节)
ASCII 效率 最高(1 字节) 低(2 字节) 最低(4 字节)
中文效率 中(3 字节) 高(2 字节) 低(4 字节)
emoji 效率 4 字节 4 字节 4 字节
字节序 有(BE/LE) 有(BE/LE)
使用场景 网络传输、文件存储 Java/C# 内部 内部处理(少用)

BOM(Byte Order Mark)

UTF-16 和 UTF-32 有**大端(BE)小端(LE)**两种字节序。BOM 是文件开头的特殊字节,用于标识字节序:

编码 BOM 字节
UTF-8 EF BB BF(可选,不推荐)
UTF-16 BE FE FF
UTF-16 LE FF FE
UTF-32 BE 00 00 FE FF
UTF-32 LE FF FE 00 00

Java 的 InputStreamReader 不会自动处理 BOM,需要手动跳过或使用第三方库。


Part 4: Java 字符串的内部实现

Java 9 之前:char[] 存储

1
2
3
4
5
// Java 8 及之前
public final class String {
private final char[] value; // UTF-16 编码
private int hash;
}

每个字符固定占 2 字节,即使是纯 ASCII 字符串也是如此。

Java 9+:Compact Strings

Java 9 引入了**紧凑字符串(Compact Strings)**优化:

1
2
3
4
5
6
// Java 9+
public final class String {
private final byte[] value; // 底层存储
private final byte coder; // 编码标识:LATIN1(0) 或 UTF16(1)
private int hash;
}
coder 值 编码 每字符字节数 适用场景
0 (LATIN1) ISO 8859-1 1 字节 纯 ASCII/Latin-1 字符串
1 (UTF16) UTF-16 2 字节 包含非 Latin-1 字符

内存节省:对于英文为主的应用,字符串内存占用减少约 40-50%

字符串的不可变性

Java 的 String不可变的(immutable)

1
2
String s = "Hello";
s = s + " World"; // 创建了一个新的 String 对象,原来的 "Hello" 不变

不可变性的优势:

  • 线程安全:多线程可以安全地共享字符串
  • 哈希缓存:hashCode 只需计算一次
  • 字符串池:相同内容的字符串可以共享同一个对象
  • 安全性:作为 HashMap 的 key、类名、文件路径等不会被意外修改

字符串池(String Pool)

1
2
3
4
5
6
7
8
String s1 = "Hello";           // 字符串池中的对象
String s2 = "Hello"; // 同一个对象
String s3 = new String("Hello"); // 堆上的新对象
String s4 = s3.intern(); // 返回池中的对象

System.out.println(s1 == s2); // true(同一个对象)
System.out.println(s1 == s3); // false(不同对象)
System.out.println(s1 == s4); // true(intern 返回池中对象)

Java 7 之前,字符串池位于永久代(PermGen);Java 7+ 移至堆(Heap),避免了 PermGen OOM。

String.intern() 的性能陷阱与调优

String.intern() 方法会将字符串放入字符串池并返回池中的引用。在高性能场景下使用 intern() 需要注意以下问题:

性能陷阱

  1. 锁竞争:字符串池内部使用同步机制,高并发场景下可能成为瓶颈
  2. 内存压力:大量使用 intern() 会导致字符串池膨胀,增加 GC 压力
  3. Full GC 频繁:字符串池在老年代,频繁扩容可能触发 Full GC

调优参数

1
2
3
4
5
# 调整字符串池大小(默认 60013)
-XX:StringTableSize=100000

# 监控字符串池状态
-XX:+PrintStringTableStatistics

最佳实践

  • 仅对重复出现的高频字符串使用 intern()(如配置键、标识符)
  • 避免在循环中对动态生成的字符串调用 intern()
  • 考虑使用 ConcurrentHashMap 等替代方案实现自定义缓存

Part 5: 正确处理 Unicode 的 Java API

码点级别的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String text = "Hello 😀 世界";

// ❌ 错误:基于 char 的遍历
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i); // 可能得到半个代理对
}

// ✅ 正确:基于码点的遍历
text.codePoints().forEach(codePoint -> {
System.out.println(
"U+" + Integer.toHexString(codePoint).toUpperCase() +
" → " + new String(Character.toChars(codePoint))
);
});

// ✅ 正确:码点数量
int charCount = text.length(); // 10(代码单元数)
int codePointCount = text.codePointCount(0, text.length()); // 9(码点数)

码点与 char 的转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 码点 → char 数组
int codePoint = 0x1F600; // 😀
char[] chars = Character.toChars(codePoint);
// chars = {0xD83D, 0xDE00}(代理对)

// 判断是否是增补字符
boolean isSupplementary = Character.isSupplementaryCodePoint(codePoint); // true

// 判断是否是代理对的一部分
boolean isHighSurrogate = Character.isHighSurrogate('\uD83D'); // true
boolean isLowSurrogate = Character.isLowSurrogate('\uDE00'); // true

// 从代理对还原码点
int restored = Character.toCodePoint('\uD83D', '\uDE00'); // 0x1F600

字符串反转的正确实现

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 错误:朴素反转会拆散代理对
String broken = new StringBuilder("Hello 😀").reverse().toString();
// StringBuilder.reverse() 实际上已经正确处理了代理对!

// ✅ 手动实现(展示原理)
public static String reverseByCodePoints(String input) {
int[] codePoints = input.codePoints().toArray();
StringBuilder result = new StringBuilder();
for (int i = codePoints.length - 1; i >= 0; i--) {
result.appendCodePoint(codePoints[i]);
}
return result.toString();
}

注意:StringBuilder.reverse() 从 Java 1.5 开始就正确处理了代理对。

字符串截取的正确实现

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 危险:substring 可能从代理对中间截断
String text = "A😀B";
String sub = text.substring(0, 2); // "A" + 高代理(损坏的字符串!)

// ✅ 安全截取:按码点截取
public static String substringByCodePoints(String str, int beginIndex, int endIndex) {
int charBegin = str.offsetByCodePoints(0, beginIndex);
int charEnd = str.offsetByCodePoints(0, endIndex);
return str.substring(charBegin, charEnd);
}

substringByCodePoints("A😀B", 0, 2); // "A😀"

Part 6: 字形簇——用户眼中的"字符"

什么是字形簇

字形簇(Grapheme Cluster) 是用户在屏幕上看到的一个"视觉字符",它可能由多个码点组成:

字形簇 码点组成 码点数 char 数
é U+0065 + U+0301(e + 组合重音符) 2 2
👨‍👩‍👧‍👦 U+1F468 + U+200D + U+1F469 + U+200D + U+1F467 + U+200D + U+1F466 7 11
🇨🇳 U+1F1E8 + U+1F1F3(区域指示符 C + N) 2 4
U+1112 + U+1161 + U+11AB(ᄒ + ᅡ + ᆫ 组合) 3 3

ZWJ(零宽连接符)

ZWJ(Zero Width Joiner,U+200D) 是一个不可见字符,用于将多个 emoji 连接成一个复合 emoji:

1
2
3
4
👨 + ZWJ + 💻 = 👨‍💻(男性技术工作者)
👩 + ZWJ + 🔬 = 👩‍🔬(女性科学家)
👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦 = 👨‍👩‍👧‍👦(家庭)
🏳 + ZWJ + 🌈 = 🏳️‍🌈(彩虹旗)

组合字符与预组合字符

同一个视觉字符可能有多种 Unicode 表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 预组合形式(NFC)
String nfc = "\u00E9"; // é(单个码点 U+00E9)

// 组合形式(NFD)
String nfd = "e\u0301"; // e + 组合重音符(两个码点)

System.out.println(nfc.equals(nfd)); // false!
System.out.println(nfc.length()); // 1
System.out.println(nfd.length()); // 2

// 正确比较:先正规化
String normalizedNfc = Normalizer.normalize(nfc, Normalizer.Form.NFC);
String normalizedNfd = Normalizer.normalize(nfd, Normalizer.Form.NFC);
System.out.println(normalizedNfc.equals(normalizedNfd)); // true

Unicode 正规化形式

形式 全称 说明
NFC Canonical Decomposition + Canonical Composition 先分解再组合(推荐)
NFD Canonical Decomposition 完全分解
NFKC Compatibility Decomposition + Canonical Composition 兼容分解再组合
NFKD Compatibility Decomposition 兼容分解

NFC 是最常用的形式,Web 标准(HTML5)推荐使用 NFC。

Java 中处理字形簇

Java 标准库没有直接提供字形簇 API,但可以使用 BreakIterator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.text.BreakIterator;

String family = "👨‍👩‍👧‍👦";

// char 数量
System.out.println(family.length()); // 11

// 码点数量
System.out.println(family.codePointCount(0, family.length())); // 7

// 字形簇数量
BreakIterator iterator = BreakIterator.getCharacterInstance();
iterator.setText(family);
int graphemeCount = 0;
while (iterator.next() != BreakIterator.DONE) {
graphemeCount++;
}
System.out.println(graphemeCount); // 1(用户看到的字符数)

Part 7: 编码转换与乱码

Java 中的编码转换

1
2
3
4
5
6
7
8
9
10
11
12
String text = "你好世界";

// String → byte[](编码)
byte[] utf8Bytes = text.getBytes(StandardCharsets.UTF_8);
byte[] gbkBytes = text.getBytes(Charset.forName("GBK"));

System.out.println(utf8Bytes.length); // 12(每个汉字 3 字节)
System.out.println(gbkBytes.length); // 8(每个汉字 2 字节)

// byte[] → String(解码)
String fromUtf8 = new String(utf8Bytes, StandardCharsets.UTF_8); // 正确
String garbled = new String(utf8Bytes, Charset.forName("GBK")); // 乱码!

常见乱码场景与解决方案

场景 原因 解决方案
网页乱码 Content-Type 未指定或指定错误 设置 Content-Type: text/html; charset=UTF-8
数据库乱码 数据库/连接/表的字符集不一致 统一使用 utf8mb4(MySQL)
文件乱码 文件编码与读取编码不匹配 使用 BOM 或显式指定编码
控制台乱码 控制台编码与程序输出编码不匹配 设置 -Dfile.encoding=UTF-8

CharsetEncoder 与 CharsetDecoder 的错误处理策略

Java 提供了 CharsetEncoderCharsetDecoder 用于精细控制编码转换过程,支持多种错误处理策略:

CharsetEncoder 处理策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder();

// 1. REPLACE(默认):替换为替换字符(通常是 '?')
encoder.onMalformedInput(CodingErrorAction.REPLACE);
encoder.onUnmappableCharacter(CodingErrorAction.REPLACE);

// 2. REPORT:抛出 CharacterCodingException
encoder.onMalformedInput(CodingErrorAction.REPORT);
encoder.onUnmappableCharacter(CodingErrorAction.REPORT);

// 3. IGNORE:忽略错误字符
encoder.onMalformedInput(CodingErrorAction.IGNORE);
encoder.onUnmappableCharacter(CodingErrorAction.IGNORE);

// 使用示例
try {
ByteBuffer buffer = encoder.encode(CharBuffer.wrap("测试字符串"));
} catch (CharacterCodingException e) {
// 处理编码异常
}

CharsetDecoder 处理策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();

// 1. REPLACE:替换为替换字符(默认是 '\uFFFD')
decoder.onMalformedInput(CodingErrorAction.REPLACE);
decoder.onUnmappableCharacter(CodingErrorAction.REPLACE);

// 2. REPORT:抛出 CharacterCodingException
decoder.onMalformedInput(CodingErrorAction.REPORT);
decoder.onUnmappableCharacter(CodingErrorAction.REPORT);

// 3. IGNORE:忽略错误字节
decoder.onMalformedInput(CodingErrorAction.IGNORE);
decoder.onUnmappableCharacter(CodingErrorAction.IGNORE);

// 使用示例
try {
CharBuffer buffer = decoder.decode(ByteBuffer.wrap(bytes));
} catch (CharacterCodingException e) {
// 处理解码异常
}

错误类型说明

  • malformed-input:输入字节序列不符合编码规范(如 UTF-8 无效字节)
  • unmappable-character:字符无法映射到目标编码(如 GBK 无法表示的 Unicode 字符)

最佳实践

  • 生产环境建议使用 REPLACE 策略,避免因个别字符导致整个流程失败
  • 对数据完整性要求高的场景使用 REPORT 策略并显式处理异常
  • 自定义替换字符:encoder.replaceWith(new byte[]{(byte)'?'})

MySQL 的 utf8 vs utf8mb4

MySQL 的 utf8 编码实际上仅支持 3 字节(即 BMP 字符)。要存储 emoji 等增补字符,必须使用 utf8mb4

1
2
3
4
5
6
7
8
9
10
11
-- ❌ 错误:utf8 无法存储 emoji
CREATE TABLE messages (
content VARCHAR(255) CHARACTER SET utf8
);
INSERT INTO messages VALUES ('Hello 😀'); -- 报错或截断!

-- ✅ 正确:使用 utf8mb4
CREATE TABLE messages (
content VARCHAR(255) CHARACTER SET utf8mb4
);
INSERT INTO messages VALUES ('Hello 😀'); -- 正常存储

文件读写的编码处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ✅ 推荐:显式指定编码
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(
new FileInputStream("data.txt"),
StandardCharsets.UTF_8
))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}

// ✅ Java 11+ 简化写法
String content = Files.readString(Path.of("data.txt"), StandardCharsets.UTF_8);

// ❌ 危险:依赖平台默认编码
String content = Files.readString(Path.of("data.txt")); // 使用系统默认编码

Part 8: 正则表达式与 Unicode

Java 正则的 Unicode 支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 匹配任意 Unicode 字母(不仅是 ASCII)
Pattern unicodeLetter = Pattern.compile("\\p{L}+");
Matcher matcher = unicodeLetter.matcher("Hello 你好 مرحبا");
while (matcher.find()) {
System.out.println(matcher.group());
}
// 输出:Hello, 你好, مرحبا

// Unicode 类别
// \p{L} - 字母(Letter)
// \p{N} - 数字(Number)
// \p{P} - 标点(Punctuation)
// \p{S} - 符号(Symbol)
// \p{Z} - 分隔符(Separator)
// \p{M} - 标记(Mark,如组合重音符)
// \p{C} - 控制字符(Control)

// 匹配 emoji(Unicode 属性)
Pattern emojiPattern = Pattern.compile("[\\x{1F600}-\\x{1F64F}]");

UNICODE_CHARACTER_CLASS 标志

1
2
3
4
5
6
7
// 默认情况下,\w 只匹配 ASCII 字母数字
Pattern asciiWord = Pattern.compile("\\w+");
// 匹配 "Hello" 但不匹配 "你好"

// 启用 UNICODE_CHARACTER_CLASS 后,\w 匹配所有 Unicode 字母数字
Pattern unicodeWord = Pattern.compile("\\w+", Pattern.UNICODE_CHARACTER_CLASS);
// 匹配 "Hello" 和 "你好"

大小写不敏感匹配

1
2
3
4
5
6
7
// ASCII 大小写不敏感
Pattern.compile("hello", Pattern.CASE_INSENSITIVE);
// 匹配 "Hello", "HELLO" 但不匹配 "héllo"

// Unicode 大小写不敏感
Pattern.compile("hello", Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
// 正确处理各种语言的大小写转换

Part 9: 国际化(i18n)实践

Locale 与字符串比较

1
2
3
4
5
6
7
8
9
10
// 土耳其语的 I/i 问题
String text = "TITLE";
Locale turkish = new Locale("tr", "TR");

System.out.println(text.toLowerCase()); // "title"(英语环境)
System.out.println(text.toLowerCase(turkish)); // "tıtle"(土耳其语:I → ı,不是 i)

// 德语的 ß 问题
String german = "straße";
System.out.println(german.toUpperCase(Locale.GERMAN)); // "STRASSE"(ß → SS)

Collator:语言感知的排序

1
2
3
4
5
6
7
8
9
// 默认排序(基于 Unicode 码点)
List<String> words = Arrays.asList("café", "caff", "cache");
Collections.sort(words);
// [cache, café, caff](é 的码点大于 f)

// 法语排序
Collator frenchCollator = Collator.getInstance(Locale.FRENCH);
words.sort(frenchCollator);
// [cache, café, caff](法语中 é 排在 f 之前)

字符串长度限制的正确实现

在数据库或 API 中限制字符串长度时,需要明确"长度"的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static int countGraphemeClusters(String text) {
BreakIterator iterator = BreakIterator.getCharacterInstance();
iterator.setText(text);
int count = 0;
while (iterator.next() != BreakIterator.DONE) {
count++;
}
return count;
}

// 不同的"长度"
String text = "Hello 👨‍👩‍👧‍👦";
System.out.println(text.length()); // 17(char 数)
System.out.println(text.codePointCount(0, text.length())); // 13(码点数)
System.out.println(countGraphemeClusters(text)); // 7(字形簇数)
System.out.println(text.getBytes(StandardCharsets.UTF_8).length); // 31(UTF-8 字节数)

总结

层次 概念 Java 表示 大小 用途
字节 存储单元 byte 8 位 文件/网络 I/O
代码单元 编码单元 char 16 位 Java 内部存储
码点 Unicode 编号 int 21 位 字符标识
字形簇 视觉字符 BreakIterator 可变 用户界面

核心原则

  1. 不要假设一个 char 就是一个字符
  2. 遍历字符串时使用 codePoints() 而非 charAt()
  3. 比较字符串前先进行 Unicode 正规化
  4. 存储和传输统一使用 UTF-8
  5. 数据库使用 utf8mb4 而非 utf8

Java 17+ 的 Unicode 版本支持情况

Java 平台随着版本更新持续升级 Unicode 标准支持:

Java 版本 Unicode 版本 主要更新
Java 8 Unicode 6.2.0 基础 Unicode 支持
Java 9 Unicode 8.0.0 新增 7,500+ 字符,包括 emoji 5.0
Java 11 Unicode 10.0.0 新增 8,518 字符,包括 emoji 11.0
Java 12 Unicode 11.0.0 新增 684 字符,包括 emoji 11.0
Java 13 Unicode 12.1.0 新增 554 字符,包括 emoji 12.0
Java 15 Unicode 13.0.0 新增 5,930 字符,包括 emoji 13.0
Java 17 Unicode 13.0.0 长期支持版本
Java 21 Unicode 15.0.0 新增 4,489 字符,包括 emoji 15.0

重要影响

  • 新字符支持:新版本支持更多语言字符、符号和 emoji
  • 规范化变化:新版本可能引入新的规范化规则
  • 性能优化:新版本通常包含字符处理性能改进

版本兼容性建议

  • 需要最新 emoji 支持的应用建议使用 Java 21+
  • 企业级应用可使用 Java 17 LTS 版本(Unicode 13.0)
  • 如需处理特定 Unicode 版本内容,应验证目标 Java 版本的支持情况

检查当前 Unicode 版本

1
2
Character.UnicodeBlock block = Character.UnicodeBlock.of('你');
System.out.println("Unicode 版本信息可通过 Character 类获取");

参考资料