ExecutorService 併發指南

FunTester發表於2024-10-31

在軟體開發不斷髮展的世界中,有效管理併發任務的能力至關重要。傳統的執行緒方法可能變得繁瑣且容易出錯,特別是在處理大量非同步操作時。這時,ExecutorService 登場了:它是 Java 併發框架中一個強大的抽象,旨在簡化和最佳化非同步任務執行。

本文我們將深入探討 ExecutorService 其核心功能,探索各種執行緒池配置,以解決 Java 應用程式中的現實世界併發挑戰。透過閱讀、實踐本文內容,你將掌握以下能力;

  • 簡化非同步程式設計: 抽象執行緒管理允許專注於任務的邏輯,而不是執行緒建立和生命週期的細節。
  • 提高可擴充套件性: 輕鬆管理執行緒池,使應用程式能夠高效地處理不同的工作負載。
  • 增強可維護性: 透過集中執行緒管理實現更乾淨、更易讀和可維護的應用程式。

ExecutorService 概覽

傳統上,執行緒被用來實現併發。每個執行緒就像處理器上的一個單獨核心,一次專注於一個任務。然而,直接管理執行緒可能是一個 雜技 行為。這種做法需要擔心建立執行緒、處理它們的生命週期(啟動、停止)以及當多個執行緒訪問共享資源(如資料庫或檔案系統)時可能出現的同步問題。

這就是 ExecutorService 可靠的地方。它作為管理 非同步任務 的高階抽象。非同步任務本質上是可以獨立提交和執行的工作,而不必阻塞主執行執行緒。ExecutorService 負責執行緒池管理,讓開發者專注於任務的邏輯,而不是執行緒的複雜性。

使用 ExecutorService 與原始執行緒管理相比的好處:

  • 簡化的程式碼: 編寫的是任務的功能程式碼(例如,處理影像、下載檔案),而不是執行緒管理(建立和管理工作執行緒)。這導致程式碼更乾淨、更易於維護。
  • 提高的可擴充套件性: ExecutorService 管理一個執行緒池。如果一個執行緒忙於處理影像,另一個可以下載檔案,確保程式能夠高效地處理不同的工作負載。
  • 增強的錯誤處理: ExecutorService 提供了處理任務執行期間可能發生的錯誤的方法。開發者不必編寫單獨的程式碼來捕獲和處理個別執行緒丟擲的異常。
  • 資源管理: ExecutorService 控制執行緒池中的執行緒數量,防止建立太多執行緒,可能會壓垮系統的資源。這就像有一個定義好的工作執行緒數量,以避免用太多工超載處理器。

基礎功能

現在我們已經理解了 ExecutorService 的魔力,讓我們看看如何將其付諸實踐。以下是如何建立一個ExecutorService 例項並提交任務進行非同步執行的方法:

建立 ExecutorService

Java 的 Executors 類提供了各種工廠方法來建立不同型別的 ExecutorService 例項。這裡是一個常見的例子:

ExecutorService executorService = Executors.newFixedThreadPool(5);

這段程式碼建立了一個大小為 5 的固定執行緒池的 ExecutorService。這意味著 ExecutorService 將管理一個 5 個執行緒的池來執行您的任務。您可以根據您的需求選擇其他配置,如newSingleThreadExecutor(一個執行緒)或newCachedThreadPool(動態調整執行緒池大小)。

提交任務

有兩種主要方式向 ExecutorService 提交任務:

  • submit(Callable<T> task) 這個方法接受一個Callable物件作為輸入。Callable介面擴充套件了Runnable,但允許開發者從任務執行中返回結果。當呼叫submit時,ExecutorService 安排任務執行並返回一個Future物件。
  • execute(Runnable task) 這個方法接受一個Runnable物件作為輸入。Runnable介面定義了一個要非同步執行的單個方法run()。與submit不同,execute不返回結果。

Future 的力量:管理任務執行

當我們使用submit(Callable<T> task)時,ExecutorService 返回一個Future物件。這個Future物件作為任務最終結果的佔位符。它提供了幾種管理任務執行的方法:

  • get(): 這個方法阻塞呼叫執行緒,直到任務完成執行,然後返回Callable中的call()方法產生的結果。
  • isDone(): 這個方法檢查任務是否已完成執行。如果任務已完成,則返回true;否則返回false
  • cancel(boolean mayInterruptIfRunning): 這個方法嘗試取消任務執行。mayInterruptIfRunning引數指定是否應該中斷當前執行的執行緒。

執行緒池機制

ExecutorService 的核心是一個強大的概念——執行緒池。它就像一群等待被工頭(ExecutorService)分配任務的工人(執行緒)。理解執行緒池對於有效利用 ExecutorService 至關重要。

執行緒池在行動

工頭(ExecutorService)有一群具有特定技能(任務型別)的工人(執行緒)。當建築任務(提交)到達時,工頭將它們分配給池中的可用工人。這確保了任務的有效執行,而不需要為每個單獨的任務建立新的工人。

選擇正確的配置

Executors類提供了各種工廠方法,用於建立具有不同執行緒池配置的 ExecutorService 例項:

  • newFixedThreadPool(int nThreads) 這個方法建立了一個大小為nThreads的固定執行緒池的 ExecutorService。這適用於工作負載可預測的場景。固定池大小確保了一致的併發級別,但如果工作負載超過了可用執行緒,任務可能會排隊等待可用的工人。
  • newSingleThreadExecutor() 這個方法建立了一個池中只有一個執行緒的 ExecutorService。這適用於需要嚴格順序執行或彼此依賴的任務。然而,它限制了併發性,並且可能不適用於處理多個獨立任務。
  • newCachedThreadPool() 這個方法建立了一個動態調整大小的執行緒池的 ExecutorService。池大小可以根據需要增長以處理傳入的任務。然而,這種靈活性帶來了潛在的缺點:

平衡效能和資源

執行緒池的大小和配置顯著影響應用程式的效能和資源利用。以下是如何找到平衡的方法:

  • 執行緒池大小: 更大的執行緒池允許更多的併發任務執行,但它也消耗更多的資源。選擇一個與平均工作負載相符的大小,以避免資源耗盡或未充分利用。
  • 排隊行為: 當執行緒池已滿且沒有工人可用時,任務可能會排隊等待稍後執行。Executors類不直接控制排隊行為。然而,一些底層實現可能使用有界佇列(如果佇列已滿,則拒絕任務)或無界佇列(即使佇列已滿,任務也會繼續新增,可能導致 OutOfMemoryError 異常)。

ExecutorService 高階功能

現在我們已經探討了 ExecutorService 的核心功能,讓我們深入瞭解一些高階主題,以獲得全面、深刻的理解:

優雅關閉

ExecutorService 不應該被突然遺棄。以下是兩個關鍵方法,用於優雅地關閉它:

  • shutdown() 這個方法向 ExecutorService 發出訊號,表示不應再提交新任務。佇列中的任務或當前正在執行的任務將被允許完成,然後 ExecutorService 才會終止。這就像告知工頭 ExecutorService 停止接受新的建築工程任務,但允許正在進行的專案完成。
  • shutdownNow() 這個方法嘗試停止所有當前正在執行的任務,並防止任何新任務被提交。這就像工頭緊急停止所有建築活動。然而,要小心——突然停止任務可能導致工作不完整或資料不一致。

拒絕策略

當我們向一個已滿的執行緒池的 ExecutorService 提交任務時,如果執行緒池無法接受新的任務,這些任務會被提交到執行緒池的阻塞佇列中。如果阻塞佇列也已滿,就會觸發一個名為 RejectedExecutionHandler 的機制。預設情況下,ThreadPoolExecutor 使用的拒絕策略是 AbortPolicy,即丟擲 RejectedExecutionException 異常,導致任務被拒絕。

為了處理被拒絕的任務,我們可以選擇不同的策略:

  1. AbortPolicy(預設策略):
    • 直接丟擲 RejectedExecutionException 異常,阻止任務被提交。
    • 適用於需要立即發現問題並採取措施的場景。
  2. CallerRunsPolicy:
    • 呼叫者執行緒會執行被拒絕的任務,這樣可以降低新的任務被提交的速度。
    • 適用於希望繼續執行被拒絕任務但不需要嚴格的執行順序的情況。
  3. DiscardPolicy:
    • 直接丟棄被拒絕的任務,不丟擲異常。
    • 適用於可以接受部分任務被丟棄的情況,但這種策略可能導致任務丟失而不易察覺。
  4. DiscardOldestPolicy:
    • 丟棄阻塞佇列中最舊的任務,然後重新嘗試提交當前任務。
    • 適用於更關注最新任務而願意捨棄舊任務的場景。
  5. 自定義處理策略:
    • 透過實現 RejectedExecutionHandler 介面,開發者可以定義自己的拒絕處理邏輯。例如,可以記錄日誌、報警,甚至將任務重新加入佇列。

選擇適合的拒絕策略,能幫助你更好地控制執行緒池在高負載情況下的行為,確保系統的穩定性。

多工協調

ExecutorService 提供了超出簡單任務提交的功能:

  • invokeAll(Collection<? extends Callable<T>> tasks) 這個方法允許提交一系列 Callable 任務,並返回包含結果的 List<Future>。它阻塞呼叫執行緒直到所有任務完成。這適用於等待一組任務的完成並檢索它們各自的結果。
  • invokeAny(Collection<? extends Callable<T>> tasks) 這個方法提交一系列 Callable 任務,但只等待第一個完成。它返回已完成任務的結果,如果所有任務都失敗則丟擲異常。這在您只需要一個組中任何一個任務的結果時非常有用,無論哪個任務首先完成。

ExecutorService 實踐

ExecutorService 在許多需要非同步處理以提高效能和響應性的場景中表現出色。讓我們透過一些程式碼示例來探索一些常見用例:

網路請求

當需要從多個 API 併發地獲取資料以提升 Web 應用程式的感知效能時,ExecutorService 可以發揮重要作用。透過它,我們可以高效地管理執行緒池,提交多個並行任務,從而在最短的時間內獲取所有 API 的響應。這種方式不僅提升了資料獲取速度,還減少了單個 API 請求的等待時間,從而顯著改善使用者體驗。

以下是一個使用 ExecutorService 併發地從多個 API 獲取資料的示例:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.List;
import java.util.ArrayList;

public class ConcurrentApiRequests {
    public static void main(String[] args) {
        // 建立一個固定大小的執行緒池
        ExecutorService executor = Executors.newFixedThreadPool(3);

        // 假設我們有多個API的URL列表
        List<String> apiUrls = List.of(
            "https://funtester.com/posts/1",
            "https://funtester.com/posts/2",
            "https://funtester.com/posts/3"
        );

        // 儲存每個任務的Future物件
        List<Future<String>> futures = new ArrayList<>();

        for (String url : apiUrls) {
            Callable<String> task = () -> {
                // 模擬傳送HTTP請求(使用 HttpClient 或 OkHttp 等工具)
                // 這裡為了簡化,直接返回URL
                return fetchDataFromApi(url);
            };

            // 提交任務給執行緒池
            Future<String> future = executor.submit(task);
            futures.add(future);
        }

        // 處理所有的Future物件,獲取結果
        for (Future<String> future : futures) {
            try {
                // 獲取任務執行的結果
                String result = future.get();
                System.out.println("API Response: " + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        // 關閉執行緒池
        executor.shutdown();
        try {
            // 等待所有任務完成
            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                executor.shutdownNow(); // 強制關閉
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
    }

    // 模擬API資料獲取
    private static String fetchDataFromApi(String url) throws Exception {
        // 這裡可以實際呼叫HttpClient或其他HTTP庫傳送請求並獲取資料
        // 為了簡化,直接返回URL作為結果
        Thread.sleep(1000); // 模擬網路延遲
        return "Data from " + url;
    }
}

關鍵步驟說明:

  1. 執行緒池建立: 使用 Executors.newFixedThreadPool(3) 建立一個固定大小為 3 的執行緒池,允許最多 3 個任務併發執行。
  2. 提交任務: 將每個 API 請求封裝為一個 Callable 任務,並提交給 ExecutorService,返回一個 Future 物件。Future 用於非同步獲取任務的執行結果。
  3. 處理任務結果: 透過遍歷 Future 列表,呼叫 future.get() 獲取每個任務的結果。此操作是阻塞的,但因為任務是併發執行的,整體效能大大提升。
  4. 執行緒池關閉: 呼叫 executor.shutdown() 關閉執行緒池,並使用 awaitTermination 等待所有任務完成。如果等待超時,則呼叫 shutdownNow 強制關閉。

透過 ExecutorService 併發地從多個 API 獲取資料,可以顯著提升 Web 應用程式的感知效能,讓使用者感受到更快的響應速度和更流暢的互動體驗。在實際應用中,根據 API 的特性和系統資源合理調整執行緒池配置,是獲得最佳效能的關鍵。

影像處理

在需要對一批上傳的影像進行後臺處理(如調整影像大小)時,ExecutorService 是一個非常有效的工具。它可以非同步處理這些任務,而不會阻塞主執行緒,從而保持應用程式的響應性。

以下是一個使用 ExecutorService 來非同步調整一批影像大小的示例:

import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import javax.imageio.ImageIO;

public class ImageResizer {
    public static void main(String[] args) {
        // 建立一個固定大小的執行緒池
        ExecutorService executor = Executors.newFixedThreadPool(4);

        // 模擬上傳的影像檔案列表
        List<File> imageFiles = List.of(
            new File("path/to/image1.jpg"),
            new File("path/to/image2.jpg"),
            new File("path/to/image3.jpg")
        );

        // 儲存每個任務的Future物件
        List<Future<File>> futures = new ArrayList<>();

        for (File imageFile : imageFiles) {
            Callable<File> task = () -> resizeImage(imageFile, 200, 200); // 將影像調整為200x200的大小

            // 提交任務給執行緒池
            Future<File> future = executor.submit(task);
            futures.add(future);
        }


    // 影像大小調整方法
    private static File resizeImage(File inputFile, int width, int height) throws IOException {
        // 讀取原始影像
        BufferedImage originalImage = ImageIO.read(inputFile);

        // 建立一個縮放後的影像
        Image scaledImage = originalImage.getScaledInstance(width, height, Image.SCALE_SMOOTH);
        BufferedImage resizedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

        // 在新的BufferedImage中繪製縮放後的影像
        Graphics2D g2d = resizedImage.createGraphics();
        g2d.drawImage(scaledImage, 0, 0, null);
        g2d.dispose();

        // 儲存調整大小後的影像
        File outputFile = new File("resized_" + inputFile.getName());
        ImageIO.write(resizedImage, "jpg", outputFile);

        return outputFile;
    }
}

程式碼步驟說明:

  1. 執行緒池建立: 使用 Executors.newFixedThreadPool(4) 建立一個固定大小為 4 的執行緒池,允許最多 4 個影像處理任務併發執行。
  2. 提交任務: 將每個影像的大小調整操作封裝為一個 Callable<File> 任務,並提交給 ExecutorService。每個任務返回一個 Future<File>,用於非同步獲取處理結果。
  3. 影像大小調整: 在 resizeImage 方法中,透過 Image.getScaledInstance 方法調整影像大小,並使用 Graphics2D 將縮放後的影像繪製到新的 BufferedImage 上,然後將其儲存為新的檔案。
  4. 處理任務結果: 透過遍歷 Future 列表,呼叫 future.get() 獲取每個任務的結果。此操作阻塞當前執行緒直到任務完成,但由於任務是併發執行的,整個過程依然很高效。
  5. 執行緒池關閉: 呼叫 executor.shutdown() 關閉執行緒池,確保所有任務完成後再結束應用。

這種方法適用於 Web 應用、桌面應用或伺服器後臺任務,例如使用者上傳多張圖片時,應用可以迅速響應使用者上傳操作,而在後臺調整圖片大小,以便稍後用於展示或儲存。

後臺任務

在應用程式中,某些任務可能需要在後臺執行,例如傳送電子郵件、記錄資料、處理檔案等。這些任務通常需要一定的時間完成,而如果在主執行緒中執行這些任務,可能會導致應用程式的 UI 變得不響應。為了保持 UI 的流暢性和使用者體驗,使用 ExecutorService 來非同步處理這些後臺任務是非常有效的。

下面是一個簡單的示例,展示瞭如何使用 ExecutorService 來非同步傳送電子郵件和記錄資料,而不阻塞主執行緒:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BackgroundTaskExample {
    public static void main(String[] args) {
        // 建立一個單執行緒的ExecutorService,用於處理後臺任務
        ExecutorService executor = Executors.newSingleThreadExecutor();

        // 模擬UI操作,例如使用者點選按鈕後觸發的任務
        System.out.println("UI is responsive. Performing background tasks...");

        // 非同步傳送電子郵件
        executor.submit(() -> sendEmail("user@example.com", "Welcome", "Thank you for signing up!"));

        // 非同步記錄資料
        executor.submit(() -> logData("User signed up with email user@example.com"));

        // 主執行緒繼續執行其他操作
        System.out.println("UI can continue to interact with the user...");

        // 關閉ExecutorService
        executor.shutdown();
    }

    // 模擬傳送電子郵件的方法
    private static void sendEmail(String recipient, String subject, String body) {
        try {
            // 模擬傳送電子郵件的延遲
            Thread.sleep(2000);
            System.out.println("Email sent to " + recipient + " with subject: " + subject);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Email sending was interrupted");
        }
    }

    // 模擬記錄資料的方法
    private static void logData(String data) {
        try {
            // 模擬記錄資料的延遲
            Thread.sleep(1000);
            System.out.println("Data logged: " + data);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Data logging was interrupted");
        }
    }
}

程式碼說明:

  1. 執行緒池建立: 使用 Executors.newSingleThreadExecutor() 建立一個單執行緒的 ExecutorService。這種型別的執行緒池適合處理多個任務但確保任務按順序執行。
  2. 非同步任務提交: 使用 executor.submit() 提交任務。這些任務在後臺非同步執行,主執行緒無需等待任務完成即可繼續處理其他操作。
  3. 任務實現: 任務的實現可以是任何需要耗時操作的程式碼,例如傳送電子郵件或記錄資料。為了模擬這些操作的延遲,使用了 Thread.sleep()
  4. 主執行緒的執行: 在任務執行的同時,主執行緒依然保持對 UI 的控制權,可以繼續響應使用者操作。透過這種方式,應用程式的響應性得以保持。
  5. 執行緒池關閉: 在所有任務提交完畢後,呼叫 executor.shutdown() 關閉 ExecutorService。這種關閉方式允許已經提交的任務繼續執行,而不會接受新任務。

實際應用場景:

  • 傳送通知: 當使用者完成某些操作後,應用程式可能需要傳送確認郵件或簡訊。這些操作可以非同步進行,不阻礙使用者的後續操作。
  • 資料處理: 使用者提交資料後,應用可以立即響應使用者,而將資料處理的任務(如儲存到資料庫、生成報告等)交給後臺執行緒執行。
  • 日誌記錄: 應用程式可以在後臺記錄重要操作日誌,而不會影響前臺的使用者互動。

透過 ExecutorService,我們可以有效地將這些後臺任務與主執行緒分離,實現應用程式的高效執行和良好的使用者體驗。

經驗分享

最佳實踐

選擇正確的 ExecutorService 配置對最佳化效能和資源至關重要。以下是一些最佳實踐:

  • 分析您的工作負載: 瞭解任務的性質(如 CPU 繫結或 I/O 繫結)和預期的併發任務數量至關重要。CPU 繫結任務需要接近 CPU 核心數的執行緒池,而 I/O 繫結任務可以使用更多執行緒。分析工作負載可以幫助確定適當的執行緒池大小,最佳化效能和資源使用。
  • 深思熟慮地擴充套件: 初始時選擇較小的執行緒池規模,根據實際情況逐步增加執行緒數。這種做法可以避免因執行緒過多導致的資源耗盡問題。逐步擴充套件能更好地適應工作負載的變化,並確保系統穩定性。
  • 考慮固定與快取: 對於可預測的工作負載,固定執行緒池(FixedThreadPool)提供穩定的效能。對於高度可變的工作負載,可選擇快取執行緒池(CachedThreadPool),它會動態調整執行緒數量,但需注意無界佇列可能導致資源耗盡。
  • 處理被拒絕的任務: 定義適當的拒絕策略來優雅地處理執行緒池滿的情況。可以記錄被拒絕的任務、稍後重試,或丟擲異常供應用程式處理。設定自定義拒絕處理器能提高系統的可靠性和靈活性。

避免的陷阱

  • 資源洩漏: 使用 ExecutorService 後,務必呼叫 shutdown()shutdownNow() 方法來關閉它。否則,執行緒池中的空閒執行緒和其他資源可能會持續佔用記憶體,導致資源洩漏和效能下降。確保在不再需要執行緒池時進行正確關閉,以維護系統資源的健康。
  • 執行緒飢餓: 在使用快取執行緒池時,頻繁的短暫任務可能導致執行緒池不斷建立和銷燬執行緒。這種行為會消耗大量資源,並可能使長期執行的任務無法獲得足夠的 CPU 時間。為長時間執行的任務考慮使用固定執行緒池,這樣可以保持執行緒池的穩定性和任務處理的公平性。
  • 未檢查的異常: 非同步任務在執行過程中可能會丟擲異常。如果不進行適當的異常處理,可能導致任務失敗並影響應用程式的穩定性。確保在提交任務時實現異常處理機制,捕獲並記錄異常,防止應用程式因未處理的異常崩潰。透過 Futureget() 方法獲取任務結果時也要處理 ExecutionException

監控和管理

  • JMX(Java 管理擴充套件): JMX 提供了強大的工具來監控和管理執行緒池的效能。透過 JMX,可以實時檢視執行緒池的核心指標,如活動執行緒數、任務佇列大小和任務完成時間。這有助於即時瞭解執行緒池的執行狀況,並做出必要的調整,保持系統效能的穩定性。
  • 自定義監控: 實施自定義監控解決方案可以更精確地跟蹤執行緒池的效能指標。透過定製監控指令碼或工具,可以獲取特定的指標資料,幫助識別系統瓶頸或資源耗盡問題。例如,可以監控任務提交速率、執行緒池使用情況和任務處理延遲等,以最佳化執行緒池配置和系統資源利用。
  • 分析工具: 使用像 JProfiler 或 YourKit 這樣的分析工具,可以深入分析執行緒行為,識別潛在問題。這些工具能夠提供執行緒活動檢視、上下文切換情況和鎖競爭分析,幫助發現如執行緒飢餓、執行緒過度上下文切換等效能瓶頸。透過這些分析,能夠有效最佳化執行緒池配置,提高系統效能。
FunTester 原創精華
  • 混沌工程、故障測試、Web 前端
  • 服務端功能測試
  • 效能測試專題
  • Java、Groovy、Go
  • 白盒、工具、爬蟲、UI 自動化
  • 理論、感悟、影片
如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章