上周帮同事排查一个线上问题:同一段报文,本地跑得好好的,部署到测试环境就变成一串问号和方块。代码翻来覆去看了半小时,最后定位到一行new String(bytes)—— 没写编码,换了个运行环境,默认字符集从 UTF-8 变成了 GBK,整条链路就崩了。这类问题在 Java 里出现频率极高,因为byte 数组和 String 字符串的转换看起来只是两个构造方法、一个getBytes()的事,实际上中间横着一层字符编码,任何一端没对齐,结果就是乱码、长度对不上、甚至数据被悄悄改掉。
这篇内容我打算把这件事从头捋一遍:这两个类型为什么必须转换、转换过程中编码表扮演什么角色、getBytes()和new String()有哪些坑、不同业务场景该选什么方案、出了问题怎么定位。适合已经会写 Java、但在实际项目里被字节和字符串来回折磨过的同学,也适合刚入门想提前避开这些坑的朋友。文中给出的代码都可以直接复制到 IDE 里跑,建议边看边试。
1. 字节与字符的边界:转换这件事为什么值得单独拎出来讲
1.1 一个字符不等于一个字节,编码表才是中间人
很多人对这两个类型的直觉是"String 就是一堆字符,byte 数组就是一堆数字,转一下而已"。这个直觉在处理纯 ASCII 的时候碰巧成立,一旦出现中文、日文、emoji,就立刻失效。
原因很朴素:String 在 JVM 里存的是字符(char / code point),byte 数组存的是字节(8 位二进制),两者之间不存在天然映射。字符"中"在内存里是 U+4E2D 这个码点,而它落成字节是几个、分别是多少,完全取决于你用哪张编码表。UTF-8 会把"中"变成三个字节E4 B8 AD,GBK 会把它变成两个字节D6 D0,UTF-16BE 又会变成4E 2D。同一个字符,三种编码,三种字节序列。
所以转换的本质不是"类型转换",而是"翻译"。翻译需要词典,编码表就是那本词典。String.getBytes()是"查词典把字符写成字节",new String(bytes)是"查词典把字节读回字符"。词典用错了,读出来的东西自然驴唇不对马嘴。更麻烦的是,这种错误往往不报异常,只是安静地产生一串你认不出的字符,等到业务层发现数据不对,已经过去好几个小时了。
1.2 两个方向各自对应哪些真实场景
搞清楚转换发生在哪,才知道该往哪个方向使劲。我按自己遇到过的场景归了归类:
| 转换方向 | 典型场景 | 关键诉求 |
|---|---|---|
| String → byte[] | 写入文件、网络发送、HTTP 请求体、MQ 消息体、加密前处理、签名计算 | 字节序列必须与对端约定一致 |
| byte[] → String | 读取文件、接收响应、解析报文、日志打印、配置读取 | 解码必须与发送方编码一致 |
| 双向往返 | 缓存 key 生成、数据落库前的编解码、跨语言接口对接 | 往返必须无损,能还原原始内容 |
举几个具体的。做接口对接时,对方要求对请求体做 MD5 签名,这时候你要先把 JSON 字符串转成 byte 数组再算摘要,这一步用什么编码会直接导致签名对不上,因为 UTF-8 和 GBK 下同一个中文字符串的字节完全不同,算出来的 MD5 当然也不同。再比如串口通信或者某些老系统的定长报文协议,报文里每个字段占固定字节数,你在 Java 侧如果用 String 存,长度对不上就是灾难。
还有一个特别容易被忽视的场景:日志打印。很多人在打印字节数组时直接Arrays.toString(bytes)或者new String(bytes),前者输出[-28, -72, -83]这种十进制数字,根本没法肉眼比对;后者在编码不对时输出一堆问号。正确做法是转成十六进制字符串,一眼就能看出字节对不对。
1.3 不指定编码的"裸转"为什么是隐患的源头
new String(bytes)和str.getBytes()这两个无参方法,用的都是Charset.defaultCharset(),也就是运行环境的平台默认编码。这个值在 JDK 18 之前取决于操作系统和启动参数,Windows 中文版常年是 GBK,Linux 服务器一般是 UTF-8,Docker 基础镜像如果不设置LANG,甚至可能是 POSIX/ASCII。
这意味着同一份字节码,在开发同学的 Windows 笔记本上跑出来是对的,在测试环境的 Linux 容器里就是错的。更隐蔽的是,JDK 18 开始(JEP 400)默认字符集统一改成了 UTF-8,于是老项目升级 JDK 之后,原本"碰巧能跑"的代码可能突然开始乱码,因为默认值变了。
我的态度很直接:这两个无参方法在生产代码里应该被视为禁用 API。不是它们本身有问题,而是它们把"用什么编码"这个关键决策交给了运行环境,而运行环境是不可控的。写代码的人当时可能觉得省事,接手的人就得花几个小时去猜。
2. getBytes 与 new String 的默认编码陷阱
2.1 默认编码随环境漂移的实际表现
先看一段能复现问题的代码:
public class CharsetDemo { public static void main(String[] args) { String text = "中文测试"; // 无参:使用平台默认编码 byte[] bytes = text.getBytes(); System.out.println("默认编码: " + java.nio.charset.Charset.defaultCharset()); System.out.println("字节长度: " + bytes.length); // 显式指定 System.out.println("UTF-8 长度: " + text.getBytes(java.nio.charset.StandardCharsets.UTF_8).length); System.out.println("GBK 长度: " + text.getBytes(java.nio.charset.Charset.forName("GBK")).length); } }在 UTF-8 环境下,输出是 12 字节;切到 GBK 环境,输出变成 8 字节。短短四行代码,字节数就差了一半。如果你的下游系统按固定长度切分报文,或者数据库字段按字节数限长,这个差异直接就是线上事故。
还有一种更迷惑的情况:写入和读取用了不同的编码。比如你用 UTF-8 把字符串写进文件,用默认 GBK 读出来,中间没有任何异常,只是内容变成了 "涓枃娴嬭瘯" 这种鬼东西。这不是数据坏了,而是解码用错了词典。原始字节完好无损,换回 UTF-8 解码立刻恢复正常。
2.2 显式指定 Charset 的三种写法与取舍
Java 提供了三个重载,选哪个有讲究:
String s = "hello 中文"; // 写法一:字符串编码名,受检异常,容易拼错 try { byte[] b1 = s.getBytes("UTF-8"); } catch (java.io.UnsupportedEncodingException e) { // 实际上 UTF-8 一定存在,但编译器强制你处理 e.printStackTrace(); } // 写法二:Charset 对象,需要 Charset.forName,可能抛运行时异常 byte[] b2 = s.getBytes(java.nio.charset.Charset.forName("GBK")); // 写法三:StandardCharsets 常量,推荐 byte[] b3 = s.getBytes(java.nio.charset.StandardCharsets.UTF_8);优先用StandardCharsets。它把常用的几个编码做成了常量,UTF_8、ISO_8859_1、US_ASCII、UTF_16、UTF_16BE、UTF_16LE,编译期就能检查拼写,也不会抛受检异常。注意StandardCharsets里没有 GBK,因为 GBK 不是 Java 规范强制要求所有实现都支持的编码,需要时用Charset.forName("GBK"),并缓存这个 Charset 实例,别在循环里反复查。
至于解码方向,同理:
byte[] bytes = "hello 中文".getBytes(java.nio.charset.StandardCharsets.UTF_8); String s1 = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); // 推荐 String s2 = new String(bytes, "GBK"); // 需要 try-catch2.3 一个复现乱码的最小实验与排查思路
如果你想亲眼看看"编码不匹配"到底长什么样,跑这段:
byte[] bytes = "中文".getBytes(java.nio.charset.StandardCharsets.UTF_8); System.out.println("原字节: " + toHex(bytes)); System.out.println("UTF-8 解: " + new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); System.out.println("GBK 解: " + new String(bytes, java.nio.charset.Charset.forName("GBK"))); // 辅助方法:字节数组转十六进制,排查时必备 static String toHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02X ", b)); } return sb.toString().trim(); }输出大概是:原字节E4 B8 AD E6 96 87,UTF-8 解出"中文",GBK 解出四个乱七八糟的字符。因为 GBK 是双字节编码,它把E4 B8当成一个字、AD E6当成一个字、96 87当成一个字,三组全错。
看到乱码时的排查顺序,我一般是这么走的:第一步,把原始 byte 数组打印成十六进制,确认字节本身对不对(跟对端抓包或者日志里的期望值比);第二步,确认两端编码声明是否一致,包括代码里的 Charset、配置文件、HTTP 头的 Content-Type、数据库连接串;第三步,检查有没有 BOM,Windows 记事本保存的 UTF-8 文件开头会带EF BB BF三个字节,用new String解出来第一个字符是\uFEFF,肉眼看不见但会让equals判断失败,字符串长度也莫名多 1。
提示:判断一段乱码是不是编码问题,最简单的办法是拿原始字节用正确的编码重新解一次,能还原就是编码错,还原不了才是数据在传输中被破坏了。
3. 按场景分方案:文本、二进制、定长协议不能一套打法
3.1 文本场景:全链路统一 UTF-8 是最省心的选择
如果是真正的文本内容(用户昵称、文章正文、JSON、XML、SQL 语句),原则只有一条:从产生到消费,每一个环节都声明 UTF-8。不要依赖默认值,不要在中途某一层"顺手"转一次 GBK。
具体的落点包括:文件读写用Files.readAllBytes配合new String(bytes, StandardCharsets.UTF_8),JDK 11 之后可以直接Files.readString(path, StandardCharsets.UTF_8);流式读取用new InputStreamReader(in, StandardCharsets.UTF_8),不要用无参构造;HTTP 请求设置Content-Type: application/json;charset=UTF-8;数据库连接串加characterEncoding=utf8;编译参数可以加-encoding UTF-8。
还有一点常被忽略:JVM 启动参数。虽然 JDK 18 之后默认就是 UTF-8,但老项目还是建议显式加上-Dfile.encoding=UTF-8,避免在不同机器上表现不一致。这个参数影响Charset.defaultCharset(),也影响System.out的编码(后者还受stdout.encoding影响),日志文件里出现乱码时经常是这里的问题。
3.2 二进制数据场景:别拿 String 当容器,用 Base64 或 Hex
这是最容易出事的场景。加解密、消息摘要、压缩、序列化,这些操作产出的都是 byte 数组,它们本质上是二进制,不是文本。很多人图省事,直接new String(aesEncrypt(data))塞进 JSON 字段或者数据库,结果必然是灾难。
原因很简单:任意 byte 数组里可能包含0x00、0xFF这类在目标编码里无意义的字节。用 UTF-8 解码一段随机字节,非法序列会被替换成 U+FFFD 替换字符,这个替换是不可逆的,原始字节直接丢失。你拿着转换后的字符串再getBytes,得到的字节跟加密结果完全不是一回事,解密自然失败。
正确做法是编码成可打印字符集:
import java.util.Base64; import java.util.HexFormat; byte[] cipher = {0x00, (byte) 0xFF, 0x1A, 0x7F}; // Base64:体积膨胀约 33%,最常用 String b64 = Base64.getEncoder().encodeToString(cipher); byte[] back1 = Base64.getDecoder().decode(b64); // Hex:体积膨胀 100%,但肉眼可读,适合日志和调试 String hex = HexFormat.of().formatHex(cipher); // JDK 17+ byte[] back2 = HexFormat.of().parseHex(hex); // JDK 17 之前手写 Hex StringBuilder sb = new StringBuilder(); for (byte b : cipher) { sb.append(String.format("%02x", b)); }Base64 和 Hex 都是可逆的,往返之后字节完全一致。Base64 胜在体积小,适合传输和存储;Hex 胜在可读性,适合日志、调试、抓包比对。JDK 8 起java.util.Base64是标准库,不用引第三方包;JDK 17 起HexFormat也是标准库。如果项目还在用 JDK 8,Hex 就自己写个工具方法,别引一堆工具库只为了转个十六进制。
3.3 定长报文场景:ISO-8859-1 做字节透明映射
有些场景比较特殊:报文协议规定某个字段占固定字节数,你需要按字节下标去截取、替换、拼接,但又想用 String 的 API 来操作。这时候ISO-8859-1(也叫 Latin-1)是个神器。
它的特性是:0x00 到 0xFF 这 256 个字节,每一个都唯一映射到一个 Unicode 字符,一一对应,绝不丢失。也就是说byte[] → String → byte[]往返完全无损,而且每个字符恰好对应一个字节,String.length()直接等于字节数。
byte[] packet = new byte[64]; // 假设第 10 到 13 字节存放金额,定长 4 字节 String view = new String(packet, java.nio.charset.StandardCharsets.ISO_8859_1); String field = view.substring(10, 14); // 下标即字节下标,直观 byte[] newField = field.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); System.arraycopy(newField, 0, packet, 10, newField.length);注意这只是"搬运字节"的技巧,field的内容本身不是给人看的文本,不要拿去展示,也不要对它做trim()、toUpperCase()这类字符操作,否则会破坏字节。真正的文本字段,还是老老实实按协议约定的编码(GBK 或 UTF-8)对整个字段的字节区间单独解码。
4. 那些年踩过的转换坑与定位链路
4.1 三类典型现象:问号、方块、和"长度对不上"
不同编码错误的表征不一样,认识这些表征能大幅缩短排查时间:
| 现象 | 常见原因 | 验证方法 |
|---|---|---|
大量?问号 | 目标编码不支持该字符,比如用 US-ASCII 编码中文 | 打印Charset.defaultCharset()和代码里的 Charset |
一串锟斤拷或 `` | 字节被当成错误编码解码,非法序列被替换为 U+FFFD | 取原始字节用 UTF-8 重新解码 |
| 中文变拼音/日文/乱码符号 | UTF-8 字节被 GBK 解读,或反之 | 用toHex对比两端字节 |
| 字符串长度比预期多 1 | UTF-8 BOMEF BB BF | 检查首字符是否为\uFEFF |
| 字节数和字符串长度都对,但内容不对 | 中途做过一次错误的往返转换 | 全链路打点,逐段比对十六进制 |
锟斤拷这个词挺有意思,它其实是"错误解码后的替换字符再被错误编码"的产物,看到它基本可以断定是编码链路出了问题,而不是数据源坏了。
关于 BOM,补一个处理办法:
public static String stripBom(String s) { if (s != null && !s.isEmpty() && s.charAt(0) == '\uFEFF') { return s.substring(1); } return s; }或者从字节层面直接跳过:
byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Path.of("data.txt")); int offset = 0; if (bytes.length >= 3 && (bytes[0] & 0xFF) == 0xEF && (bytes[1] & 0xFF) == 0xBB && (bytes[2] & 0xFF) == 0xBF) { offset = 3; } String content = new String(bytes, offset, bytes.length - offset, java.nio.charset.StandardCharsets.UTF_8);注意bytes[0] & 0xFF这一步不能省,Java 的 byte 是有符号的,0xEF作为 byte 是负数,直接跟 int 比较会因为符号扩展出错。这个细节我在代码评审里见过太多次。
4.2 按字节截断导致的多字节字符被劈成两半
这个坑在"字段长度限制"场景里极其常见。比如数据库某个字段限 100 字节,你想截断一个长字符串,很自然会写str.substring(0, 100)。但substring的单位是char 个数,不是字节数。
一个中文字符在 UTF-8 下占 3 字节,substring(0, 100)得到 100 个字符,实际可能是 300 字节,超长;反过来如果先getBytes再Arrays.copyOf(bytes, 100),可能会从一个 3 字节字符中间切断,产生非法 UTF-8 序列,解码时报错或者出现替换字符。
正确的截断逻辑是"按字节截,但要退回到字符边界":
import java.nio.charset.StandardCharsets; public static byte[] truncateUtf8(String s, int maxBytes) { byte[] bytes = s.getBytes(StandardCharsets.UTF_8); if (bytes.length <= maxBytes) { return bytes; } int end = maxBytes; // UTF-8 的续字节以 10 开头,即最高两位是 10,往前退到字符起始字节 while (end > 0 && (bytes[end] & 0xC0) == 0x80) { end--; } return java.util.Arrays.copyOf(bytes, end); }逻辑解释一下:UTF-8 里,一个字符的起始字节形如0xxxxxxx(单字节)或110xxxxx、1110xxxx、11110xxx(多字节首字节),后续字节都形如10xxxxxx。如果切点正好落在10xxxxxx上,说明切在了字符中间,往前退到首字节即可。这段逻辑不长,但真出问题的时候,很多人第一反应是换编码、加长度,绕一大圈。
如果项目里有 emoji 或者生僻字,情况还要复杂一层:Java 的String.length()返回的是 char 数,而一个 emoji 在 Java 里占两个 char(代理对),UTF-8 下占 4 个字节。所以判断字符串"长度"时,得先想清楚是要 char 数、码点数还是字节数,这三个值经常不相等。
4.3 加解密与签名场景下的字节对齐问题
这个坑的破坏力最大,因为它通常不会表现为乱码,而是表现为"解密失败"或"验签不通过",排查方向容易被误导到密钥、算法上去。
典型错误链路是这样的:明文String→ 用 UTF-8 转 byte → AES 加密 → 得到 byte 数组 →用new String()转成字符串存进 JSON→ 从 JSON 读出来 →再getBytes()转回 byte→ 解密失败。
问题出在中间两次转换。加密输出的字节是随机的,很可能包含非法 UTF-8 序列,第一次转换就有字节被替换成 U+FFFD 丢了;第二次转换即使编码一致,也还原不出原始密文。
正确做法只有一句:二进制结果全程保持 byte 数组,需要字符串化时用 Base64。
import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; byte[] encrypt(byte[] plain, byte[] key) throws Exception { Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES")); return cipher.doFinal(plain); } // 使用 String plaintext = "要加密的内容"; byte[] cipherBytes = encrypt(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8), key); String transport = Base64.getEncoder().encodeToString(cipherBytes); // 这样传才安全摘要计算同理。做 MD5 或 SHA-256 签名时,参与计算的字节序列必须和对方逐字节一致,这就要求编码也必须一致。我遇到过一次签名永远对不上的问题,最后发现双方约定的是 UTF-8,但其中一方的框架默认用了平台编码,在测试环境(GBK)跑就崩了,本地(UTF-8)跑就没事。定位过程就是在签名前把待签字符串的十六进制打出来,两边一比,肉眼可见第一段字节就不一样。
4.4 大数组转换的内存与性能问题
new String(byte[])和getBytes()都是复制操作,不是视图。一个 100MB 的 byte 数组转成 String,瞬时内存占用会翻倍还多,因为 String 内部用的是 char 或者 byte 的压缩表示,加上对象头和可能的编码转换中间产物,峰值可能到 200MB 以上。在容器内存受限的环境里,这个过程很容易触发 OOM。
处理大文件或者大报文时,不要整体转,用流:
try (java.io.BufferedReader reader = java.nio.file.Files.newBufferedReader( java.nio.file.Path.of("big.txt"), java.nio.charset.StandardCharsets.UTF_8)) { String line; while ((line = reader.readLine()) != null) { process(line); } }如果是二进制大文件,用InputStream分块读,每块单独处理,处理完立刻释放引用。另外,String是不可变对象,反复拼接会产生大量临时对象,用StringBuilder在最后一步toString()。
还有一个不太起眼但确实影响性能的点:在循环里反复Charset.forName("GBK")。这个方法内部要查表、可能有锁,虽然不是特别慢,但在百万次循环里累积起来很可观。正确姿势是提到循环外面,或者用static final字段缓存。
5. 把转换收成工具类:从能跑到可控
5.1 工具类代码与必要的边界校验
散落在各处的new String(bytes)是技术债,收进工具类至少能让编码约定集中在一处。下面是我常用的一个最小版本:
import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Base64; public final class BytesUtils { private static final Charset GBK = Charset.forName("GBK"); private BytesUtils() {} public static byte[] toBytes(String s) { return s == null ? null : s.getBytes(StandardCharsets.UTF_8); } public static byte[] toBytes(String s, Charset charset) { return s == null ? null : s.getBytes(charset); } public static String toString(byte[] bytes) { return bytes == null ? null : new String(bytes, StandardCharsets.UTF_8); } public static String toString(byte[] bytes, Charset charset) { return bytes == null ? null : new String(bytes, charset); } public static String toString(byte[] bytes, int offset, int length, Charset charset) { if (bytes == null) { return null; } if (offset < 0 || length < 0 || offset + length > bytes.length) { throw new IndexOutOfBoundsException( "offset=" + offset + ", length=" + length + ", total=" + bytes.length); } return new String(bytes, offset, length, charset); } public static String toBase64(byte[] bytes) { return bytes == null ? null : Base64.getEncoder().encodeToString(bytes); } public static byte[] fromBase64(String s) { return s == null ? null : Base64.getDecoder().decode(s); } public static String toHex(byte[] bytes) { if (bytes == null) { return null; } StringBuilder sb = new StringBuilder(bytes.length * 2); for (byte b : bytes) { sb.append(Character.forDigit((b >> 4) & 0x0F, 16)) .append(Character.forDigit(b & 0x0F, 16)); } return sb.toString(); } }几个设计考虑说明一下。默认方法走 UTF-8,因为这是当前绝大多数场景的正确选择;保留带Charset参数的重载,给 GBK 之类需要显式约定的场景留口子;toString(bytes, offset, length, charset)这个重载专门给定长报文用,省掉先Arrays.copyOf再解码的那次复制;toHex用Character.forDigit而不是String.format,在大量调用时前者快一个数量级,日志里频繁打印字节数组时差距明显。
空值处理统一返回 null 而不是空字符串,这个选择有点争议。我的理由是:null 和空字符串在业务上语义不同,工具类不应该替调用方做决定,让上层明确判断更安全。如果团队约定统一返回空字符串,改一行就行,关键是要一致。
5.2 单元测试该覆盖哪些刁钻输入
字符编码这块,测试用例写得不够刁钻的话,很容易漏掉真问题。我一般会覆盖这几类:
import org.junit.jupiter.api.Test; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.*; class BytesUtilsTest { private static final Charset GBK = Charset.forName("GBK"); @Test void 往返转换应无损_纯中文() { assertRoundTrip("中文测试内容"); } @Test void 往返转换应无损_混合内容() { assertRoundTrip("abc中文123!@#"); } @Test void 往返转换应无损_emoji与生僻字() { assertRoundTrip("表情😀𠀀生僻字"); } @Test void 空数组应返回空字符串() { assertEquals("", BytesUtils.toString(new byte[0])); } @Test void 截断不应破坏字符边界() { String s = "中文中文中文"; // UTF-8 下 18 字节 byte[] cut = truncateUtf8(s, 7); // 7 不是 3 的倍数,必然切在字符中间 // 可以用正确编码完整解码,说明没有产生非法序列 String decoded = new String(cut, StandardCharsets.UTF_8); assertFalse(decoded.contains("\uFFFD")); } @Test void 越界参数应抛异常() { assertThrows(IndexOutOfBoundsException.class, () -> BytesUtils.toString(new byte[4], 2, 5, StandardCharsets.UTF_8)); } private void assertRoundTrip(String origin) { byte[] bytes = origin.getBytes(StandardCharsets.UTF_8); assertEquals(origin, new String(bytes, StandardCharsets.UTF_8)); assertEquals(origin, new String( (origin + origin).getBytes(StandardCharsets.UTF_8), 0, bytes.length, StandardCharsets.UTF_8).equals(origin) ? origin : origin); } }上面那个辅助方法写得有点绕,实际项目里直接断言origin.equals(new String(origin.getBytes(UTF_8), UTF_8))就够了。重点是用例本身要包含 emoji(4 字节、代理对)、生僻字(可能超过 BMP 范围)、混合中英文数字、空字符串、以及从中间截断的场景。这几类过了,基本能覆盖 90% 的编码问题。
另外强烈建议加一个跨编码的负面测试:把 UTF-8 字节用 GBK 解码,断言结果不等于原文。这类测试能防止后来的人"顺手"把编码参数删掉还浑然不觉。
5.3 团队层面的编码约定与代码审查清单
工具类只是兜底,真正的防线是约定和评审。我在几个项目里推行过的做法,效果还不错:
约定层面,明确规定:所有涉及String与byte[]互转的地方,必须显式传 Charset;默认编码统一 UTF-8;二进制数据一律用 Base64 或 Hex 字符串化;对外接口文档里必须写明字符编码;老系统对接如果确实要用 GBK,在工具类里定义常量,不允许出现魔法字符串。
评审层面,我有个简短的检查清单,看到这几类代码就重点看:
- 搜索
new String(,确认每个调用都带了 Charset 参数,或通过工具类间接带了 - 搜索
.getBytes(),同样确认没有无参调用 - 搜索
substring(后接getBytes或getBytes后接substring,检查是否有跨字节边界截断 - 检查日志中打印 byte 数组的地方,是否用了 Hex
- 检查加解密、签名相关代码,确认二进制没有经过 String 中转
- 检查文件读写、网络请求,确认编码声明与对端一致
配置层面,把 JVM 的-Dfile.encoding=UTF-8写进启动脚本,把容器镜像的LANG环境变量设成C.UTF-8,数据库连接串加编码参数,HTTP 客户端的默认字符集显式设成 UTF-8。这些都是一次性配置,配好了能省掉大量"为什么本地能跑线上不能跑"的争论。
最后一个实操小心得:线上排查编码问题时,别急着改代码,先把关键的字节序列打出来。在数据流的关键节点(接收处、处理前、发送前)各打一次 Hex,两边一对比,问题在哪一段立刻清楚。我见过太多人凭直觉改编码,改了三五轮反而把原本正确的地方改坏了。字节是不会骗人的,看清楚再动手。