用多线程提高CPU密集型任务的运行效率
在真实的业务开发中,我们常常会遇到CPU密集型(CPU bound)的任务,为了提高这些任务的执行效率,我们需要使用多线程技术。
多线程是一个复杂的话题,本文不会全面展开讨论这个主题,而是通过一个简单的例子来演示在特定情况下,我们使用多线程技术来提高程序的运行效率。
单线程的局限
我们来看一个例子:
public static void main(String[] args) throws NoSuchAlgorithmException {
String password = "*123xyz";
long sum = 0;
var digest = MessageDigest.getInstance("SHA-256");
// 1. 将密码提到外部提前转为字节数组,避免在循环内部重复转换
byte[] pwdBytes = password.getBytes(StandardCharsets.UTF_8);
long t1 = System.nanoTime();
for (int i = 0; i < 2000 * 10000; i++) {
byte[] hash = digest.digest(pwdBytes);
sum += hash[0];
}
long t2 = System.nanoTime();
System.out.println("sum: " + sum);
System.out.println((t2 - t1) / 1_000_000 + " ms");
}上面程序会对某个固定的密码进行2千万次的hash计算。
我的电脑是4核心CPU(物理4核心),使用的MacOS系统。
这些计算都运行在主线程(main线程)中,通过观察看到(MacOS可以使用Stats开源软件,windows可以使用任务管理器),电脑上的4核CPU的占用率大约都在 25% 左右(即 1/4 的算力)。这是因为操作系统调度器为了散热均衡或负载调度,会在运行过程中把这个主线程在 CPU的Core 0、1、2、3 之间来回切换,但在任何特定瞬间,都只有 1 个核心在真正跑这段代码。
但我的电脑有4个核心,相当于还有3个核心处于闲置状态,为了提高运行效率,可以使用多线程技术。
多线程版本:固定线程池
代码如下:
public static void main(String[] args) throws InterruptedException, NoSuchAlgorithmException, ExecutionException {
String password = "*123xyz";
// 1. 自动获取当前 CPU 的核心数(如果是 4 核,cores 就是 4)
int totalCount = 2000 * 10000;
int cores = Runtime.getRuntime().availableProcessors();
int countPerThread = totalCount / cores;
var futures = new ArrayList<Future<Long>>();
byte[] pwdBytes = password.getBytes(StandardCharsets.UTF_8);
long t1 = System.nanoTime();
// 2. 利用 Java 19+ 的 try-with-resources 自动等待线程池关闭
try (var executor = Executors.newFixedThreadPool(cores)) {
for (int i = 0; i < cores; i++) {
Future<Long> future = executor.submit(() -> {
// 每个线程中,都有各自的 localSum 和 MessageDigest 实例,因为MessageDigest非线程安全,需要每个线程设置一个
long localSum = 0;
var digest = MessageDigest.getInstance("SHA-256");
// 在循环内部进行哈希计算
for (int j = 0; j < countPerThread; j++) {
byte[] hash = digest.digest(pwdBytes);
localSum += hash[0];
}
return localSum;
});
futures.add(future);
}
} // 离开 try 作用域时,主线程会自动阻塞等待所有任务执行完毕
// 3. 主线程汇总结果
long totalSum = 0;
for (Future<Long> f : futures) {
totalSum += f.get();
}
long t2 = System.nanoTime();
// 4. 打印结果
System.out.println("CPU 核心数: " + cores);
System.out.println("totalSum: " + totalSum);
System.out.println((t2 - t1) / 1_000_000 + " ms");
}可以看出,多线程版本比单线程版本的代码复杂不少。因为在多线程中,我们需要额外注意线程安全、多线程资源管理等问题。
再次运行多线程版本,可以发现,4个核心占用率大约都在 100% 左右。
通过多次运行对比,单线程版本平均耗时5517ms,多线程版本平均耗时1846 ms。多线程版本的运行速度是单线程版本的3倍左右。
需要注意的是,我们是在代码中通过捕获时间差来测试运行时间的,这种测试方式略显粗糙,如果要严谨测试,建议使用JMH(Java Microbenchmark Harness)工具。
对于这种"批量数据 + 简单聚合"的CPU密集型任务,还可以使用JDK 8中推出的并行流方式。
并行流方式
代码如下:
public static void main(String[] args) {
String password = "*123xyz";
long totalCount = 2000 * 10000;
byte[] pwdBytes = password.getBytes(StandardCharsets.UTF_8);
// 利用 ThreadLocal 确保 ForkJoinPool 中的每个工作线程仅创建一次 MessageDigest
ThreadLocal<MessageDigest> digestThreadLocal = ThreadLocal.withInitial(() -> {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
});
long t1 = System.nanoTime();
// 依靠 LongStream.range 实现自动切分与 Fork/Join 归并
long totalSum = LongStream.range(0, totalCount)
.parallel()
.map(i -> {
MessageDigest digest = digestThreadLocal.get();
byte[] hash = digest.digest(pwdBytes);
return hash[0];
})
.sum();
long t2 = System.nanoTime();
System.out.println("汇总结果: " + totalSum);
System.out.println("CPU 核心数: " + Runtime.getRuntime().availableProcessors());
System.out.println("耗时: " + (t2 - t1) / 1_000_000 + " ms");
}测试发现,并行流方式的运行耗时和固定线程池版本运行耗时差不多。因为它们本质上做的是同一件事:把 2000万次哈希计算铺到 4 个核心上执行。
当然,它们也有区别:
- 任务切分方式:固定线程池版中,是静态、粗粒度切分,我们手动把总任务量按核心数均分成 4 大块,一次性提交 4 个任务;并行流版中,是动态、细粒度切分,底层 Spliterator 会递归地把 LongStream.range 拆成更小的子区间,配合 ForkJoinPool 的工作窃取(work-stealing)机制自动做负载均衡。
- 线程池来源:固定线程池版中,用的是我们自己新建的专属线程池(newFixedThreadPool(cores)),跑完即关闭,和其他代码互不干扰;并行流版默认用的是 ForkJoinPool.commonPool()--这是整个 JVM 全局共享的一个池子,CompletableFuture 的异步方法、其他地方的并行流等都会用它。
所以,如果是在Spring Boot的业务接口中,处理计算密集型任务,建议使用固定线程池版。因为如果代码里某处并行流任务发生了阻塞(比如做了网络 IO),会占用 commonPool 的线程,可能"饿死"程序里其他也依赖 commonPool 的并行流/CompletableFuture 任务,造成难以排查的性能问题。