线程池简化了线程的创建和复用,但任务如何排队、失败如何收集、程序如何等待任务结束,仍需要我们明确处理。下面以批量读取文本文件为例,把这些问题串起来。
先明确这个例子要做什么
读取一个目录下的普通 .txt 文件,每个文件交给一个任务,按 UTF-8 逐行读取并统计行数。主线程汇总每个文件的结果,打印失败原因;有文件失败时,以失败状态结束程序。
这里的行数统计只是文件处理的演示,你可以将任务内部替换成自己的解析逻辑。它不递归处理子目录,文件数量也应在可控范围内。示例会保留每个文件的路径和 Future,因此不是海量文件调度器。
多线程是否更快取决于瓶颈。磁盘已经饱和时,增加并发未必提升速度;应在实际数据上测量,而不是默认并发越大越好。
几个容易混淆的概念
Executor 定义任务执行入口,ExecutorService 增加提交任务、获取结果和管理生命周期的方法,ThreadPoolExecutor 是具体实现之一。Executors 则提供常见执行器的工厂方法。
| 工厂方法 | 主要行为 | 使用时要考虑什么 |
|---|---|---|
newFixedThreadPool(n) |
最多 n 个线程同时执行任务,其他任务进入共享无界队列;工作线程会复用 | 线程数有界不代表积压任务有界 |
newCachedThreadPool() |
有可用线程就复用,没有则按需创建;默认回收空闲 60 秒的线程 | 提交速度过快可能创建过多线程 |
newSingleThreadExecutor() |
任务按顺序执行,同一时刻最多一个任务运行,使用无界队列 | 执行较慢时仍可能积压任务 |
newScheduledThreadPool(n) |
支持延迟或周期任务 | 需要明确周期任务的异常处理和取消方式 |
固定线程池不是等到线程结束后才执行下一个任务。通常是一个任务完成,工作线程继续从队列取任务。默认固定线程池中的核心线程不会仅因空闲而自动回收;也不应该把 keepAliveTime=0 简单解释成所有线程立即超时。
一个可以独立运行的例子
下面只使用 JDK API,采用 Java 8 语法。这里显式创建 5 个工作线程、容量为 64 的任务队列,并采用 CallerRunsPolicy:队列满且执行器尚未关闭时,由提交任务的线程执行任务,从而放慢继续提交的速度。
这意味着主线程也可能参与文件处理,不能把它理解为整个进程始终只有 5 个任务并行。此例的关闭由主线程统一控制;若设计成其他线程可以随时关闭执行器,需要重新处理提交和拒绝的竞态。
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class FileBatch {
public static void main(String[] args) throws Exception {
if (args.length != 1) {
throw new IllegalArgumentException("Usage: java FileBatch <directory>");
}
Path directory = Paths.get(args[0]);
if (!Files.isDirectory(directory)) {
throw new IOException("Not a directory: " + directory);
}
ThreadPoolExecutor pool = new ThreadPoolExecutor(
5, 5, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<Runnable>(64),
new ThreadPoolExecutor.CallerRunsPolicy());
Map<Path, Future<Long>> tasks = new LinkedHashMap<>();
try {
try (DirectoryStream<Path> stream =
Files.newDirectoryStream(directory, "*.txt")) {
for (Path file : stream) {
if (Files.isRegularFile(file)) {
tasks.put(file, pool.submit(() -> countLines(file)));
}
}
}
pool.shutdown();
int failed = 0;
for (Map.Entry<Path, Future<Long>> task : tasks.entrySet()) {
try {
long lines = task.getValue().get();
System.out.println(task.getKey() + ": " + lines + " lines");
} catch (ExecutionException e) {
failed++;
System.err.println("Failed: " + task.getKey());
e.getCause().printStackTrace(System.err);
}
}
if (failed > 0) {
throw new IllegalStateException(failed + " file(s) failed");
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
throw e;
} finally {
shutdownAndAwait(pool);
}
}
private static long countLines(Path file) throws IOException {
long count = 0;
try (BufferedReader reader =
Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
while (reader.readLine() != null) {
if (Thread.currentThread().isInterrupted()) {
throw new IOException("Interrupted: " + file);
}
count++;
}
}
return count;
}
private static void shutdownAndAwait(ThreadPoolExecutor pool) {
pool.shutdown();
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
System.err.println("Workers have not terminated");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
保存为 FileBatch.java 后执行:
javac -encoding UTF-8 FileBatch.java
java FileBatch ./sample-files
为什么这样收集结果
任务只负责读取自己的文件,返回一个行数,不去修改共享的失败列表。submit 返回 Future,任务中的异常会在主线程调用 get 时通过 ExecutionException 暴露。主线程可以继续检查其他文件,最后统一决定这一批任务是否成功。
如果让多个工作线程调用同一个普通 ArrayList 的 add 来记录失败文件,就会引入并发安全问题。用线程安全容器可以保护这些修改,而让任务直接返回结果,能进一步避免工作线程共享汇总状态。
主线程按登记顺序获取结果,所以较慢的前一个任务会延迟后续结果的展示,但不会阻止后续任务并发执行。如果需要谁先完成就先处理谁的结果,可以进一步使用 ExecutorCompletionService。
关闭和等待要分开理解
shutdown() 不再接受新任务,但会让已提交的任务继续执行;它本身不等待任务全部结束。awaitTermination 等待的是执行器终止,超时时返回 false,不能把调用过这个方法当成任务已经完成的证明。
正常情况下,上面的程序先通过每个 Future 获取结果,再等待线程池终止。因此清理方法中的 30 秒是关闭阶段的等待时间,不是每个文件的执行超时。若业务要求限制任务时长,应另外设计超时和取消策略。
发生中断时,程序请求停止剩余任务,并恢复当前线程的中断标志。shutdownNow() 只是尝试中断,不会强制终止任意代码;任务必须配合检查中断,阻塞 IO 也不一定立即响应。示例在行间检查中断,但不能据此承诺所有 IO 都能及时退出。
最后,逐行读取避免一次把整个文件加载到内存,但路径和 Future 仍会随文件数增长。用于更大规模任务时,需要进一步控制在途任务和结果的保留数量。