为什么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 代码的原因:)