知识

如何在 Java 中用 OkHttp 和 Apache HttpClient 使用住宅代理

Java 里的代理:OkHttp 的 proxyAuthenticator 坑、Apache HttpClient 默认每路由 5 的连接池上限、entity 消费,以及按请求的地理轮换。

Chris Collins

Chris Collins

2026年7月25日 · 3 分钟阅读

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-600

country-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),因为地理就住在那里。而围绕 Responsetry-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:永远复用客户端

OkHttpClientCloseableHttpClient 都是重量级、线程安全、被设计成在整个应用中共享的。它们持有连接池,而每个请求都造一个新的,就要每次都付一次穿过代理的完整 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-AuthorizationproxyAuthenticator;Apache 用一个作用域限定到代理主机的 CredentialsProvider。因为 gateway 把定位编码在用户名里,所以那个用户名要进认证器/凭据里,而不是进 URL。

我的 Java 抓取器在有负载时会卡,哪怕只用一个客户端。为什么? 最有可能的是 Apache 池的每路由上限(默认偏低)在限住你,或者你没有消费响应 entity、于是连接从不回到池里。把 setDefaultMaxPerRoute 提到你的并发数,并用 try-with-resources 消费每一个响应。

在 Java 里我怎么按请求轮换 IP? 变换代理用户名。在 OkHttp 里,用 newBuilder() 按身份派生一个客户端(它共享池)。在 Apache HttpClient 里,按请求传一个带该身份 CredentialsProviderHttpClientContext。两者都能让一个共享客户端服务多个身份。

我该把连接超时和响应超时分开设吗? 该。一个单独的连接超时和一个响应/套接字超时,让你能把慢连接(你这侧,或没有匹配的 IP)和慢响应(目标)区分开,这正是诊断超时时的关键区分。单独一个笼统的超时会掩盖到底是哪个阶段失败了。

抓取该用 OkHttp 还是 Apache HttpClient? 两者都好用。OkHttp 更轻、API 更干净;Apache HttpClient 更可配置、也历史更久。按易用性和你已有的依赖来选;上面的代理配置和那些坑,对两者都适用。

底线

一旦你尊重每个客户端的规矩,Java 加住宅代理就很稳:通过正确的机制提供代理凭据(OkHttp 的 proxyAuthenticator、Apache 的 CredentialsProvider),绝不内联到 URL 里;共享一个长生命周期的客户端、并由它派生身份变体;把 Apache 的每路由池上限提到与你的并发相匹配;永远消费并关闭响应,好让连接回到池里;并把连接超时和响应超时分开。用变换代理用户名来切换地理或会话,并按主机约束并发。

把这些做对,两个客户端就都会跑出 JVM 应有的样子。把它们指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始