7. 多个线程如何分工处理 PDF
发布于 • 阅读量 0
7. 多个线程如何分工处理 PDF
前面已经把 Thread、start()、join()、Runnable 这些基础点过了一遍。
现在可以往前走一步:多个线程怎么分工处理一批 PDF。
这个问题看起来简单,但其实很关键。
因为多线程处理文件时,最怕的不是代码跑不起来,而是几个线程都在干同一批活,最后变成重复处理,甚至互相覆盖输出文件。
先看一个容易写错的版本
假设 input 目录下有多个 PDF。
我一开始可能会下意识这样写:
Thread thread1 = new Thread(() -> {
for (File file : files) {
processPdf(file);
}
}, "pdf-thread-1");
Thread thread2 = new Thread(() -> {
for (File file : files) {
processPdf(file);
}
}, "pdf-thread-2");
thread1.start();
thread2.start();
这段代码看起来是两个线程都启动了,但问题很明显:两个线程都在遍历同一个 files 数组。
也就是说:
pdf-thread-1 处理 test1.pdf
pdf-thread-2 也处理 test1.pdf
pdf-thread-1 处理 test2.pdf
pdf-thread-2 也处理 test2.pdf
这不是分工,这是重复劳动。
如果输出路径一样,还可能出现两个线程同时写同一个目标文件的问题。轻则结果被覆盖,重则文件损坏。
所以,多线程处理文件时,第一步不是想着“多开几个线程”,而是先想清楚:
每个线程到底负责哪些文件?
最简单的分工方式:按索引分配
如果我现在只有两个线程,可以用一个很简单的方式分工:
线程1:处理下标 0、2、4、6...
线程2:处理下标 1、3、5、7...
也就是一个处理偶数下标,一个处理奇数下标。
这种方式不算多高级,但很适合理解多线程分工。
比如 files 里有 6 个 PDF:
0 -> a.pdf
1 -> b.pdf
2 -> c.pdf
3 -> d.pdf
4 -> e.pdf
5 -> f.pdf
那么分工后就是:
pdf-thread-1:a.pdf、c.pdf、e.pdf
pdf-thread-2:b.pdf、d.pdf、f.pdf
这样每个文件只会被一个线程处理。
完整代码
新建类:
com.succos.thread.ThreadTaskSplitDemo
代码如下:
package com.succos.thread;
import java.io.File;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class ThreadTaskSplitDemo {
public static void main(String[] args) throws InterruptedException {
File inputDir = new File("input");
File[] files = inputDir.listFiles(file ->
file.isFile() && file.getName().toLowerCase().endsWith(".pdf")
);
if (files == null || files.length == 0) {
System.out.println("input 目录下没有 PDF 文件");
return;
}
List<File> pdfList = Arrays.stream(files)
.collect(Collectors.toList());
long start = System.currentTimeMillis();
Thread thread1 = new Thread(() -> {
processFiles(pdfList, 0);
}, "pdf-thread-1");
Thread thread2 = new Thread(() -> {
processFiles(pdfList, 1);
}, "pdf-thread-2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
long end = System.currentTimeMillis();
System.out.println("--------------------------------");
System.out.println("全部 PDF 处理完成");
System.out.println("文件数量:" + pdfList.size());
System.out.println("总耗时:" + (end - start) + " ms");
}
private static void processFiles(List<File> pdfList, int startIndex) {
for (int i = startIndex; i < pdfList.size(); i += 2) {
File file = pdfList.get(i);
System.out.println(Thread.currentThread().getName()
+ " 开始处理:"
+ file.getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
System.out.println(Thread.currentThread().getName()
+ " 处理完成:"
+ file.getName());
}
}
}
这里我暂时用 Thread.sleep(3000) 模拟 PDF 处理。
如果要接入真正的 PDF 水印方法,把 Thread.sleep(3000) 换成 PdfWatermarkService.addWaterMakerOfPDF(...) 就行。
重点看 processFiles 方法
这个方法是核心:
private static void processFiles(List<File> pdfList, int startIndex) {
for (int i = startIndex; i < pdfList.size(); i += 2) {
File file = pdfList.get(i);
System.out.println(Thread.currentThread().getName()
+ " 开始处理:"
+ file.getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
System.out.println(Thread.currentThread().getName()
+ " 处理完成:"
+ file.getName());
}
}
这里有两个参数:
List<File> pdfList
int startIndex
pdfList 是所有 PDF 文件。
startIndex 表示当前线程从哪个下标开始处理。
线程 1 调用的是:
processFiles(pdfList, 0);
所以它从下标 0 开始。
然后循环里是:
i += 2
所以它处理的是:
0、2、4、6...
线程 2 调用的是:
processFiles(pdfList, 1);
所以它从下标 1 开始。
同样每次加 2,所以它处理的是:
1、3、5、7...
这样两个线程就不会处理同一个文件。
为什么不是每个线程都遍历全部文件?
这个地方其实对应真实项目里的一个基本原则:任务要先拆清楚,再并发执行。
并发不是说我开了两个线程,程序就自动知道怎么分工。
线程只负责执行代码。
如果我给两个线程的是同一段“遍历全部文件”的逻辑,那它们就真的都会遍历全部文件。
所以线程本身不会帮我避免重复处理。
分工逻辑要我自己写清楚。
就像下面这两种写法,差别很大。
第一种是重复处理:
线程1:处理所有文件
线程2:处理所有文件
第二种才是分工:
线程1:处理一部分文件
线程2:处理另一部分文件
写多线程代码时,这个意识很重要。
换成真实 PDF 水印处理
如果我要把模拟处理换成真实水印处理,可以把 processFiles 改成这样:
private static void processFiles(List<File> pdfList, int startIndex) {
PdfWatermarkService service = new PdfWatermarkService();
File outputDir = new File("output");
if (!outputDir.exists()) {
outputDir.mkdirs();
}
for (int i = startIndex; i < pdfList.size(); i += 2) {
File file = pdfList.get(i);
String targetPath = outputDir.getAbsolutePath()
+ File.separator
+ file.getName().replace(".pdf", "-watermark.pdf");
FileItemContext fileItemContext = new FileItemContext();
fileItemContext.setSourcePath(file.getAbsolutePath());
fileItemContext.setTargetPath(targetPath);
fileItemContext.setWaterMakeText("上下文网");
System.out.println(Thread.currentThread().getName()
+ " 开始处理:"
+ file.getName());
try {
service.addWaterMakerOfPDF(fileItemContext);
System.out.println(Thread.currentThread().getName()
+ " 处理完成:"
+ file.getName());
} catch (Exception e) {
System.out.println(Thread.currentThread().getName()
+ " 处理失败:"
+ file.getName()
+ ",原因:"
+ e.getMessage());
}
}
}
如果用这段代码,记得在类上加两个 import:
import com.succos.dto.FileItemContext;
import com.succos.service.PdfWatermarkService;
这里我把 PdfWatermarkService 放在方法内部创建:
PdfWatermarkService service = new PdfWatermarkService();
在这个普通 Java 练习工程里这样写没问题。
如果是在 Spring Boot 项目里,通常会交给 Spring 管理,注入进来,而不是自己 new。
这种分工方式有什么问题?
按奇偶下标分工很好理解,但它也有明显限制。
比如有些 PDF 很小,1 秒就处理完了;有些 PDF 很大,可能要 20 秒。
如果刚好大文件都分给了 pdf-thread-1,小文件都分给了 pdf-thread-2,就会出现这种情况:
pdf-thread-2 很快处理完,开始闲着;
pdf-thread-1 还在慢慢处理大文件。
也就是说,这种静态分配方式不够灵活。
它适合学习线程分工,但不适合复杂的生产场景。
真实项目里,更常见的做法是:
把每个 PDF 都当成一个独立任务;
放进任务队列;
哪个线程空闲了,就取下一个任务处理。
这就是线程池的思路。
所以现在这节代码,本质上是在手动分配任务。
后面用 ThreadPoolExecutor 时,这部分分工就会交给线程池和任务队列来做。
和线程池的区别
现在的写法是:
我自己决定 pdf-thread-1 处理哪些文件;
我自己决定 pdf-thread-2 处理哪些文件;
我自己启动线程;
我自己 join 等待。
线程池的写法会变成:
我把每个 PDF 封装成一个任务;
我把任务提交给线程池;
线程池内部决定哪个线程处理哪个任务;
我只关心任务是否完成。
所以这节其实是一个过渡。
它让我先看清楚“多个线程必须分工”这个问题。
等后面上线程池时,就能理解为什么要有任务队列,为什么不能只想着开线程。
这一节小结
这一节我主要记住几点:
1. 多线程处理文件时,不能让每个线程都遍历全部文件;
2. 线程不会自动分工,分工逻辑必须自己设计;
3. 简单场景下,可以按索引分配任务,比如一个线程处理偶数下标,一个线程处理奇数下标;
4. 多个线程处理不同文件时,要避免输出路径冲突;
5. 手动分工适合学习,真实批量任务更适合用线程池。
用一句话总结:
多线程不是把同一段循环复制给多个线程,而是要把任务拆开,让不同线程处理不同任务。
下一节开始看线程安全问题。
因为只要多个线程开始同时运行,就会遇到共享变量的问题。最经典的例子就是 count++ 为什么不安全。