13161216443

您所在位置: 首頁> java技術> 線程池調整真的很重要

線程池調整真的很重要

發布百知教育 來源:java技術 2019-05-10

知道嗎,你的Java web應用其實是使用線程池來處理請求的。這一實現細節被許多人忽略,但是你遲早都需要理解線程池如何使用,以及如何正確地根據應用調整線程池配置。這篇文章的目的是為了解釋線程模型——什么是線程池、以及怎樣正確地配置線程池。


 

單線程模型

 

讓我們從一些基礎的線程模型開始,然后再隨著線程模型的演變進行更深一步的學習。你使用的任何應用服務器或框架,如Tomcat、Dropwizard、Jetty等,它們的基本原理其實是相同的。Web服務器的最底層實際上是一個socket。這個socket監聽并接受到達的TCP連接。一旦一個連接被建立,就可以通過這個新建立的連接讀取、解析信息,然后將這些信息包裝成一個HTTP請求。這個HTTP請求還將被移交至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();

}

這段代碼在8080端口上創建了一個ServerSocket,緊接著通過循環來監聽和接受新到達的連接。一旦連接建立,會將socket傳遞給handleRequest方法。這個方法可能會讀取該HTTP請求,處理這個請求,然后寫回一個響應。在這個簡單的例子中,handleRequest方法從socket中讀取簡單的一行數據,然后返回一個簡短的HTTP響應。但是,handleRequest有可能需要處理一些更復雜的任務,例如讀數據庫或者執行其它一些IO操作。

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個請求。

 

多線程模型

 

盡管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();

        }

    }

}

// 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方法。

新線程的建立使調用accept方法的線程能夠處理更多的TCP連接,這樣服務器就能并發地處理請求了。這一技術被稱為“thread per request”(一個線程處理一個請求),也是現在最流行的服務器技術。值得注意的是,還有一些其它的服務器技術,如NGINXNode.js采用的事件驅動異步模型,它們都沒有使用線程池。因此,它們都不在本文的討論范圍內。

thread per request”方式里創建新線程(稍后銷毀這個線程)的操作是昂貴的,因為Java虛擬機和操作系統都需要為這一操作分配資源。另外,在上面那段代碼的中,可以創建的線程數量不受限制的。這么做的隱患很大,因為它可能導致服務器資源迅速枯竭。

 

資源枯竭

 

每個線程都需要一定的內存空間來作為自己的??臻g。在最近的64位虛擬機版本中,默認的??臻g是1024KB。如果server收到很多請求,或者handleRequest方法的執行時間變得比較長,就會造成服務器產生很多并發線程。如果要維護1000個線程,僅就??臻g而言,虛擬機就必須耗費1GBRAM空間。另外,為處理請求,每個線程都會在堆上產生許多對象,這就有可能導致虛擬機的堆空間被迅速占滿,給虛擬機的垃圾收集器帶來很大壓力,造成頻繁的垃圾回收,最終導致OutOfMemoryErrors。

線程消耗的不僅是RAM資源,這些線程還可能消耗其它有限的資源,例如文件句柄、數據庫連接等。過多地消耗這類資源可能導致一些其它的錯誤或造成系統崩潰。因此,要防止系統資源被線程耗盡,就必須對服務器產生的線程數量做出限制。

通過使用-Xss參數來調整每個線程的??臻g,可以在一定程度上解決資源枯竭的問題,但它絕不是靈丹妙藥。一個小的??臻g可以使得每個線程占用的內存減小,但這樣可能會造成

StackOverflowErrors棧溢出錯誤。??臻g的調整方式不盡相同,但是對許多應用來說,1024KB過于浪費了,而256KB512KB會更加合適。Java所允許的最小??臻g的大小是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策略提供了一個簡單的方式來限制任務提交的速度。但是這樣做可能是有害的,因為它會阻塞一個原本不應被阻塞的線程。

一個好的默認策略應該是DiscardAbort,它們都會使線程池丟棄新到達的任務。這樣服務器就能容易地向客戶端響應一個錯誤,例如HTTP503錯誤“Service unavailable”。有的人可能會認為,隊列的長度應該是允許增長的,這樣所有的任務最終都能被執行。但是用戶是不愿意長時間等待的,而且若任務到達的速度超過任務處理的速度,隊列將會無限地增長。隊列是被用來緩沖突然爆發的請求,或者處理短期任務的,通常情況下,隊列應該是空的。

 

多少線程合適呢?

 

現在,我們知道了如何創建一個線程池。但是有一個更困難的問題,線程池里應該創建多少個線程呢?我們已經知道了線程池中的最大線程數量應該被限制,才不會導致系統資源耗盡。這些系統資源包括了內存(堆棧)、打開的文件句柄、打開的TCP連接、打開的數據庫連接以及其它有限的系統資源。相反的,如果線程執行的是CPU密集型任務而不是IO密集型任務,服務器的物理內核數就應該被視為是有限的資源,這樣創建的線程數就不應該超過系統的內核數。系統應創建多少線程取決于這個應用執行的任務。開發人員應使用現實的請求來對系統進行負載測試,測試不同的線程池大小配置對系統的影響。每次測試都增加線程池的大小,直到系統達到崩潰的臨界點。這個方法使你可以發現線程池線程數量的上限。超過這個上限,系統的資源將耗盡。在某些情況下,可以謹慎地增加系統的資源,例如分配更多的RAM空間給JVM,或者調整操作系統使其支持同時打開更多的文件句柄。然而,在某些情況下創建的線程數量會達到我們測試出的理論上限,這非常值得我們注意。稍后還會看到這方面的內容。

 

利特爾法則

 

排隊論,特別的,Littles 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運行的地方)。


上一篇:Hello World 程序的起源與歷史

下一篇:應屆生去公司找個Java程序員的職位需要什么技能?

相關推薦

www.akpsimsu.com

有位老師想和您聊一聊

關閉

立即申請