OkHttp3原始碼分析[複用連線池]
OkHttp系列文章如下
1. 概述
HTTP中的keepalive連線
在網路效能優化中,對於延遲降低與速度提升的有非常重要的作用。
通常我們進行http連線時,首先進行tcp握手,然後傳輸資料,最後釋放
這種方法的確簡單,但是在複雜的網路內容中就不夠用了,建立socket需要進行3次握手,而釋放socket需要2次握手(或者是4次)。重複的連線與釋放tcp連線就像每次僅僅擠1mm的牙膏就合上牙膏蓋子接著再開啟接著擠一樣。而每次連線大概是TTL一次的時間(也就是ping一次),在TLS環境下消耗的時間就更多了。很明顯,當訪問複雜網路時,延時(而不是頻寬)將成為非常重要的因素。
當然,上面的問題早已經解決了,在http中有一種叫做keepalive connections
的機制,它可以在傳輸資料後仍然保持連線,當客戶端需要再次獲取資料時,直接使用剛剛空閒下來的連線而不需要再次握手
在現代瀏覽器中,一般同時開啟6~8個keepalive connections
的socket連線,並保持一定的鏈路生命,當不需要時再關閉;而在伺服器中,一般是由軟體根據負載情況(比如FD最大值、Socket記憶體、超時時間、棧記憶體、棧數量等)決定是否主動關閉。
Okhttp支援5個併發KeepAlive,預設鏈路生命為5分鐘(鏈路空閒後,保持存活的時間)
當然keepalive也有缺點,在提高了單個客戶端效能的同時,複用卻阻礙了其他客戶端的鏈路速度,具體來說如下
- 根據TCP的擁塞機制,當總水管大小固定時,如果存在大量空閒的
keepalive connections
(我們可以稱作殭屍連線
或者洩漏連線
),其它客戶端們的正常連線速度也會受到影響,這也是運營商為何限制P2P連線數的道理 - 伺服器/防火牆上有併發限制,比如
apache
伺服器對每個請求都開執行緒,導致只支援150個併發連線(資料來源於nginx官網),不過這個瓶頸隨著高併發server軟硬體的發展(golang/分散式/IO多路複用)將會越來越少 - 大量的DDOS產生的殭屍連線可能被用於惡意攻擊伺服器,耗盡資源
好了,以上科普完畢,本文主要是寫客戶端的,服務端不再介紹。
下文假設伺服器是經過專業的運維配置好的,它預設開啟了keep-alive
,並不主動關閉連線
2. 連線池的使用與分析
首先先說下原始碼中關鍵的物件:
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
,它被反覆執行aquire
與release
操作(點選函式可以進入github檢視),這兩個函式其實是在改變Connection
中的List<WeakReference<StreamAllocation>>
大小。List
中Allocation的數量也就是物理socket被引用的計數(Refference
Count),如果計數為0的話,說明此連線沒有被使用,是空閒的,需要通過下文的演算法實現回收;如果上層程式碼仍然引用,就不需要關閉連線。
引用計數法:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用。它不能處理迴圈引用的問題。
2.1. 例項化
在原始碼中,我們先找ConnectionPool
例項化的位置,它是直接new出來的,而它的各種操作卻在OkHttpClient
的static區實現了Internal.instance
介面作為ConnectionPool
的包裝。
至於為什麼需要這麼多此一舉的分層包裝,主要是為了讓外部包的成員訪問非
public
方法,詳見這裡註釋
2.2. 構造
-
連線池內部維護了一個叫做
OkHttp ConnectionPool
的ThreadPool
,專門用來淘汰末位的socket,當滿足以下條件時,就會進行末位淘汰,非常像GC1. 併發socket空閒連線超過5個 2. 某個socket的keepalive時間大於5分鐘
-
維護著一個
Deque<Connection>
,提供get/put/remove等資料結構的功能 -
維護著一個
RouteDatabase
,它用來記錄連線失敗的Route
的黑名單,當連線失敗的時候就會把失敗的線路加進去(本文不討論)
2.3 put/get操作
在連線池中,提供如下的操作,這裡可以看成是對deque的一個簡單的包裝
//從連線池中獲取
get
//放入連線池
put
//執行緒變成空閒,並呼叫清理執行緒池
connectionBecameIdle
//關閉所有連線
evictAll
隨著上述操作被更高階的物件呼叫,Connection
中的StreamAllocation
被不斷的aquire
與release
,也就是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:
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中刪除 - 遍歷結束,如果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
的實現。
如果你期待更多高質量的文章,不妨關注我或者點贊吧!
Ref
相關文章
- OkHttp3原始碼解析(三)——連線池複用HTTP原始碼
- OkHttp 3.7原始碼分析(五)——連線池HTTP原始碼
- ServiceStack.Redis的原始碼分析(連線與連線池)Redis原始碼
- OkHttp3原始碼分析[DiskLruCache]HTTP原始碼
- Android 網路程式設計(8): 原始碼解析 OkHttp 中篇[複用連線池]Android程式設計原始碼HTTP
- MOSN 原始碼解析 - 連線池原始碼
- Hikari連線池原始碼解讀原始碼
- 從原始碼分析DBCP資料庫連線池的原理原始碼資料庫
- OkHttp3原始碼分析[綜述]HTTP原始碼
- 《四 資料庫連線池原始碼》手寫資料庫連線池資料庫原始碼
- 資料庫連線池-Druid資料庫連線池原始碼解析資料庫UI原始碼
- okhttp3 攔截器原始碼分析HTTP原始碼
- OkHttp3原始碼分析[快取策略]HTTP原始碼快取
- OkHttp 原始碼剖析系列(六)——連線複用機制及連線的建立HTTP原始碼
- OkHttp3原始碼分析[任務佇列]HTTP原始碼佇列
- 【OkHttp3原始碼分析】(一)Request的executeHTTP原始碼
- 【OkHttp3原始碼分析】(二)Request的enqueueHTTP原始碼ENQ
- DBCP連線池原理分析
- MongoDB原始碼分析之連結池(ConnPool)GWMongoDB原始碼
- 從原始碼中分析關於phpredis中的連線池可持有數目原始碼PHPRedis
- SOFA 原始碼分析 — 連線管理器原始碼
- 婚戀交友原始碼開發,採用連線複用實現效能優化原始碼優化
- redis 原始碼分析:Jedis 哨兵模式連線原理Redis原始碼模式
- 【JDBC】使用OracleDataSource建立連線池用於連線OracleJDBCOracle
- 連線池
- UITableView的Cell複用原理和原始碼分析UIView原始碼
- 基於HiKariCP元件,分析連線池原理元件
- 執行緒池原始碼分析執行緒原始碼
- 以太坊交易池原始碼分析原始碼
- 用sqlalchemy構建Django連線池SQLDjango
- 以太坊原始碼分析(26)core-txpool交易池原始碼分析原始碼
- Go連線池Go
- HTTP連線池HTTP
- django連線池Django
- dubbo原始碼-執行緒池分析原始碼執行緒
- btcpool礦池原始碼分析(8)-slparserTCP原始碼
- 連線池優化之啟用PoolPreparedStatements優化
- netty原始碼分析之新連線接入全解析Netty原始碼