bigywater.tech
Java 内部原理 · 120

String代理对真实验证:substring()真实能把一个emoji从中间切断,留下无法显示的半个字符

Java的char是16位的,装不下所有Unicode字符——这篇真实用一个emoji验证代理对(surrogate pair)机制,以及charAt()、substring()这些"看起来安全"的方法,真实是怎么把一个完整字符从中间切断的。

单个emoji的length()真实是2
真实验证:一个emoji字符串.length()真实返回2不是1,因为超出基本多语言平面的字符真实需要两个char(代理对)表示
substring真实能切断代理对
真实验证:用substring(0,2)真实把包含emoji的字符串从代理对中间切开,留下一个无法单独显示的高代理项字符

上一篇:wait(timeout) 真实验证

真实验证:一个emoji字符,length()真实是2

Java的char类型是16位,能表示的Unicode码点范围是有限的——超出这个范围的字符,真实需要用两个char组合表示。

CodePointSurrogateDemo.java String emoji = "😀"; // U+1F600 emoji.length(); emoji.codePointCount(0, emoji.length());
真实实测
单个emoji字符串 emoji.length() 真实结果: 2(不是1,因为真实用了代理对)
emoji.codePointCount(0, emoji.length()) 真实结果: 1(真实的字符个数是1)

emoji.charAt(0) 真实是高代理项: \ud83d
emoji.charAt(1) 真实是低代理项: \ude00
真实验证高代理项范围: Character.isHighSurrogate(high) = true
真实验证低代理项范围: Character.isLowSurrogate(low) = true

真实结果证明了 😀(U+1F600,属于超出基本多语言平面 BMP 的补充字符)真实需要两个 char 才能表示——这就是 UTF-16 的代理对(surrogate pair)机制:一个高代理项(\ud83d,范围 \uD800-\uDBFF)加一个低代理项(\ude00,范围 \uDC00-\uDFFF)组合表示一个真实的完整字符。length() 返回的真实是 char 的个数(2),不是真实的字符个数(1)——这个差异是几乎所有代理对相关陷阱的根源。

真实复现:substring() 真实能把emoji从代理对中间切断

既然length()真实是2,如果按char位置去截取字符串,会不会真的把这两个char拆开?

CodePointSurrogateDemo.java String mixed = "A😀B"; // 真实length()是4 String cutInHalf = mixed.substring(0, 2); // 只取索引0和1:A和高代理项
真实实测
"A😀B" 真实length(): 4(真实是4:A + 代理对2个char + B)
真实用substring(0,2)只截取到代理对中间: "A?",真实长度: 2
真实截断留下的最后一个char是不完整的高代理项: \ud83d,Character.isHighSurrogate真实结果: true
真实的危险不是理论推测,是可复现的乱码。真实结果证明 substring(0, 2) 完全不知道、也不关心自己正在把一个代理对从中间切开——它只是机械地按 char 索引截取,截出来的字符串最后一个 char 真实是一个孤立的高代理项(Character.isHighSurrogate() 真实返回 true),这个字符单独存在时是无法被正确渲染的(打印出来是问号或者乱码方块,取决于终端/字体的容错处理),而且这个"半个字符"如果继续参与后续的字符串拼接、编码转换等操作,真实可能引发更复杂的问题。这不是一个边界情况,任何直接按 length()/charAt() 索引截断含有emoji、部分CJK扩展汉字等超出BMP字符的字符串的代码,真实都有踩中这个陷阱的风险。

真实验证:正确的安全遍历和截断方式

CodePointSurrogateDemo.java mixed.codePoints().forEach(cp -> ...); int safeEnd = mixed.offsetByCodePoints(0, 2); // 真实跳过2个完整码点
真实实测
真实用codePoints()正确遍历:
  码点: U+41 -> A
  码点: U+1F600 -> 😀
  码点: U+42 -> B

offsetByCodePoints(0, 2) 真实得到的安全边界: 3
真实安全截取: "A😀"(真实没有拆散代理对)

真实结果验证了两个正确工具:codePoints() 真实按完整的Unicode码点(而不是char)遍历字符串,每次真实拿到的都是一个完整、可正确显示的字符,不管它底层占用1个还是2个char。offsetByCodePoints(start, count) 则真实能帮你计算出"跳过count个完整码点之后对应的char索引"是多少——用这个方法算出的边界去做 substring(),真实能保证不会把任何代理对拆散。

分步演示:4 个 char 里,哪两个必须绑在一起

每一帧展示的 char 和方法返回值都是真实运行这段代码时的结果,不是手工画的字符表。

逐个 char 看清代理对,再看 substring(0,2) 是怎么把 emoji 从中间切断的
这一步正在访问的 char被截断/孤立的代理项

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17 得到,代理对切断和正确遍历行为重复运行完全一致。作为这一批的收尾,系列 Java 内部原理现在共 120 篇。
    • 和String 不可变性那篇一起看,能更完整理解 Java String 底层用 UTF-16(及JDK9+紧凑字符串优化)存储字符带来的一系列真实边界行为。
    • 没有涉及:正则表达式\\p{IsEmoji}或Unicode属性匹配类对代理对的真实处理方式、String.codePoints().count()和length()在含代理对字符串上的真实差异对"字符串长度限制"类业务逻辑的影响——这两个话题留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号