char 和 byte 的根本区别,如何遍历 String

1 阅读5分钟

String 类中的 byte[] value

在String 类中 byte 是存储单位,char 是字符单位,两者不是一回事——所以不能通过遍历 byte 来遍历 String 中的每一个元素。

char 和 byte 的根本区别

charbyte
位宽16 位无符号8 位有符号
范围0 ~ 65535-128 ~ 127
语义一个 UTF-16 码元(半个或整个字符)一个原始字节,没有字符含义
C 类比unsigned short / wchar_tsigned char / int8_t
默认值'\u0000'0

关键点:char 是 Java 里唯一的无符号类型,它是为 Unicode 设计的;而 byte 只是 8 个 bit,跟 "字符" 没有任何绑定关系,只有当全是 ASCII 字符时,String 才使用一个 byte 存储一个字符,但是 byte[]private ,实际上也不能直接遍历,我们可以通过 getbytes() 方法返回一个新分配的 byte[],它是通过指定编码格式重新编码后的副本,不是 value 的引用,因此这个 byte[] 是可以修改的。

String 从 char[] 改成 byte[] 是怎么回事

JDK 9 之前 String 内部是 private final char[] value,每个字符雷打不动占 2 字节。但统计发现,实际应用中 绝大多数字符串只含 ASCII 字符,高 8 位永远是 0,纯属浪费。于是 JDK 9 引入 Compact Strings:

private final byte[] value;   // 存储
private final byte coder;     // 编码标记:0 = LATIN1,1 = UTF16

构造 String 时先试着用 LATIN1 编码,只要所有字符都 ≤ 0xFF 就存成 1 字节/字符;一旦出现中文、emoji,整串自动切换成 UTF16,退回 2 字节/字符。

1

图上第二个例子是重点:Compact Strings 只对纯 ASCII 有效,混进一个中文就整串退化。使用 AI 实测了 200 万个 100 字符的字符串(字符数完全相同):

LATIN1 (100个ASCII字符 × 200 万个): 约 290 MB
UTF16  (100个中文字符 × 200 万个): 约 488 MB

byte[] 本身从 120 字节涨到 216 字节,正好翻倍;加上对象头的固定开销,整体差了约 40%~68%。

能不能直接遍历 byte?以及所有字符都只占两个或两个字节一下吗?

1. 你根本拿不到。 valueprivate final,想拿得靠反射,而且 JDK 17+ 之后模块封装还会直接抛 InaccessibleObjectException,必须加 --add-opens java.base/java.lang=ALL-UNNAMED

2. 拿到了也读不懂。 光有 byte[] 不知道 coder 是 0 还是 1,同一个字节序列在两种编码下含义完全不同。

3. 更根本的问题:byte 是编码产物,不是字符。 实测输出:

原串: a你😀   length()=4   码点数=3   UTF-8字节数=8
  getBytes()  : 61 E4 BD A0 F0 9F 98 80   <- 这是字节,不是字符

同一个字符串里,a 占 1 字节、 占 3 字节、😀 占 4 字节。UTF-8 是变长编码,字节边界和字符边界完全对不上。而 getBytes() 返回的是一份新拷贝,跟 String 内部的 byte[] 毫无关系。

最常用的遍历函数 charAt()toCharArray()

for (int i = 0; i < s.length(); i++) {
    char c = s.charAt(i);
}

特点

  1. 不创建新数组
  2. 每次调用都从 String 里取第 i 个 UTF-16 code unit。
  3. Java 9+ 中 String 内部是 byte[] + coder,charAt() 每次可能要判断编码并解码。
  4. emoji、部分生僻字会被拆成两个 char。
char[] arr = s.toCharArray();
for (char c : arr) {
}

特点

  1. 会创建一个新的 char[],把字符串内容复制进去,有额外的内存开销
  2. 之后遍历的是普通数组,访问很快。
  3. 数组可以修改,但修改不会影响原 String。

正确的遍历方式

场景推荐写法
确定只有 ASCII / 中文(99% 业务代码)charAt()toCharArray()
可能含 emoji、生僻字(U+FFFF 以上)codePoints()推荐
要按"用户看到的一个字"处理(排版、截断、光标)BreakIterator
网络传输 / 文件 IOgetBytes(StandardCharsets.UTF_8)

为什么 charAt 会出事

charAt      : a(U+0061) 你(U+4F60) ?(U+D83D) ?(U+DE00)

😀 是 U+1F600,超出 65535,UTF-16 只能用两个 char 拼(代理对 D83D + DE00)。你按 char 遍历,一个 emoji 会被掰成两半,中间那半单独拿出来是个非法字符,打印出来就是问号。

换成 codePoints() 就正常了:

codePoints(): a(U+0061)(U+4F60) 😀(U+1F600)
// 推荐写法:按码点遍历,能正确处理 emoji 和生僻字
s.codePoints().forEach(cp -> {
    System.out.printf("%s (U+%04X)%n", new String(Character.toChars(cp)), cp);
});

// 需要带索引统计字符数(注意 count 要用码点数,不是 length())
int count = s.codePointCount(0, s.length());   // a你😀 -> 3,而 s.length() 是 4

总结

byte存储层的字节(变长、无语义),char字符层的 UTF-16 码元(定长、emoji 会占两个),码点才是你心里的"一个字符"。遍历 String 请忘掉 byte,无脑用 codePoints()

  1. char(16 位无符号)是字符单位,byte(8 位有符号)是存储单位,两者不在同一层。
  2. Compact Strings 只对纯 ASCII 省空间,串里混进一个中文就整串退化为 UTF16
  3. 遍历 String,无脑用 codePoints()

另外:s.length() 返回的是 char 个数不是字符个数,a你😀 长度是 4 而实际只有 3 个字符。以后做"限制输入 10 个字"这类需求,用 length() 会出现用户输 5 个 emoji 就被截断的 bug。