在Java中,线程创建会产生显著的开销。创建线程会消耗时间,增加请求处理的延迟,并且涉及JVM和操作系统的相当多工作。为减轻这些开销,线程池应运而生。
线程池(ThreadPool)是由执行器服务(executor service)管理的工作线程池。其理念是重用现有线程,而不是为每个任务创建新线程。这可以通过减少线程创建的开销来显著提高应用程序的性能。Java的ExecutorService和ThreadPoolExecutor类提供了管理线程池的框架。
关键点
- 线程重用:线程池的线程可用于多个任务的重用。
- 任务排队:任务被提交到池中,池中的线程会提取并执行这些任务。
- 资源管理:可配置线程池大小、任务队列大小和其他参数,以高效管理资源。
1. 使用线性池的原因
- 性能:线程的创建和销毁成本较高,尤其在Java中。创建一个可供多任务重用的线程池可减少这种开销。
- 可扩展性:线程池可根据应用程序的需要进行扩展。例如,在负载较重时,可扩展线程池以处理额外的任务。
- 资源管理:线程池可帮助管理线程使用资源。例如,线程池可限制同时活动的线程数量,防止应用程序内存不足。
2. 确定线程池大小:理解系统和资源限制
了解包括硬件和外部依赖关系的系统限制,对于确定线程池大小至关重要。下面通过一个例子来详细说明这一概念。
假设在开发一个处理传入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); // 设置最大连接数
HikariDataSource dataSource = new HikariDataSource(config);
// 使用 dataSource 获取数据库连接并执行查询。
}
}
- 外部服务吞吐量:此应用程序交互的外部服务有一个限制。它一次只能处理少量请求,例如10个请求。并发更多请求可能会使服务不堪重负,导致性能下降或出现错误。CPU核心确定服务器上可用的CPU核心数量对于优化线程池大小至关重要。
int numOfCores = Runtime.getRuntime().availableProcessors();
每个核心可以同时执行一个线程。超出CPU核心数量的线程会导致过度的上下文切换,从而降低性能。
3.CPU密集型任务与I/O密集型任务
CPU密集型任务
CPU密集型任务是那些需要大量处理能力的任务,例如执行复杂计算或运行模拟。这些任务通常受限于CPU的速度,而不是I/O设备的速度,如下列任务。
- 编码或解码音频或视频文件
- 编译和链接软件
- 运行复杂模拟
- 执行机器学习或数据挖掘任务
- 玩视频游戏
要对CPU密集型任务进行优化,应考虑多线程和并行性。并行处理是一种技术,用于将较大的任务划分为较小的子任务,并将这些子任务分配到多个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(); // 获取 CPU 核心数量
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) {
// 模拟耗时计算(例如,数据库查询、复杂计算)
try {
Thread.sleep(1000); // 模拟 1 秒延迟
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return number * number;
}
}
I/O密集型任务
I/O密集型任务是那些与存储设备(例如,读写文件)、网络套接字(例如API调用)或用户输入进行交互(例如图形用户界面的交互)的任务,下面是一些典型的I/O密集型任务。
- 读取或写入大文件到磁盘(例如,保存视频文件、加载数据库)
- 通过网络下载或上传文件(例如,浏览网页、观看流媒体视频)
- 发送和接收电子邮件
- 运行Web服务器或其他网络服务
- 执行数据库查询
- Web服务器处理传入请求
优化I/O密集型任务的方式
- 在内存中缓存频繁访问的数据,以减少重复I/O操作的需要。
- 负载均衡,将I/O密集型任务分配到多个线程或进程中,以高效处理并发I/O操作。
- 使用SSD,固态硬盘(SSDs)相较于传统硬盘(HDDs)可以显著加快I/O操作。
- 使用高效数据结构,如哈希表和B树,以减少所需的I/O操作次数。
- 避免不必要的文件操作,例如多次打开和关闭文件。
4.在两种任务中确定线程数
确定CPU密集型任务的线程数量
对于CPU密集型任务,要最大化CPU利用率,而不让系统因线程过多而超负荷,防止过度的上下文切换。一个常见方法是使用可用CPU核心数量。假设需开发一个视频处理应用程序。视频编码是一项CPU密集型任务,需要应用复杂算法来压缩视频文件。有一个多核CPU可用。
- 计算可用CPU核心:使用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); // 根据需要进行调整
ExecutorService threadPool = Executors.newFixedThreadPool(numberOfThreads);
// 将视频编码任务提交到线程池。
for (int i = 0; i < 10; i++) {
threadPool.execute(() -> {
encodeVideo(); // 模拟视频编码任务
});
}
threadPool.shutdown();
}
private static void encodeVideo() {
// 模拟视频编码(CPU 密集型)任务。
// 这里进行复杂计算和压缩算法。
}
}
确定I/O密集型任务的线程数量
对于I/O密集型任务,最佳线程数通常由I/O操作的性质和预期延迟决定。您希望拥有足够的线程以保持I/O设备繁忙而不使其过载。理想的数量不必等于CPU核心的数量。考虑构建一个网页爬虫,下载网页并提取信息。这涉及HTTP请求,这是因网络延迟引起的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; // 估计的 I/O 延迟(毫秒)
int numberOfThreads = 4; // 根据预期延迟和系统能力进行调整
ExecutorService threadPool = Executors.newFixedThreadPool(numberOfThreads);
// 要抓取的 URL 列表。
String[] urlsToCrawl = {
"https://example.com",
"https://google.com",
"https://github.com",
// 在这里添加更多 URL
};
for (String url : urlsToCrawl) {
threadPool.execute(() -> {
crawlWebPage(url, expectedIOLatency);
});
}
threadPool.shutdown();
}
private static void crawlWebPage(String url, int expectedIOLatency) {
// 模拟网页抓取(I/O 密集型)任务。
// 执行 HTTP 请求并处理页面内容。
try {
Thread.sleep(expectedIOLatency); // 模拟 I/O 延迟
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
5.总结公式
确定线程池大小的公式可以写成如下形式。
线程数=可用核心数*目标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个线程。