Java 是大规模数据采集的一匹主力:成熟的 HTTP 客户端、真正的线程,以及让长时间运行的爬虫可观测的 JVM 工具链。把一个住宅代理接进大多数团队所用的两个客户端之一——OkHttp 和 Apache HttpClient——都很直接。摩擦在于每个库处理方式不同的那些细节:代理认证是怎么提供的、一个会悄无声息地限住你的连接池默认值,以及那条关于消费响应、决定池化是否根本能工作的规则。
这是与用 Python 使用住宅代理、用 Playwright、用 Go同一系列的 Java 篇:两个客户端能跑的代码,加上 Java 特有的那些陷阱。
下面的一切都使用 Shifter 的住宅 gateway:一个端点 p.shifter.io:443,所有定位都编码在用户名里。换成别家供应商,就换掉主机和凭据;形态是一样的。
一段话讲清 gateway 模型
代理用户名同时承载你的认证和你的定位。你不是靠切换端点来换国家或换会话,而是靠改用户名字符串:
customer-USERNAME-country-us-sid-abc123-ttl-600country-us 定位美国,sid 固定一个粘性会话,ttl 把那个 IP 保持 N 秒。去掉 sid/ttl,每一条新连接都会轮换。密码是恒定的。有一处值得开门见山地指出:在这两个 Java 客户端里,代理凭据都不是放进代理 URL 的,它们要经由一个专门的认证机制。这正是人们最常搞错的地方。
OkHttp
OkHttp 用一个 Proxy 对象来表示地址,用一个单独的 proxyAuthenticator 来提供凭据。别试图把 user:pass@ 编进 URL;OkHttp 不会读它。
import okhttp3.*;import java.net.InetSocketAddress;import java.net.Proxy;import java.io.IOException;
public class ProxyExample { public static void main(String[] args) throws IOException { String user = System.getenv("SHIFTER_USER") + "-country-us"; String pass = System.getenv("SHIFTER_PASS");
OkHttpClient client = new OkHttpClient.Builder() .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("p.shifter.io", 443))) .proxyAuthenticator((route, response) -> { // 当代理返回 407 时被调用。附上 Proxy-Authorization。 String credential = Credentials.basic(user, pass); return response.request().newBuilder() .header("Proxy-Authorization", credential) .build(); }) .build();
Request request = new Request.Builder().url("https://api.ipify.org").build(); try (Response response = client.newCall(request).execute()) { // try-with-resources 关闭 body System.out.println(response.body().string()); // 一个美国住宅 IP } }}有两件事要内化。user 字符串里包含了定位标志(-country-us),因为地理就住在那里。而围绕 Response 的 try-with-resources 不是可选的写法风格,它会关闭响应 body,而那正是把连接还回池里的动作。
复用客户端。 OkHttpClient 就是被设计成创建一次、然后共享的;它持有连接池和线程分发器(dispatcher),而且是线程安全的。每个请求都造一个,会把池化丢掉、并泄漏资源。要按请求变换身份,就用 newBuilder() 派生一个变体,它会共享底层的池和分发器:
// 一个基础客户端,共享。按身份的变体复用它的池 + 分发器。OkHttpClient forGeo(OkHttpClient base, String country, String sid) { String user = System.getenv("SHIFTER_USER") + "-country-" + country + (sid != null ? "-sid-" + sid + "-ttl-600" : ""); String pass = System.getenv("SHIFTER_PASS"); return base.newBuilder() .proxyAuthenticator((route, resp) -> resp.request().newBuilder() .header("Proxy-Authorization", Credentials.basic(user, pass)) .build()) .build();}给每个逻辑工作单元它自己的 sid,并在单元之间轮换,而不是在流程中途轮换(粘性 vs 轮换讲了这个区分)。
Apache HttpClient(5.x)
Apache HttpClient 通过 request config 或一个 route planner 来提供代理,通过一个作用域限定到代理主机的 CredentialsProvider 来提供凭据。
import org.apache.hc.client5.http.classic.methods.HttpGet;import org.apache.hc.client5.http.impl.classic.*;import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;import org.apache.hc.client5.http.auth.*;import org.apache.hc.client5.http.config.RequestConfig;import org.apache.hc.core5.http.HttpHost;import org.apache.hc.core5.util.Timeout;
public class ApacheProxyExample { public static void main(String[] args) throws Exception { HttpHost proxy = new HttpHost("http", "p.shifter.io", 443); String user = System.getenv("SHIFTER_USER") + "-country-us"; char[] pass = System.getenv("SHIFTER_PASS").toCharArray();
BasicCredentialsProvider creds = new BasicCredentialsProvider(); creds.setCredentials(new AuthScope(proxy), new UsernamePasswordCredentials(user, pass));
// 连接池:把每路由从默认的 5 提上去(见下面的坑)。 PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(50);
RequestConfig config = RequestConfig.custom() .setProxy(proxy) .setConnectTimeout(Timeout.ofSeconds(10)) // 连接阶段 .setResponseTimeout(Timeout.ofSeconds(30)) // 响应阶段 .build();
try (CloseableHttpClient client = HttpClients.custom() .setConnectionManager(cm) .setDefaultCredentialsProvider(creds) .setDefaultRequestConfig(config) .build()) {
HttpGet get = new HttpGet("https://api.ipify.org"); // 对响应用 try-with-resources 会消费 + 释放连接。 try (var response = client.execute(get)) { System.out.println(new String(response.getEntity().getContent().readAllBytes())); } } }}注意连接超时和响应超时是分开的,这个”连接对响应”的划分,正是让超时可被诊断的关键——当有东西挂住时。
坑 1:Apache 默认每路由最大是 5
这是 Go 里 MaxIdleConnsPerHost 那个陷阱的 Java 对应物,而且咬得很凶。PoolingHttpClientConnectionManager 在旧版本里默认是每路由 2 条、总共 20 条连接,在 5.x 里每路由上限也偏低。对着一个主机跑 50 个线程,其中大多数会阻塞着等一条连接空出来,而这看起来就跟一个慢代理一模一样。
把连接池至少设到你按主机的并发数:
cm.setMaxTotal(200);cm.setDefaultMaxPerRoute(50); // >= 你按主机的并发数如果无论你加多少线程,你爬虫的吞吐都上不去,这个默认值就是第一个该查的地方。
坑 2:消费 entity,否则连接永远不回来
两个客户端都会池化连接,而且都只有在响应被完整消费并关闭后,才会把连接还回池里。在 Apache HttpClient 里,一个未被消费的 entity 会让连接一直被占着;这么干得多了,哪怕从技术上没有任何东西在泄漏,你的池也会被饿死。
对响应用 try-with-resources(如上),或显式消费:
import org.apache.hc.core5.http.io.entity.EntityUtils;// ...EntityUtils.consume(response.getEntity()); // 排空 + 释放连接陷阱在于提前退出:在非 200 状态时不消费 entity 就退出,会让连接搁浅。在一个会撞上大量封锁的抓取器里,那就是你的大部分流量,而池会悄无声息地死掉。
坑 3:永远复用客户端
OkHttpClient 和 CloseableHttpClient 都是重量级、线程安全、被设计成在整个应用中共享的。它们持有连接池,而每个请求都造一个新的,就要每次都付一次穿过代理的完整 TCP + TLS 握手,正是延迟指南存在的意义所要消除的那部分开销。在启动时构建一个、注入它,并且只在你必须变换身份时才派生请求级的变体。
轮换地理与会话
因为定位住在用户名里,不同的身份就是不同的凭据字符串。
OkHttp: 按身份用 newBuilder() 派生一个客户端(上面展示过);它复用共享的池,所以依然高效。
Apache HttpClient: 按请求附上一个 HttpClientContext,其中携带该身份的 CredentialsProvider,这样一个客户端就能服务多个身份、无需重建:
HttpClientContext ctx = HttpClientContext.create();BasicCredentialsProvider perCall = new BasicCredentialsProvider();perCall.setCredentials(new AuthScope(proxy), new UsernamePasswordCredentials(userFor("de", "job-42"), pass));ctx.setCredentialsProvider(perCall);client.execute(get, ctx, resp -> { /* 处理 */ return null; });在逻辑工作单元之间轮换、而不是在一次之内,并按负载均衡那篇所述把工作映射到身份。
并发,按主机
把并发按目标主机加以约束,而不是用一个全局上限,好让脆弱的目标不被猛击、宽松的目标也不被饿死。每个主机一个 Semaphore 是最简单的表达:
Map<String, Semaphore> limits = Map.of( "tough-site.example", new Semaphore(4), "open-site.example", new Semaphore(32));
void fetch(String host, Runnable work) throws InterruptedException { Semaphore sem = limits.get(host); sem.acquire(); try { work.run(); } finally { sem.release(); }}越过一个目标的容忍度之后,更多线程买来的是封锁,而不是吞吐量(如何避免被封)。把连接池的每路由上限和你的每主机信号量一起来定。
验证你确实在走代理
在给别的任何东西做基准或调试之前,先确认出口 IP:
Request req = new Request.Builder().url("http://ip-api.com/json").build();try (Response r = client.newCall(req).execute()) { System.out.println(r.body().string()); // 期望是目标国家的一个住宅 IP}返回你自己的 IP,说明客户端没在用代理。挂住不动,说明本地出站被挡了。这两种情况都在超时诊断指南里有讲。
常见问题
为什么 http://user:pass@host 在 Java 里对代理不管用?
OkHttp 和 Apache HttpClient 都不会从 URL 里读取内联的代理凭据。OkHttp 用一个会设置 Proxy-Authorization 的 proxyAuthenticator;Apache 用一个作用域限定到代理主机的 CredentialsProvider。因为 gateway 把定位编码在用户名里,所以那个用户名要进认证器/凭据里,而不是进 URL。
我的 Java 抓取器在有负载时会卡,哪怕只用一个客户端。为什么?
最有可能的是 Apache 池的每路由上限(默认偏低)在限住你,或者你没有消费响应 entity、于是连接从不回到池里。把 setDefaultMaxPerRoute 提到你的并发数,并用 try-with-resources 消费每一个响应。
在 Java 里我怎么按请求轮换 IP?
变换代理用户名。在 OkHttp 里,用 newBuilder() 按身份派生一个客户端(它共享池)。在 Apache HttpClient 里,按请求传一个带该身份 CredentialsProvider 的 HttpClientContext。两者都能让一个共享客户端服务多个身份。
我该把连接超时和响应超时分开设吗? 该。一个单独的连接超时和一个响应/套接字超时,让你能把慢连接(你这侧,或没有匹配的 IP)和慢响应(目标)区分开,这正是诊断超时时的关键区分。单独一个笼统的超时会掩盖到底是哪个阶段失败了。
抓取该用 OkHttp 还是 Apache HttpClient? 两者都好用。OkHttp 更轻、API 更干净;Apache HttpClient 更可配置、也历史更久。按易用性和你已有的依赖来选;上面的代理配置和那些坑,对两者都适用。
底线
一旦你尊重每个客户端的规矩,Java 加住宅代理就很稳:通过正确的机制提供代理凭据(OkHttp 的 proxyAuthenticator、Apache 的 CredentialsProvider),绝不内联到 URL 里;共享一个长生命周期的客户端、并由它派生身份变体;把 Apache 的每路由池上限提到与你的并发相匹配;永远消费并关闭响应,好让连接回到池里;并把连接超时和响应超时分开。用变换代理用户名来切换地理或会话,并按主机约束并发。
把这些做对,两个客户端就都会跑出 JVM 应有的样子。把它们指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。