综合指南:如何确定 Java 线程池大小

Java 中的线程创建会产生显着的成本。创建线程会消耗时间,增加请求处理的延迟,并且涉及 JVM 和操作系统的大量工作。为了减轻这些开销,线程池发挥了作用。

在本文中,我们将深入研究确定理想线程池大小的艺术。经过微调的线程池可以从系统中提取最佳性能,并帮助您轻松应对峰值工作负载。然而,重要的是要记住,即使使用线程池,线程的管理本身也可能成为瓶颈。

使用线程池的原因

  • 性能:线程的创建和销毁可能会很昂贵,尤其是在 Java 中。线程池通过创建可重复用于多个任务的线程池来帮助减少这种开销。
  • 可扩展性:线程池可以扩展以满足应用程序的需求。例如,在重负载下,可以扩展线程池来处理额外的任务。
  • 资源管理:线程池可以帮助管理线程使用的资源。例如,线程池可以限制在任何给定时间可以活动的线程数量,这有助于防止应用程序耗尽内存。

假设您正在开发一个处理传入 HTTP 请求的Web 应用程序。每个请求可能涉及处理数据库中的数据以及调用外部第三方服务。您的目标是确定有效处理这些请求的最佳线程池大小。

数据库连接池:假设您使用 HikariCP 等连接池来管理数据库连接。您已将其配置为允许最多 100 个连接。如果您创建的线程多于可用连接,这些额外的线程最终将等待可用连接,从而导致资源争用和潜在的性能问题。

以下是配置 HikariCP 数据库连接池的示例:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

public class DatabaseConnectionExample {
    public static void main(String[] args) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
        config.setUsername(
"username");
        config.setPassword(
"password");
        config.setMaximumPoolSize(100);
// Set the maximum number of connections

        HikariDataSource dataSource = new HikariDataSource(config);

       
// Use the dataSource to get database connections and perform queries.
    }
}

外部服务吞吐量:与应用程序交互的外部服务有其局限性。它只能同时处理几个请求,比如一次 10 个请求。同时发送更多请求可能会使服务不堪重负,导致性能下降或出错。

CPU 内核:确定服务器上可用的 CPU 内核数量对于优化线程池大小至关重要。
int numOfCores = Runtime.getRuntime().availableProcessors();

每个内核可同时执行一个线程。超过 CPU 内核的线程数量会导致过度的上下文切换,从而降低性能。

CPU 密集型任务和 I/O 密集型任务
CPU 密集型任务是指需要大量处理能力的任务,如执行复杂计算或运行模拟。这些任务通常受限于 CPU 的速度,而不是 I/O 设备的速度。

  • 音频或视频文件的编码或解码
  • 编译和链接软件
  • 运行复杂的模拟
  • 执行机器学习或数据挖掘任务
  • 玩视频游戏

优化:
多线程和并行:并行处理是一种技术,用于将较大的任务划分为较小的子任务,并将这些子任务分配给多个 CPU 内核或处理器,以利用并发执行的优势,提高整体性能

假设你有一个包含大量数字的数组,你想利用并行处理的优势,使用多个线程同时计算每个数字的平方。

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

public class ParallelSquareCalculator {
    public static void main(String[] args) {
        int[] numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        int numThreads = Runtime.getRuntime().availableProcessors(); // Get the number of CPU cores
        ExecutorService executorService = Executors.newFixedThreadPool(numThreads);

        for (int number : numbers) {
            executorService.submit(() -> {
                int square = calculateSquare(number);
                System.out.println(
"Square of " + number + " is " + square);
            });
        }

        executorService.shutdown();
        try {
            executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private static int calculateSquare(int number) {
       
// Simulate a time-consuming calculation (e.g., database query, complex computation)
        try {
            Thread.sleep(1000);
// Simulate a 1-second delay
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        return number * number;
    }
}

IO 密集型任务是指与存储设备(如读/写文件)、网络套接字(如调用 API)或用户输入(如图形用户界面中的用户交互)交互的任务。

  • 向磁盘读取或写入大文件(如保存视频文件、加载数据库等)
  • 通过网络下载或上传文件(如浏览网页、观看流媒体视频)
  • 收发电子邮件
  • 运行网络服务器或其他网络服务
  • 执行数据库查询
  • 处理传入请求的网络服务器。

优化:

  • 缓存: 在内存中缓存频繁访问的数据,以减少重复 I/O 操作的需要。
  • 负载平衡:将有 I/O 约束的任务分配给多个线程或进程,以高效处理并发 I/O 操作。
  • 使用固态硬盘:与传统硬盘(HDD)相比,固态硬盘(SSD)可显著加快 I/O 操作速度。
  • 使用哈希表和 B 树等高效数据结构,减少所需的 I/O 操作次数。
  • 避免不必要的文件操作,如多次打开和关闭文件。


确定线程数:对于 CPU 密集型任务:
对于 CPU 密集型任务,你需要最大限度地提高 CPU 利用率,同时避免线程过多导致系统不堪重负,因为线程过多会导致上下文切换过多。常用的经验法则是使用可用的 CPU 内核数

实际例子:视频编码
想象一下,您正在开发一个视频处理应用程序。视频编码是一项受 CPU 限制的任务,您需要应用复杂的算法来压缩视频文件。您有一个可用的多核 CPU。

确定 CPU 绑定任务的线程数:
计算可用 CPU 内核:使用 Java 中的 Runtime.getRuntime().availableProcessors() 来确定可用 CPU 内核的数量。假设有 8 个内核。
创建线程池:创建一个线程池,其大小应接近或略低于可用 CPU 内核数。在这种情况下,您可能会选择 6 或 7 个线程,以便为其他任务和系统进程留出一些 CPU 容量。

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

public class VideoEncodingApp {
    public static void main(String[] args) {
        int availableCores = Runtime.getRuntime().availableProcessors();
        int numberOfThreads = Math.max(availableCores - 1, 1); // Adjust as needed

        ExecutorService threadPool = Executors.newFixedThreadPool(numberOfThreads);

       
// Submit video encoding tasks to the thread pool.
        for (int i = 0; i < 10; i++) {
            threadPool.execute(() -> {
                encodeVideo();
// Simulated video encoding task
            });
        }

        threadPool.shutdown();
    }

    private static void encodeVideo() {
       
// Simulate video encoding (CPU-bound) task.
       
// Complex calculations and compression algorithms here.
    }
}

确定线程数:对于 I/O 密集型任务:
对于 I/O 密集型任务,线程的最佳数量通常取决于 I/O 操作的性质和预期延迟。你需要有足够多的线程来保持 I/O 设备的忙碌,同时又不会使其超负荷。理想的线程数不一定等于 CPU 内核数。

实际示例:网页抓取
考虑构建一个网页爬虫,下载网页并提取信息。这涉及到 HTTP 请求,由于网络延迟,这属于 I/O 绑定任务。

确定 I/O 绑定任务的线程数:
分析 I/O 延迟:估算预期的 I/O 延迟,这取决于网络或存储。例如,如果每个 HTTP 请求大约需要 500 毫秒才能完成,则可能需要考虑 I/O 操作的一些重叠。
创建线程池:创建一个线程池,其大小应兼顾并行性和预期的 I/O 延迟。不一定每个任务都需要一个线程;相反,可以使用一个较小的线程池来有效管理 I/O 绑定任务。

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

public class WebPageCrawler {
    public static void main(String[] args) {
        int expectedIOLatency = 500; // Estimated I/O latency in milliseconds
        int numberOfThreads = 4;
// Adjust based on your expected latency and system capabilities

        ExecutorService threadPool = Executors.newFixedThreadPool(numberOfThreads);

       
// List of URLs to crawl.
        String[] urlsToCrawl = {
           
"https://example.com",
           
"https://google.com",
           
"https://github.com",
           
// Add more URLs here
        };

        for (String url : urlsToCrawl) {
            threadPool.execute(() -> {
                crawlWebPage(url, expectedIOLatency);
            });
        }

        threadPool.shutdown();
    }

    private static void crawlWebPage(String url, int expectedIOLatency) {
       
// Simulate web page crawling (I/O-bound) task.
       
// Perform HTTP request and process the page content.
        try {
            Thread.sleep(expectedIOLatency);
// Simulating I/O latency
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

我们能遵循一个具体的公式吗?
确定线程池大小的公式可以写成下面这样:

线程数 = 可用内核数 * 目标 CPU 利用率 * (1 + 等待时间 / 服务时间)

  • 可用内核数:这是应用程序可用的 CPU 内核数。需要注意的是,这与 CPU 的数量不同,因为每个 CPU 可能有多个内核。
  • 目标 CPU 利用率:这是您希望应用程序使用的 CPU 时间百分比。如果目标 CPU 利用率设置过高,应用程序可能会反应迟钝。如果设置过低,应用程序将无法充分利用可用的 CPU 资源。
  • 等待时间:这是线程等待 I/O 操作完成所花费的时间。这可能包括等待网络响应、数据库查询或文件操作。
  • 服务时间:这是线程执行计算所花费的时间。
  • 阻塞系数:这是等待时间与服务时间的比率。它用来衡量线程等待 I/O 操作完成的时间与其执行计算的时间之比。

使用示例
假设您的服务器有 4 个 CPU 内核,您希望您的应用程序使用 50% 的可用 CPU 资源。

您的应用程序有两类任务:I/O 密集型任务和 CPU 密集型任务。

I/O 密集型任务的阻塞系数为 0.5,这意味着它们有 50% 的时间在等待 I/O 操作完成。

线程数 = 4 个内核 * 0.5 * (1 + 0.5) = 3 个线程

CPU 密集型任务的阻塞系数为 0.1,这意味着它们需要花费 10% 的时间等待 I/O 操作完成。

线程数 = 4 个内核 * 0.5 * (1 + 0.1) = 2.2 个线程

在这个例子中,你将创建两个线程池,一个用于 I/O 密集型任务,另一个用于 CPU 密集型任务。I/O 密集型线程池有 3 个线程,CPU 密集型线程池有 2 个线程。