为什么Java多线程不会对线程数提供线性加速

我的 Java 多线程代码如下所示。我预计如果我使用n 个线程(n < 可用内核),总执行时间将是使用单个线程的 1/n。但是实验结果并没有证明这一点:1 个线程为 4128 毫秒,2 个线程为 2200 毫秒,3 个线程为 3114 毫秒,4 个线程为 3031 毫秒。

public static void main(String[] args) {
    List<Document> doc = createDocuments(50000);
    int numThreads = 4;
    List<List> partitionedDocs = partitionData(doc, numThreads);
    CountDownLatch latch = new CountDownLatch(numThreads);
    List<Thread> threads = new ArrayList<>();
    List<Runnable> executors = new ArrayList<>();
    for (List input : partitionedDocs) {
        Runnable executor = new SplitDocuments(input, " ", latch);
        executors.add(executor);
        threads.add(new Thread(executor));
    }
    for (Thread t : threads) {
        t.start();
    }
    try {
        latch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

对于 main 中使用的方法:

public static List<Document> createDocuments(Integer size) {
    List<Document> x = new ArrayList<>();
    String text = "";
    for (int i=0; i<500; i++) {
        text += "abc ";
    }
    for (int i=0; i<size; i++) {
        Document doc = new Document(i, text);
        x.add(doc);
    }
    return x;
}

private static List<List> partitionData(List input, int numThreads) {
    List<List> results = new ArrayList<>();
    int subListSize = input.size()/numThreads;
    for (int i=0; i<numThreads; i++) {
        if (i==numThreads-1) {
            results.add(input.subList(i*subListSize, input.size()));
        }
        else {
            results.add(input.subList(i*subListSize, (i+1)*subListSize));
        }
    }
    return results;
}

对于 SplitDocuments 类:

public class SplitDocuments implements Runnable {
    private String splitter;
    private List<Document> docs = new ArrayList<>();
    private CountDownLatch latch;
    private List<Document> result = new ArrayList<>();

    public SplitDocuments(List<Document> docs, String splitter, CountDownLatch latch) {
        this.docs = docs;
        this.splitter = splitter;
        this.latch = latch;
    }

    @Override
    public void run() {
        System.out.println("number of docs: " + this.docs.size());
        long start = System.currentTimeMillis();
        for (Document t : this.docs) {
            Document temp = new Document(t);
            temp.setTokens(Arrays.asList(t.text.split(this.splitter)));
            this.result.add(temp);
        }
        this.latch.countDown();
        System.out.println(String.format("split documents costs: %d ms", System.currentTimeMillis() - start));
    }

    public List<Document> getResult() {
        return result;
    }
}

回答

我不确定我可以说任何关于你的时间的具体内容,因为目前还不清楚你是如何准确地测量/计算数字的,但你是对的,“多线程并没有对线程数量提供线性加速......大多数情况下为1/n "。为什么?

我确定您听说过阿姆达尔定律 ( https://en.wikipedia.org/wiki/Amdahl%27s_law)。只有当您没有程序的任何顺序部分,即无法并行化的程序部分时,您才能实现“1/n”。但是,即使您自己的代码/业务逻辑/算法(或其要并行化的部分)没有这样的部分,也有执行您的代码的底层软件和硬件层,这些层可能会由于争用而引入一些顺序部分一些资源。例如,您一次只能通过单模光纤或单对双绞线传输一个以太网数据包;您的硬盘一次只能在其磁头的一个位置写入数据;由于前端总线和内存总线,您可以顺序访问主内存;L2/L3 缓存失效需要额外的工作才能同步;翻译后备缓冲区 (TLB) 未命中导致步行到主内存(参见上文关于前端总线和内存总线的内容);在堆中的 JVM 中分配一个新对象(当线程本地分配缓冲区(TLAB)已满时)需要一些内部同步;您可以在压缩时在 GC 上暂停世界,这看起来像是代码的一个连续部分;类加载和 JIT 活动将它们的类似顺序的部分添加到应用程序中;第三方库可能对静态值有一些同步;等等等等。它看起来像是代码的连续部分;类加载和 JIT 活动将它们的类似顺序的部分添加到应用程序中;第三方库可能对静态值有一些同步;等等等等。它看起来像是代码的连续部分;类加载和 JIT 活动将它们的类似顺序的部分添加到应用程序中;第三方库可能对静态值有一些同步;等等等等。

因此,您只能在非常非常简单的情况下看到“1/n”,即您的应用程序代码和底层都没有使用任何共享资源。这是一个例子

public class Test {
    public static int NUM_OF_THREADS = 4;
    public static int NUM_OF_ITERATIONS = 100_000_000;

    static class Job extends Thread {
        private final AtomicLong counter = new AtomicLong();

        @Override
        public void run() {
            for (int i = 0; i < NUM_OF_ITERATIONS; i++) {
                counter.incrementAndGet();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        final Job[] jobs = new Job[NUM_OF_THREADS];
        for (int i = 0; i < jobs.length; i++) {
            jobs[i] = new Job();
        }
        final long startNanos = System.nanoTime();
        for (Job job : jobs) {
            job.start();
        }
        for (Job job : jobs) {
            job.join();
        }
        final long spentNanos = System.nanoTime() - startNanos;
        System.out.printf("Time spent (s): %f%n", (spentNanos / 1_000_000_000d));
    }
}

每个线程都会增加自己的计数器实例。对于NUM_OF_THREADS= {1,2,3,4} 我得到相同的执行时间~0.6 sec。这意味着我们有“1/n”。我们增加了计算次数,但我们并没有花更多的时间。我们不分配新内存,因此不会遇到 GC 暂停,我们不在线程之间共享任何数据(一个计数器/易失性 long 位于所有者核心的 L1 缓存中)

现在,让我们在线程之间共享资源。现在线程增加计数器的一个实例。这应该会导致缓存失效和密集的内核间通信:

public class Test {
    ...
    private static final AtomicLong counter = new AtomicLong();
    ...
    static class Job extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < NUM_OF_ITERATIONS; i++) {
                counter.incrementAndGet();
            }
        }
    }
    ...

经过如此小的修改,我们看到性能急剧下降。4 个线程花费了大约 8 秒来完成所有迭代。8 秒 vs 0.6 秒(!)

这是一个说明,仅仅理解像 big-O 这样的理论事物是不够的,但是理解事物在幕后如何工作,计算机如何准确地执行我们的代码非常重要。拥有一个性能模型很重要,即使是一个非常简化的模型。

更新:在您将测量添加到代码中之后...您的测量看起来正确,以及您如何对输入数据进行分区。这意味着您确实有一个隐藏的执行顺序部分。在玩过你的代码之后,我认为你有一个内存管理瓶颈,你在代码中分配了太多内存,这会影响执行。我到底做了什么...

我拿了你的代码并添加了一个虚拟的 Document 类。然后我尝试配置 JVM 以使用以下选项让所有数据适合 Eden/Young 一代:-Xmx8G -Xms8G -XX:NewRatio=10 -XX:+PrintGCDetails

使用 numThreads=1 运行约 1 秒,使用 numThreads=4 运行约 0.2x-0.3x 秒。比率为~1/4-1/3。这是预料之中的。GC 详细信息如下所示:

Heap
 PSYoungGen      total 2446848K, used 1803997K [0x0000000715580000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 2097664K, 86% used [0x0000000715580000,0x0000000783737448,0x0000000795600000)
  from space 349184K, 0% used [0x00000007aab00000,0x00000007aab00000,0x00000007c0000000)
  to   space 349184K, 0% used [0x0000000795600000,0x0000000795600000,0x00000007aab00000)
 ParOldGen       total 5592576K, used 0K [0x00000005c0000000, 0x0000000715580000, 0x0000000715580000)
  object space 5592576K, 0% used [0x00000005c0000000,0x00000005c0000000,0x0000000715580000)
 Metaspace       used 2930K, capacity 4494K, committed 4864K, reserved 1056768K
  class space    used 299K, capacity 386K, committed 512K, reserved 1048576K

您会看到 Eden 未满,也未发生 Full GC。

然后我将文档数从 50_000 更改为 100_000... numThreads=1 运行约 2 秒,numThreads=4 运行约 1 秒。比率为~1/2。发生了什么?... GC 详细信息包含以下新消息:

[GC (Allocation Failure) [PSYoungGen: 2097664K->349175K(2446848K)] 2097664K->1627175K(8039424K), 0,5229844 secs] [Times: user=4,78 sys=0,45, real=0,53 secs] 

这意味着 JVM 无法在 Eden 中分配新对象,必须触发垃圾收集过程。我们在 GC 中花费了“real=0.53 secs”。

如果您在代码中分配更多内存或拥有更小的堆大小,您会得到更糟糕的结果(对于 1 个线程与 4 个线程的情况,我给出了~1/1.4的比率),因为 Full GC 触发了:

[Full GC (Ergonomics) [PSYoungGen: 349172K->0K(2446848K)] [ParOldGen: 4675608K->4925474K(5592576K)] 5024780K->4925474K(8039424K), [Metaspace: 2746K->2746K(1056768K)], 5,7606117 secs] [Times: user=56,58 sys=0,26, real=5,76 secs] 

这就是为什么我们(在金融科技领域)更喜欢用 Java 编写分配/无 GC 代码的原因:)


以上是为什么Java多线程不会对线程数提供线性加速的全部内容。
THE END
分享
二维码
< <上一篇
下一篇>>