CodeGym /Các khóa học /JAVA 25 SELF /Làm việc với tệp lớn: chunking, memory mapping

Làm việc với tệp lớn: chunking, memory mapping

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

1. Chunking — đọc tệp theo từng phần

Như đã bàn trong bài trước, chunking cho phép làm việc với tệp theo từng phần, không cần nạp toàn bộ vào bộ nhớ. Điều này đặc biệt quan trọng khi xử lý khối dữ liệu rất lớn. Nếu tệp chỉ 10 MB, thường sẽ không có vấn đề — bạn có thể nạp toàn bộ và xử lý theo cách nào cũng được. Nhưng nếu tệp đạt 10 GB, còn RAM chỉ 8 GB, lại thêm trình duyệt với hàng chục tab và IDE đang mở? Cố gắng đọc cả tệp như vậy thường kết thúc bi thảm: OutOfMemoryError, chương trình treo và nước mắt của lập trình viên.

Những ví dụ thực tế về các tệp lớn như vậy xuất hiện liên tục: log máy chủ trong một tháng có thể chiếm hàng chục gigabyte, các tệp CSV lớn chứa hàng triệu dòng, còn video, lưu trữ và dump cơ sở dữ liệu thậm chí còn lớn hơn.

Ý tưởng cốt lõi vẫn vậy: đừng cố “nuốt chửng cả con voi”, hãy xử lý từng miếng. Chính chunking cho phép xử lý an toàn và hiệu quả bằng cách chia tệp thành các phần có thể kiểm soát được.

Nhắc lại về chunking

Chunk (phần, khối) là một phần của tệp với kích thước xác định. Thay vì đọc tất cả cùng lúc, ta đọc, ví dụ, từng 4 MB (hoặc 64 KB, hoặc 1 MB — tùy tình huống).

Nguyên tắc:

  • Mở luồng để đọc tệp.
  • Tạo bộ đệm — mảng byte có kích thước cố định.
  • Trong vòng lặp, đọc từ tệp vào bộ đệm cho đến khi hết tệp.
  • Xử lý riêng từng “miếng”.

Ví dụ: sao chép tệp lớn theo từng phần

Giả sử chúng ta có một tệp rất lớn cần sao chép. Hãy viết chương trình thực hiện việc này một cách “bài bản”.

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class BigFileCopy {
    public static void main(String[] args) throws IOException {
        String source = "bigfile.dat";
        String dest = "bigfile_copy.dat";
        int bufferSize = 4 * 1024 * 1024; // 4 MB

        try (FileInputStream in = new FileInputStream(source);
             FileOutputStream out = new FileOutputStream(dest)) {

            byte[] buffer = new byte[bufferSize];
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                out.write(buffer, 0, bytesRead);
                // Có thể thêm hiển thị tiến độ hoặc xử lý dữ liệu
            }
        }
        System.out.println("Sao chép hoàn tất!");
    }
}

Để làm việc với tệp trong Java, ta thường dùng các luồng chuẩn FileInputStreamFileOutputStream. Một thực tiễn tốt là sử dụng bộ đệm khoảng 4 MB — đủ để đọc/ghi hiệu quả trên các ổ đĩa hiện đại. Trong vòng lặp, chương trình đọc từng phần tệp và ghi ngay sang tệp mới, không cố giữ toàn bộ tệp trong bộ nhớ.

Cách tiếp cận này giúp tiết kiệm RAM, tránh các lỗi như OutOfMemoryError và làm việc được với các tệp hầu như ở mọi kích thước, kể cả 100 GB trở lên.

2. Chunking để xử lý dữ liệu

Thường thì nhiệm vụ không chỉ là sao chép tệp, mà còn là tìm một chuỗi nhất định, đếm số lần xuất hiện, thay thế gì đó, v.v.

Ví dụ: tìm chuỗi trong tệp văn bản lớn

Nếu tệp là văn bản, dùng luồng ký tự và đọc theo từng dòng sẽ tiện hơn:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class BigFileSearch {
    public static void main(String[] args) throws IOException {
        String file = "biglog.txt";
        String keyword = "ERROR";
        int count = 0;

        try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.contains(keyword)) {
                    count++;
                }
            }
        }
        System.out.println("Tìm thấy " + count + " dòng chứa ERROR");
    }
}

Tại sao cách này vẫn hoạt động với các tệp cỡ gigabyte?

  • BufferedReader đọc tệp theo từng phần (mặc định bộ đệm là 8 KB, nhưng có thể tăng lên).
  • Tại mỗi thời điểm, bộ nhớ chỉ giữ một dòng.

Kích thước bộ đệm: chọn thế nào?

Quy tắc vàng: bộ đệm quá nhỏ — quá nhiều lần truy cập đĩa; quá lớn — lãng phí bộ nhớ.

  • Với HDD/SSD hiện đại, thường hợp lý là bộ đệm 64 KB – 4 MB.
  • Với SSD rất nhanh hoặc qua mạng — có thể lớn hơn (8–16 MB).
  • Với tệp văn bản — có thể tăng bộ đệm trong BufferedReader.

Hãy thử nghiệm! Đo thời gian chạy của chương trình với các kích thước bộ đệm khác nhau. Đôi khi tăng bộ đệm cho tốc độ nhanh hơn 2–3 lần, đôi khi — gần như không khác biệt.

3. Memory-mapped files (ánh xạ tệp vào bộ nhớ)

Đó là gì?

Memory mapping là cách “ánh xạ” tệp trực tiếp vào bộ nhớ của tiến trình bằng cơ chế của hệ điều hành. Trong Java, ta dùng lớp MappedByteBuffer thuộc gói java.nio. Tệp như thể trở thành một mảng byte rất lớn mà bạn có thể truy cập trực tiếp, không cần đọc/ghi từng phần một cách tường minh.

Cách làm này đặc biệt hữu ích khi làm việc với tệp cực lớn. Hệ điều hành sẽ tự nạp các phần cần thiết vào bộ nhớ, còn bạn có thể truy cập bất kỳ vị trí nào trong tệp như với một mảng thông thường. Memory-mapped files mang lại tốc độ truy cập ngẫu nhiên rất cao. Ví dụ, khi cần đọc nhanh các phần ở nhiều vị trí khác nhau mà không nạp toàn bộ tệp.

Trong mã trông như thế nào?

import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MemoryMappedRead {
    public static void main(String[] args) throws Exception {
        String fileName = "bigfile.dat";
        try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
             FileChannel channel = file.getChannel()) {

            long fileSize = channel.size();
            int chunkSize = 1024 * 1024 * 128; // 128 MB — kích thước của một mapping

            long position = 0;
            while (position < fileSize) {
                long size = Math.min(chunkSize, fileSize - position);
                MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);

                // Đọc dữ liệu từ buffer như từ một mảng
                for (int i = 0; i < size; i++) {
                    byte b = buffer.get(i);
                    // Xử lý byte (ví dụ: tìm giá trị cụ thể)
                }

                position += size;
            }
        }
        System.out.println("Đọc qua memory mapping đã hoàn tất!");
    }
}

RandomAccessFileFileChannel cho phép truy cập tệp ở mức thấp. Lời gọi channel.map sẽ ánh xạ một vùng của tệp vào bộ nhớ. Truy cập dữ liệu thông qua bộ đệm MappedByteBuffer.

Ưu điểm của memory mapping là gì?

  • Rất nhanh cho truy cập ngẫu nhiên vào các phần khác nhau của tệp.
  • Có thể làm việc với các tệp lớn hơn dung lượng RAM hiện có (HĐH tự nạp các trang cần thiết).
  • Được dùng trong các hệ CSDL hiện đại, chỉ mục, log lớn.

Nhược điểm là gì?

  • Không phải lúc nào cũng phù hợp cho ghi (đặc biệt trên hệ thống tệp mạng).
  • Giới hạn về kích thước mapping (thường đến 2 GB cho một mapping trên JVM 32-bit).
  • Nếu quên đóng tệp, có thể bị “khóa” tệp (đặc biệt trên Windows).
  • Không phải mọi thao tác với tệp đều nhanh hơn — nếu chỉ cần đọc tuần tự, bộ đệm thông thường thường không thua kém.

4. Ví dụ thực tiễn

Ví dụ 1: Tìm chuỗi con trong tệp lớn bằng memory mapping

Giả sử chúng ta có tệp 10 GB và muốn tìm trong đó một dãy byte nhất định (ví dụ chuỗi "SECRET").

import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;

public class MemoryMappedSearch {
    public static void main(String[] args) throws Exception {
        String fileName = "hugefile.bin";
        byte[] target = "SECRET".getBytes(StandardCharsets.UTF_8);

        try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
             FileChannel channel = file.getChannel()) {

            long fileSize = channel.size();
            int chunkSize = 128 * 1024 * 1024; // 128 MB

            long position = 0;
            while (position < fileSize) {
                long size = Math.min(chunkSize, fileSize - position);
                MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);

                for (int i = 0; i < size - target.length; i++) {
                    boolean found = true;
                    for (int j = 0; j < target.length; j++) {
                        if (buffer.get(i + j) != target[j]) {
                            found = false;
                            break;
                        }
                    }
                    if (found) {
                        System.out.println("Tìm thấy tại vị trí " + (position + i));
                        // Có thể dừng tìm kiếm hoặc tiếp tục
                    }
                }
                position += size;
            }
        }
    }
}

Lưu ý:
Nếu chuỗi con có thể bị “đứt đoạn” giữa hai chunk, cần tạo vùng chồng lấn giữa các chunk với độ dài bằng độ dài chuỗi cần tìm.

5. Những lưu ý hữu ích

Khi nào dùng chunking và khi nào — memory mapping?

  • Chunking — cách tiếp cận đa dụng cho mọi loại tệp (văn bản, nhị phân, log, lưu trữ). Rất phù hợp cho xử lý tuần tự.
  • Memory mapping — siêu hiệu quả cho truy cập ngẫu nhiên, làm việc với chỉ mục lớn, CSDL, các tìm kiếm nhanh trên tệp khổng lồ.

Nếu chưa biết chọn gì — hãy bắt đầu với chunking! Memory mapping là công cụ mạnh nhưng “cấp thấp” hơn và đòi hỏi sự cẩn trọng.

Khuyến nghị

  • Sử dụng try-with-resources để tự động đóng luồng và kênh.
  • Đừng mở quá nhiều tệp cùng lúc: HĐH có giới hạn về số lượng file descriptor mở.
  • Đừng map các phần quá lớn — điều này có thể dẫn đến lỗi (đặc biệt trên JVM 32-bit).
  • Đối với xử lý song song, có thể chia tệp thành các chunk và xử lý ở các luồng riêng (nhưng cần tránh “bóp nghẹt” đĩa và vượt quá bộ nhớ).

6. Các lỗi thường gặp khi làm việc với tệp lớn

Lỗi số 1: cố gắng nạp toàn bộ tệp lớn vào bộ nhớ.
Đây là vấn đề rất phổ biến — đặc biệt với người mới. Nếu tệp lớn hơn 1–2 GB, hãy dùng chunking hoặc đọc theo dòng, nếu không chương trình sẽ “ngã” với OutOfMemoryError.

Lỗi số 2: bộ đệm quá nhỏ.
Bộ đệm 512 byte không phải là tối ưu hóa mà là “tự sát” về hiệu năng. Hãy dùng bộ đệm từ 64 KB trở lên.

Lỗi số 3: quên đóng luồng hoặc kênh.
File descriptor sẽ còn treo, tệp không bị xóa hoặc không được giải phóng cho đến khi khởi động lại JVM. Hãy dùng try-with-resources.

Lỗi số 4: sử dụng memory mapping không đúng cách.
Nếu tệp bị tiến trình khác thay đổi trong khi đang mapping, bạn có thể nhận dữ liệu không nhất quán hoặc lỗi. Không dùng memory mapping cho các tệp thường xuyên thay đổi.

Lỗi số 5: không tính đến vùng chồng lấn giữa các chunk khi tìm chuỗi con.
Nếu chuỗi cần tìm có thể nằm “ở chỗ tiếp giáp” của hai chunk, hãy tạo chồng lấn theo độ dài chuỗi đó giữa các chunk.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION