本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:xttblog2,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
【腾讯云】1核2G5M轻量应用服务器50元首年,高性价比,助您轻松上云
前段时间,我在朋友圈说,我使用 AI 半小时不到帮助业务搞定了她原本需要一天才能搞定的工作。
当时我用的是 WorkBuddy,主要是那个时候它有签到捡积分,相当于不花钱。我用 AI 给业务做了一个任务,相当于可以定时执行,美滋滋。
但是随着时间的推移,业务的积分快用完了。于是,我就让 WorkBuddy 把任务用 Java 代码固定下来,后面直接在内网服务器上定时执行即可。
这个任务是一个多线程爬虫脚本。有时它能完成工作,有时却假死了。没有报错,也没有退出。
之前忙,也没时间去排查问题,就让业务自己重启一下服务。
今天闲了一下,被业务看到了,业务让我给她看看具体的问题。我一番操作,竟然踩坑了 Java 17!Jsoup 爬虫永久卡死,竟是 JDK 底层惹的祸?
文章配图参见 https://mp.weixin.qq.com/s/z420Pl5plV7p8vHuD5HH0g。
诡异的“假死”
AI 的爬虫逻辑很简单,使用 CountDownLatch 开启 10 个线程并发抓取,每个线程负责分页查询数据库中的待抓取 URL,然后使用 Jsoup 发起请求并解析入库。整个代码不超过 300 行。
其中的核心代码片段如下所示。
// 设置 15 秒超时,伪装 User-Agent
Document doc = Jsoup.connect(targetUrl)
.timeout(15000)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...")
.get();
程序跑了半个小时后,彻底没动静了。为了定位问题,我果断使用 jstack <PID> 导出了线程快照(Thread Dump),结果发现了令人窒息的一幕,主线程在“死等”。
// 主线程等待子线程结束
"main" #1 ...
java.lang.Thread.State: WAITING (parking)
at java.util.concurrent.CountDownLatch.await(...)
at com.xttblog.crawler.BatchCrawlerTask.start(...)
而对应的子线程掉进了“网络黑洞”里。
AI 分析线程快照后,发现好几个子线程都卡在了一样的地方。
"Thread-20" #83 ...
java.lang.Thread.State: WAITING (parking)
at java.util.zip.GZIPInputStream.read(...)
at jdk.internal.net.http.ResponseSubscribers$HttpResponseInputStream.read(...)
at org.jsoup.helper.HttpConnection.get(...) // Jsoup 请求卡死
疑点来了,AI 明明设置了 .timeout(15000),为什么 15 秒后没有抛出 SocketTimeoutException,反而无限期地卡在了 GZIPInputStream.read() 上?
JDK 11+ 底层缺陷
我继续让 AI 分析了一下,发现 AI 说网上确实也有很多人以为这是 Jsoup 的 Bug,或者认为是目标网站的反爬虫机制太厉害。但实际上,这是 Java 11 引入并延续至今的 java.net.http.HttpClient 的一个著名底层设计缺陷!
为什么 Jsoup 会踩坑?
从 JDK 11 开始,Java 内置了全新的 HttpClient。Jsoup 底层做了环境适配,当检测到运行环境是 Java 11+(包括 Java 17)时,它会“聪明”地放弃老旧的 HttpURLConnection,改用新的 HttpClient 来发起请求。
超时失效底层原因
在 HttpClient 的设计中,timeout() 的作用域存在盲区。
它只能限制建立连接和等待响应头的时间。一旦服务器返回了 HTTP 200 状态码和 Header,HttpClient 就认为“请求已成功”,撤销了超时定时器。于是,当我们使用流式读取(Jsoup 为节省内存默认使用流)时,如果服务器不发送后续数据,底层的 InputStream.read() 就会无视超时设置,永久阻塞。
这个问题已经有很多人遇到了,导致该问题在 OpenJDK 官方 Bug 库中有不少记录,如 JDK-8219134、JDK-8255250 等,核心痛点在于流式读取阶段缺乏底层 Socket Read Timeout 的控制。
反爬虫机制的神助攻
国内外,尤其是一些海外很多网站(或 WAF 防火墙)在检测到爬虫高频访问时,不会直接返回 403,而是采用恶心的 “TCP 黑洞/半连接”策略,即:
- 正常返回 HTTP 200 和
Content-Encoding: gzip响应头。 - 发送前几个字节的 GZIP 数据(刚好够通过魔数校验)。
- 停止发送任何数据,但不断开 TCP 连接。
此时,Java 的 GZIPInputStream 发现数据不够解压,就会疯狂向底层的 HttpClient 流索要数据。而底层 Socket 因为没收到断开信号,就在那里死等。
定时器已撤销,连接未断开,你的线程就这样被永久“挂起”了!子线程无法结束,countDown() 无法执行,主线程的 CountDownLatch.await() 自然就成了死锁。
破局之法
既然知道了这是 JDK 新 HTTP 客户端的“胎里带”缺陷,就不应该再试图去调优 Jsoup 的 timeout() 了。针对不可控的外部公网爬虫场景,AI 又给了两条绝对可靠的出路。
使用 HttpURLConnection
HttpURLConnection 虽然 API 繁琐,但它的 setReadTimeout() 是直接映射到操作系统底层 Socket 的 SO_TIMEOUT 上的。无论服务器怎么伪装、GZIP 怎么卡半截,时间一到,操作系统强行抛异常,绝对可靠!
Document doc = null;
HttpURLConnection conn = null;
try {
URL url = new URL(targetUrl);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestProperty("User-Agent", "Mozilla/5.0 ...");
conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
conn.setConnectTimeout(10000);
conn.setReadTimeout(15000); // 底层 SO_TIMEOUT,绝对生效!
if (conn.getResponseCode() == 200) {
try (InputStream in = conn.getInputStream()) {
InputStream decodedStream = in;
if ("gzip".equalsIgnoreCase(conn.getContentEncoding())) {
decodedStream = new java.util.zip.GZIPInputStream(in);
}
// 将解压后的流喂给 Jsoup 解析
doc = Jsoup.parse(decodedStream, "UTF-8", targetUrl);
}
}
} finally {
// 核心:无论成功、超时还是报错,必须强制断开底层 TCP 连接!
if (conn != null) {
conn.disconnect();
}
}
爬虫标配 OkHttp
另外一种方案是,引入 OkHttp。它拥有极其完善的连接池和严格的 readTimeout 控制,是对抗“反爬虫黑洞”的最强武器。
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS) // 对流读取绝对有效
.build();
Request request = new Request.Builder().url(targetUrl).build();
try (Response response = client.newCall(request).execute()) {
Document doc = Jsoup.parse(response.body().byteStream(), "UTF-8", targetUrl);
// ... 解析逻辑
}
反正,我不懂爬虫,AI 说啥就是啥,顺手让 AI 给改了就行。
总结
AI 老好用了,以至于钉钉的无招说出了日抛这种话。
日抛的越多,犯错了谁来背锅呢?AI 写的代码是不是需要 check 呢?AI 被投毒了怎么办?AI 写的就不用测试了吗?
总之,AI 可以用,但程序员必须对它的一切负责。Linus Torvalds 在 Linux 社区也说了,内核也可以用 AI 来写,但是谁提交的,谁负责。

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号:xttblog2。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!
本文原文出处:业余草: » AI 写爬虫用了 Jsoup,踩坑 JDK 17 底层 Bug