byte数组和String字符串之间的转换,大概是 Java 里被搜索次数最多、同时被写错次数也最多的一行代码。我做过不少接口对接和数据清洗的活,凡是线上突然冒出来的“中文变问号”“报文长度对不上”“图片存进数据库后打不开”,把日志往前翻,十有八九能翻到某一句new String(bytes)或者str.getBytes()。这行代码本身没有任何问题,问题在于它把“用哪种字符集”这个关键决定交给了运行环境,而运行环境在开发机和服务器上往往不一样。下面我按自己平时排查和落地的顺序,把这件事从头拆一遍:先是字节和字符的底层关系,再是几种转换路径的取舍,然后是能直接复制过去用的代码,最后是我踩过的坑和一套固定的排查动作。刚接触 Java 的同学可以照着敲一遍,写过几年的人可以重点看第三、四节,那里有几个不太容易注意到但很致命的细节。
1. 字节与字符不是一回事:先把底层关系捋顺
1.1 一次导出乱码引出的基本问题
前两年我参与过一个内部报表导出功能的重构,场景很普通:把一批业务数据拼成 CSV,写入文件供办公软件打开。功能上线当天下午,几位同事反馈打开后中文全是乱码,英文和数字正常。代码里写的是new String(bytes)和line.getBytes(),中间夹着一个FileOutputStream。在开发同学自己的电脑上跑,一点问题没有;换到另一台机器上就乱。原因说出来很简单——两边的默认字符集不同,一边按 UTF-8 解释字节,另一边按 GBK 解释字节,同一串字节被翻译成了两套字符,于是就成了乱码。
这件事说明一个很基础但经常被跳过的认知:String是给人看的字符序列,byte[]是给机器处理的字节序列,它们之间不存在“天然等价”的关系,中间必须有一个翻译官,也就是字符集。字符集规定了两件事:一个字符用几个字节表示、每个字节的具体取值是什么。没有指定字符集的转换,等于让翻译官自己挑语言,结果自然不可控。
所以后面所有的讨论,都围绕一个原则展开:转换必须显式声明字符集,并且收发两端必须约定一致。只要记住这一条,八成的问题根本不会发生。
1.2 String 在内存里到底存了什么
很多人以为String内部就是一个char[],这个说法在 Java 9 之后已经不准确了。早期版本确实是char[] value,每个字符固定占两个字节,采用 UTF-16 编码,所以"中".length()是 1,但占用内存是 2 字节。Java 9 引入了紧凑字符串(Compact Strings),内部改成byte[] value加一个byte coder标志位:如果整个字符串的字符都能用单字节表示(Latin-1 范围),就用coder = 0,每个字符存一个字节;只要出现一个超出范围的字符,就整体退化为coder = 1,按 UTF-16 存储。
这个变化带来的直接影响是内存占用下降,但对使用者来说是透明的,length()、charAt()、substring()的行为都没有变。真正需要留意的是一点:String内部用什么编码,跟getBytes()输出的编码毫无关系。getBytes(UTF_8)永远按 UTF-8 规则重新计算字节,不会因为内部是 Latin-1 就少算。反过来也一样,new String(bytes, UTF_8)解码出来的字符,内部怎么存由 JVM 自己决定。把这两层混在一起想,是很多理解偏差的源头。
还有一点容易被忽略:Java 的char是 16 位,只能表示基本多文种平面(BMP)内的字符。像某些生僻字和大部分 emoji,在 UTF-16 里需要两个char(也就是代理对)来表示,长度按 2 算。如果你写代码时用str.length()去算“字符个数”,遇到这类字符就会算错。要算真正的字符数,得用codePointCount(0, str.length())。
1.3 编码表:字符到字节的映射规则
常见字符集的差别,用一张表说清楚最省事。下面这张表是我自己整理的口径,平时排查时直接对照看:
| 编码 | 单字符占用字节 | 是否变长 | 能表示中文 | 典型误用场景 |
|---|---|---|---|---|
| US-ASCII | 1 | 否 | 否 | 中文直接变成? |
| ISO-8859-1 | 1 | 否 | 否 | 被当成“万能字节容器”乱用 |
| GBK | 1 到 2 | 是 | 是 | 与 UTF-8 互转后出现方块字 |
| UTF-8 | 1 到 4 | 是 | 是 | 忘记声明,走上默认编码 |
| UTF-16 | 2 或 4 | 是 | 是 | 与 UTF-8 混用,多出 BOM |
UTF-8 的设计很巧妙:ASCII 范围内的字符仍然占 1 字节,跟老编码完全兼容;超出范围的字符用首字节的高位标记后续字节的数量。所以一个中文字符在 UTF-8 里通常是 3 字节,一个 emoji 是 4 字节。GBK 则是双字节为主,一个常用汉字 2 字节。这就能解释一个常见现象:同一段中文,按 UTF-8 算长度是 3 的倍数,按 GBK 算是 2 的倍数,如果你拿字节长度去做数据库字段校验或者报文长度限制,两边算出来的数根本对不上。
ISO-8859-1 需要单独说一句,它把 0x00 到 0xFF 的每个字节一一映射到一个字符,不做任何取舍。这个特性在某些场景下被当成“字节透传”的技巧使用,后面第 2 节会详细讲它的适用边界和风险。
1.4 乱码的三种典型形态
乱码不是一种病,是三种病。分辨清楚形态,排查能省一半时间。
第一种,中文变成一堆?。这说明目标编码里根本没有这个字符的映射。典型情况是用 US-ASCII 或者 ISO-8859-1 去编码中文,编码器找不到对应关系,只好用替换字符顶上。这种情况是不可逆的,原始信息已经丢了,再怎么转也回不来。
第二种,中文变成ä¸Â这种西文乱码。这说明字节本身是对的(大概率是 UTF-8),但被用 ISO-8859-1 解释了一遍。这类乱码是可逆的,把乱码字符串按 ISO-8859-1 取回字节,再按 UTF-8 解码,通常能救回来。我在处理历史数据时用过这招,成功率很高。
第三种,中文变成方块或问号夹杂的怪字符。这是 GBK 和 UTF-8 互转错位造成的,因为两种编码的字节长度不一样,错位之后后续内容全部跟着歪。这种最难救,尤其是中间还混了英文和标点时,边界完全对不上。
注意:第二种乱码之所以可逆,前提是中间没有经过任何有损处理。一旦数据被写进数据库、被 JSON 序列化、被日志框架截断,救回来的概率就大幅下降。所以发现乱码的第一时间应该保留原始字节,而不是在字符串层面反复折腾。
2. 转换 API 的选型:四条路径与它们的适用边界
2.1 构造器与 getBytes:默认编码是最大的坑
最常用的写法就两个方法:new String(byte[])和String.getBytes()。这两个重载都不带字符集参数,它们使用的是Charset.defaultCharset(),也就是 JVM 启动时根据操作系统区域设置推断出来的默认编码。在中文 Windows 上通常是 GBK,在很多 Linux 容器里是 UTF-8,在部分精简镜像里甚至可能是 ANSI_X3.4-1968(也就是 ASCII)。
问题就出在这里:开发机是 GBK,容器是 UTF-8,代码一字未改,行为却变了。更麻烦的是这种 bug 往往只在特定数据上出现——纯英文数字怎么转都对,一遇到中文就炸,测试用例覆盖不到就漏到线上了。
从 Java 18 开始,默认字符集统一改成了 UTF-8(对应的提案编号是 JEP 400),这个坑被填上了一部分。但现实是大量项目还跑在 Java 8 和 Java 11 上,而且即便升级到 18,也不该依赖默认值——代码的可读性和可移植性比省几个字符重要得多。
所以我的建议很直接:项目里禁止出现不带字符集参数的new String(byte[])和getBytes()。这条规则可以写进代码规范,也可以用静态检查工具扫出来,成本极低,收益极高。
2.2 StandardCharsets 与 Charset 对象怎么选
指定字符集有两种写法。老写法是传字符串常量:
byte[] bytes = str.getBytes("UTF-8"); String s = new String(bytes, "UTF-8");这两个方法都会抛UnsupportedEncodingException,一个受检异常。实际上 UTF-8 是 JVM 必须支持的编码,这个异常永远不可能发生,但你还是得写 try-catch,或者在方法签名上声明,非常啰嗦。
新写法是用StandardCharsets常量:
byte[] bytes = str.getBytes(StandardCharsets.UTF_8); String s = new String(bytes, StandardCharsets.UTF_8);StandardCharsets里预置了六个常量:UTF_8、ISO_8859_1、US_ASCII、UTF_16、UTF_16BE、UTF_16LE。这些常量不会抛异常,编译器还能做常量折叠,性能略好。需要动态指定编码时(比如编码名来自配置文件),才用Charset.forName("GBK"),它抛的是非受检的IllegalCharsetNameException和UnsupportedCharsetException,处理起来更自然。
我个人的取舍标准是:能用常量就用常量,只有在编码名运行时才确定的情况下才用Charset.forName。另外Charset.isSupported(name)可以用来做前置校验,避免配置写错导致启动失败。
2.3 Base64:二进制数据别硬塞进 String
有一类需求经常被误判:把图片、加密结果、压缩后的数据转成字符串,方便放进 JSON、XML 或者配置文件。这时候如果直接new String(imageBytes, UTF_8),会发生什么?UTF-8 解码器遇到不符合规则的字节序列,会把它替换成 U+FFFD(替换字符),原始字节彻底丢失,再getBytes回去已经不是原来那串数据了。这就是“图片存进数据库后打不开”的经典成因。
正确的通道是 Base64。它把每 3 个字节映射成 4 个 ASCII 字符,输出只包含A-Z a-z 0-9 + /和补位的=,任何字符集下都不会被破坏。
byte[] raw = Files.readAllBytes(Paths.get("demo.png")); // 编码:字节 -> 字符串 String encoded = Base64.getEncoder().encodeToString(raw); // 解码:字符串 -> 字节 byte[] restored = Base64.getDecoder().decode(encoded); System.out.println(Arrays.equals(raw, restored)); // trueJDK 8 起自带java.util.Base64,不用再引入第三方库。它提供三个编码器:基本型、URL 安全型(把+和/换成-和_,适合放进 URL 参数和文件名)、MIME 型(按 76 字符换行,适合邮件场景)。
注意:Base64 会让数据体积膨胀约 33%,这是拿空间换安全的代价。如果数据量很大,别一次性全读进内存,用
Base64.getEncoder().wrap(OutputStream)做流式处理,边读边编,内存占用是常数级的。
2.4 ISO-8859-1 的字节透传与它的局限
前面提到过,ISO-8859-1 把每个字节一对一映射成字符,不做任何替换。利用这个特性,可以做到“字节进、字节出、原样还原”:
byte[] raw = {0x00, (byte) 0xFF, (byte) 0x80, 0x7F}; String carrier = new String(raw, StandardCharsets.ISO_8859_1); byte[] back = carrier.getBytes(StandardCharsets.ISO_8859_1); System.out.println(Arrays.equals(raw, back)); // true这个技巧在历史上被用在 HTTP 头部解析、某些二进制协议适配、以及绕过框架的字符串强转上。它能工作,是因为映射是双射的。
但它有几个致命限制。第一,字符串长度会变成原来的字节数,一个 3 字节的中文会变成 3 个“字符”,任何按字符数做的长度校验全部失效。第二,这个字符串一旦经过 JSON 序列化、URL 编码、日志输出、数据库存储,就可能被中间的某一层重新编码,还原就断了。第三,可读性极差,后来接手的人看到这行代码基本要靠猜。
所以我的态度是:新代码一律用 Base64,ISO-8859-1 透传只用于维护老代码时的兼容处理。如果确实要在两者之间选,问自己一个问题:这串字符会不会离开当前进程?会,就选 Base64。
2.5 第三方工具的定位
市面上有一些通用工具库提供Hex、Base64、Charset相关的封装,用起来确实省事。但这类需求用 JDK 自带的能力完全够用,多引入一个依赖换来的收益很有限,反而增加了版本冲突和供应链风险。我的做法是:项目里已经有这个依赖,就用;没有,就不为这一件事引入。十六进制转换自己写十来行代码就能搞定,还省得处理依赖升级。
3. 完整实操:从最小示例到可复用的工具方法
3.1 环境与测试用例的设计
验证转换是否正确,光拿一句“你好世界”测是不够的,得设计一组有覆盖度的用例。我平时用这五类:
- 纯 ASCII:
"hello",用来确认基础路径没坏; - 常用中文:
"你好,世界",用来暴露编码不一致; - 中英混排加标点:
"订单号 A001,金额 100 元",用来检查边界; - 生僻字或 emoji:
"𠮷"、"😀",用来检验代理对和 4 字节字符; - 空字符串和
null,用来检验空值处理。
生僻字和 emoji 这两类最容易被忽略。用"😀".getBytes(StandardCharsets.UTF_8).length得到的是 4,用"😀".length()得到的是 2,两者不一致,如果你在某个地方按length()去切分字节数组,就会把一个字符切两半,产生非法字节序列。
我习惯把这些用例写成一个参数化的单元测试,每次改动编码相关代码时跑一遍。代码不长,但省下过好几次返工。
3.2 六种转换写法逐个跑通
下面这几个方法可以直接抄进工具类。每一个都显式声明了字符集,没有依赖默认值。
最基础的一对,字符串和字节互转:
public static byte[] toBytes(String text) { if (text == null) { return null; } return text.getBytes(StandardCharsets.UTF_8); } public static String toString(byte[] bytes) { if (bytes == null) { return null; } return new String(bytes, StandardCharsets.UTF_8); }带偏移量的版本,处理网络报文时很常用。注意第二个参数是起始下标,第三个参数是长度,不是结束下标,这里写错一次就少读或多读一段:
public static String toString(byte[] bytes, int offset, int length) { return new String(bytes, offset, length, StandardCharsets.UTF_8); }十六进制版本,日志打印和调试时很好用。注意b & 0xFF这个操作不能省,byte是有符号的,负数直接转int会做符号扩展,比如-1会变成-1而不是255,输出就成了FFFFFFFF:
public static String toHex(byte[] bytes) { char[] digits = "0123456789ABCDEF".toCharArray(); char[] out = new char[bytes.length * 2]; for (int i = 0; i < bytes.length; i++) { int v = bytes[i] & 0xFF; out[i * 2] = digits[v >>> 4]; out[i * 2 + 1] = digits[v & 0x0F]; } return new String(out); }Base64 版本,前面已经写过,这里补一个 URL 安全的变体:
String urlSafe = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); byte[] back = Base64.getUrlDecoder().decode(urlSafe);withoutPadding()去掉末尾的=,放进 URL 更干净,但接收方需要知道这一点,两端要约定一致。
最后是“能还原”的校验方法,实际项目里很有价值。与其猜转换对不对,不如直接比对字节:
public static boolean roundTripOk(String original, Charset cs) { byte[] bytes = original.getBytes(cs); String back = new String(bytes, cs); return original.equals(back); }这个方法的用途是提前发现“字符集不能表示某些字符”的问题。比如用 GBK 去转一个 emoji,roundTripOk会返回 false,说明信息在转换中丢失了,需要换方案。
3.3 流式处理:大报文和大文件别一次性读进来
Files.readAllBytes和new String(byte[])的组合在文件小的时候没问题,文件一大就会出状况。一个 500MB 的日志文件,先读成字节数组再转字符串,中间会同时存在字节数组、字符数组、字符串三份数据,内存瞬间翻好几倍。
正确的做法是用Reader和Writer,它们内部持有字符集,按块读取并按字符解码,天然处理多字节字符的边界:
try (Reader reader = new InputStreamReader( new FileInputStream("input.txt"), StandardCharsets.UTF_8); Writer writer = new OutputStreamWriter( new FileOutputStream("output.txt"), StandardCharsets.UTF_8)) { char[] buffer = new char[8192]; int n; while ((n = reader.read(buffer)) != -1) { writer.write(buffer, 0, n); } }这里要特别提醒一个反例:不要自己用固定大小的字节数组去切分多字节字符。有些同学写网络分包逻辑时,按 1024 字节切一段,然后每段单独new String(chunk, UTF_8)。只要切点落在某个 3 字节中文的中间,这一段末尾就会解出一个替换字符,下一段开头也会解出乱码,而且这个错误只在特定长度下出现,测试极难覆盖。要按块处理,就让CharsetDecoder或者InputStreamReader来管边界,它们内部维护了未完成字节的缓冲区。
3.4 非法字节与容错策略
真实数据里永远会有脏字节。可能是传输截断,可能是磁盘坏块,也可能是历史遗留的混合编码数据。默认情况下,解码器对非法字节的处理是“替换”,也就是静默地变成 U+FFFD,程序不报错但数据已经错了。这在排查阶段是灾难,因为你不知道哪里出的问题。
CharsetDecoder允许你显式控制这个行为:
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); try { CharBuffer cb = decoder.decode(ByteBuffer.wrap(bytes)); String text = cb.toString(); } catch (CharacterCodingException e) { // 字节流不是合法的 UTF-8,记录原始字节再决定怎么处理 log.warn("非法字节序列,十六进制: {}", toHex(bytes)); }三种策略各有用途:REPORT直接抛异常,适合数据校验场景,宁可失败也不要脏数据;REPLACE用替换字符顶上,适合展示类场景,让用户看到部分内容总比页面崩掉好;IGNORE直接丢弃非法字节,一般不建议用,因为它会让数据悄悄变短,长度校验全部失效。
我自己的习惯是:入口处用 REPORT 做严格校验,落库前再校验一次;展示层用 REPLACE 兜底。这样脏数据在进入系统时就被拦住了,而不是等到用户投诉才发现。
提示:编码器也有类似的问题。
String.getBytes()遇到目标字符集无法表示的字符时,同样会静默替换成?。要检测这种情况,可以先编码再解码,比对结果是否与原字符串相等,前面那个roundTripOk方法就是干这个的。
4. 踩坑记录与排查手册
4.1 乱码定位的固定动作
遇到乱码,别急着改代码,按这个顺序走一遍,通常十分钟内能定位。
第一步,确认乱码字符串本身能不能救。把乱码按 ISO-8859-1 取回字节,再按 UTF-8 解码,看中文有没有回来。能回来,说明是“把 UTF-8 字节当单字节编码解释了”,问题出在某个中间环节的字符集声明上。
第二步,打印字节的十六进制。对着编码规则看,中文字符开头通常是E4到E9,如果日志里看到的是C4 E3这种,那基本是 GBK。用toHex把原始字节打出来,比对着字符串猜有效得多。
第三步,检查链路每一段的字符集。从数据产生到最终展示,中间可能经过:业务代码、序列化框架、HTTP 客户端、网关、数据库连接、前端页面。每一段都要确认。最常见的是 HTTP 响应头里的Content-Type少了charset参数,浏览器只好按默认规则猜。
第四步,看 JVM 的默认字符集。Charset.defaultCharset()打一行日志,再确认启动参数有没有-Dfile.encoding。容器化部署时这一项经常和本地不一致。
这四步做完,九成的问题都能定位。
4.2 高频问题速查表
下面这张表是我这些年攒下来的,按现象查原因比从头分析快:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
中文变成? | 目标字符集不支持该字符 | 换 UTF-8,检查是否已经丢数据 |
中文变成丠| UTF-8 字节被按 ISO-8859-1 解释 | 按 ISO-8859-1 取字节,再按 UTF-8 解 |
| 中文变成方块 | GBK 与 UTF-8 混用 | 全链路统一为 UTF-8 |
| 本地正常、服务器乱码 | 默认字符集不一致 | 显式指定字符集,别用无参版本 |
| 开头多出不可见字符 | UTF-8 BOM(EF BB BF) | 读入时剥离前三个字节 |
| 图片或附件下载后打不开 | 二进制被当字符串转换 | 改 Base64 或直接传输字节 |
| 长度校验偶尔失败 | 中英文混排导致字节长度变化 | 按字节算长度,别按字符算 |
| emoji 被截断成半个 | 按length()切分 | 用codePointCount或按字节边界切 |
表格里第一条需要展开说一句:变成?通常是不可逆的,因为编码器在替换时已经把原始信息扔掉了。所以看到?的第一反应应该是去源头找还有没有原始字节,而不是在乱码字符串上想办法。
BOM 那条也值得单独提。UTF-8 本身不需要 BOM,但有些工具在写文件时会自动加上。BOM 是三个字节EF BB BF,解码后会变成一个不可见字符 U+FEFF。如果你的程序用 JSON 解析或者做字符串首字符比较,就会莫名其妙失败,而且肉眼完全看不出问题。处理办法是在读取时先判断前三个字节,是的话跳过。用InputStreamReader的话,可以手动读掉这三个字节再交给 reader。
4.3 性能与内存的几个细节
String.getBytes()每次调用都会分配一个新数组,返回的数组是独立的,修改它不影响原字符串——这一点经常被误解,有人以为可以“拿到内部数组改一改”,实际上改的是副本。反过来,new String(bytes)也只是复制内容,之后改bytes不影响字符串。
在循环里反复做转换是个常见的性能陷阱。我见过一段代码在 for 循环里对同一段固定文本调用getBytes,循环十万次,白白分配了十万个数组。这种常量文本应该提前转好,放在循环外面。
另外,String的+拼接在循环里会生成大量中间对象,如果拼接的内容包含中文,每次拼接都要重新编码一次。改成StringBuilder再统一toString(),能省下不少开销。
还有一个不太显眼的地方:日志。很多日志框架在拼接消息时才做字符串转换,但如果参数里传的是byte[],toString()出来的是类似[B@1b6d3586这样的对象标识,完全没用。想让日志里有价值,得自己做十六进制或者 Base64 转换,而且要考虑长度——一个几 MB 的字节数组直接打进日志,磁盘和采集系统都会受影响。
注意:涉及敏感数据的字节数组,转成十六进制或 Base64 再打日志同样会泄露内容。日志里只打长度和前若干个字节就够了,完整内容走专门的调试开关。
5. 落地场景:把编码约定写进项目规则
5.1 接口与存储的编码约定
单点代码写对了不够,还得让整个链路一致。我在项目里推行的做法是三条硬规矩。
第一条,对外接口一律 UTF-8。HTTP 请求和响应的Content-Type明确写charset=UTF-8,不要依赖默认值。文件上传下载同理,文件名编码单独处理,很多兼容性问题就出在文件名上。
第二条,数据库连接串显式指定字符集。以常见的连接配置为例,加上characterEncoding=UTF-8这类参数,避免驱动层用默认值。建表时字符集也要显式声明,别依赖库级默认。
第三条,代码里禁止无参的new String(byte[])和getBytes()。这条可以靠代码规范约束,也可以在评审时重点看。静态检查工具能配上规则更好,机器管比人管可靠。
这三条落地之后,新人接手时的学习成本会低很多,因为他不需要猜这段代码到底用的什么编码。
5.2 加密、摘要与序列化里的字节处理
做摘要(比如常见的哈希计算)和加解密时,输入输出都是字节数组,中间很容易出现一次不该有的字符串转换。举个典型错误:先把密码转成字符串,再对字符串做哈希,最后把结果转成字符串存库。中间只要有任何一步用了默认字符集,不同机器算出来的摘要就不一样,登录校验就会偶发失败。
正确做法是全程在字节层面操作,只有最终落库或展示时才做一次编码,而且明确用十六进制或者 Base64:
byte[] digest = MessageDigest.getInstance("SHA-256") .digest(password.getBytes(StandardCharsets.UTF_8)); String stored = toHex(digest);密钥、盐值这类数据也一样,从配置里读出来时如果是字符串,先按十六进制或 Base64 解码成字节再用,别直接getBytes()。字符串到字节的那一步,在有编码差异的环境里就是不确定的。
序列化场景也是重灾区。某些序列化格式内部按 UTF-8 处理字符串,某些按平台默认。跨语言、跨版本传输时,最好在协议层就约定死编码,并且用带长度前缀的字节帧,别靠分隔符切分——分隔符本身也可能是多字节字符的一部分。
5.3 把检查做成习惯
编码问题有个特点:它的后果往往延迟暴露,可能上线一周后才被某个用户碰到。所以靠测试阶段发现问题不太现实,更靠谱的是把检查前移。
我在团队里推的做法是写一个小的工具类,把常用的转换都封进去,业务代码只调工具类,不直接调 JDK 的转换方法。这样审查点集中在一个文件里,改起来也方便。工具类里每个方法都带显式字符集,带空值处理,带长度校验。
另外,在关键的数据入口加一段自检逻辑,成本不高但很有用:收到字节后,先按约定字符集做一次严格解码(CodingErrorAction.REPORT),解码失败就直接拒绝并记录十六进制内容。这一步能拦住大部分脏数据,也能在出问题时第一时间留下现场。
还有个小技巧:在日志里记录报文长度时,同时记录字节长度和字符长度两个值。两者不匹配时(比如字节长度是字符长度的 3 倍),基本可以判断这段内容是中文为主的 UTF-8,一眼就能看出编码是不是符合预期。这个习惯帮我定位过好几次问题,尤其是怀疑某段数据被中间层重新编码的时候。
我个人在这些年的实际使用中发现,编码问题从来不是技术难题,难的是它太容易被忽略。写代码时多敲几个字符,把StandardCharsets.UTF_8显式写上去,看起来是重复劳动,实际上是在给未来的自己省时间。我现在接手任何一份代码,第一件事就是全局搜一遍无参的getBytes和new String(byte[]),搜出来的每一处都要确认一遍上下文——这个动作花不了几分钟,但能提前拆掉不少雷。至于二进制数据,记住一条就够了:它不属于字符串的世界,要么走 Base64,要么就在字节层面待着,别硬塞进String里,塞进去就再也拿不回来了。