CodeGym /Các khóa học /JAVA 25 SELF /Duyệt hệ thống tệp song song: Files.walk + parallel() và ...

Duyệt hệ thống tệp song song: Files.walk + parallel() và ForkJoin

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Vấn đề: xử lý hiệu quả nhiều tệp trong thư mục như thế nào

Trong các ứng dụng hiện đại, ta thường gặp bài toán: xử lý số lượng lớn tệp trong một thư mục và các thư mục con của nó. Ví dụ:

  • Đếm tổng số dòng trong tất cả các tệp ".java" của dự án.
  • Tìm tất cả các tệp được chỉnh sửa trong tháng vừa qua.
  • Sao chép hoặc xóa tệp theo một tiêu chí nhất định.

Nếu số tệp ít, một vòng lặp thông thường là đủ. Nhưng khi có hàng nghìn và hàng chục nghìn, đặc biệt khi trên mỗi tệp thực hiện các thao tác “nặng” (đọc, parse, phân tích), thời gian tăng lên đáng kể.

Câu hỏi: làm thế nào để tăng tốc xử lý số lượng lớn tệp?
Trả lời: sử dụng song song — xử lý các tệp đồng thời ở nhiều luồng.

2. Công cụ để duyệt hệ thống tệp

Files.walk()

Từ Java 8+, có một cách tiện lợi để duyệt cây thư mục — phương thức Files.walk() thuộc gói java.nio.file. Nó trả về một Stream<Path> — tất cả tệp và thư mục, bắt đầu từ thư mục chỉ định.

Ví dụ:

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

Path start = Paths.get("src");
try (Stream<Path> stream = Files.walk(start)) {
    stream.forEach(System.out::println);
}
  • Files.walk(start) — trả về stream của tất cả tệp và thư mục, bao gồm cả các thư mục con.
  • Có thể chỉ định độ sâu tối đa khi duyệt: Files.walk(start, 3).

Files.find()

Nếu cần lọc ngay theo tiêu chí (ví dụ, chỉ các tệp ".java"), hãy dùng Files.find():

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

Path start = Paths.get("src");

try (Stream<Path> stream = Files.find(
        start,
        Integer.MAX_VALUE,
        (path, attr) -> path.toString().endsWith(".java"))) {
    stream.forEach(System.out::println);
}
  • Files.find() nhận một bộ lọc (BiPredicate<Path, BasicFileAttributes>) có được đường dẫn và thuộc tính tệp.

3. Xử lý song song: parallel() và ForkJoinPool

Stream song song: .parallel()

Bất kỳ Stream nào cũng có phương thức parallel(). Gọi nó sẽ khiến việc xử lý phần tử diễn ra trên nhiều luồng.

Files.walk(start)
    .parallel()
    .forEach(path -> processFile(path));

Mỗi tệp sẽ được xử lý song song (nếu có thể), đặc biệt hiệu quả với các thao tác “nặng”: đọc, parse, tính toán.

Bên trong hoạt động thế nào? ForkJoinPool

Các stream song song dùng một pool luồng dùng chung — ForkJoinPool.commonPool(). Đây là một pool “thông minh”, phân phối nhiệm vụ giữa các luồng.

  • Theo mặc định số luồng = số bộ xử lý khả dụng: Runtime.getRuntime().availableProcessors().
  • Mô hình song song “fork/join” phù hợp cho các tác vụ độc lập — như xử lý từng tệp riêng lẻ.

Khi nào nên dùng .parallel()?

  • Khi việc xử lý mỗi tệp độc lập với các tệp khác.
  • Khi thao tác “nặng” (tốn CPU hoặc chờ IO lâu).
  • Khi số tệp nhiều (hàng trăm, hàng nghìn).

Không nên dùng stream song song:

  • Nếu số tệp ít (chi phí tách luồng có thể lớn hơn lợi ích).
  • Nếu cần thứ tự nghiêm ngặt hoặc có phụ thuộc giữa các phần tử.

4. Các lựa chọn khác và tinh chỉnh mức song song

Khi nào nên dùng ExecutorService?

Stream song song phù hợp cho các trường hợp đơn giản. Nhưng nếu bạn cần:

  • Kiểm soát chính xác số luồng (với tác vụ IO-bound, thường có lợi khi số luồng nhiều hơn số nhân CPU).
  • Quản lý hàng đợi, hủy, retry, xử lý lỗi.
  • Xây dựng các pipeline nhiệm vụ phức tạp hơn.

Khi đó hãy dùng ExecutorService:

import java.nio.file.*;
import java.util.concurrent.*;

ExecutorService executor = Executors.newFixedThreadPool(8);
Files.walk(start)
    .filter(Files::isRegularFile)
    .forEach(path -> executor.submit(() -> processFile(path)));
executor.shutdown();

Tinh chỉnh ForkJoinPool

Theo mặc định, common pool dùng số luồng bằng số bộ xử lý. Có thể thay đổi thông qua system property (trước khi stream song song được sử dụng lần đầu):

System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");
  • Sau khi đặt, tất cả stream song song sẽ dùng tối đa 16 luồng.

CPU-bound vs IO-bound tác vụ

  • CPU-bound: tải nặng CPU (tính toán, parse, nén). Số luồng ≈ số nhân.
  • IO-bound: nhiều thời gian chờ đĩa/mạng. Thường có lợi khi số luồng lớn hơn số nhân.

Stream song song không phải lúc nào cũng tối ưu cho tác vụ IO-bound — thường hiệu quả hơn khi dùng ExecutorService riêng với pool lớn hơn.

5. Ví dụ: tìm kiếm và xử lý tệp song song

Hãy tính tổng số dòng trong tất cả tệp ".java" của dự án bằng cách duyệt song song.

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

public class LineCounter {
    public static void main(String[] args) throws IOException {
        Path start = Paths.get("src");

        long totalLines = Files.walk(start)
            .parallel() // xử lý song song!
            .filter(p -> p.toString().endsWith(".java"))
            .mapToLong(LineCounter::countLines)
            .sum();

        System.out.println("Tổng số dòng mã: " + totalLines);
    }

    // Phương thức đếm số dòng trong tệp
    private static long countLines(Path path) {
        try (Stream<String> lines = Files.lines(path)) {
            return lines.count();
        } catch (IOException e) {
            System.err.println("Lỗi đọc tệp: " + path);
            return 0;
        }
    }
}

Điều gì đang diễn ra:

  • Files.walk(start) — duyệt tất cả đường dẫn.
  • parallel() — bật xử lý song song.
  • filter(...) — chỉ giữ các tệp ".java".
  • mapToLong(...) — đếm số dòng trong mỗi tệp.
  • sum() — cộng dồn kết quả.

Ưu điểm: tận dụng nhiều luồng trong khi mã vẫn gọn gàng.

6. Những lưu ý quan trọng và lỗi thường gặp

  • Không phải mọi tác vụ đều nhanh hơn nhờ song song. Với tập tệp nhỏ hoặc thao tác nhanh, chi phí phụ có thể làm chậm chương trình.
  • Đóng tài nguyên. Khi làm việc với tệp, hãy dùng try-with-resources — để descriptor không bị “rò rỉ”. Ví dụ, Files.lines(path) trong try(...).
  • Song song lồng nhau. Chạy stream song song bên trong các tác vụ song song khác (nested parallelism) hiếm khi hiệu quả và có thể làm suy giảm hiệu năng.
  • Tác dụng phụ. Tránh ghi vào cấu trúc/tệp dùng chung mà không đồng bộ. Ưu tiên các thao tác “thuần” trên từng phần tử.

7. Sơ đồ: duyệt tệp song song hoạt động thế nào

flowchart TD
    A["Files.walk(start)"] --> B["Stream<Path>"]
    B --> C{".parallel()?"}
    C -- Không --> D[forEach thông thường]
    C -- Có --> E["forEach song song (ForkJoinPool)"]
    E --> F[Xử lý tệp trên nhiều luồng]

8. Lỗi thường gặp khi xử lý tệp song song

Lỗi số 1: Dùng stream song song cho tác vụ nhỏ — chi phí phụ lớn hơn lợi ích.

Lỗi số 2: Kỳ vọng stream song song tăng tốc tác vụ IO-bound như với CPU-bound. Với IO thường cần ExecutorService với pool lớn hơn.

Lỗi số 3: Ngoại lệ trong lambda không được xử lý — nếu không xử lý IOException, luồng có thể dừng giữa chừng và kết quả không đầy đủ.

Lỗi số 4: Race khi ghi vào biến hoặc tệp dùng chung — hãy đồng bộ hóa truy cập hoặc tránh tác dụng phụ.

Lỗi số 5: Quên đóng tài nguyên — hãy dùng try-with-resources cho mọi thao tác với tệp.

Lỗi số 6: Cố thay đổi ForkJoinPool.commonPool() sau lần sử dụng đầu tiên — cần đặt thông qua System.setProperty(...) từ trước.

Lỗi số 7: Dùng stream song song bên trong stream song song khác — thường dẫn đến suy giảm hiệu năng.

1
Nhiệm vụ
JAVA 25 SELF, mức độ, bài học
Đã khóa
Đếm tổng số tệp trong thư mục và các thư mục con
Đếm tổng số tệp trong thư mục và các thư mục con
1
Nhiệm vụ
JAVA 25 SELF, mức độ, bài học
Đã khóa
Tìm kiếm song song tệp theo ngày sửa đổi
Tìm kiếm song song tệp theo ngày sửa đổi
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION