繁体   English   中英

为什么 Java 多线程不给线程数线性加速

[英]Why Java multi-threading does not give a linear speedup to the number of threads

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

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 class:

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”。 但是,即使您自己的代码/业务逻辑/算法(或其要并行化的部分)没有这样的部分,也有执行您的代码的底层软件和硬件层,并且这些层可能会因为争用而引入一些顺序部分一些资源。 例如,您一次只能通过单模光纤或单根双绞线传递一个以太网数据包; 您的硬盘一次只能在其磁头的一个 position 中写入数据; 由于前端总线和 memory 总线,您可以顺序访问主 memory; L2/L3 缓存失效需要做额外的工作才能同步; 转换后备缓冲区 (TLB) 未命中导致走到主 memory(有关前端总线和 memory 总线的信息,请参见上文); 在堆中的 JVM 中分配一个新的 object(当线程本地分配缓冲区(TLAB)已满时)需要一些内部同步; 您可以在压缩时在 GC 上暂停,这看起来像是代码的顺序部分; 类加载和 JIT 的活动将它们的顺序部分添加到应用程序中; 第 3 方库可能在 static 值上有一些同步; 等等等等

因此,您只能在非常非常简单的情况下看到“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”。 我们增加了计算的次数,但我们没有花更多的时间。 我们不会分配新的 memory,因此我们不会遇到 GC 暂停,因此我们不会在线程之间共享任何数据(一个计数器/volatile 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 秒对 0.6 秒(!)

这是一个说明,仅仅理解诸如 big-O 之类的理论知识是不够的,但理解事物在底层是如何工作的,计算机如何准确地执行我们的代码是非常重要的。 重要的是要有一个性能 model,即使是一个非常简化的。

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

我拿走了你的代码并添加了一个虚拟文档 class。 然后我尝试配置 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 中分配新的 object 并且必须触发垃圾收集过程。 我们在 GC 中花费了“real=0,53 secs”。

如果您在代码中分配更多的 memory 或具有更小的堆大小,则由于触发了 Full GC,您会得到更糟糕的结果(我给出的 1 线程与 4 线程的比率约为 1/1.4 ):

[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 代码:)

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM