1. Vì sao đọc tệp lớn bằng một luồng — giống như khiêng gạch từng viên
Khi bạn làm việc với các tệp lớn — hàng chục hoặc hàng trăm MB, thậm chí GB — việc đọc hoặc ghi đơn luồng nhanh chóng trở thành nút thắt. Một luồng đơn giản là không kham nổi tải: ổ đĩa có thể cung cấp dữ liệu nhanh hơn tốc độ chương trình xử lý.
Ngay cả khi bạn có SSD nhanh, nút thắt không nằm ở ổ đĩa mà ở chi phí phụ trợ — chuyển ngữ cảnh, làm việc với bộ đệm, chuyển đổi dữ liệu trong bộ nhớ. Kết quả là hiệu năng giảm, còn CPU thì rảnh rỗi vì các lõi còn lại không làm gì cả.
Giả sử bạn quyết định đếm số từ trong một file log khổng lồ. Nếu làm tuần tự, một luồng sẽ nhai file một cách đơn điệu, còn bạn — chỉ ngồi chờ. Còn nếu chia file thành các phần và giao cho nhiều luồng xử lý, tiến độ sẽ nhanh hơn nhiều: mỗi luồng xử lý phần của mình và cuối cùng bạn gần như tận dụng hết tiềm năng của ổ đĩa.
Trong thực tế, trên SSD có băng thông 2 GB/s, đọc đơn luồng chỉ đạt khoảng 300–500 MB/s. Còn nếu đọc song song — bạn có thể vắt kiệt thiết bị lưu trữ tới mức tối đa của nó.
2. Chunking — khiến tệp “làm việc” cho bạn
Khi tệp quá lớn để xử lý nguyên khối, cách hợp lý nhất là chia nó thành các phần. Kỹ thuật này gọi là chunking (từ chunk — “đoạn/khối”). Ý tưởng rất đơn giản: bạn chia tệp lớn thành vài phân đoạn logic và giao cho mỗi luồng một vùng riêng.
Mỗi luồng biết từ độ lệch (offset) nào nó phải bắt đầu và dừng ở đâu. Nó chỉ đọc phần của mình, xử lý dữ liệu, sau đó kết quả được gom lại thành tổng cuối cùng.
Cách tiếp cận này cho phép tận dụng đồng thời tất cả các lõi CPU và tăng tốc đáng kể, đặc biệt nếu bạn có SSD hoặc ổ NVMe hiện đại. Với các tác vụ như đếm dòng, tìm kiếm văn bản hay tổng hợp thống kê, chunking hoạt động như bộ tăng áp — đơn giản là thêm tốc độ mà không cần nhiều nỗ lực.
Cách chọn kích thước chunk
Kích thước chunk gần giống kích thước khẩu phần ăn: quá nhỏ — bạn sẽ mệt vì phải “xắt nhỏ”, quá lớn — khó “tiêu hóa”. Tất cả phụ thuộc vào bài toán và khả năng của máy bạn.
Thông thường, dải 8–64 MB mỗi luồng cho kết quả tốt. Với đa số bài toán, chọn khoảng 10–20 MB là ổn, nhưng không có con số hoàn hảo — tất cả cần thử nghiệm. Điều quan trọng là phần phải đủ lớn để không mất thời gian vào các lần chuyển ngữ cảnh thừa, và không quá lớn để làm đầy cache CPU hoặc chiếm hết bộ nhớ.
Nếu bạn làm việc với văn bản — ví dụ đếm từ hoặc tìm kiếm — cần đảm bảo các chunk không “xé” dòng hoặc từ ở giữa. Thường thì giải quyết đơn giản: tạo một chút chồng lấn giữa các phần hoặc dịch ranh giới đến ký tự xuống dòng gần nhất. Như vậy xử lý vẫn chính xác và kết quả sạch, dễ dự đoán.
3. Công cụ truy cập theo vị trí: FileChannel và MappedByteBuffer
FileChannel: I/O theo vị trí
FileChannel là một lớp trong gói java.nio.channels cho phép làm việc với tệp ở mức thấp, bao gồm cả đọc và ghi dữ liệu tại vị trí tùy ý trong tệp.
Phương thức chính:
- position(long newPosition) — đặt vị trí (offset) cho đọc/ghi.
- read(ByteBuffer dst, long position) — đọc dữ liệu từ tệp vào bộ đệm, bắt đầu từ vị trí chỉ định (không thay đổi vị trí hiện tại của kênh!).
- write(ByteBuffer src, long position) — ghi dữ liệu vào tệp từ vị trí chỉ định.
Ví dụ: đọc một phần của tệp
try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
long chunkSize = 16 * 1024 * 1024; // 16 MB
long offset = 0;
ByteBuffer buffer = ByteBuffer.allocate((int) chunkSize);
int bytesRead = channel.read(buffer, offset);
// buffer chứa 16 MB đầu tiên của tệp
}
Ưu điểm:
- Có thể đọc/ghi từ bất kỳ vị trí nào.
- Tiện cho xử lý song song: mỗi luồng làm việc với phần riêng của mình.
MappedByteBuffer: Memory-mapped files
MappedByteBuffer là bộ đệm đặc biệt cho phép “map” (ánh xạ) một phần tệp vào bộ nhớ. Hệ điều hành tự lo việc nạp dữ liệu từ đĩa vào bộ nhớ và ngược lại.
Hoạt động thế nào?
- Bạn “map” (ánh xạ) một phần tệp vào bộ nhớ.
- Đọc và ghi vào bộ đệm — hệ điều hành tự nạp các trang cần thiết.
- Không có lời gọi read/write rõ ràng — mọi thứ diễn ra qua bộ nhớ.
Ví dụ:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
long chunkSize = 16 * 1024 * 1024; // 16 MB
long offset = 0;
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, chunkSize);
// Giờ buffer hoạt động như một mảng byte, nhưng dữ liệu được đọc từ đĩa theo nhu cầu truy cập
}
Ưu điểm:
- Tốc độ rất cao (đặc biệt trên SSD).
- Đơn giản: đọc/ghi như một mảng.
Nhược điểm:
- Sử dụng bộ nhớ ảo — nếu tệp rất lớn, có thể “chiếm” nhiều bộ nhớ.
- Khó kiểm soát thời điểm giải phóng khỏi bộ nhớ (buffer có thể treo lâu hơn cần thiết).
- Không phải lúc nào cũng tiện cho tệp rất lớn (trên 2–4 GB trên hệ 32-bit).
4. Ví dụ: đọc song song và đếm từ
Xét bài toán: đếm số từ trong một tệp văn bản lớn (ví dụ, log kích thước 10 GB) bằng xử lý song song.
Bước 1. Chia tệp thành các chunk
- Lấy kích thước tệp: long fileSize = Files.size(path);
- Chọn kích thước chunk, ví dụ 16 MB.
- Tính offset cho mỗi chunk: offset = chunkIndex * chunkSize;
- Chunk cuối có thể nhỏ hơn về kích thước.
Bước 2. Tạo nhiệm vụ cho các luồng
- Với mỗi chunk tạo Callable<Integer> (hoặc Runnable), trong đó:
- Mở phần tệp của mình qua FileChannel.read(ByteBuffer, offset) hoặc MappedByteBuffer.
- Đếm số từ trong phần của mình.
- Trả về kết quả (số từ).
Bước 3. Chạy các nhiệm vụ qua ExecutorService
- Tạo pool luồng: ExecutorService pool = Executors.newFixedThreadPool(N);
- Gửi nhiệm vụ vào pool: List<Future<Integer>> results = pool.invokeAll(tasks);
- Tổng hợp kết quả: cộng các giá trị từ tất cả Future.
Ví dụ mã (đơn giản hóa):
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
public class ParallelWordCount {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
long fileSize = Files.size(path);
int chunkSize = 16 * 1024 * 1024; // 16 MB
int chunks = (int) ((fileSize + chunkSize - 1) / chunkSize);
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < chunks; i++) {
long offset = (long) i * chunkSize;
long size = Math.min(chunkSize, fileSize - offset);
results.add(pool.submit(() -> {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, size);
byte[] bytes = new byte[(int) size];
buffer.get(bytes);
String text = new String(bytes);
// Quan trọng: xử lý ranh giới chunk để không cắt ngang một từ!
return countWords(text);
}
}));
}
int totalWords = 0;
for (Future<Integer> f : results) {
totalWords += f.get();
}
pool.shutdown();
System.out.println("Total words: " + totalWords);
}
private static int countWords(String text) {
// Cách đơn giản nhất: tách theo khoảng trắng và lọc chuỗi rỗng
String[] words = text.split("\\s+");
int count = 0;
for (String w : words) {
if (!w.isBlank()) count++;
}
return count;
}
}
Lưu ý: trong bài toán thực tế, cần xử lý cẩn thận ranh giới các chunk để không cắt ngang từ hoặc dòng giữa hai luồng. Thông thường tạo một overlap nhỏ (ví dụ, +100 byte) và điều chỉnh đầu/cuối chunk.
5. Tổng kết và các thực hành tốt nhất
- Với tệp lớn, hãy dùng chia thành chunk và xử lý song song.
- Dùng FileChannel cho truy cập theo vị trí, và MappedByteBuffer cho tệp memory-mapped.
- Chọn kích thước chunk bằng thử nghiệm; tham chiếu theo cache CPU và băng thông ổ đĩa.
- Xử lý cẩn thận ranh giới các chunk (đặc biệt với văn bản).
- Để xử lý song song, sử dụng ExecutorService và thread pool.
- Đừng lạm dụng số lượng luồng: thường đủ 2–4 luồng trên SSD.
- Theo dõi mức dùng bộ nhớ: MappedByteBuffer có thể chiếm nhiều bộ nhớ ảo.
6. Các lỗi thường gặp khi làm việc với tệp lớn và chunking
Lỗi 1: Đọc toàn bộ tệp vào bộ nhớ. Khi xử lý tệp lớn, điều này có thể dẫn tới OutOfMemoryError. Thay vào đó, hãy đọc dữ liệu theo từng phần (chunk).
Lỗi 2: Xử lý ranh giới chunk không đúng. Nếu cắt tệp mà không tính tới ranh giới dòng hoặc từ, dữ liệu có thể bị “xé” và kết quả sẽ không chính xác.
Lỗi 3: Kích thước chunk không tối ưu. Chunk quá nhỏ tạo chi phí quản lý luồng thừa, còn chunk quá lớn — sử dụng bộ nhớ kém hiệu quả.
Lỗi 4: Không đóng FileChannel. Điều này dẫn đến rò rỉ tài nguyên. Hãy sử dụng try-with-resources để đảm bảo kênh được đóng.
Lỗi 5: Quá nhiều luồng. Nếu có quá nhiều luồng, ổ đĩa không kịp phục vụ yêu cầu và hiệu năng giảm thay vì tăng.
GO TO FULL VERSION