1. Giới thiệu
Trong thế giới hiện đại, dữ liệu tăng nhanh hơn cả nấm mọc sau mưa. Đôi khi bạn phải làm việc với các tệp có kích thước hàng chục hoặc thậm chí hàng trăm gigabyte — đó có thể là log, dump cơ sở dữ liệu hoặc các kho lưu trữ khổng lồ. Cố gắng đọc toàn bộ tệp vào bộ nhớ thường kết thúc tệ: chương trình hoặc “ngốn” hết RAM, hoặc bắt đầu chạy chậm một cách đau đớn.
Lý do thì hiển nhiên. Bộ nhớ không phải vô hạn, và nếu tệp vượt quá dung lượng của nó, bạn có nguy cơ gặp OutOfMemoryError. Ngay cả khi còn đủ bộ nhớ, việc đọc và xử lý tuần tự một tệp khổng lồ trong một luồng có thể kéo dài hàng giờ. Thêm vào đó là giới hạn của chính ổ đĩa: tốc độ đọc của nó là cố định, nhưng nếu dùng nhiều luồng, đặc biệt trên SSD, bạn có thể tăng tốc độ xử lý đáng kể.
Vì vậy kết luận chính rất đơn giản: các tệp lớn cần được xử lý theo từng phần, gọi là các chunk, và nếu có thể thì làm việc song song. Chính cách tiếp cận này cho phép xử lý gigabyte dữ liệu mà không phải chịu quá nhiều đau đớn.
2. Giải pháp: mẫu Chunking
Chunking — là một mẫu (pattern) trong đó tệp lớn được chia thành những phần nhỏ, dễ quản lý (chunks), có thể xử lý độc lập với nhau.
Tương tự:
Thay vì ăn cả quả dưa hấu một lúc, bạn cắt nó thành từng miếng và ăn từng miếng. Vừa dễ vừa nhanh!
Cách hoạt động?
- Xác định kích thước tệp.
- Dùng File.length() hoặc Files.size(Path) để biết tệp có bao nhiêu byte.
- Tính kích thước chunk.
- Thường chọn 10–20 MB (có thể lớn/nhỏ hơn — tùy bài toán và phần cứng).
- Nên lưu kích thước chunk trong biến chunkSize và chọn bội số kích thước block của đĩa để đạt hiệu năng tối đa.
- Tạo danh sách tác vụ.
- Mỗi tác vụ là xử lý một chunk: đọc, parse, mã hóa, nén, v.v.
- Có thể chạy các tác vụ song song bằng thread pool.
Minh họa:
+-------------------+
| File |
+-------------------+
| [chunk 1] |
| [chunk 2] |
| [chunk 3] |
| ... |
| [chunk N] |
+-------------------+
3. Hiện thực xử lý song song
Sử dụng ExecutorService hoặc ForkJoinPool
Để xử lý các chunk song song, hãy dùng các công cụ đa luồng tiêu chuẩn của Java:
- ExecutorService — thread pool có kích thước cố định (Executors.newFixedThreadPool(n)).
- ForkJoinPool — cho các tác vụ đệ quy và cách tiếp cận “chia để trị”.
Ví dụ:
ExecutorService pool = Executors.newFixedThreadPool(4); // 4 luồng
for (int i = 0; i < chunkCount; i++) {
final int chunkIndex = i;
pool.submit(() -> {
processChunk(file, chunkIndex, chunkSize);
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.HOURS);
Mỗi tác vụ đọc chunk của riêng mình và xử lý độc lập.
4. Cơ chế then chốt: RandomAccessFile và FileChannel
RandomAccessFile
RandomAccessFile cho phép “di chuyển” trong tệp và đọc từ vị trí mong muốn.
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(chunkStart); // Di chuyển đến đầu của chunk
byte[] buffer = new byte[chunkSize];
int bytesRead = raf.read(buffer);
// Xử lý buffer
}
- seek(long pos) — di chuyển “con trỏ” đến vị trí cần thiết.
- Có thể chỉ đọc đúng dải byte cần thiết.
FileChannel
FileChannel — cách hiện đại và nhanh hơn (đặc biệt với tệp lớn).
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(chunkSize);
channel.position(chunkStart);
int bytesRead = channel.read(buffer);
// Xử lý buffer
}
- position(long newPosition) — đặt vị trí để đọc.
- Có thể đọc đúng dải cần thiết mà không đụng vào phần còn lại của tệp.
5. So sánh chunking với transferTo/transferFrom
transferTo/transferFrom
Các phương thức FileChannel.transferTo() và transferFrom() cho phép sử dụng cái gọi là sao chép bằng không (zero-copy). Ý tưởng rất đơn giản: dữ liệu có thể được sao chép hoặc di chuyển trực tiếp giữa các tệp và luồng, bỏ qua các buffer của JVM. Điều này giúp thao tác rất nhanh. Hạn chế duy nhất — không thể thay đổi dữ liệu “ngay trên đường”, chỉ có thể sao chép; nhưng với nhiều bài toán, cách này tăng tốc đáng kể khi làm việc với khối lượng lớn dữ liệu.
Ví dụ:
try (FileChannel src = FileChannel.open(srcPath, READ);
FileChannel dst = FileChannel.open(dstPath, WRITE)) {
src.transferTo(0, src.size(), dst);
}
Chunking
Tóm lại, Chunking là cách làm việc với tệp lớn theo từng phần, từng chunk. Nó không chỉ dùng để sao chép dữ liệu, mà còn để xử lý: có thể parse, mã hóa, nén hoặc tìm kiếm thông tin ngay trong quá trình. Mỗi chunk có thể được xử lý độc lập, và nếu muốn còn có thể xử lý song song, giúp tăng tốc đáng kể.
Ý tưởng đơn giản: nếu bài toán chỉ là sao chép, tốt hơn hãy dùng transferTo hoặc transferFrom, nơi dữ liệu đi thẳng, nhanh và không có bản sao thừa. Nhưng nếu cần thao tác với nội dung — tìm kiếm, thay đổi, phân tích — thì chunking trở thành công cụ không thể thay thế.
6. Giới hạn và cạm bẫy
Chi phí phụ do luồng
- Tạo quá nhiều luồng có thể dẫn đến giảm hiệu năng (chuyển ngữ cảnh, tranh chấp tài nguyên).
- Thường số luồng được chọn bằng hoặc lớn hơn một chút so với số nhân CPU.
Giới hạn của ổ đĩa
- Dù bạn có 100 luồng thì ổ đĩa vẫn không thể đọc nhanh hơn tốc độ tối đa của nó.
- Trên SSD, đọc song song có thể mang lại cải thiện; trên HDD — hầu như không.
Nhu cầu đồng bộ hóa
- Nếu xử lý các chunk độc lập — mọi thứ đều đơn giản.
- Nếu cần gom kết quả chung (ví dụ, tính tổng tất cả số trong tệp), bạn sẽ phải đồng bộ truy cập biến dùng chung (ví dụ, dùng AtomicLong hoặc gom kết quả vào một danh sách riêng).
Ranh giới chunk
- Nếu tệp là văn bản, cần cẩn thận: đừng cắt đôi dòng hoặc ký tự.
- Với tệp nhị phân (archive, ảnh) — thường có thể cắt thoải mái.
- Với tệp văn bản, thường tạo “vùng chồng lấn” giữa các chunk hoặc tìm ký tự xuống dòng gần nhất.
7. Ví dụ: tính tổng các số trong một tệp lớn theo cách song song
Bài toán:
Có một tệp chứa hàng triệu số (mỗi dòng một số). Cần tính tổng thật nhanh.
Kế hoạch từng bước:
- Xác định kích thước tệp.
- Chọn kích thước chunk (ví dụ, 10 MB).
- Với mỗi chunk:
- Tìm ký tự xuống dòng gần nhất (để không cắt đôi số).
- Đọc chunk, parse số, tính tổng.
- Gom tổng từ tất cả các chunk.
Khung mã:
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Long>> results = new ArrayList<>();
for (int i = 0; i < chunkCount; i++) {
final int chunkIndex = i;
results.add(pool.submit(() -> {
// Mở RandomAccessFile, tìm ranh giới của chunk
// Đọc, parse số, tính tổng
long chunkSum = 0L;
return chunkSum;
}));
}
long total = 0;
for (Future<Long> f : results) {
total += f.get();
}
pool.shutdown();
System.out.println("Tổng: " + total);
8. Tổng kết và best practices
- Chunking — mẫu phổ quát để xử lý tệp lớn: chia thành các chunk, xử lý độc lập, rồi gom kết quả.
- Dùng RandomAccessFile hoặc FileChannel để đọc từ vị trí mong muốn.
- Để xử lý song song — ExecutorService hoặc ForkJoinPool.
- Để sao chép không cần xử lý — hãy dùng transferTo/transferFrom (zero-copy).
- Theo dõi kích thước chunk, số lượng luồng và giới hạn của ổ đĩa.
- Với tệp văn bản — cẩn thận với ranh giới dòng.
- Với tệp nhị phân có thể cắt tùy ý, nếu không có đặc thù định dạng.
9. Những lỗi điển hình khi làm việc với chunking
Lỗi #1: Tệp quá lớn. Cố đọc toàn bộ tệp vào bộ nhớ — nhận OutOfMemoryError.
Lỗi #2: Quá nhiều luồng. Tạo quá nhiều luồng — hệ thống bắt đầu “ì ạch” do chuyển ngữ cảnh.
Lỗi #3: Cắt đôi dòng. Không tính đến ranh giới dòng trong tệp văn bản — dẫn đến dòng “bị cắt” và lỗi parse.
Lỗi #4: Dùng sai phương thức. Cố dùng transferTo/transferFrom để xử lý dữ liệu — không hiệu quả, các phương thức này chỉ dành cho sao chép.
Lỗi #5: Quên đồng bộ. Không đồng bộ khi gom kết quả — dẫn đến tổng không chính xác hoặc các bug khác.
Lỗi #6: Rò rỉ tài nguyên. Không đóng tệp/kênh — gây rò rỉ tài nguyên.
GO TO FULL VERSION