Windows上多线程Java应用程序的CPU使用率过低

18
我正在开发一个Java应用程序,用于解决一类数值优化问题——更准确地说是大规模线性规划问题。一个问题可以分成多个小子问题并行求解。由于子问题数量超过CPU核心数量,因此我使用ExecutorService,并将每个子问题定义为一个Callable提交给ExecutorService。解决子问题需要调用本地库——在这种情况下是线性规划求解器。 问题 我可以在Unix和Windows系统上运行该应用程序,最多可使用44个物理核心和256g内存,但对于大型问题,Windows的计算时间比Linux高一个数量级。Windows不仅需要更多的内存,而且随着时间的推移,CPU利用率从一开始的25%下降到几个小时后的5%。以下是Windows任务管理器的截图:

Task Manager CPU utilization

观察结果:
  • 解决大规模问题的解决方案时间范围从几小时到几天,并且消耗高达32g的内存(在Unix上)。子问题的解决时间在毫秒级别。
  • 对于只需要几分钟就能解决的小问题,我不会遇到这个问题。
  • Linux默认使用两个套接字,而Windows需要我在BIOS中显式激活内存交错,以便应用程序利用两个核心。无论我是否这样做,都不会对整体CPU利用率的恶化产生影响。
  • 当我在VisualVM中查看线程时,所有池线程都在运行,没有一个处于等待状态或其他状态。
  • 根据VisualVM的显示,90%的CPU时间花费在本地函数调用上(解决小型线性规划问题)。
  • 垃圾收集不是问题,因为应用程序不创建和反引用大量对象。此外,大多数内存似乎是分配在堆外的。对于最大实例,Linux上的4g堆足够,而Windows上则需要8g。
我尝试过的:
  • 各种JVM参数,高XMS,高元空间,UseNUMA标志,其他GC。
  • 不同的JVM(Hotspot 8、9、10、11)。
  • 不同线性规划求解器的不同本地库(CLP、Xpress、Cplex、Gurobi)。
问题:
  • 是什么导致了一个大型多线程Java应用程序在Linux和Windows之间的性能差异,而且还会频繁使用本地调用?
  • 我能否改变实现中的一些内容来帮助Windows,例如,我应该避免使用接收成千上万个可调用对象的ExecutorService并改用什么?

我没有。你为什么认为这会解决问题? - Nils
1
你的问题听起来应该会将CPU推到100%,但实际上只有25%。对于某些问题,ForkJoinPool比手动调度更有效率。 - Karol Dowbecki
2
在循环使用热点版本时,您是否确保使用的是“服务器”而不是“客户端”版本?您在Linux上的CPU利用率是多少?此外,Windows连续运行数天令人印象深刻!你的秘密是什么? :P - erickson
3
也许尝试使用Xperf生成FlameGraph。这可能会让你了解CPU正在做什么(希望包括用户模式和内核模式),但我从未在Windows上尝试过。 - Karol Dowbecki
1
@Nils,无论是运行在Unix还是Windows上,它们都使用相同的接口来调用本地库吗?我问这个问题是因为看起来不一样。例如:Windows使用JNA,Linux使用JNI。 - S.R.
显示剩余25条评论
5个回答

2
对于Windows,每个进程的线程数量受进程的地址空间限制(也可以参见Mark Russinovich - Pushing the Limits of Windows: Processes and Threads)。当接近极限时,这会导致副作用(上下文切换减慢、碎片化等)。对于Windows,我建议将工作负载分配给一组进程。对于我多年前遇到的类似问题,我实现了一个Java库来更方便地处理此问题(Java 8),如果您感兴趣,请看一下:Library to spawn tasks in an external process

这看起来非常有趣!但我还有两个犹豫:1)序列化和通过套接字发送对象会带来性能开销;2)如果我想序列化所有东西,这将包括与任务链接的所有依赖项 - 重写代码需要一些工作 - 尽管如此,感谢您提供的有用链接。 - Nils
我完全理解你的担忧,重新设计代码需要付出一些努力。在遍历图形时,您需要引入一个阈值来确定何时将工作分割成新的子进程。为了解决第二个问题,请查看Java内存映射文件(java.nio.MappedByteBuffer),通过它,您可以有效地在进程之间共享数据,例如您的图形数据。祝你好运 :) - geri

0

听起来像是Windows将一些内存缓存到页面文件中,在一段时间没有被使用后,这就是为什么CPU受到磁盘速度瓶颈的原因。

您可以使用进程资源管理器进行验证,并检查有多少内存被缓存。


你认为吗?有足够的可用内存。为什么Windows会开始交换内存呢?不管怎样,谢谢。 - Nils
至少在我的笔记本电脑上,Windows 有时会交换最小化的应用程序,即使内存足够。 - Sam Washington

0

我认为这种性能差异是由操作系统管理线程的方式造成的。JVM隐藏了所有的操作系统差异。有很多网站可以阅读相关信息,例如this。但这并不意味着差异消失了。

我猜你正在使用Java 8+ JVM。基于这个事实,我建议你尝试使用流和函数式编程特性。当你有许多小而独立的问题需要解决,并且希望轻松地从顺序执行转换为并行执行时,函数式编程非常有用。好消息是你不必定义一个策略来确定你需要管理多少线程(就像ExecutorService一样)。只需举个例子(摘自here):

package com.mkyong.java8;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class ParallelExample4 {

    public static void main(String[] args) {

        long count = Stream.iterate(0, n -> n + 1)
                .limit(1_000_000)
                //.parallel()   with this 23s, without this 1m 10s
                .filter(ParallelExample4::isPrime)
                .peek(x -> System.out.format("%s\t", x))
                .count();

        System.out.println("\nTotal: " + count);

    }

    public static boolean isPrime(int number) {
        if (number <= 1) return false;
        return !IntStream.rangeClosed(2, number / 2).anyMatch(i -> number % i == 0);
    }

}

结果: 对于普通流,需要1分10秒。对于并行流,只需要23秒。P.S 在i7-7700、16G RAM、Windows 10上测试过。
因此,我建议您了解Java中的函数编程、流和Lambda函数,并尝试使用您的代码实现一些测试(适应这个新环境)。

我在软件的其他部分中使用流,但在这种情况下,任务是在遍历图形时创建的。我不知道如何使用流来包装它。 - Nils
你能遍历图形,构建列表,然后使用流吗? - xcesco
并行流只是对ForkJoinPool的语法糖而已。我已经尝试过(请参见@KarolDowbecki上面的评论)。 - Nils

0

请问您能否发布系统统计信息吗?如果只有Task Manager这个工具可用,那也足够提供一些线索。它可以轻易地告诉您是否有任务在等待IO - 根据您的描述听起来是罪犯。这可能是由于某些内存管理问题,或者库可能会将一些临时数据写入磁盘等原因。

当您说25%的CPU利用率时,您是否意味着同时只有几个核心在忙碌工作?(可能所有核心都会不时工作,但不是同时。)您会检查系统中实际上创建了多少个线程(或进程)吗?该数字总是大于核心数吗?

如果有足够的线程,其中许多线程是否处于空闲状态等待某些东西?如果是真的,您可以尝试中断(或连接调试器)以查看它们正在等待什么。


我已经添加了一个任务管理器的截图,代表了这个问题的执行情况。应用程序本身会创建与机器上物理核心数量相同的线程。Java对这个数字做出了超过50个线程的贡献。正如之前所说,VisualVM显示所有线程都在忙碌中(绿色)。它们只是在Windows上没有将CPU推到极限。但在Linux上则不同。 - Nils
@Nils 我怀疑你并不是真的同时拥有所有线程,而实际上只有9-10个。它们随机分配在所有核心上,因此平均利用率为9/44 = 20%。你能否直接使用Java线程而不是ExecutorService来查看差异?创建44个线程并从任务池/队列中获取Runnable/Callable并不困难。(尽管VisualVM显示所有Java线程都很忙,但现实可能是这44个线程被快速调度,以便在VisualVM的采样周期内所有线程都有机会运行。) - Xiao-Feng Li
这是一个想法,而且我实际上在某个时候确实这样做了。在我的实现中,我还确保本地访问对于每个线程都是本地的,但这并没有任何区别。 - Nils

0
如果你不断地开始和结束新的线程,这可能是原因。通过使用线程池来重用线程,例如FixedThreadPool。
ExecutorService executorService = Executors.newFixedThreadPool(10);
Future<String> future = executorService.submit(() -> "Hello World");
// some operations
String result = future.get();

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接