处理过老项目的人,估计都跟Java的URLConnection打过交道。这玩意儿是JDK自带的最原始的HTTP客户端,用起来繁琐,但架不住它不用引任何第三方依赖,在一些维护了很多年的老系统里,业务代码还在靠它跑着日任务、同步数据、调内部接口。今天聊的这个坑,就是从这些老代码里最常见的:用URLConnection发送POST请求,带上中文参数,服务端收到了乱码——要么是“浣犲ソ”这种形似汉字却根本看不懂的鬼东西,要么就是一堆问号,运气好点的可能是“锟斤拷”三字经。
乱码是结果,根源在于编码不一致。具体说,是写字节流的那一步没有指定字符集,或者指定错了。这篇文章直接把这个问题的前因后果、正确写法、踩坑全流程讲透,适合正在维护老项目、或者需要临时用原生JDK写网络请求的开发者。
1. 先搞清楚乱码是怎么产生的
1.1 一段必然翻车的代码
先看一段典型的“看起来没问题”的代码。很多刚接触URLConnection的人,甚至是写过几年的老手,都写过类似的东西:
public static String post(String urlStr, String body) throws IOException { URL url = new URL(urlStr); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); OutputStream os = conn.getOutputStream(); os.write(body.getBytes()); // 问题就出在这一行 os.flush(); os.close(); return readResponse(conn); }调用的时候传一个带中文的body,比如name=张三&age=25,服务端拿到手就成了name=寮犱笁&age=25。
这个寮犱笁就是典型的UTF-8字节被GBK解码后产生的乱码——三个字节被拆成两个两个的读,拼出了两个完全不相关的中文字符。如果是反过来,GBK字节被UTF-8解码,多半会直接出现??或者一堆Unicode替换符�。
问题几乎都可以锁定在body.getBytes()这一行。String.getBytes()不带参数时,会使用JVM默认字符集。开发机上Windows中文系统默认就是GBK,Linux服务器多半是UTF-8。同样的代码,不同的环境跑出来的结果完全不一样,这就是为什么有些人在本地测得好好的,一部署到Linux服务器上就开始乱码。
1.2 编码问题的本质:字符到字节的翻译规则
要说清楚乱码,得回到编码的最基本概念。我们在代码里写的String,在JVM内存里是以Unicode字符形式存在的,本身跟文件系统、网络传输没有关系。当我们要把一个字符串通过网络发送出去的时候,必须把它转换成字节序列,这个“字符到字节”的翻译规则,就叫字符集。
中文在主要字符集里的字节长度差异非常大,这是很多坑的根源:
- UTF-8:一个汉字3个字节
- GBK:一个汉字2个字节
- ISO-8859-1:不支持中文,中文会变成
?
"张三"这个字符串,用UTF-8编码是E5 BC A0 E4 B8 89共6个字节,用GBK编码是D5 C5 C8 FD共4个字节。同样的字节序列,如果收发双方用的翻译规则不一致,要么多读字节、要么少读字节、要么直接丢字节,乱码就是必然的。
URLConnection的getOutputStream()返回的是原始的字节输出流,它不做任何编码转换,你写进去什么字节,它就原封不动发出去。所以编码这一步的责任完全在调用方。忘记指定字符集,就等同于把选择权交给了运行环境的默认配置,这在跨平台部署时就是个定时炸弹。
2. 正确发送POST请求的核心写法
2.1 在写字节时显式指定字符集
既然问题出在字节转换这一步,最直接、最根本的修法就是不要让String自己决定字符集,而是明确告诉它用哪种编码。
OutputStream os = conn.getOutputStream(); os.write(body.getBytes("UTF-8")); os.flush(); os.close();就这么一行改动,乱码问题就解决了一大半。但实际生产中,我更推荐用OutputStreamWriter来包一层,而不是直接传byte[]:
OutputStreamWriter writer = new OutputStreamWriter(conn.getOutputStream(), "UTF-8"); writer.write(body); writer.flush(); writer.close();两种方式本质一样,都是把字符串按UTF-8转成字节再写给输出流。区别在于OutputStreamWriter更直观,后续如果要做字符串级别的写入、追加、格式化,会方便不少。
这里有个细节很多人会忽略:JVM 1.7之后,String.getBytes("UTF-8")这种带字符集参数的写法不会抛出UnsupportedEncodingException——等等,实际上这个方法签名还是会抛。Java 10开始,直接传StandardCharsets.UTF_8更优雅:
os.write(body.getBytes(StandardCharsets.UTF_8));用StandardCharsets的好处是不用写try-catch,不会因为手滑把"UTF-8"拼成"UTF8"或者"utf-8"这种运行时才发现问题的情况。虽然"UTF-8"和"utf-8"解析结果一样,但拼写检查是编译期做不了的。
2.2 Content-Type里的charset只是半张护身符
修好了字节流,接下来是请求头。很多教程会让你这样写:
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");这个charset=UTF-8的作用,是告诉服务端“我这个请求体的字节是按UTF-8编码的,请按UTF-8解码”。它确实重要,但必须跟实际写字节时用的编码保持一致。
如果写了charset=UTF-8,但body.getBytes()用的是GBK默认编码,服务端按UTF-8去解GBK字节,乱码依旧。反过来,如果写字节时用了UTF-8,但没设置charset,服务端可能按自己的默认编码(有些老框架默认ISO-8859-1)去解,也会乱。
所以这两件事不是二选一,而是必须同时做对:
- 写字节时显式指定UTF-8
- 请求头里声明
charset=UTF-8
打个比方,这就好比寄快递:你发货时要用统一的包装盒(字节编码),快递单上要写明收货地点(charset声明),包装盒和单据信息必须对应,快递才能准确送达。
2.3 表单参数别忘了URL编码
POST表单请求还有一个跟乱码形影不离的问题:application/x-www-form-urlencoded格式要求所有非ASCII字符和特殊字符都要做URL编码。
String param1 = URLEncoder.encode("张三", "UTF-8"); String param2 = URLEncoder.encode("25", "UTF-8"); String body = "name=" + param1 + "&age=" + param2;URL编码之后,中文变成一个%开头的一串十六进制字符,比如%E5%BC%A0%E4%B8%89,这些字符都是ASCII,不会产生编码歧义。服务端拿到参数后会用URLDecoder.decode(value, "UTF-8")还原成中文。
这里有一个非常隐蔽的坑:URLDecoder和URLEncoder默认使用的字符集也是系统默认值。如果你用URLEncoder.encode("张三")不带参数,Windows下会用GBK编码,得到%D5%C5%C8%FD,服务端却按UTF-8解码,还原出来的就是错乱字符。所以编码和解码都要显式传UTF-8。
另外注意:空格会被编码成+,&、=也会被转义。如果业务参数里包含这些字符,老老实实用encode,不要自己手动拼接字符串再想起处理,那是最容易出边界bug的做法。
3. 可复用的标准POST请求工具方法
3.1 先写一个完整可用的版本
把上面的要点合成一个工具方法,这是我在项目中实际使用并打磨过的版本:
public static String doPost(String urlStr, Map<String, String> params, int connectTimeout, int readTimeout) throws IOException { URL url = new URL(urlStr); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setDoInput(true); conn.setUseCaches(false); conn.setConnectTimeout(connectTimeout); conn.setReadTimeout(readTimeout); conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); conn.setRequestProperty("Accept", "*/*"); StringBuilder paramBuilder = new StringBuilder(); for (Map.Entry<String, String> entry : params.entrySet()) { if (paramBuilder.length() > 0) { paramBuilder.append("&"); } paramBuilder.append(URLEncoder.encode(entry.getKey(), "UTF-8")) .append("=") .append(URLEncoder.encode(entry.getValue(), "UTF-8")); } byte[] bodyBytes = paramBuilder.toString().getBytes(StandardCharsets.UTF_8); conn.setFixedLengthStreamingMode(bodyBytes.length); OutputStream os = conn.getOutputStream(); os.write(bodyBytes); os.flush(); os.close(); int code = conn.getResponseCode(); if (code != HttpURLConnection.HTTP_OK) { throw new IOException("HTTP error code: " + code); } return readResponseBody(conn.getInputStream()); }这里有几个点值得展开说说:
第一,先拼好参数字符串,再一次性getBytes(StandardCharsets.UTF_8),不要分多次调用getBytes()。因为每次getBytes()的结果如果被缓存或重复使用,很容易出现部分参数用了UTF-8、部分参数走了默认字符集的情况。统一一次转换,字节流从头到尾编码一致。
第二,conn.setDoInput(true)容易被漏掉。POST请求一般需要读响应,不设置DoInput的话,部分环境下getInputStream()会报错或拿不到数据。虽然JDK的实现里,setDoOutput(true)时默认会连带把DoInput也设成true,但显式写出来更保险,语义也清晰。
第三,conn.setUseCaches(false)是针对GET请求缓存的问题,POST请求本身一般不缓存,但加上这个设置能避免一些代理服务器或服务器的异常缓存行为。
3.2 Content-Length的字节长度坑
上面代码里有一行conn.setFixedLengthStreamingMode(bodyBytes.length),这行代码背后藏着一个经典坑。
很多人在设置请求头的Content-Length时,会写成:
conn.setRequestProperty("Content-Length", String.valueOf(body.length()));这行代码是错误的,而且错得很隐蔽。body.length()返回的是字符个数,不是字节个数。"张三"这个字符串,字符长度是2,UTF-8编码后是6字节。如果你告诉服务端Content-Length是2,服务端会按2字节去读取请求体,读到E5 BC就结束了,剩下的A0 E4 B8 89会被当成下一次请求的一部分,或者直接导致协议解析错误。
正确做法是用body.getBytes(StandardCharsets.UTF_8).length来拿字节长度——但注意,如果你调用两遍getBytes(),理论上两次结果是一样的,但万一在这期间JVM的默认字符集被改(这在某些极端环境里真会发生),两遍结果就可能不一致。保险起见,像我上面的写法,先byte[] bodyBytes = ...,再用bodyBytes.length,永远只用同一份字节数组。
至于为什么用setFixedLengthStreamingMode而不是setRequestProperty("Content-Length", ...),原因是:setFixedLengthStreamingMode会在流写入过程中由JDK自动校验写入的字节数是否与声明一致。如果你实际写入的字节数超过了声明的长度,它会抛IOException,这等于把长度错误在客户端就暴露出来,而不是让服务端去猜。调试期开着这个模式,能省下大量排查时间。
3.3 连接超时和读取超时务必设置
URLConnection默认的超时时间是0,也就是无限等待。这在生产环境是个非常危险的行为——服务端如果挂了或者网络异常,你的线程会一直卡在getInputStream()上,看起来就是“程序卡死、请求无响应”。
conn.setConnectTimeout(3000); // 3秒连不上就放弃 conn.setReadTimeout(10000); // 10秒读不到数据就放弃具体数值根据业务场景调整,内部接口可以短一点,外部慢接口要留足余量。但绝对不要用0,除非你明确知道自己在做什么。
另外,HttpURLConnection在JDK 9+之后,如果没设置超时,在某些网络环境下底层连接还会带一个比较长的空闲超时,调试时更难复现问题。设了超时,既保护了线程资源,也让故障能更早暴露。
3.4 响应读取同样可能乱码
标题说的是POST请求乱码,但实际排查中,发送正常了,响应又乱码的情况太常见了。我大概有一半的时间都花在处理响应的读取上。
private static String readResponseBody(InputStream in) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; int len; while ((len = in.read(buffer)) != -1) { baos.write(buffer, 0, len); } return baos.toString("UTF-8"); }这里baos.toString("UTF-8")是按UTF-8解读响应字节。但严格来说,正确的做法是先去解析响应头Content-Type,看看服务端声明的是什么编码:
String contentType = conn.getContentType(); // 例如 "application/json; charset=utf-8" // 从这个字符串里解析出 charset 部分面对现实吧:很多老系统的接口响应头里根本没有charset,或者写的是charset=ISO-8859-1,但实际返回的是UTF-8字节。这种“声明的编码和实际编码不一致”的接口,只能靠抓包比对或者看对方代码来确认。
我的习惯是:响应体先按Content-Type里的charset解析,取不到就尝试UTF-8,如果得到的字符串里出现大量�替换符或一些不正常的字符,再回退尝试GBK。这个探测逻辑虽然朴素,但在我处理过的老系统里命中率很高。
4. 常见问题排查与避坑清单
4.1 不同乱码形态背后的编码组合
乱码的表现不是随机的,不同的乱码形态可以反推收发两端的编码差异。我把实际遇到过的乱码形态整理成了一张表:
| 客户端发送 | 服务端解码 | 乱码表现 | 典型场景 |
|---|---|---|---|
| UTF-8 | GBK | 鍙犱笁这类大量“鍙”开头字 | Windows服务端,客户端Linux |
| GBK | UTF-8 | ??或�替换符 | Linux服务端,客户端Windows |
| UTF-8 | ISO-8859-1 | å¼ ä¸这种带å的字符串 | 老框架默认ISO-8859-1 |
| GBK | ISO-8859-1 | 寮犱笁乱码中带正常一半 | 双端都错了但各错各的 |
这张表最大的价值是帮助定位方向。看到鍙开头的乱码,基本可以断定是UTF-8字节被GBK解码了,于是直接去查服务端解析方式;看到??,说明某个环节用了不支持中文的字符集。定位速度比盲目改配置快很多。
4.2 排查编码问题时必须查的四个环节
乱码问题不能盯着代码的一份文件看。HTTP请求从发起到服务端处理,中间有多个环节,每一环都可能篡改编码。我总结了四个必查环节:
第一环,客户端写入环节。也就是getBytes()、OutputStreamWriter用的是哪个字符集。维护老项目时,经常一个工具类里写死了UTF-8,另一个地方用getBytes()不带参数,两个场景混用,排查时得把每一处写字节的代码都看一遍。
第二环,请求头声明环节。服务端如何解析请求体,很大程度取决于Content-Type的charset。如果被调方用的框架是严格按照charset来解码的,那你声明错了,字节再对也没用。特别是某些严格场景下,charset缺失会直接回退到ISO-8859-1。
第三环,服务端接收解码环节。服务端框架的默认字符集、过滤器里有没有做编码转换、容器(如老版本Tomcat)的URIEncoding配置,都会影响最终解码结果。这个环节往往不在你的控制范围内,所以要尽量在客户端做到“字节正确+声明正确”,把问题抛给对方定位。
第四环,框架或中间件的自动转码。有些RPC框架或HTTP组件(不管是客户端还是服务端)会做隐形转码,比如把字符串先按一种编码取字节,再按另一种编码转回字符串。这种最恶心,排查时要在这些关键节点前后打印日志、对比十六进制内容。
排查乱码的正确姿势是:在客户端把送往网络的原始字节打出来,十六进制格式;同时在服务端入口把收到的原始字节打出来,仍然是十六进制;对比这两串字节。如果两串字节一致,说明传输没问题,问题出在某一侧的“字符串到字节”或“字节到字符串”转换环节。如果两串字节都不一致,那还有中间环节改了数据,直奔代理和网关查。
4.3 老项目改造时候选方案
如果你是在维护老项目,为了修一个乱码问题把URLConnection换成OkHttp或HttpClient,有时候动静太大,没那个必要。但如果这个工具类已经改过好几次,每次都因为编码问题出bug,那就值得考虑换掉了。
我自己的判断标准是:
- 只是某个接口乱码,其他都正常:直接修编码,改完加个测试用例钉死
- 工具类里到处是
getBytes()、new String(bytes)不带字符集:全局改造,统一走StandardCharsets.UTF_8 - 项目组还在用JDK 8,但允许引入第三方库:换OkHttp,API友好很多,编码处理更透明
- 项目已经升级到JDK 11+:直接换
java.net.http.HttpClient,不用引包
不管换成哪个库,核心的编码原理没有变:你仍然要负责、或者至少要清楚每个环节用了什么字符集。换了新库,只是API更不容易写错,不代表可以闭眼不管编码。
4.4 额外两个容易忽略的小坑
最后一个部分,讲两个我在实战中遇到过、但翻遍大部分教程都找不到答案的小问题。
第一个是conn.getOutputStream()和conn.getInputStream()的调用顺序。如果你先调用了getInputStream()再写getOutputStream(),会拿到一个输入流,然后写输出流时可能直接抛协议异常或数据错乱。正确顺序是先写出请求体,关闭输出流,再读取响应。
第二个是连接复用的坑。HttpURLConnection在JDK里默认开启了keep-alive,也就是说连接用完不立刻断开。如果你读取响应时没有把输入流完全读完就关闭,连接返回到连接池时还残留着未读数据,下一次复用这个连接时,很可能会读到上一次响应的残留内容,表现就是“第一次请求正常,第二次返回的数据串了”。修法是读取响应时循环读到read() == -1,确保整个响应体被完整消费。
这两个坑跟乱码原理无关,但都是老项目里排查“诡异请求行为”时的高频原因,顺手记在这里。
5. 最后的实战体会
这段代码我踩过的坑比写出来的字还多。刚开始遇到乱码时,我第一反应是改页面编码、改请求头,折腾半天发现根本没碰到病根。直到有一次,我把发送前后的原始字节用十六进制打出来,才开始真正理解编码这个事。
从那以后,我处理任何编码问题的第一步都是问两个问题:写字节时用了什么编码,读字节时按什么编码解析。这两个问题的答案一致,乱码就不存在;答案不一致,乱码一定存在。什么乱码形态、请求头声明、系统默认字符集,统统是围绕这两个问题展开的。
另外想给打算长期吃Java这碗饭的朋友一个建议:就算现在项目里已经在用OkHttp或者Java 11以上的HttpClient,也值得花半天时间把URLConnection的这套编码机制摸清楚。因为HTTP请求的本质从来没有什么变化——把字符串变成字节、定义好字节的含义、传输到对端、对端按同样的规则还原成字符串。新库只是把这套逻辑包装得更易懂,底层还是在回答这两个问题。
如果你现在还在被URLConnection的乱码搞得头疼,按文章的顺序检查一遍:先看getBytes()带了什么参数,再看Content-Type的charset写没写对,再看参数有没有URLEncoder,最后抓一下服务端的原始字节。四步走完,乱码的影子基本就找着了。