知道嗎,你的Java web應用實際上是使用線程池來處理請求的。這一實現細節被許多人忽略,可是你早晚都須要理解線程池如何使用,以及如何正確地根據應用調整線程池配置。這篇文章的目的是爲了解釋線程模型——什麼是線程池、以及怎樣正確地配置線程池。html
讓咱們從一些基礎的線程模型開始,而後再隨着線程模型的演變進行更深一步的學習。你使用的任何應用服務器或框架,如Tomcat、Dropwizard、Jetty等,它們的基本原理實際上是相同的。Web服務器的最底層其實是一個socket。這個socket監聽並接受到達的TCP鏈接。一旦一個鏈接被創建,就能夠經過這個新創建的鏈接讀取、解析信息,而後將這些信息包裝成一個HTTP請求。這個HTTP請求還將被移交至web應用程序,來完成請求的動做。java
咱們將經過一個簡單的服務器程序來展現線程在其中所起到的做用。這個服務器程序展現了大部分應用服務器的底層實現細節。讓咱們以一個簡單的單線程web服務器程序開始,它的代碼像下面這樣:node
ServerSocket listener = new ServerSocket(8080); try { while (true) { Socket socket = listener.accept(); try { handleRequest(socket); } catch (IOException e) { e.printStackTrace(); } } } finally { listener.close(); }
這段代碼在8080端口上建立了一個ServerSocket,緊接着經過循環來監聽和接受新到達的鏈接。一旦鏈接創建,會將socket傳遞給handleRequest方法。這個方法可能會讀取該HTTP請求,處理這個請求,而後寫回一個響應。在這個簡單的例子中,handleRequest方法從socket中讀取簡單的一行數據,而後返回一個簡短的HTTP響應。可是,handleRequest有可能須要處理一些更復雜的任務,例如讀數據庫或者執行其它一些IO操做。nginx
final static String response = "HTTP/1.0 200 OKrn" + "Content-type: text/plainrn" + "rn" + "Hello Worldrn"; 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(); } }
由於只有一個線程處理全部的socket,所以只有在徹底處理好一個請求後,才能再接受下一個請求。在實際的應用中,handleRequest方法可能須要通過100毫秒才能返回,那麼這個服務器程序在一秒中,只能按順序處理10個請求。git
儘管handleRequest可能會被IO操做阻塞,CPU卻多是空閒的,它能夠處理其它更多請求,但這對單線程模型來講是不能實現的。所以,經過建立多個線程,可使服務器程序實現併發操做:github
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(); } } } // Main loop here ServerSocket listener = new ServerSocket(8080); try { while (true) { Socket socket = listener.accept(); new Thread( new HandleRequestRunnable(socket) ).start(); } } finally { listener.close(); }
上面這段代碼中,accept()方法仍然是在一個單線程循環中被調用。可是當TCP鏈接創建,socket建立時,服務器就建立一個新的線程。這個新生的線程將執行和單線程模型中同樣的handleRequest方法。web
新線程的創建使調用accept方法的線程可以處理更多的TCP鏈接,這樣服務器就能併發地處理請求了。這一技術被稱爲「thread per request」(一個線程處理一個請求),也是如今最流行的服務器技術。值得注意的是,還有一些其它的服務器技術,如NGINX和Node.js採用的事件驅動異步模型,它們都沒有使用線程池。所以,它們都不在本文的討論範圍內。數據庫
「thread per request」方式裏建立新線程(稍後銷燬這個線程)的操做是昂貴的,由於Java虛擬機和操做系統都須要爲這一操做分配資源。另外,在上面那段代碼的中,能夠建立的線程數量是不受限制的。這麼作的隱患很大,由於它可能致使服務器資源迅速枯竭。apache
每一個線程都須要必定的內存空間來做爲本身的棧空間。在最近的64位虛擬機版本中,默認的棧空間是1024KB。若是server收到不少請求,或者handleRequest方法的執行時間變得比較長,就會形成服務器產生不少併發線程。若是要維護1000個線程,僅就棧空間而言,虛擬機就必須耗費1GB的RAM空間。另外,爲處理請求,每一個線程都會在堆上產生許多對象,這就有可能致使虛擬機的堆空間被迅速佔滿,給虛擬機的垃圾收集器帶來很大壓力,形成頻繁的垃圾回收,最終致使OutOfMemoryErrors。後端
線程消耗的不只是RAM資源,這些線程還可能消耗其它有限的資源,例如文件句柄、數據庫鏈接等。過多地消耗這類資源可能致使一些其它的錯誤或形成系統崩潰。所以,要防止系統資源被線程耗盡,就必須對服務器產生的線程數量作出限制。
經過使用-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類來提交任務(Runnable)。提交的任務將會被線程池中的線程執行,而不是經過新建立的線程執行。在這個例子中,全部的請求都經過一個線程數量固定爲4的線程池來完成。這個線程池限制了併發執行的請求數量,從而限制了系統資源的使用。
除了newFixedThreadPool方法建立的線程池外,Executors類還提供了newCachedThreadPool 方法來建立線程池。這種線程池一樣有沒法限制線程數量的問題,可是它會優先使用線程池中已建立的空閒線程來處理請求。這種類型的線程池特別適用於執行短時間任務的請求,由於它們不會長時間的阻塞外部資源。
ThreadPoolExecutors 類也能夠直接建立,這樣就能夠對它進行一些個性化的配置。例如能夠配置線程池內最小線程數和最大線程數,也能夠配置線程建立和銷燬的策略。稍後,本文將介紹這樣的例子。
對於線程數量固定的線程池,善於觀察的讀者可能會提出這樣的一個疑問:當線程池中的線程都在工做時,一個新的請求到達,會發生什麼呢?當線程池中的線程都在工做時,ThreadPoolExecutor可能會使用一個隊列來組織新到達的請求,直到線程池中有空閒的線程可使用。Executors.nexFixedThreadPool方法會默認建立一個沒有長度限制的LinkedList。這個LinkedList也可能會產生系統資源耗盡的問題,雖然這個過程會比較緩慢,由於隊列中的請求所佔用的資源比線程佔用的資源要少得多。可是在咱們的例子中,隊列中的每一個請求都保持着一個socket,而每個socket都須要打開一個文件句柄,操做系統對同時打開的文件句柄數量是有限制的,因此隊列中保持socket並非一個好的方式,除非必須這麼作。所以,限制工做隊列的長度也是有意義的。
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個元素的LinkedBlockingQueue。一樣的,類ArrayBlockingQueue也能夠被用來限制隊列的長度。
若是全部的線程都在執行任務,並且工做隊列也被請求填滿了,此時對於新到達請求的處理方式,取決於ThreadPoolExecutor構造方法的最後一個參數。在咱們這個例子中,咱們使用的是DiscardPolicy,這個參數會讓線程池丟棄新到達的請求。還有一些其它的處理策略,例如AbortPolicy會讓Executor拋出一個異常,CallerRunsPolicy會使任務在它的調用端線程池中執行。CallerRunsPolicy策略提供了一個簡單的方式來限制任務提交的速度。可是這樣作多是有害的,由於它會阻塞一個本來不該被阻塞的線程。
一個好的默認策略應該是Discard或Abort,它們都會使線程池丟棄新到達的任務。這樣服務器就能容易地向客戶端響應一個錯誤,例如HTTP的503錯誤「Service unavailable」。有的人可能會認爲,隊列的長度應該是容許增加的,這樣全部的任務最終都能被執行。可是用戶是不肯意長時間等待的,並且若任務到達的速度超過任務處理的速度,隊列將會無限地增加。隊列是被用來緩衝忽然爆發的請求,或者處理短時間任務的,一般狀況下,隊列應該是空的。
如今,咱們知道了如何建立一個線程池。可是有一個更困難的問題,線程池裏應該建立多少個線程呢?咱們已經知道了線程池中的最大線程數量應該被限制,纔不會致使系統資源耗盡。這些系統資源包括了內存(堆棧)、打開的文件句柄、打開的TCP鏈接、打開的數據庫鏈接以及其它有限的系統資源。相反的,若是線程執行的是CPU密集型任務而不是IO密集型任務,服務器的物理內核數就應該被視爲是有限的資源,這樣建立的線程數就不該該超過系統的內核數。
系統應建立多少線程取決於這個應用執行的任務。開發人員應使用現實的請求來對系統進行負載測試,測試不一樣的線程池大小配置對系統的影響。每次測試都增長線程池的大小,直到系統達到崩潰的臨界點。這個方法使你能夠發現線程池線程數量的上限。超過這個上限,系統的資源將耗盡。在某些狀況下,能夠謹慎地增長系統的資源,例如分配更多的RAM空間給JVM,或者調整操做系統使其支持同時打開更多的文件句柄。然而,在某些狀況下建立的線程數量會達到咱們測試出的理論上限,這很是值得咱們注意。稍後還會看到這方面的內容。
排隊論,特別的,Little’s Law,能夠用來幫助咱們理解線程池的一些特性。簡單地說,利特爾法則解釋了這三種變量的關係:L—系統裏的請求數量、λ—請求到達的速率和W—每一個請求的處理時間。例如,若是每秒10個請求到達,處理一個請求須要1秒,那麼系統在每一個時刻都有10個請求在處理。若是處理每一個請求的時間翻倍,那麼系統每時刻須要處理的請求數也翻倍爲20,所以須要20個線程。
任務的執行時間對於系統中正在處理的請求數量有着很大的影響,一些後端資源的遲延,例如數據庫,一般會使得請求的處理時間延長,從而致使線程池中的線程被迅速用盡。所以,理論上測出的線程數上限對於這種狀況就不是很合適,這個上限值還應該考慮到線程的執行時間,並結合理論上的上限值。
例如,假設JVM最多能同時處理的請求數爲1000。若是咱們預計每一個請求須要耗費的時間不超過30秒,那麼,在最壞的狀況下咱們每秒能同時處理的請求數不會超過33 ⅓個。可是,若是一切都很順利,每一個請求只需使用500ms就能夠完成,那麼經過1000個線程應用每秒就能夠處理2000個請求。當系統忽然出現短暫的任務執行遲延的問題時,經過使用一個隊列來減緩這一問題是可行的。
若是線程池的線程數量過少,咱們就沒法充分利用系統資源,這使得用戶須要花費很長時間來等待請求的響應。可是,若是容許建立過多的線程,系統的資源又會被耗盡,這會對系統形成更大的破壞。
不只僅是本地的資源被耗盡,其它一些應用也會受到影響。例如,許多應用都使用同一個後端數據庫進行查詢等操做。數據庫有併發鏈接數量的限制。若是一個應用不加限制地佔用了全部數據庫鏈接,其它獲取數據庫鏈接的應用都將被阻塞。這將致使許多應用運行中斷。
更糟的是,資源耗盡還會引起一些連鎖故障。設想這樣一個場景,一個應用有許多個實例,這些實例都運行在一個負載均衡器以後。若是一個實例由於過多的請求而佔用了過多內存,JVM就須要花更多的時間進行垃圾收集工做,那麼JVM處理請求的時間就減小了。這樣一來,這個應用實例處理請求的能力下降了,系統中的其它實例就必須處理更多的請求。其它的實例也會由於請求數過多以及線程池大小沒有限制的緣由產生資源枯竭等問題。這些實例用盡了內存資源,致使虛擬機進行頻繁地內存收集操做。這樣的惡性循環會在這些實例中產生,直到整個系統奔潰。
我見過許多沒有進行負載測試的應用,這些應用可以建立任意多的線程。一般狀況下,這些應用只要不多數量的線程就能處理好以必定速率到達的請求。可是,若是應用須要使用其它的一些遠程服務來處理用戶請求,而這個遠程服務的處理能力忽然下降了,這將增大W的值(應用處理請求的平均時間)。這樣,線程池的線程就會被迅速用盡。若是對應用進行線程數量的負載測試,那麼資源枯竭問題就會在測試中顯現出來。
對於微服務架構和面向服務的架構(SOA)來講,它們一般須要請求一些後端服務。線程池的配置很是容易致使程序失敗,所以必須謹慎地配置線程池。若是遠程服務的性能降低,系統中的線程數量就會迅速達到線程池的上限,其它後續到達的服務就會被丟棄。這些後續的請求也許並非要使用性能出現故障的服務,可是它們都只能被丟棄了。
針對不一樣的後端服務請求,設置不一樣的線程池能夠解決這一問題。在這個模式中,仍然使用同一個線程池來處理用戶的請求,可是當用戶的請求須要調用一個遠程服務時,這個任務就被傳遞給一個指定的後端線程池。這樣處理用戶請求的主線程池就不會由於調用後端服務而產生很大的負擔。當後端服務出現故障時,只有調用這個服務的線程池纔會受到影響。
使用多個線程池還有一個好處,就是它能幫助避免出現死鎖問題。若是每一個空閒線程都由於一個還沒有處理完畢的請求阻塞,就會發生死鎖,沒有一個線程能夠繼續往下執行。若是使用多個線程池,理解好每一個線程池應負責的工做,那麼死鎖的問題就能在必定程度上避免。
一個最佳實踐是給須要遠程調用的請求規定一個截止時間。若是遠程服務在規定的時間內沒有響應,就丟棄這個請求。這樣的技術也能夠用在線程池中,若是線程處理某個請求的時間超過了規定時間,那麼這個線程就應被中止,爲新到達的請求騰出資源,這樣也就給W(處理請求的平均時間)規定了上限。雖然這樣的作法看起來有些浪費,可是若是一個用戶(特別是當用戶在使用瀏覽器時),在等待請求的響應,那麼30秒之後,瀏覽器不管如何也會放棄這個請求,或者更有可能的是:用戶不會耐心地等待這個請求響應,而是進行其它操做去了。
快速失敗也是一個能夠用來處理後端請求的線程池方案。若是後端服務失效了,線程池中的線程數會迅速到達上限,這些線程都在等待沒有響應的後端服務。若是使用快速失敗機制,當後端服務被標記爲失效時,全部的後續請求都會迅速失敗,而不是進行沒必要要的等待。固然,它也須要一種機制來判斷後端什麼時候恢復爲可用的。
最後,若是一個請求須要獨立地調用多個後端服務,那麼這個請求就應能並行地調用這些後端服務,而不是順序地進行。這樣就能下降請求的等待時間,但這是以增長線程數爲代價的。
幸運的是,有一個很是好的庫hystrix,這個庫封裝了許多很好的線程策略,而後以很是簡單和友好的方式將這些接口暴露出來。
我但願這篇文章能改進你對線程池的理解。一個合適的線程池配置須要理解應用的需求,還須要考慮這幾個因素,系統容許的最大線程數、處理用戶請求所需的時間。好的線程池配置不只能夠避免系統出現連鎖故障,還能幫助計劃和提供服務。
即便你的應用沒有直接地使用一個線程池,它們也間接地經過應用服務器或其它更高級的抽象形式使用了線程池。Tomcat、JBoss、Undertow、Dropwizard 都提供了多種可配置的線程池(這些線程池正是你編寫的Servlet運行的地方)。
原文連接: blog.bramp.net 翻譯: ImportNew.com - justyoung
譯文連接: http://www.importnew.com/1763...[ 轉載請保留原文出處、譯者和譯文連接。]