Java Web應用調優線程池

不論你是否關注,Java Web應用都或多或少的使用了線程池來處理請求。線程池的實現細節可能會被忽視,但是有關於線程池的使用和調優遲早是需要了解的。本文由淺入深,介紹了Java線程池的使用,以及正確配置線程池的方法。所謂老司機帶路,帶你輕鬆上道。

最簡單的單線程

我們先從基礎開始。無論使用哪種應用服務器或者框架(如Tomcat、Jetty等),他們都有類似的基礎實現。Web服務的基礎是套接字(socket),套接字負責監聽端口,等待TCP連接,並接受TCP連接。一旦TCP連接被接受,即可從新創建的TCP連接中讀取和發送數據。

爲了能夠理解上述流程,我們不直接使用任何應用服務器,而是從零開始構建一個簡單的Web服務。該服務是大部分應用服務器的縮影。一個簡單的單線程Web服務大概是這樣的:

ServerSocket listener = new ServerSocket(8080);

try {

while (true) {

Socket socket = listener.accept;

try {

handleRequest(socket);

} catch (IOException e) {

e.printStackTrace;

}

}

} finally {

listener.close;

}

上述代碼創建了一個服務端套接字(ServerSocket),監聽8080端口,然後循環檢查這個套接字,查看是否有新的連接。一旦有新的連接被接受,這個套接字會被傳入handleRequest方法。這個方法會將數據流解析成HTTP請求,進行響應,並寫入響應數據。在這個簡單的示例中,handleRequest方法僅僅實現數據流的讀入,返回一個簡單的響應數據。在通常實現中,該方法還會複雜的多,比如從數據庫讀取數據等。

final static String response =

“HTTP/1.0 200 OK\r\n” +

“Content-type: text/plain\r\n” +

“\r\n” +

“Hello World\r\n”;

public static void handleRequest(Socket socket) throws IOException {

// Read the input stream, and return “200 OK”

try {

BufferedReader in = new BufferedReader(

new InputStreamReader(socket.getInputStream));

log.info(in.readLine);

OutputStream out = socket.getOutputStream;

out.write(response.getBytes(StandardCharsets.UTF_8));

} finally {

socket.close;

由於只有一個線程來處理請求,每個請求都必須等待前一個請求處理完成之後才能夠被響應。假設一個請求響應時間爲100毫秒,那麼這個服務器的每秒響應數(tps)只有10。

Java Web應用調優線程池:沒你想的那麼複雜

更進一步,多線程提升

雖然handleRequest方法可能阻塞在IO上,但是CPU仍然可以處理更多的請求。但是在單線程情況下,這是無法做到的。因此,可以通過創建多線程的方式,來提升服務器的並行處理能力。

public static class HandleRequestRunnable implements Runnable {

final Socket socket;

public HandleRequestRunnable(Socket socket) {

this.socket = socket;

}

public void run {

new Thread(new HandleRequestRunnable(socket)).start;

這裏,accept方法仍然在主線程中調用,但是一旦TCP連接建立之後,將會創建一個新的線程來處理新的請求,既在新的線程中執行前文中的handleRequest方法。

通過創建新的線程,主線程可以繼續接受新的TCP連接,且這些信求可以並行的處理。這個方式稱爲“每個請求一個線程(thread per request)”。當然,還有其他方式來提高處理性能,例如NGINX和Node.js使用的異步事件驅動模型,但是它們不使用線程池,因此不在本文的討論範圍。

在每個請求一個線程實現中,創建一個線程(和後續的銷燬)開銷是非常昂貴的,因爲JVM和操作系統都需要分配資源。另外,上面的實現還有一個問題,即創建的線程數是不可控的,這將可能導致系統資源被迅速耗盡。

Java Web應用調優線程池:沒你想的那麼複雜

ERROR!資源耗盡

每個線程都需要一定的棧內存空間。在最近的64位JVM中,默認的棧大小是1024KB。如果服務器收到大量請求,或者handleRequest方法執行很慢,服務器可能因爲創建了大量線程而崩潰。例如有1000個並行的請求,創建出來的1000個線程需要使用1GB的JVM內存作爲線程棧空間。另外,每個線程代碼執行過程中創建的對象,還可能會在堆上創建對象。這樣的情況惡化下去,將會超出JVM堆內存,併產生大量的垃圾回收操作,最終引發內存溢出(OutOfMemoryErrors)。

這些線程不僅僅會消耗內存,它們還會使用其他有限的資源,例如文件句柄、數據庫連接等。不可控的創建線程,還可能引發其他類型的錯誤和崩潰。因此,避免資源耗盡的一個重要方式,就是避免不可控的數據結構。

順便說下,由於線程棧大小引發的內存問題,可以通過-Xss開關來調整棧大小。縮小線程棧大小之後,可以減少每個線程的開銷,但是可能會引發棧溢出(StackOverflowErrors)。對於一般應用程序而言,默認的1024KB過於富裕,調小爲256KB或者512KB可能更爲合適。Java允許的最小值是160KB。

解決方案:線程池

爲了避免持續創建新線程,可以通過使用簡單的線程池來限定線程池的上限。線程池會管理所有線程,如果線程數還沒有達到上限,線程池會創建線程到上限,且儘可能複用空閒的線程。

ServerSocket listener = new ServerSocket(8080);

ExecutorService executor = Executors.newFixedThreadPool(4);

executor.submit( new HandleRequestRunnable(socket) );

在這個示例中,沒有直接創建線程,而是使用了ExecutorService。它將需要執行的任務(需要實現Runnables接口)提交到線程池,使用線程池中的線程執行代碼。示例中,使用線程數量爲4的固定大小線程池來處理所有請求。這限制了處理請求的線程數量,也限制了資源的使用。

除了通過newFixedThreadPool方法創建固定大小線程池,Executors類還提供了newCachedThreadPool方法。複用線程池還是有可能導致不可控的線程數,但是它會儘可能使用之前已經創建的空閒線程。通常該類型線程池適合使用在不會被外部資源阻塞的短任務上。

Java Web應用調優線程池:沒你想的那麼複雜

策略:工作隊列

使用了固定大小線程池之後,如果所有的線程都繁忙,再新來一個請求將會發生什麼呢?ThreadPoolExecutor使用一個隊列來保存等待處理的請求,固定大小線程池默認使用無限制的鏈表。注意,這又可能引起資源耗盡問題,但只要線程處理的速度大於隊列增長的速度就不會發生。然後前面示例中,每個排隊的請求都會持有套接字,在一些操作系統中,這將會消耗文件句柄。由於操作系統會限制進程打開的文件句柄數,因此最好限制下工作隊列的大小。

public static ExecutorService newBoundedFixedThreadPool(int nThreads, int capacity) {

return new ThreadPoolExecutor(nThreads, nThreads,

0L, TimeUnit.MILLISECONDS,

new LinkedBlockingQueue<Runnable>(capacity),

new ThreadPoolExecutor.DiscardPolicy);

}

public static void boundedThreadPoolServerSocket throws IOException {

ServerSocket listener = new ServerSocket(8080);

ExecutorService executor = newBoundedFixedThreadPool(4, 16);

這裏我們沒有直接使用Executors.newFixedThreadPool方法來創建線程池,而是自己構建了ThreadPoolExecutor對象,並將工作隊列長度限制爲16個元素。

如果所有的線程都繁忙,新的任務將會填充到隊列中,由於隊列限制了大小爲16個元素,如果超過這個限制,就需要由構造ThreadPoolExecutor對象時的最後一個參數來處理了。示例中,使用了拋棄策略(DiscardPolicy),即當隊列到達上限時,將拋棄新來的任務。初次之外,還有中止策略(AbortPolicy)和調用者執行策略(CallerRunsPolicy)。前者將拋出一個異常,而後者會再調用者線程中執行任務。

對於Web應用來說,最優的默認策略應該是拋棄或者中止策略,並返回一個錯誤給客戶端(如HTTP 503錯誤)。當然也可以通過增加工作隊列長度的方式,避免拋棄客戶端請求,但是用戶請求一般不願意進行長時間的等待,且這樣會更多的消耗服務器資源。工作隊列的用途,不是無限制的響應客戶端請求,而是平滑突發暴增的請求。通常情況下,工作隊列應該是空的。

提升性能:線程數調優

前面的示例展示瞭如何創建和使用線程池,但是,使用線程池的核心問題在於應該使用多少線程。首先,我們要確保達到線程上限時,不會引起資源耗盡。這裏的資源包括內存(堆和棧)、打開文件句柄數量、TCP連接數、遠程數據庫連接數和其他有限的資源。特別的,如果線程任務是計算密集型的,CPU核心數量也是資源限制之一,一般情況下線程數量不要超過CPU核心數量。

由於線程數的選定依賴於應用程序的類型,可能需要經過大量性能測試之後,才能得出最優的結果。當然,也可以通過增加資源數的方式,來提升應用程序的性能。例如,修改JVM堆內存大小,或者修改操作系統的文件句柄上限等。然後,這些調整最終還是會觸及理論上限。

Java Web應用調優線程池:沒你想的那麼複雜

利特爾法則

利特爾法則描述了在穩定系統中,三個變量之間的關係。

Java Web應用調優線程池:沒你想的那麼複雜

其中L表示平均請求數量,λ表示請求的頻率,W表示響應請求的平均時間。舉例來說,如果每秒請求數爲10次,每個請求處理時間爲1秒,那麼在任何時刻都有10個請求正在被處理。回到我們的話題,就是需要使用10個線程來進行處理。如果單個請求的處理時間翻倍,那麼處理的線程數也要翻倍,變成20個。

理解了處理時間對於請求處理效率的影響之後,我們會發現,通常理論上限可能不是線程池大小的最佳值。線程池上限還需要參考任務處理時間。

假設JVM可以並行處理1000個任務,如果每個請求處理時間不超過30秒,那麼在最壞情況下,每秒最多隻能處理33.3個請求。然而,如果每個請求只需要500毫秒,那麼應用程序每秒可以處理2000個請求。

拆分線程池

在微服務或者面向服務架構(SOA)中,通常需要訪問多個後端服務。如果其中一個服務性能下降,可能會引起線程池線程耗盡,從而影響對其他服務的請求。

應對後端服務失效的有效辦法是隔離每個服務所使用的線程池。在這種模式下,仍然有一個分派的線程池,將任務分派到不同的後端請求線程池中。該線程池可能因爲一個緩慢的後端而沒有負載,而將負擔轉移到了請求緩慢後端的線程池中。

另外,多線程池模式還需要避免死鎖問題。如果每個線程都阻塞在等待未被處理請求的結果上時,就會發生死鎖。因此,多線程池模式下,需要了解每個線程池執行的任務和它們之間的依賴,這樣可以儘可能避免死鎖問題。

總結

即使沒有在應用程序中直接使用線程池,它們也很有可能在應用程序中被應用服務器或者框架間接使用。Tomcat、JBoss、Undertow、Dropwizard等框架,都提供了調優線程池(servlet執行使用的線程池)的選項。

好了,老司機就說到這裏。希望現在你可以對線程池有了一定的瞭解和興趣,通過了解應用的需求,組合最大線程數和平均響應時間,可以得出一個合適的線程池配置。

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