问题背景项目中有一个消息投递履历的异步消费逻辑需要将消息的extra扩展字段保存到数据库数据库字段定义为VARCHAR(500)。为了防止超长字符串写入数据库报错需要在入库前对extra做截断处理。项目中的其他字段截断逻辑一直是这么写的// bizNo 截断 if (deliveryHistory.getBizNo().length() 32) { deliveryHistory.setBizNo(deliveryHistory.getBizNo().substring(0, 32)); } // target 截断 if (StringUtils.isNotEmpty(content.getTarget()) content.getTarget().length() 64) { deliveryHistory.setTarget(content.getTarget().substring(0, 64)); }看起来没什么问题length()判断长度substring(0, n)截断简单直接。于是在给extra加截断逻辑时我一开始也打算沿用同样的写法String extra deliveryHistory.getExtra(); if (StringUtils.isNotEmpty(extra) extra.length() 500) { deliveryHistory.setExtra(extra.substring(0, 500)); }等等——extra是一个扩展字段业务方可以往里面塞各种内容包括用户昵称、备注信息其中很可能包含 Emoji 表情。而Java 的String.length()返回的是char的数量不是字符的数量。这就有坑了。陷阱揭秘char 与 Code PointJava 内部使用 UTF-16 编码存储字符串。对于 BMPBasic Multilingual Plane基本多文种平面内的字符即码点值 ≤0xFFFF的字符一个char就能表示但对于 BMP 之外的字符——比如大部分 Emoji 表情、生僻汉字——它们的码点值 0xFFFF在 UTF-16 中需要用一对char即 surrogate pair代理对来表示。来个直观的例子String emoji ; System.out.println(emoji.length()); // 输出: 2 System.out.println(emoji.codePointCount(0, emoji.length())); // 输出: 1一个 Emoji 表情length()返回 2因为它由两个char代理对组成但codePointCount()返回 1因为它只是一个 Unicode 码点。这意味着什么如果我们用substring(0, 500)来截断当第 500 个char恰好落在一个代理对的中间时String text Hello .repeat(100); // 大量 Emoji String truncated text.substring(0, 500); // 如果截断位置刚好切在代理对中间结果就是 // 1. 末尾出现一个孤立的 high surrogate高代理项 // 2. 这个 surrogate 无法还原成任何有效字符 // 3. 写入数据库或展示时可能出现乱码、问号、甚至异常更严重的是如果数据库使用的是按字符计数而不是按字节计数的VARCHAR语义那么substring(0, 500)截出来的 500 个char实际可能只有 400 多个真正的字符既没有充分利用字段长度又可能在末尾留下乱码。正确姿势基于码点截断Java 提供了基于码点Code Point的 API可以安全地按真正的字符来截断字符串String extra deliveryHistory.getExtra(); if (StringUtils.isNotEmpty(extra)) { int maxChars 500; int codePointCount extra.codePointCount(0, extra.length()); if (codePointCount maxChars) { int truncateIndex extra.offsetByCodePoints(0, maxChars); String truncated extra.substring(0, truncateIndex); deliveryHistory.setExtra(truncated); log.warn(Extra truncated from {} code points to {}, codePointCount, maxChars); } }逐行解释方法作用extra.codePointCount(0, extra.length())计算字符串中的码点数量即真正的字符个数extra.offsetByCodePoints(0, maxChars)从位置 0 开始向后偏移 maxChars 个码点返回对应的char索引extra.substring(0, truncateIndex)按char索引截断因为truncateIndex是码点边界所以不会切断代理对关键在于offsetByCodePoints——它会自动跳过代理对确保返回的索引落在码点边界上不会把一个 Emoji 切成两半。对比两种截断方式用一个包含 Emoji 的字符串来做对比String text Hi你好; // ❌ 按 char 截断 text.substring(0, 3); // Hi\ufffd — Emoji 被切成了半个末尾是乱码 // ✅ 按码点截断 int idx text.offsetByCodePoints(0, 3); text.substring(0, idx); // Hi — 完整保留了 Emoji再来看一个更极端的场景String text A ‍‍‍ B; // 家庭 Emoji由多个码点组成ZWJ 序列 System.out.println(text.length()); // 117个char用于ZWJ序列 1个A 1个B surrogate... System.out.println(text.codePointCount(0, text.length())); // 取决于具体组合注意ZWJZero Width Joiner序列是另一个更复杂的话题多个码点组合显示为一个 Emoji。基于码点的截断能保证不切断代理对但可能会切断 ZWJ 序列导致 Emoji 显示异常。不过对于 截断超长字符串入库 这个场景码点级别的安全已经足够——至少不会产生无效的 UTF-16 字符串。回顾项目中其他字段的截断既然知道了substring按char截断的风险那项目中其他字段的截断是否也需要改不一定字段内容是否含 Emoji截断方式是否安全bizNo业务流水号纯字母数字不可能substring(0, 32)✅ 安全target投递目标手机号/邮箱等不可能substring(0, 64)✅ 安全content消息内容可能含 Emojisubstring(0, 200)⚠️ 有风险extra扩展字段JSON可能含 Emoji码点截断✅ 安全对于内容完全可控、不可能出现非 BMP 字符的字段用substring没有问题。但对于可能包含 Emoji、生僻字等内容的字段基于码点的截断才是正确选择。总结String.length()返回的是char的数量不是字符的数量。当字符串包含 Emoji、生僻汉字等 BMP 外字符时length()和substring()都可能产生意料之外的结果。按码点截断才是安全的做法。这不是什么高深的知识点但恰恰是这种低级的陷阱最容易在 Code Review 中被忽略。核心 API 速查