Java多线程光线追踪性能劣于单线程的原因及优化问询
为何多线程版本性能劣于单线程?
1. 内存分配与GC压力过载
当前多线程实现中,每个线程都要创建完整的Vec3[] partialImage数组(400×400=16万元素),4线程就会产生64万Vec3对象;再加上采样循环里频繁创建的Ray、临时Vec3,内存分配量暴增。M1的JVM分代GC在多线程高频小对象分配场景下,会触发频繁的Young GC,哪怕是低停顿的ZGC也有短暂停顿,这会直接抵消多线程的并行收益,甚至拖慢整体速度。而单线程内存分配节奏平缓,GC次数少,开销更低。
2. 任务划分破坏缓存局部性
每个线程都遍历完整图像的像素,导致CPU缓存的空间局部性完全失效——不同线程随机访问分散的内存地址,缓存行频繁失效。M1的Apple Silicon缓存架构对局部性极度敏感,缓存失效带来的性能损失远大于多线程并行的收益;而单线程按扫描线连续访问,缓存命中率极高。
3. 不必要的世界副本开销
每个线程都调用world.createCopy()创建HittableList副本,无论深拷贝还是浅拷贝,都会带来额外的内存分配和初始化开销,且在多线程下被放大N倍(N为线程数)。但实际上渲染过程中世界场景是只读的,完全不需要多份副本。
4. 多线程合并阶段的冗余操作
每个线程生成独立的partialImage数组,最后还要遍历合并到finalImage,这额外增加了内存拷贝和遍历的开销。
优化方案
1. 按扫描线划分任务,提升缓存命中率
将图像按连续扫描线拆分给不同线程,每个线程处理一段连续的行,保证内存访问的连续性,最大化缓存命中率。同时取消partialImage数组,直接写入共享的finalImage(因为每个线程操作的索引范围完全独立,无需同步):
public void multiThreadedRender(final HittableList world, int threads) { initialize(); // 这里不需要拆分samplesPerPixel,每个线程处理完整的采样次数,任务按行划分 System.out.print("P3\n" + imageWidth + ' ' + imageHeight + "\n255\n"); Vec3[] finalImage = new Vec3[imageWidth * imageHeight]; for (int i = 0; i < finalImage.length; i++) { finalImage[i] = new Vec3(); } ExecutorService executorService = Executors.newFixedThreadPool(threads); List<Future<Void>> futures = new ArrayList<>(); int linesPerThread = imageHeight / threads; for (int thread = 0; thread < threads; thread++) { final int startLine = thread * linesPerThread; // 最后一个线程处理剩余所有行 final int endLine = (thread == threads - 1) ? imageHeight : (thread + 1) * linesPerThread; Callable<Void> renderTask = () -> { for (int j = startLine; j < endLine; j++) { for (int i = 0; i < imageWidth; i++) { Vec3 pixelColor = new Vec3(0,0,0); Vec3 pixelCenter = pixel00Loc .plus(pixelDeltaU.multiply(i)) .plus(pixelDeltaV.multiply(j)); for (int sample = 0; sample < samplesPerPixel; sample++) { Ray r = getRay(i, j, pixelCenter); pixelColor.plusEquals(rayColor(r, maxDepth, world)); // 直接复用原world } finalImage[j * imageWidth + i] = pixelColor; } } return null; }; futures.add(executorService.submit(renderTask)); } executorService.shutdown(); try { executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); } catch (InterruptedException e) { e.printStackTrace(); } for (Vec3 color : finalImage) { System.out.print(Color.getColor(color, samplesPerPixel)); } }
2. 减少对象创建,复用局部变量
对频繁创建的小对象(如Ray、临时Vec3),使用ThreadLocal在每个线程内复用,避免重复分配内存:
// 在任务内部初始化线程局部变量 ThreadLocal<Ray> threadRay = ThreadLocal.withInitial(Ray::new); ThreadLocal<Vec3> tempVec = ThreadLocal.withInitial(Vec3::new); // 采样循环内复用对象 Ray r = threadRay.get(); r.setOrigin(pixelCenter.plus(...)); r.setDirection(...); Vec3 temp = tempVec.get(); temp.set(...); pixelColor.plusEquals(temp);
也可以直接用float[]存储RGB值替代Vec3对象,减少对象头开销,进一步提升缓存效率。
3. 取消不必要的world副本
渲染过程中场景是只读的,所有线程直接共享原HittableList对象即可,彻底消除拷贝开销。
4. 优化JVM GC参数
针对M1平台,使用低停顿的ZGC并调整堆大小,减少GC对多线程的影响:
java -XX:+UseZGC -Xmx8g Main
5. 对比多进程与优化后多线程
多进程之所以快,是因为每个进程有独立堆空间,GC互不干扰,但进程间存在启动和通信开销。优化后的多线程共享内存,无额外通信开销,性能应该能接近甚至超过多进程方案。
内容的提问来源于stack exchange,提问作者BobcatNoah

