OkHttp系列文章如下
HTTP中的keepalive连接
在网络性能优化中,对于延迟降低与速度提升的有非常重要的作用。
通常我们进行http连接时,首先进行tcp握手,然后传输数据,最后释放
图源: Nginx closed
这种方法的确简单,但是在复杂的网络内容中就不够用了,创建socket需要进行3次握手,而释放socket需要2次握手(或者是4次)。重复的连接与释放tcp连接就像每次仅仅挤1mm的牙膏就合上牙膏盖子接着再打开接着挤一样。而每次连接大概是TTL一次的时间(也就是ping一次),在TLS环境下消耗的时间就更多了。很明显,当访问复杂网络时,延时(而不是带宽)将成为非常重要的因素。
当然,上面的问题早已经解决了,在http中有一种叫做keepalive connections
的机制,它可以在传输数据后仍然保持连接,当客户端需要再次获取数据时,直接使用刚刚空闲下来的连接而不需要再次握手
图源: Nginx keep_alive
在现代浏览器中,一般同时开启6~8个keepalive connections
的socket连接,并保持一定的链路生命,当不需要时再关闭;而在服务器中,一般是由软件根据负载情况(比如FD最大值、Socket内存、超时时间、栈内存、栈数量等)决定是否主动关闭。
当然keepalive也有缺点,在提高了单个客户端性能的同时,复用却阻碍了其他客户端的链路速度,具体来说如下
好了,以上科普完毕,本文主要是写客户端的,服务端不再介绍。
下文假设服务器是经过专业的运维配置好的,它默认开启了keep-alive
,并不主动关闭连接
首先先说下源码中关键的对象:
Call
: 对http的请求封装,属于程序员能够接触的上层高级代码Connection
: 对jdk的socket物理连接的包装,它内部有List<WeakReference<StreamAllocation>>
的引用StreamAllocation
: 表示Connection
被上层高级代码的引用次数ConnectionPool
: Socket连接池,对连接缓存进行回收与管理,与CommonPool有类似的设计Deque
: Deque也就是双端队列,双端队列同时具有队列和栈性质,经常在缓存中被使用,这个是java基础在okhttp中,连接池对用户,甚至开发者都是透明的。它自动创建连接池,自动进行泄漏连接回收,自动帮你管理线程池,提供了put/get/clear的接口,甚至内部调用都帮你写好了。
在以前的内存泄露中我写到,我们知道在socket连接中,也就是Connection
中,本质是封装好的流操作,除非手动close
掉连接,基本不会被GC掉,非常容易引发内存泄露。所以当涉及到并发socket编程时,我们就会非常紧张,往往写出来的代码都是try/catch/finally
的迷之缩进,却又对这样的代码无可奈何。
在okhttp中,在高层代码的调用中,使用了类似于引用计数的方式跟踪Socket流的调用,这里的计数对象是StreamAllocation
,它被反复执行与操作(点击函数可以进入github查看),这两个函数其实是在改变Connection
中的List<WeakReference<StreamAllocation>>
大小。List
中Allocation的数量也就是物理socket被引用的计数(Refference Count),如果计数为0的话,说明此连接没有被使用,是空闲的,需要通过下文的算法实现回收;如果上层代码仍然引用,就不需要关闭连接。
引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用。它不能处理循环引用的问题。
2.1. 实例化
在源码中,我们先找ConnectionPool
实例化的位置,它是直接new出来的,而它的各种操作却在OkHttpClient
的实现了Internal.instance
接口作为ConnectionPool
的包装。
至于为什么需要这么多此一举的分层包装,主要是为了让外部包的成员访问非
public
方法,详见这里
2.2. 构造
连接池内部维护了一个叫做OkHttp ConnectionPool
的ThreadPool
,专门用来淘汰末位的socket,当满足以下条件时,就会进行末位淘汰,非常像GC
1. 并发socket空闲连接超过5个
2. 某个socket的keepalive时间大于5分钟
维护着一个Deque<Connection>
,提供get/put/remove等数据结构的功能
维护着一个RouteDatabase
,它用来记录连接失败的Route
的黑名单,当连接失败的时候就会把失败的线路加进去(本文不讨论)
2.3 put/get操作
在连接池中,提供如下的操作,这里可以看成是对deque的一个简单的包装
//从连接池中获取
get
//放入连接池
put
//线程变成空闲,并调用清理线程池
connectionBecameIdle
//关闭所有连接
evictAll
随着上述操作被更高级的对象调用,Connection
中的StreamAllocation
被不断的与,也就是List<WeakReference<StreamAllocation>>
的大小将时刻变化
2.4 Connection自动回收的实现
java内部有垃圾回收GC,okhttp有socket的回收;垃圾回收是根据对象的引用树实现的,而okhttp是根据RealConnection
的虚引用StreamAllocation
引用计数是否为0实现的。我们先看代码
cleanupRunnable:
当用户socket连接成功,向连接池中put
新的socket时,回收函数会被主动调用,线程池就会执行cleanupRunnable
,如下
//Socket清理的Runnable,每当put操作时,就会被主动调用
//注意put操作是在网络线程
//而Socket清理是在`OkHttp ConnectionPool`线程池中调用
while (true) {
//执行清理并返回下场需要清理的时间
long waitNanos = cleanup(System.nanoTime());
if (waitNanos == -1) return;
if (waitNanos > 0) {
synchronized (ConnectionPool.this) {
try {
//在timeout内释放锁与时间片
ConnectionPool.this.wait(TimeUnit.NANOSECONDS.toMillis(waitNanos));
} catch (InterruptedException ignored) {
}
}
}
}
这段死循环实际上是一个阻塞的清理任务,首先进行清理(clean),并返回下次需要清理的间隔时间,然后调用wait(timeout)
进行等待以释放锁与时间片,当等待时间到了后,再次进行清理,并返回下次要清理的间隔时间...
Cleanup:
使用了类似于GC的标记-清除算法
,也就是首先标记出最不活跃的连接(我们可以叫做泄漏连接
,或者空闲连接
),接着进行清除,流程如下:
long cleanup(long now) {
int inUseConnectionCount = 0;
int idleConnectionCount = 0;
RealConnection longestIdleConnection = null;
long longestIdleDurationNs = Long.MIN_VALUE;
//遍历`Deque`中所有的`RealConnection`,标记泄漏的连接
synchronized (this) {
for (RealConnection connection : connections) {
// 查询此连接内部StreamAllocation的引用数量
if (pruneAndGetAllocationCount(connection, now) > 0) {
inUseConnectionCount++;
continue;
}
idleConnectionCount++;
//选择排序法,标记出空闲连接
long idleDurationNs = now - connection.idleAtNanos;
if (idleDurationNs > longestIdleDurationNs) {
longestIdleDurationNs = idleDurationNs;
longestIdleConnection = connection;
}
}
if (longestIdleDurationNs >= this.keepAliveDurationNs
|| idleConnectionCount > this.maxIdleConnections) {
//如果(`空闲socket连接超过5个`
//且`keepalive时间大于5分钟`)
//就将此泄漏连接从`Deque`中移除
connections.remove(longestIdleConnection);
} else if (idleConnectionCount > 0) {
//返回此连接即将到期的时间,供下次清理
//这里依据是在上文`connectionBecameIdle`中设定的计时
return keepAliveDurationNs - longestIdleDurationNs;
} else if (inUseConnectionCount > 0) {
//全部都是活跃的连接,5分钟后再次清理
return keepAliveDurationNs;
} else {
//没有任何连接,跳出循环
cleanupRunning = false;
return -1;
}
}
//关闭连接,返回`0`,也就是立刻再次清理
closeQuietly(longestIdleConnection.socket());
return 0;
}
太长不想看的话,就是如下的流程:
Deque
中所有的RealConnection
,标记泄漏的连接空闲socket连接超过5个
&&keepalive时间大于5分钟
),就将此连接从Deque
中移除,并关闭连接,返回0
,也就是将要执行wait(0)
,提醒立刻再次扫描目前还可以塞得下5个连接,但是有可能泄漏的连接(即空闲时间即将达到5分钟)
),就返回此连接即将到期的剩余时间,供下次清理全部都是活跃的连接
),就返回默认的keep-alive
时间,也就是5分钟后再执行清理没有任何连接
),就返回-1
,跳出清理的死循环再次注意:这里的“并发”==(“空闲”+“活跃”)==5,而不是说并发连接就一定是活跃的连接
pruneAndGetAllocationCount:
如何标记并找到最不活跃的连接呢,这里使用了pruneAndGetAllocationCount
的,它主要依据弱引用是否为null
而判断这个连接是否泄漏
//类似于引用计数法,如果引用全部为空,返回立刻清理
private int pruneAndGetAllocationCount(RealConnection connection, long now) {
//虚引用列表
List<Reference<StreamAllocation>> references = connection.allocations;
//遍历弱引用列表
for (int i = 0; i < references.size(); ) {
Reference<StreamAllocation> reference = references.get(i);
//如果正在被使用,跳过,接着循环
//是否置空是在上文`connectionBecameIdle`的`release`控制的
if (reference.get() != null) {
//非常明显的引用计数
i++;
continue;
}
//否则移除引用
references.remove(i);
connection.noNewStreams = true;
//如果所有分配的流均没了,标记为已经距离现在空闲了5分钟
if (references.isEmpty()) {
connection.idleAtNanos = now - keepAliveDurationNs;
return 0;
}
}
return references.size();
}
RealConnection
连接中的StreamAllocationList
,它维护着一个弱引用列表StreamAllocation
是否为空(它是在线程池的put/remove手动控制的),如果为空,说明已经没有代码引用这个对象了,需要在List中删除StreamAllocation
删空了,就返回0
,表示这个连接已经没有代码引用了,是泄漏的连接
;否则返回非0的值,表示这个仍然被引用,是活跃的连接。上述实现的过于保守,实际上用filter就可以大致实现,伪代码如下
return references.stream().filter(reference -> {
return !reference.get() == null;
}).count();
通过上面的分析,我们可以总结,okhttp使用了类似于引用计数法与标记擦除法的混合使用,当连接空闲或者释放时,StreamAllocation
的数量会渐渐变成0,从而被线程池监测到并回收,这样就可以保持多个健康的keep-alive连接,Okhttp的黑科技就是这样实现的。
最后推荐一本《图解HTTP》,日本人写的,看起来很不错。
再推荐阅读开源Redis客户端Jedis的源码,可以看下它的JedisFactory
的实现。
如果你期待更多高质量的文章,不妨关注我或者点赞吧!
因篇幅问题不能全部显示,请点此查看更多更全内容