CodeGym /课程 /JAVA 25 SELF /文件的并行处理:ForkJoin、并行流

文件的并行处理:ForkJoin、并行流

JAVA 25 SELF
第 59 级 , 课程 1
可用

1. 并行流(parallelStream):简单且易用

如果你已经使用过 Stream API,就知道过滤、映射与收集集合有多便利。一个重要的加分项是:只需一行代码就能让任何流变成并行流——调用 parallel(),集合元素就会被并发处理。

这对独立的文件操作尤其有用:统计行数、查找子串、复制、压缩等。

示例:并行统计目录中所有文件的行数

方案 1:顺序执行

import java.nio.file.*;
import java.io.IOException;
import java.util.List;

public class LogLineCounter {
    public static void main(String[] args) throws IOException {
        Path logDir = Paths.get("logs");
        long totalLines = 0;
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(logDir, "*.log")) {
            for (Path file : stream) {
                long lines = Files.lines(file).count();
                totalLines += lines;
            }
        }
        System.out.println("所有日志的总行数:" + totalLines);
    }
}

说明:一切按顺序进行,一个文件接一个文件。如果文件很多且很大,就得等很久。

方案 2:并行!

import java.nio.file.*;
import java.io.IOException;
import java.util.stream.Stream;

public class LogLineCounterParallel {
    public static void main(String[] args) throws IOException {
        Path logDir = Paths.get("logs");
        try (Stream<Path> files = Files.list(logDir)) {
            long totalLines = files
                .filter(path -> path.toString().endsWith(".log"))
                .parallel() // 魔法就在这里!
                .mapToLong(file -> {
                    try (Stream<String> lines = Files.lines(file)) {
                        return lines.count();
                    } catch (IOException e) {
                        e.printStackTrace();
                        return 0L;
                    }
                })
                .sum();
            System.out.println("所有日志的总行数:" + totalLines);
        }
    }
}

说明:关键一行是 .parallel()。在多核处理器上,程序通常会明显更快。

它是如何工作的?

  • parallel() 将普通流转换为并行流。底层使用公共的 ForkJoinPool(默认线程数等于 CPU 核心数)。
  • 每个文件独立处理,结果通过终端操作聚合(例如 sum())。
  • 如果文件很少——可能没有加速;如果有数百个——通常会有明显收益。

重要!

  • 并行流并不会让 I/O 操作本身更快;它让多个操作可以同时进行。在快速介质(SSD)上有帮助,在慢速介质(HDD)上可能会受到磁盘“瓶颈”的限制。

2. ForkJoinPool:“分而治之”的实战

ForkJoin 是一个“分而治之”的并行计算框架:把大任务拆成子任务并行执行,然后合并结果。由专门的线程池 ForkJoinPool 进行管理。它正是并行流“幕后”所用的实现,但你也可以直接使用它以获得更大的灵活性。

这种模型对递归结构(目录树)、大型数据数组以及容易分解为独立部分的任务特别有效。

示例:对目录树进行递归搜索

查找所有 ".txt" 文件(包括子文件夹),并统计总行数。

import java.nio.file.*;
import java.util.concurrent.*;
import java.util.*;
import java.io.IOException;
import java.util.stream.Collectors;

public class FolderLineCounter extends RecursiveTask<Long> {
    private final Path dir;

    public FolderLineCounter(Path dir) {
        this.dir = dir;
    }

    @Override
    protected Long compute() {
        List<FolderLineCounter> subTasks = new ArrayList<>();
        long lines = 0;
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
            for (Path entry : stream) {
                if (Files.isDirectory(entry)) {
                    FolderLineCounter task = new FolderLineCounter(entry);
                    task.fork(); // 启动子任务
                    subTasks.add(task);
                } else if (entry.toString().endsWith(".txt")) {
                    try (Stream<String> fileLines = Files.lines(entry)) {
                        lines += fileLines.count();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        // 汇总子任务的结果
        for (FolderLineCounter task : subTasks) {
            lines += task.join();
        }
        return lines;
    }

    public static void main(String[] args) {
        Path root = Paths.get("big_folder");
        ForkJoinPool pool = new ForkJoinPool();
        FolderLineCounter counter = new FolderLineCounter(root);
        long totalLines = pool.invoke(counter);
        System.out.println("所有 .txt 文件的总行数:" + totalLines);
    }
}

这里发生了什么:

  • 为每个文件夹创建一个独立任务(FolderLineCounter),对子目录创建各自的子任务(fork())。
  • 文件在当前层进行统计,在所有子任务 join() 之后汇总结果。

ForkJoin 的优势是什么?

  • 对大型层级结构(目录树)效果显著。
  • 最大化利用 CPU 核心。
  • 可以精确控制并行化与任务边界。

3. 实用应用场景

批量文件处理

例如,需要把上千张照片复制到备份文件夹。

import java.nio.file.*;
import java.util.List;
import java.util.stream.Collectors;

public class ParallelFileCopier {
    public static void main(String[] args) throws Exception {
        Path sourceDir = Paths.get("photos");
        Path destDir = Paths.get("photos_backup");
        Files.createDirectories(destDir);

        List<Path> files = Files.list(sourceDir)
                .filter(Files::isRegularFile)
                .collect(Collectors.toList());

        files.parallelStream().forEach(file -> {
            try {
                Path destFile = destDir.resolve(file.getFileName());
                Files.copy(file, destFile, StandardCopyOption.REPLACE_EXISTING);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        System.out.println("所有文件已复制完成!");
    }
}

说明:每个文件在独立线程中复制。文件数量很大时,加速效果明显。

并行压缩/解压

同样可以通过 parallelStream() 或自定义 ForkJoinPool 来并行化压缩、哈希重算、图像格式转换等操作。

4. 重要注意事项与限制

  • I/O 操作不一定总能从并行中获益。 如果磁盘或网络是“瓶颈”,同时跑上百个并行任务只会增加资源竞争。
  • 不要启动过多线程。 默认情况下,并行流使用公共的 ForkJoinPool.commonPool(),其并行度 ≈ CPU 核心数。可以通过属性 "java.util.concurrent.ForkJoinPool.common.parallelism" 调整,但请谨慎操作。
  • 别忘了同步。 如果多个线程写入同一个文件/对象——请使用同步与队列;对于彼此独立的文件——不需要同步。

5. 了解 FileChannel 与定位访问

在更高级的场景中(例如并行读取同一个大文件的不同部分),可以使用 java.nio.channels.FileChannel,它支持定位读/写。

示例:在不同线程中读取文件的不同区块

import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.nio.ByteBuffer;

public class FileChunkReader implements Runnable {
    private final Path path;
    private final long position;
    private final int size;

    public FileChunkReader(Path path, long position, int size) {
        this.path = path;
        this.position = position;
        this.size = size;
    }

    @Override
    public void run() {
        try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
            ByteBuffer buffer = ByteBuffer.allocate(size);
            channel.read(buffer, position);
            // 处理数据
            System.out.println("已读取 " + buffer.position() + " 字节,自位置 " + position);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

说明:同时启动多个这样的任务——每个任务读取自己的范围。不过要注意:并非所有磁盘和文件系统都能很好地承受强并行负载。

6. 并行文件处理中的常见错误

错误 1:未同步的并行写入同一个文件。 数据会交叉、破坏。请使用队列、缓冲与写入同步。

错误 2:并行度过高。 在较弱的硬件/很小的文件上,并行流引入的开销可能导致变慢。

错误 3:忽略 I/O 错误。 在并行流中异常很容易被“吞掉”——请在 lambda 内处理、记录日志,并计及失败情况。

错误 4:资源未关闭。 始终为流/通道使用 try-with-resources,否则会出现泄漏与奇怪错误。

错误 5:对 parallel() 抱有“魔法”期待。 并行只有在工作量足够且资源(CPU、快速磁盘)可用时才会加速。单靠调用 parallel() 并不是银弹。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION