
我印象最深的一次线上事故是某次日志系统升级后运营同学反馈后台看到的用户昵称全变成了“测试”这种乱码。排查了三个小时最后定位到一行new String(bytes)——那个类在 JDK 8 的老机器上跑得好好的迁到新环境后就崩了因为默认字符集从 GBK 变成了 UTF-8。这就是byte数组和String字符串转换这件事最典型的样子代码只有一行坑却有几十个。这篇内容就把这件事从头到尾拆开讲透包括编码表到底是什么、getBytes和new String的每一个重载该怎么选、文件与网络两种真实场景下怎么落地、以及我这些年踩过的坑整理成的速查表。适合刚接触 Java 的同学打地基也适合写了几年代码但一直靠“加个 UTF-8 试试”蒙过去的朋友系统补一遍。核心关键词就一个Java 里 byte 数组和 String 字符串的互转但要把这件事做对牵扯到的知识点远比一行代码多。1. 先搞清楚 byte[] 和 String 到底是什么关系1.1 一个存字节一个存字符中间隔着编码表很多人把这两个类型当成“同一种东西的两种写法”这是所有乱码问题的源头。byte[]是纯粹的字节序列每个元素是 8 位二进制取值范围 -128 到 127Java 里 byte 是有符号的它本身没有任何“含义”你给它0xE6 0xB5 0x8B它只是三个数字不知道这是“测”字还是某个图片的前三个字节。String则完全是另一个层面的东西。它在 JVM 内部是一个char[]JDK 9 之后改成了byte[]加编码标志 coder 的紧凑实现但对外语义不变每个char是一个 UTF-16 码元代表“字符”这个抽象概念。你在代码里写测JVM 存的是码点 U6D4B。所以两者之间的转换不是“类型强转”而是**编码encode和解码decode**两个方向完全相反的操作String→byte[]把这个字符按某张码表翻译成字节序列叫编码。UTF-8 下“测”是E6 B5 8B三个字节GBK 下是B2 E2两个字节。byte[]→String拿一张码表把字节序列还原成字符叫解码。必须用编码时那张同一张表否则就是鸡同鸭讲。用生活化的类比String是“意思”byte[]是“用某种语言说出来的声音”。同一句话用中文说和用英文说声音波形完全不同字节不同但意思一样字符串一样。你把中文录音交给一个只会英语的人去听用错解码表他听到的就是一堆无意义的音节乱码。这个类比记住后面所有问题都能自己推出来。1.2 为什么必须显式指定字符集Java 提供String.getBytes()和new String(byte[])这两个无参版本它们用的是Charset.defaultCharset()也就是 JVM 的默认字符集。问题就在这个“默认”上JDK 17 及以前默认字符集取决于操作系统和启动参数Linux 上常见 UTF-8中文 Windows 上是 GBK还能被-Dfile.encodingxxx覆盖。JDK 18 开始JEP 400默认值统一成 UTF-8不再随系统变。这意味着同一份 jar 包在开发同学的 Mac 上、在测试的 Windows 机器上、在生产的 Linux 容器里getBytes()的结果可能完全不一样。这不是理论风险我见过一个项目把用户上传的文件名用getBytes()存进数据库本地测试全过上线后所有中文文件名全乱因为本地是 UTF-8、生产容器里 LANG 没设对默认成了 ANSI_X3.4-1968其实就是 ASCII中文直接变成一堆?。提示任何涉及String与byte[]互转的地方都不要省略字符集参数。这不是风格问题是正确性问题。IDE 里可以用 SpotBugs 或 ErrorProne 的DefaultCharset检查规则强制拦住。1.3 三种场景判断你该用哪条路在动手写代码前先花十秒钟判断你面对的是哪类数据走错路后面全是坑数据性质典型来源正确处理方式真正的文本配置文件、JSON、日志、用户昵称用明确的字符集编码解码如 UTF-8二进制数据图片、压缩包、加密后的密文、序列化对象不要转 String用 Base64 或十六进制表示协议层的定长字节报文头、定长字段、硬件通信按协议约定的字符集解码通常 ASCII 或 GBK第二类是最容易出事的。有人把图片读成byte[]后直接new String(bytes)塞进 JSON 字段结果图片里恰好有0x00或者非法 UTF-8 序列解码时被替换成UFFFD再编码回去字节数就变了文件彻底损坏。二进制数据转文本只有 Base64体积涨 33%和十六进制体积涨 100%两条路没有第三条。2. 常用写法的逐个拆解与选型理由2.1 编码方向getBytes 的重载怎么选String提供三个编码方法差别很实在// 1. 无参用 JVM 默认字符集跨环境不确定不推荐 byte[] a str.getBytes(); // 2. 传字符集名字符串写错时才在运行时抛异常且编译期无法检查 byte[] b str.getBytes(UTF-8); // 抛 UnsupportedEncodingException // 3. 传 Charset 常量类型安全不抛受检异常推荐 byte[] c str.getBytes(StandardCharsets.UTF_8);第三种是首选StandardCharsets这个类在 JDK 7 就引入了里面预置了UTF_8、ISO_8859_1、US_ASCII、UTF_16、UTF_16BE、UTF_16LE六个常量都是单例还能省掉一次查表。如果你的目标编码不在里面比如 GBK用Charset.forName(GBK)拿对象后缓存起来别在循环里反复 forName——forName内部要走一次线程安全的缓存查找虽然不慢但在百万次级别的循环里也是实打实的开销。这里有个细节值得说清楚getBytes遇到目标字符集无法表示的字符时不会抛异常而是静默替换成?0x3F。比如中文.getBytes(StandardCharsets.US_ASCII)得到的是[0x3F, 0x3F]信息已经永久丢失而且你从返回值上看不出任何异常。这是最阴险的一类 bug因为程序不报错、流程照跑只是数据悄悄没了。注意如果你的业务不能接受字符丢失就得用CharsetEncoder把onUnmappableCharacter设成CodingErrorAction.REPORT让它在遇到无法映射的字符时抛CharacterCodingException主动暴露问题。2.2 解码方向new String 的构造器与替换字符解码方向String提供了一组构造器常用的也有三个String s1 new String(bytes); // 默认字符集不推荐 String s2 new String(bytes, UTF-8); // 抛受检异常 String s3 new String(bytes, StandardCharsets.UTF_8); // 推荐还有一个带偏移量的版本new String(bytes, offset, length, charset)处理网络报文时非常好用——你从缓冲区里读到一个完整包前面 8 个字节是包头后面 20 个字节是用户名直接new String(buf, 8, 20, StandardCharsets.UTF_8)就切出来了不用先Arrays.copyOfRange复制一份省一次内存分配。同样地解码遇到非法字节序列时默认也不抛异常而是插入替换字符UFFFD显示成一个菱形问号 。这个行为是“宽容解码”对日志展示类场景友好但对数据校验场景是灾难——你拿到一个包含的字符串根本不知道原始字节是什么也还原不回去。要严格控制就用CharsetDecoderCharsetDecoder decoder StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); try { String s decoder.decode(ByteBuffer.wrap(bytes)).toString(); } catch (CharacterCodingException e) { // 数据有问题按业务流程处理 }这套写法我在做报文解析和数据导入时用得最多尤其是导入 CSV 时能第一时间发现“用户传了个 GBK 编码的文件但我们按 UTF-8 读”这种情况而不是等到几千行数据导入完才看到满屏乱码。2.3 一个常被忽略的点byte 的有符号性byte在 Java 里是带符号的0xE6这个字节打印出来是-26而不是230。这在做字节级比较时非常容易翻车。看这段代码byte[] bytes 测.getBytes(StandardCharsets.UTF_8); for (byte b : bytes) { System.out.print(b ); // 输出 -26 -75 -117 }想要看到 230、181、139必须b 0xFF。这个坑在做 Base64 编码、哈希计算、CRC 校验时最常见。我见过有人写if (bytes[0] 0xEF)判断 BOM编译器会报错说 int 不能和 byte 比较其实不会报错0xEF是 int 类型的 239bytes[0]会被提升成 int如果那个字节真是 0xEF值是 -17-17 239结果是 false判断永远不成立BOM 检测彻底失效而且没有任何报错。正确写法是(bytes[0] 0xFF) 0xEF。3. 实操落地四条真实链路的完整实现3.1 文件读写字符集只在流的两端生效文件场景是最容易理解编码链路的地方。要记住一个原则InputStream/OutputStream处理的是字节Reader/Writer处理的是字符字符集参数只出现在两者转换的那一层。// 写文件字符串 → 字节 → 磁盘 try (Writer w new OutputStreamWriter( new FileOutputStream(out.txt), StandardCharsets.UTF_8)) { w.write(测试内容); } // 读文件磁盘 → 字节 → 字符串 try (Reader r new InputStreamReader( new FileInputStream(out.txt), StandardCharsets.UTF_8)) { char[] buf new char[1024]; int n; StringBuilder sb new StringBuilder(); while ((n r.read(buf)) ! -1) { sb.append(buf, 0, n); } }注意InputStreamReader内部自带一个解码器它会缓存跨读取块的半个字符。这是它比“先读到 byte[] 再 new String”更可靠的根本原因一个 3 字节的汉字如果刚好被 1024 字节的缓冲区切成 1 字节 2 字节两半InputStreamReader会把前 1 字节留在内部缓冲里等下一块数据来了再拼起来解码而你自己new String(buf)处理每一块那半个汉字就会被替换成文件里每个 1024 边界处都可能出现一个乱码。JDK 7 之后更推荐Files工具类一行搞定且默认就是 UTF-8String content Files.readString(Path.of(out.txt), StandardCharsets.UTF_8); Files.writeString(Path.of(out.txt), content, StandardCharsets.UTF_8);小文件几 MB 以内用这个最省事大文件还是要走流式readString会把整个文件读进内存。3.2 网络报文长度字段一定是字节长度做 TCP 私有协议或者自定义报文时“长度字段”填什么是最常见的分歧点。答案很明确填字节长度不是字符串长度。byte[] body username.getBytes(StandardCharsets.UTF_8); ByteBuffer buf ByteBuffer.allocate(4 body.length); buf.putInt(body.length); // 4 字节长度头值是字节数 buf.put(body); byte[] packet buf.array();为什么不能用username.length()因为String.length()返回的是 UTF-16 码元个数和字节数在非 ASCII 场景下完全对不上。测的长度是 1UTF-8 字节数是 3GBK 字节数是 2这种增补平面字符length()返回 2一个代理对UTF-8 字节数是 4。用字符数当字节数发出去接收端要么少读、要么多读直接导致粘包解析错位后面的报文全部报销。接收端按同样规则解ByteBuffer buf ByteBuffer.wrap(raw); int len buf.getInt(); byte[] body new byte[len]; buf.get(body); String username new String(body, StandardCharsets.UTF_8);还有一个隐蔽的问题如果报文里同时有英文和中文长度字段用字节算是对的但定长字段比如协议规定用户名固定占 32 字节要格外小心。“张三”在 UTF-8 下是 6 字节剩下的 26 字节怎么填一般用 0x00 补齐解码时再trim掉尾部填充。如果用空格补齐正好遇到一个字符串本身以空格结尾trim 之后数据就变了。这类协议细节必须和对接方写进接口文档不能靠猜。3.3 二进制数据Base64 与十六进制的正确姿势前面说过二进制不能直接new String那到底怎么转成文本传给前端或存进 JSON两个方案Base64JDK 8 起有官方实现byte[] data Files.readAllBytes(Path.of(avatar.png)); // 编码二进制 → 文本体积约涨 33% String b64 Base64.getEncoder().encodeToString(data); // 解码文本 → 二进制严格模式拒绝非法字符 byte[] back Base64.getDecoder().decode(b64); // URL 安全的变体用于放进 query 参数或文件名 String urlSafe Base64.getUrlEncoder().withoutPadding().encodeToString(data);这里必须提醒Base64 不是加密是编码任何人拿到字符串都能还原出原图。有人拿它当“简单加密”保护用户数据这是严重误用。另外 JDK 的解码器默认对非法字符是严格拒绝的会抛IllegalArgumentException而MIME解码器会忽略换行和空白处理带换行的邮件内容时用它。十六进制适合调试打印、哈希值、短字节串String hex HexFormat.of().formatHex(bytes); // JDK 17 byte[] back HexFormat.of().parseHex(hex); // 低版本兼容写法 StringBuilder sb new StringBuilder(bytes.length * 2); for (byte b : bytes) { sb.append(String.format(%02x, b 0xFF)); // 注意 0xFF }%02x里的b 0xFF是必须的原因前面 2.3 节讲过不加的话负数会被格式化成ffffffxx这种八位串长度直接翻四倍解析全乱。用String.format在循环里性能一般十万字节以上建议换成查表法或者 JDK 17 的HexFormat。3.4 高频调用的性能账别在循环里造对象如果转换发生在热点路径上比如网关每秒处理几万条消息getBytes和new String的开销就值得算一算。这两个方法每次调用都会分配新的数组或对象在 GC 压力大的服务里会显著增加年轻代回收频率。优化思路是复用CharsetEncoder/CharsetDecoder它们内部维护编码状态一个实例不允许多线程并发使用所以配ThreadLocal最合适private static final ThreadLocalCharsetEncoder ENCODER ThreadLocal.withInitial(() - StandardCharsets.UTF_8.newEncoder() .onMalformedInput(CodingErrorAction.REPLACE) .onUnmappableCharacter(CodingErrorAction.REPLACE)); public static String fastEncode(byte[] bytes) throws CharacterCodingException { CharBuffer cb ENCODER.get().decode(ByteBuffer.wrap(bytes)); return cb.toString(); }实测在单线程百万次调用的压测下这种写法比new String(bytes, UTF_8)大约快 15% 到 20%收益不算特别夸张。所以我的一般建议是先别优化用StandardCharsets.UTF_8写清楚逻辑等 Profiler 真的指到这一行再改。过早优化引入的 ThreadLocal 泄漏、编码器状态污染问题比那 20% 的性能更值钱。4. 乱码排查实录与常见问题速查4.1 从乱码形态反推错在哪一步乱码不是随机出现的它的形态能直接告诉你编码和解码各用了什么。这张表是我这些年攒下来的经验基本能覆盖八成情况看到的现象典型原因怎么修中文全变成?编码时用了 ASCII 或 ISO-8859-1换成支持中文的编码表中文变成测试UTF-8 字节被当作 ISO-8859-1 解码解码时指定 UTF-8中文变成娴嬭瘯UTF-8 字节被当作 GBK 解码解码时指定 UTF-8出现锟斤拷UTF-8 解码失败生成替换字符后被 GBK 再编码修好源头的解码环节文字前后出现 文件带 UTF-8 BOM 头跳过前 3 字节或去掉 BOM结尾个别字变乱码按字节截断切断了多字节字符按字符边界截断偶发乱码重启就好依赖默认字符集环境不一致所有调用显式指定字符集测试这一类特别有意思它本质是“用错了但没丢信息”因为 ISO-8859-1 是单字节到 0-255 的完整映射理论上可以把字符串按 ISO-8859-1 再编码回去拿到原始字节重新用 UTF-8 解一遍就能救回来// 救回被错误按 ISO-8859-1 解码的字符串 byte[] raw broken.getBytes(StandardCharsets.ISO_8859_1); String fixed new String(raw, StandardCharsets.UTF_8);这个“补救法”在对接老旧系统时救过我好几次但它有前提中间那次错误解码必须是无损的ISO-8859-1 满足如果中途经过了 GBK 或者出现了替换字符信息已经丢失这种方法就无效了。4.2 按字节截断最简单的函数最隐蔽的 bug产品需求里经常有“列表页只显示前 N 个字”有人图省事写成new String(bytes, 0, N)这里 N 是字节数。只要内容里有中文这个函数就会切出半个汉字解码后就是一个。我见过一个短信发送模块这么干导致用户收到的部分短信末尾带乱码投诉了一片。正确做法是按字符截断public static String truncateByChars(String s, int maxChars) { if (s null || s.length() maxChars) { return s; } String sub s.substring(0, maxChars); // 避免把代理对切成两半 if (Character.isHighSurrogate(sub.charAt(sub.length() - 1))) { sub sub.substring(0, sub.length() - 1); } return sub; }如果业务约束是字节数上限比如短信按字节计费70 字节一条那就得算出每个字符编码后的字节数一个一个往里塞塞到超限为止。这里要特别注意substring操作的是 UTF-16 码元如果遇到 emoji会在代理对中间切一刀得到一个孤立的低位代理这个字符串再拿去编码时会变成?又是一个静默丢数据。Character.isHighSurrogate那两行就是防这个的很多人不知道substring也有这个坑。4.3 BOM 头看不见的三字节用记事本或者 Excel 另存的 UTF-8 文件开头常常带三个字节EF BB BF这就是 BOM字节顺序标记。Java 的new String(bytes, StandardCharsets.UTF_8)不会自动剥掉它会原样保留成\uFEFF字符挂在字符串最前面。后果是用这份数据去匹配枚举值、去查 Map、去和另一个字符串equals全部返回 false但从日志上看两个字符串一模一样打印出来肉眼看不出差别因为\uFEFF是零宽字符。这个问题我在做数据导入时遇到过查了大半天最后用hexdump看字节才发现开头多了东西。// 读取时去掉 BOM byte[] bytes Files.readAllBytes(path); 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, StandardCharsets.UTF_8);处理 CSV 导入时我现在的习惯是第一行先charAt(0) \uFEFF判断一下剥掉再解析表头否则第一列的字段名永远匹配不上。4.4 几条不那么教科书的心得第一单元测试要覆盖非 ASCII。“测试”“日本語”“한국어”“”这四个字符串一起上能筛掉绝大多数编码问题。我见过很多测试用例全用 “abc” 和 “hello”在 ASCII 范围内 UTF-8 和 GBK 编码完全一致怎么测都是绿的一上中文就崩。第二数据库连接的字符集要单独确认。代码里全用 UTF-8 了结果 JDBC URL 里没加characterEncodingutf8驱动用默认值连上去写入时就乱。MySQL 8 的默认值比 5.7 好一些但老项目迁移时最好显式写。连接池配置变更后也要重新验证一遍别只测应用层。第三不要在数字、日期上做字符串中转。比如把时间戳转成 String 再转成 byte[] 再存多此一举还引入编码风险。long直接写ByteBuffer.putLong就是 8 字节定长效率高、无歧义。反过来byte[]转long用ByteBuffer.getLong别用Long.parseLong(new String(bytes))后者既慢又对字节长度敏感。第四日志里打印字节内容用十六进制不要用new String。排查编码问题时看到原始字节才能判断打印一个乱码字符串再猜是没意义的。我习惯写一个HexFormat.of().formatHex(bytes)的辅助方法出问题时直接看E6 B5 8B这种序列一眼就知道是不是 UTF-8 的中文。第五String和byte[]的互转方法不要包在一个“通用工具类”里到处用。见过不少项目有个StringUtil.bytesToStr(byte[])里面用的是默认字符集。这种工具方法流传得越广后来排查越痛苦因为调用点几十上百个谁也不知道哪个环节用了什么编码。宁可每个场景显式写一行带StandardCharsets.UTF_8的代码可读性和可维护性都更好。5. 一套可以直接抄的封装与自检清单5.1 先确认字符集在运行时到底接受哪些名字不同 JDK 版本、不同运行环境支持的字符集别名不完全一样。UTF-8、utf8、UTF8在很多实现里等价但GBK、GB2312、GB18030的关系更微妙——GB18030 是超集能表示 GBK 表示不了的部分字符。拿到一个不确定来源的编码名先验证Charset cs Charset.forName(GB18030); System.out.println(cs.name()); // GB18030 System.out.println(cs.canEncode(测)); // true System.out.println(Charset.isSupported(GBK)); // truecanEncode这个方法特别实用在把用户输入编码成字节之前先判断一下比事后发现变成了?强得多。还有一个Charset.availableCharsets()能列出当前 JVM 支持的全部字符集出问题时打印一下能确认是不是环境裁掉了某些字符集实现精简版 JRE 有这种情况。5.2 一个安全的转换工具骨架下面这个类把前面讲的关键点都收进去了显式字符集、严格模式和宽容模式分开、BOM 处理、以及字节长度计算。可以直接拿去改public final class Bytes { private Bytes() {} /** 编码严格模式遇到无法表示的字符直接抛异常 */ public static byte[] encode(String text) throws CharacterCodingException { requireNonNull(text); CharsetEncoder enc StandardCharsets.UTF_8.newEncoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); ByteBuffer bb enc.encode(CharBuffer.wrap(text)); byte[] out new byte[bb.remaining()]; bb.get(out); return out; } /** 解码严格模式非法字节序列抛异常 */ public static String decode(byte[] bytes) throws CharacterCodingException { requireNonNull(bytes); CharsetDecoder dec StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); return dec.decode(ByteBuffer.wrap(bytes)).toString(); } /** 计算字符串在指定编码下的字节长度不做真实编码性能更好 */ public static int byteLength(String text, Charset charset) { CharsetEncoder enc charset.newEncoder(); int total 0; for (int i 0; i text.length(); ) { int cp text.codePointAt(i); i Character.charCount(cp); // 单个码点的编码长度ASCII 1多数欧洲字符 1-2中文 3增补平面 4 total (int) enc.maxBytesPerChar() 0 ? encodedLength(cp, charset) : 0; } return total; } private static int encodedLength(int codePoint, Charset charset) { if (charset.equals(StandardCharsets.UTF_8)) { if (codePoint 0x80) return 1; if (codePoint 0x800) return 2; if (codePoint 0x10000) return 3; return 4; } return String.valueOf(Character.toChars(codePoint)) .getBytes(charset).length; } }byteLength那块我特意给了两条路UTF-8 走位运算直接算零分配其他字符集退回实际编码取长度稍慢但通用。在做报文长度预估、短信分包、数据库字段长度校验时这个函数比直接getBytes().length省掉大量临时数组。5.3 上线前问自己五个问题写到这儿把判断逻辑压缩成五条遇到这类代码时挨个过一遍这个字节序列到底代表文本还是数据是数据就立刻停手改用 Base64 或十六进制。编码和解码两端用的是同一张表吗尤其是跨系统、跨语言、跨文件格式的地方要白纸黑字写进文档。字符集是显式写的还是默认的只要看到无参的getBytes()或new String(byte[])就当 bug 处理。长度字段是字节数还是字符数报文、数据库、截断场景一律按字节计。出错时是静默替换还是抛异常关键业务数据用严格模式展示类数据可以用宽容模式。5.4 最后分享一个排查小技巧线上出乱码时最快的定位方式不是加日志重跑而是在本地复现字节层面。把出问题的那段字符串的十六进制打出来再对比预期的编码结果。比如用户名字显示成测试你把它按 ISO-8859-1 编码拿到E6 B5 8B E8 AF 95一看就是标准 UTF-8 的中文序列问题立刻定位在解码环节用错了表。这一步花不到一分钟比翻代码快得多。我在实际使用中发现编码问题之所以难查是因为它从来不会在出错的那一刻报错而是等数据流转了好几层之后在一个完全无关的地方以乱码的形式冒出来。所以养成一个习惯凡是String与byte[]交界的地方就是编码风险点也是你写代码时最该停一秒的地方。这一秒能省掉后面几小时的排查。