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。
多執行緒
雖然handleRequest方法可能阻塞在IO上,但是CPU仍然可以處理更多的請求。但是在單執行緒情況下,這是無法做到的。因此,可以通過建立多執行緒的方式,來提升伺服器的並行處理能力。
public static class HandleRequestRunnable implements Runnable { final Socket socket; public HandleRequestRunnable(Socket socket) { this.socket = socket; } public void run() { try { handleRequest(socket); } catch (IOException e) { e.printStackTrace(); } } } ServerSocket listener = new ServerSocket(8080); try { while (true) { Socket socket = listener.accept(); new Thread(new HandleRequestRunnable(socket)).start(); } } finally { listener.close(); }
這裡,accept()方法仍然在主執行緒中呼叫,但是一旦TCP連線建立之後,將會建立一個新的執行緒來處理新的請求,既在新的執行緒中執行前文中的handleRequest方法。
通過建立新的執行緒,主執行緒可以繼續接受新的TCP連線,且這些信求可以並行的處理。這個方式稱為“每個請求一個執行緒(thread per request)”。當然,還有其他方式來提高處理效能,例如 NGINX 和 Node.js 使用的非同步事件驅動模型,但是它們不使用執行緒池,因此不在本文的討論範圍。
在每個請求一個執行緒實現中,建立一個執行緒(和後續的銷燬)開銷是非常昂貴的,因為JVM和作業系統都需要分配資源。另外,上面的實現還有一個問題,即建立的執行緒數是不可控的,這將可能導致系統資源被迅速耗盡。
資源耗盡
每個執行緒都需要一定的棧記憶體空間。在最近的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); try { while (true) { Socket socket = listener.accept(); executor.submit( new HandleRequestRunnable(socket) ); } } finally { listener.close(); }
在這個示例中,沒有直接建立執行緒,而是使用了ExecutorService。它將需要執行的任務(需要實現Runnables介面)提交到執行緒池,使用執行緒池中的執行緒執行程式碼。示例中,使用執行緒數量為4的固定大小執行緒池來處理所有請求。這限制了處理請求的執行緒數量,也限制了資源的使用。
除了通過 newFixedThreadPool 方法建立固定大小執行緒池,Executors類還提供了 newCachedThreadPool 方法。複用執行緒池還是有可能導致不可控的執行緒數,但是它會盡可能使用之前已經建立的空閒執行緒。通常該型別執行緒池適合使用在不會被外部資源阻塞的短任務上。
工作佇列
使用了固定大小執行緒池之後,如果所有的執行緒都繁忙,再新來一個請求將會發生什麼呢?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); try { while (true) { Socket socket = listener.accept(); executor.submit( new HandleRequestRunnable(socket) ); } } finally { listener.close(); } }
這裡我們沒有直接使用Executors.newFixedThreadPool方法來建立執行緒池,而是自己構建了ThreadPoolExecutor物件,並將工作佇列長度限制為16個元素。
如果所有的執行緒都繁忙,新的任務將會填充到佇列中,由於佇列限制了大小為16個元素,如果超過這個限制,就需要由構造ThreadPoolExecutor物件時的最後一個引數來處理了。示例中,使用了 拋棄策略(DiscardPolicy) ,即當佇列到達上限時,將拋棄新來的任務。初次之外,還有 中止策略(AbortPolicy) 和 呼叫者執行策略(CallerRunsPolicy) 。前者將丟擲一個異常,而後者會再呼叫者執行緒中執行任務。
對於Web應用來說,最優的預設策略應該是拋棄或者中止策略,並返回一個錯誤給客戶端(如 HTTP 503 錯誤)。當然也可以通過增加工作佇列長度的方式,避免拋棄客戶端請求,但是使用者請求一般不願意進行長時間的等待,且這樣會更多的消耗伺服器資源。工作佇列的用途,不是無限制的響應客戶端請求,而是平滑突發暴增的請求。通常情況下,工作佇列應該是空的。
執行緒數調優
前面的示例展示瞭如何建立和使用執行緒池,但是,使用執行緒池的核心問題在於應該使用多少執行緒。首先,我們要確保達到執行緒上限時,不會引起資源耗盡。這裡的資源包括記憶體(堆和棧)、開啟檔案控制程式碼數量、TCP連線數、遠端資料庫連線數和其他有限的資源。特別的,如果執行緒任務是計算密集型的,CPU核心數量也是資源限制之一,一般情況下執行緒數量不要超過CPU核心數量。
由於執行緒數的選定依賴於應用程式的型別,可能需要經過大量效能測試之後,才能得出最優的結果。當然,也可以通過增加資源數的方式,來提升應用程式的效能。例如,修改JVM堆記憶體大小,或者修改作業系統的檔案控制程式碼上限等。然後,這些調整最終還是會觸及理論上限。
利特爾法則
利特爾法則 描述了在穩定系統中,三個變數之間的關係。
其中L表示平均請求數量,λ表示請求的頻率,W表示響應請求的平均時間。舉例來說,如果每秒請求數為10次,每個請求處理時間為1秒,那麼在任何時刻都有10個請求正在被處理。回到我們的話題,就是需要使用10個執行緒來進行處理。如果單個請求的處理時間翻倍,那麼處理的執行緒數也要翻倍,變成20個。
理解了處理時間對於請求處理效率的影響之後,我們會發現,通常理論上限可能不是執行緒池大小的最佳值。執行緒池上限還需要參考任務處理時間。
假設JVM可以並行處理1000個任務,如果每個請求處理時間不超過30秒,那麼在最壞情況下,每秒最多隻能處理33.3個請求。然而,如果每個請求只需要500毫秒,那麼應用程式每秒可以處理2000個請求。
拆分執行緒池
在微服務或者面向服務架構(SOA)中,通常需要訪問多個後端服務。如果其中一個服務效能下降,可能會引起執行緒池執行緒耗盡,從而影響對其他服務的請求。
應對後端服務失效的有效辦法是隔離每個服務所使用的執行緒池。在這種模式下,仍然有一個分派的執行緒池,將任務分派到不同的後端請求執行緒池中。該執行緒池可能因為一個緩慢的後端而沒有負載,而將負擔轉移到了請求緩慢後端的執行緒池中。
另外,多執行緒池模式還需要避免死鎖問題。如果每個執行緒都阻塞在等待未被處理請求的結果上時,就會發生死鎖。因此,多執行緒池模式下,需要了解每個執行緒池執行的任務和它們之間的依賴,這樣可以儘可能避免死鎖問題。
總結
即使沒有在應用程式中直接使用執行緒池,它們也很有可能在應用程式中被應用伺服器或者框架間接使用。 Tomcat 、 JBoss 、 Undertow 、 Dropwizard 等框架,都提供了調優執行緒池(servlet執行使用的執行緒池)的選項。
希望本文能夠提升對執行緒池的瞭解。通過了解應用的需求,組合最大執行緒數和平均響應時間,可以得出一個合適的執行緒池配置。
相關文章
- 詳解執行緒池的作用及Java中如何使用執行緒池執行緒Java
- Java執行緒池二:執行緒池原理Java執行緒
- 聊聊面試中的 Java 執行緒池面試Java執行緒
- 淺析Java中的執行緒池Java執行緒
- Java中執行緒池,你真的會用嗎?Java執行緒
- Java優雅關閉執行緒池Java執行緒
- java中執行緒池的生命週期與執行緒中斷Java執行緒
- Web應用擴充套件系列(2):如何確定Web應用執行緒池的大小Web套件執行緒
- golang執行緒池在IO多路複用中的應用Golang執行緒
- Java多執行緒——執行緒池Java執行緒
- Java多執行緒-執行緒池的使用Java執行緒
- 如何優雅的關閉Java執行緒池Java執行緒
- Java執行緒池Java執行緒
- java 執行緒池Java執行緒
- Java中的執行緒池用過吧?來說說你是怎麼理解執行緒池吧?Java執行緒
- 淺談執行緒池(中):獨立執行緒池的作用及IO執行緒池執行緒
- Java中命名執行器服務執行緒和執行緒池Java執行緒
- java執行緒池趣味事:這不是執行緒池Java執行緒
- java多執行緒9:執行緒池Java執行緒
- Java多執行緒18:執行緒池Java執行緒
- weblogic執行緒池引數調優配置方法Web執行緒
- JAVA執行緒池的使用Java執行緒
- java--執行緒池--建立執行緒池的幾種方式與執行緒池操作詳解Java執行緒
- 搞懂Java執行緒池Java執行緒
- Java Executors(執行緒池)Java執行緒
- Java執行緒池(Executor)Java執行緒
- 詳談執行緒池的理解和應用執行緒
- Java執行緒池一:執行緒基礎Java執行緒
- 【Java】【多執行緒】執行緒池簡述Java執行緒
- java多執行緒系列之執行緒池Java執行緒
- Java程式效能調優阿姆達爾定律、快取元件、並行開發、執行緒池、JVM調優Java快取元件並行執行緒JVM
- Java執行緒池核心執行緒用盡後為何優先排隊而不是繼續建立執行緒直至最大執行緒數?Java執行緒
- Java 執行緒池執行原理分析Java執行緒
- java效能調優記錄(執行緒阻塞)Java執行緒
- SpringBoot執行緒池和Java執行緒池的實現原理Spring Boot執行緒Java
- Java執行緒池的那些事Java執行緒
- 淺談執行緒池(上):執行緒池的作用及CLR執行緒池執行緒
- 如何優雅的使用執行緒池執行緒