httpclient源碼分析-如何重用連接

httpclient源碼分析-如何重用連接

最近公司服務器後臺程序,在訪問第三方數據接口的時候,出現了佔用連接數過多,導致本地端口占用過多以及超過Linux系統單進程的打開文件限制數。
公司服務器是利用common-httpclient工具訪問第三方服務器,代碼結構類似如下(具體程序公司機密,以相似結構的程序代替):
HttpClient client = new HttpClient();
HttpMethod method = new GetMethod("http://www.apache.org");
try {
  client.executeMethod(method);
  byte[] responseBody = method.getResponseBody();
  String returnData = new String(responseBody,”utf-8”);
  System.out.println(returnData);
} catch (HttpException e) {
  e.printStackTrace();
} catch (IOException e) {
  e.printStackTrace();
}finally{
  method.releaseConnection();
}
針對上述代碼,公司程序要同時處理很多請求,每次請求都會執行上述的代碼,導致問題如下:
1. 在Linux服務器上,利用命令netstat -pnt |grep :80 查看連接數,發現有大量TIME_WAIT的連接存在。
2. 後來發現第一個問題的原因,是由於連接沒有釋放,所以採用method.releaseConnection();每次請求完,將連接釋放掉。但是,此時又出現一個問題,就是在訪問高峯期,出現大量的CLOSE_WAIT的連接。雖然這個連接可以釋放,但是釋放週期還是相對較長,不能滿足短時間大量併發訪問的需求。
3. 還有一個問題,就是當併發數超過一定數量後,程序再次發送http請求是,會出現java.net.SocketException: Too many open files 的異常,導致請求失敗。


我們已經知道第一個問題的原因,是因爲在每次請求之後,沒有主動釋放該次請求所致。但是原理如何,當時並未深究,直到最近服務器頻繁出現第二個問題,所以不得不放下工作,深入源碼跟蹤一下,究竟是什麼原因導致的問題。
首先,根據第一個問題的解決辦法,找到httpclient的GetMethod類,查看其releaseConnection()的釋放連接原理,源碼部分如下:
// 該方法在基類HttpMethodBase中,由GetMethod繼承過來使用
public void releaseConnection() {
        try {
            if (this.responseStream != null) {
                try {
                    // 只關閉響應流,無關連接
                    this.responseStream.close();
                } catch (IOException ignore) {
                }
            }
        } finally {
// 關閉連接的方法
            ensureConnectionRelease();
        }
    }
由ensureConnectionRelease()一路追蹤,最終在httpclient的默認連接管理器中找到真正關閉連接的邏輯代碼:
// @SimpleHttpConnectionManager
// 該方法默認情況下,並沒有主動關閉連接,而只是清空了響應流,以便複用
public void releaseConnection(HttpConnection conn) {
        if (conn != httpConnection) {
            throw new IllegalStateException("Unexpected release of an unknown connection.");
        }

// 這個判斷纔是關鍵所在:
// 重點是alwaysClose這個參數,在舊版的common-httpclient中,並沒有設置這個參數的地方
// 在新版的Apache的httpclient中,纔可以在創建HttpClient對象時,通過連接管理器指定該參數的值
// alwaysClose這個參數表明,如果設置這個參數爲true,那麼就會每次都關閉連接,
// 如果沒有設置,默認爲false,那麼就不關閉連接,留着複用
        if (this.alwaysClose) {
// 這個纔是真正關閉連接啊,尼瑪不讓隨便執行!
// 看到最後才知道其實大有深意
            httpConnection.close();
        } else {
// 看看下面的原註釋,就明白了,被坑了!
            // make sure the connection is reuseable
            finishLastResponse(httpConnection);
        }
        
        inUse = false;

// 這一點也尤爲重要,牽涉到一個徹底關閉連接的方式 
// 爲了不打斷思路,下面再詳細說
        idleStartTime = System.currentTimeMillis();
    }
看過上面的代碼才恍然大悟,原來我們一直追求的出發點有問題(問題的詳細情況,放在最後說)。我們公司服務器是單臺服務器頻繁訪問第三方服務器,所以按需求來說,應該保持長連接(在http裏面,就是實現連接的複用,並非真正意義上的長連接)纔好,而不應該追求每次都把連接關閉掉,因爲每次開啓和釋放http連接都很消耗時間和系統資源。鑑於這一點,下面我來說說上述代碼註釋裏提到的idleStartTime = System.currentTimeMillis();問題,爲什麼要加這一句,爲什麼這一句可以解決關閉連接的問題,權當是插播吧:
通過對httpclient的研究發現,默認情況下idleStartTime= Long.MAX_VALUE,而源程序判斷連接空閒時間的標準是:
// @SimpleHttpConnectionManager
// 在連接空閒一定時間後,關閉掉連接
// 參數是用戶設定的允許空閒時間
public void closeIdleConnections(long idleTimeout) {
// 這段邏輯相信你懂得
        long maxIdleTime = System.currentTimeMillis() - idleTimeout;
// 試想,如果把idleStartTime的值設置爲Long.MAX_VALUE,這個連接還能關掉嗎?
// 所以,讀到這裏應該知道,httpclient默認就是拼命想保持長連接的
// 除非用戶不想保持,自己設置關閉
        if (idleStartTime <= maxIdleTime) {
            httpConnection.close();
        }
    }
我在程序裏悲劇的發現,沒有源程序主動調用closeIdleConnections()的地方,所以這個方法估計是給我們自己用的。所以,解決關閉連接問題的一個方法就是
先調用method.releaseConnection();
再調用client.getHttpConnectionManager().closeIdleConnections(0);
其他還有幾個關閉連接的方法,會在最後列舉出來,此處重點解說原理。
下面接着說保持http長連接的問題(我一直想達到的目標)。爲了敘述具備一些條理性,還是從開頭的程序講起吧。
1. HttpClient client = new HttpClient();
這句話是爲了創建一個httpclient的整體對象,之後的一切操作其實都是作爲這個對象的參數或者執行對象,在這個對象的控制範圍內執行。httpclient實例化的過程如下:
public HttpClient() {
this(new HttpClientParams());
}
// 上面的構造函數調用下面的構造函數
public HttpClient(HttpClientParams params) {
super();
if (params == null) {
throw new IllegalArgumentException("Params may not be null");  
}
this.params = params;
this.httpConnectionManager = null;
Class clazz = params.getConnectionManagerClass();
if (clazz != null) {
try {
this.httpConnectionManager = (HttpConnectionManager) clazz.newInstance();
} catch (Exception e) {
LOG.warn("Error instantiating connection manager class, defaulting to"
+ " SimpleHttpConnectionManager", 
e);
}
}
if (this.httpConnectionManager == null) {
// 默認創建一個SimpleHttpConnectionManager管理器
// 這個管理器是一個簡單連接池,默認只維護一個連接
this.httpConnectionManager = new SimpleHttpConnectionManager();
}
if (this.httpConnectionManager != null) {
this.httpConnectionManager.getParams().setDefaults(this.params);
}
}


2. client.executeMethod(method);
上述語句具體執行連接方法,我們跟蹤一下,看看這個方法裏面,到底發生了什麼:
// 部分代碼
// 實例化方法管理者,由方法管理者去執行方法
HttpMethodDirector methodDirector = new HttpMethodDirector(
getHttpConnectionManager(),
hostconfig,
this.params,
(state == null ? getState() : state)); 
methodDirector.executeMethod(method);
return method.getStatusCode();
關於HttpMethodDirector,源碼註釋是這樣說的:Handles the process of executing a method including authentication, redirection and retries.
進入methodDirector.executeMethod(method)查看,部分重點代碼如下:
第一點,根據請求的目的配置,選擇是否要重用上次的連接
// 1
// 重用連接,如果本次請求的目的服務器同上次請求的目的服務器不一致
// 則釋放上個連接,以便重新創建
if (this.conn != null && !hostConfiguration.hostEquals(this.conn)) {
this.conn.setLocked(false);
// 釋放原先的連接
this.conn.releaseConnection();
this.conn = null;
}
在這裏,提到了釋放連接的方法:releaseConnection()。跟蹤這個方法,層層進入,最後進入SimpleHttpConnectionManager#releaseConnection(),這個方法的源碼在前面已經分析過,翻到前面看一下就會發現,系統默認的alwaysClose爲false,也就是默認這個連接不被釋放,而是準備重用。 那麼我有一個疑問沒弄明白:在上面第一點的代碼中,已經明確表示當前連接已經不需要了,需要再創建一個全新的連接了,但是這裏爲什麼不把當前連接完全關閉,而是要保留下來?保留下來的這個連接已經不再使用,會在什麼時候被關閉掉?當這個連接還沒有釋放,就置爲null,那麼這個連接會被Java虛擬機垃圾回收嗎?最重要的一個問題,如果這個連接的目的配置都沒有變,但是連接超時被關掉了,那麼系統如何知道該鏈接已經不可用,如果不可用了怎麼解決?啊,痛苦!

痛苦完,接着走!
第二點,如果上個連接無法重用,那麼重新創建連接:


// 2
// 如果連接已經改變,無法複用,則獲取新連接,通過連接管理器獲取
//(此處我們使用的默認連接管理器SimpleHttpConnectManager)
if (this.conn == null) {
// 獲取新連接,並將連接保存在該類中
this.conn = connectionManager.getConnectionWithTimeout(hostConfiguration,
  this.params.getConnectionManagerTimeout());
}
進入獲取連接的方法查看:

// 部分代碼
// 這個方法的最後一個參數timeout,沒有看見被使用的地方,默認爲0
public HttpConnection getConnectionWithTimeout(
HostConfiguration hostConfiguration, long timeout) {
if (httpConnection == null) {
httpConnection = new HttpConnection(hostConfiguration);
httpConnection.setHttpConnectionManager(this);
httpConnection.getParams().setDefaults(this.params);

}
上面沒有什麼可說的,接着看:
第三點,實際執行請求的方法

// 3
// 實際執行請求的方法
executeWithRetry(method);
跟蹤方法查看

// 2.0
// 這段邏輯負責處理連接請求過程中,出現的問題
// 如果這個請求不成功,那麼就一直不停發送請求
// 直到這個請求被成功執行或者 拋出了無法繼續執行的異常,這時候纔會停止循環


while (true) {
execCount++;
try {

// 2.1
// 這個方法是重要方法,負責檢查當前連接是否有效
// 如果當前連接沒有被打開,或者已經被第三方服務器關閉
// 那麼,就關閉當前連接(關閉該鏈接中的socket對象)
if (this.conn.getParams().isStaleCheckingEnabled()) {
this.conn.closeIfStale();
}
// 2.3
// 如果當前連接還沒有打開,那麼當前代碼負責打開,
// 這樣就與上一部分代碼形成呼應
if (!this.conn.isOpen()) {
// 2.3.0
// 在這個open方法裏面,重新創建了當前連接的底層socket對象
this.conn.open(); 
}

applyConnectionParams(method); 
method.execute(state, this.conn);
// 如果該方法執行沒有異常
// 表示請求成功,那麼就中斷循環
break;
} catch (HttpException e) {
// 如果拋出跟http協議有關的異常,證明該連接請求非法
// 所以該請求不應該繼續被執行,拋出異常中斷循環
throw e;
} catch (IOException e) {
// 這個裏面包含一大段代碼
// 代碼的作用就是,如果本次請求不成功,要重試執行
// 如果判斷要重試,就類似於ontinue
// 如果判斷不能繼續重試,就拋出異常
LOG.info("Retrying request");
}
}


我們來分析一下上面的代碼:
註釋2.0處,明確表述,我這個請求第三方服務器的方法,會用while(true)一直執行,那麼什麼時候退出呢,如果請求成功了,就退出;如果請求不成功,那麼就視情況而定,如果情況很惡劣,比如說你發的請求根本就不是http協議的,那麼對不起,程序不會繼續循環了,而是拋出異常退出;如果情況不是很惡劣,那麼久多次請求,直到成功爲止。
註釋2.1處,是檢查當前的連接是否還有效,是不是已經被第三方服務器關閉了。如果連接還有效,那麼就跳過;如果連接已經無效,那麼就關閉掉,這個關閉,並不是釋放掉這個連接對象,而是將連接底層用的socket對象置爲null,並且將連接的狀態isOpen置爲false。這樣做的好處是什麼呢,就是這個連接依然可以用,只是當真正發送請求的時候,底層的socket對象再重新創建一個出來,這樣就不用換連接對象,但其實底層傳輸數據的socket對象已經變了。那麼,如果執行了這段代碼,就證明該連接對象的底層socket對象已經爲null了,那麼程序又是在什麼時候把這個對象重新創建出來的?我們接着看註釋2.2處的代碼。
註釋2.2處,這個地方判斷當前的連接是否處於open狀態,如果沒有處於打開狀態,則重新打開連接。就是在這個conn.open()方法裏面,程序重新創建了底層socket對象:
if (this.socket == null) {
this.socket = socketFactory.createSocket(host, port, localAddress, 0, this.params);
}
看完這段代碼,首先感覺解決我前面的痛苦,原因如下:
看到此處我們應該清楚,在httpclient中,不用刻意去維護通訊的長連接有效性,如果當前連接還沒有被關閉,那麼就使用當前連接通訊;如果當前連接已經被關閉了,那麼再重新創建一個socket對象通訊。這樣,我們剛開始創建的連接對象還是同一個不變,但是底層的socket對象其實已經變了。
那麼這樣做有什麼好處呢?我們可以想一下這種情況,在兩臺頻繁通訊的服務器之間,tcp連接不會因爲空閒時間過長而被中斷,那麼就可以一直使用當前tcp連接通訊,不用每次耗費大量資源去重新建立tcp連接;如果兩臺服務器通訊不頻繁了,當期連接被其中一個終止了,那麼就自動再創建一個TCP的socket連接通訊。這樣的處理方式,對於用戶來說是透明的,不用刻意去維持連接有效性,好像一直在保持通訊似的。
但是這個方法也有侷限性,就是這樣的做法只能保證客戶端向服務器發送請求是隨時可以且能夠請求到,但是服務器不能保證隨時能聯繫上客戶端。所以,把這一點用到http協議上,再恰當不過。
但是這樣做,還是會有可能創建多個連接,所以httpclient官方文檔也給出方案,建議使用網絡心跳的方式,一直保持長連接。但是這個必要性根據具體的需求而定。爲了方便以後查看,我把這段代碼也提供出來,這段代碼是看的一個帖子評論裏寫的:

import org.apache.commons.httpclient.util.IdleConnectionTimeoutThread;
// 創建線程
IdleConnectionTimeoutThread thread = new IdleConnectionTimeoutThread();
// 註冊連接管理器
thread.addConnectionManager(httpClient.getHttpConnectionManager());
// 啓動線程
thread.start();
// 在最後,關閉線程
thread.shutdown();
我上面分析的代碼,僅僅是針對開頭的代碼結構分析的,實際上,這個代碼結構僅僅是針對單線程處理的,官方也建議不要再多線程中去使用。因爲httpclient默認創建的連接管理器同時只維護一個連接,所以多線程有可能降低效能,即使他是線程安全的。而httpclient還有一個可以維護多個連接的連接管理器,運行多線程的時候可以自己創建多連接的管理器。
以上這些是基於common-httpclient-3.0版本分析的,新版的httpclient4.0已經交由Apache的httpclient項目開發了。所以這個版本的研究到此爲止,下次研究就面向新版本啦!
再說一下關於我們公司遇到的問題,原因分析了一下,就是程序中,每次請求都會創建一個httpclient對象,而每個httpclient對象都會重新創建一個socket連接,所以在訪問的高峯期,socket連接數過多,而又不能完全釋放,就造成了同時很多連接處於被打開狀態。而在Linux系統中,每一個進程能打開的文件數是有限制的,每個socket在Linux上都是一個文件,所以纔會出現本文開頭的諸多問題。
這兩天我看網上很多人也遇到了這個問題,給出的解決辦法有兩種:
1. 擴大Linux系統上每個進程能夠打開的文件數。這個辦法可以在一定程度上暫時解決問題,但是訪問量繼續增大的話,就又會出現當前問題。
2. 每訪問一次就徹底關閉掉鏈接。這個方法在一方面可以解決1的問題,但是又會頻繁耗費系統資源,而且在併發訪問量特別大的時候,也可能出現1的問題。
對於以上所總結的內容,僅針對單線程而言,多線程的話,可能會有不一樣的結果,僅供參考。

關於上面提到的徹底關閉連接的方法,請參考文章:http://www.iteye.com/topic/234759


參考資料:

http://www.iteye.com/topic/234759

http://blog.csdn.net/javaalpha/article/details/6159442

http://blog.sina.com.cn/s/blog_616e189f01018rpk.html










發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章