Java Internals & Concurrency/Stream file — Files.lines, memory-mapped, channel
44/75
Bài 44 / 75~20 phútI/O & NIOMiễn phí lượt xem

Stream file — Files.lines, memory-mapped, channel

Xử lý file lớn stream-based với Files.lines, try-with-resources. Memory-mapped file (MappedByteBuffer) cho random access, FileChannel + Buffer cho control chi tiết. Pattern xử lý file 10GB không OOM.

TL;DR: Ba kỹ thuật xử lý file lớn: (1) Files.lines + Stream API — lazy line-by-line, memory constant, cover 90% text processing, bắt buộc try-with-resources; (2) MappedByteBuffer — map file vào virtual memory, random access nhanh như đọc RAM, file lớn hơn heap vẫn OK, nhưng limit 2GB mỗi region và không unmap chủ động được; (3) FileChannel + ByteBuffer — control chi tiết, có transferTo zero-copy (sendfile) cho copy/streaming. Quy tắc chọn: text sequential → Files.lines; binary random access → mapped; copy hiệu năng cao → transferTo.

Quay lại bài toán quen thuộc từ bài Path và Files: đọc log file 10GB. Thử Files.readAllLines:

List<String> lines = Files.readAllLines(Path.of("app.log"));   // OOM

10GB text tốn hơn 10GB heap. Từ Java 9, Compact Strings (JEP 254) lưu chuỗi Latin-1 với 1 byte/char nên char array tổng ≈ size file — nhưng mỗi dòng còn cộng object header (~16 byte) + reference trong List, log dòng ngắn đội thêm 30-50%. Heap default vài GB nên crash ngay khi load.

Chuyển sang BufferedReader loop while (readLine) — chạy được nhưng imperative:

try (BufferedReader r = Files.newBufferedReader(path)) {
    long errorCount = 0;
    String line;
    while ((line = r.readLine()) != null) {
        if (line.contains("ERROR")) errorCount++;
    }
    System.out.println(errorCount);
}

6 dòng cho task đơn giản. Intent "đếm ERROR" không rõ từ code — phải skim qua loop structure.

Files.lines + Stream API kết hợp tốt nhất của cả hai: memory constant (không OOM), code declarative (đúng business intent):

try (Stream<String> lines = Files.lines(path)) {
    long errorCount = lines.filter(l -> l.contains("ERROR")).count();
    System.out.println(errorCount);
}

3 dòng. Đọc là hiểu. Memory ~KB buffer, scale với file bất kỳ size.

1. Files.lines + Stream API — pattern chuẩn cho text

Cách hoạt động

Ví dụ đếm ERROR ở trên là dạng chuẩn (thêm charset tường minh: Files.lines(path, StandardCharsets.UTF_8)). Cơ chế bên dưới:

  1. Files.lines mở file qua BufferedReader internal.
  2. Wrap BufferedReader thành Stream<String> qua Spliterator (iterator chuyên cho Stream API — biết cách duyệt tuần tự và tự chia nhỏ để chạy song song).
  3. Mỗi element stream được tạo khi pipeline yêu cầu — gọi readLine() lấy dòng kế.
  4. Stream lazy (xem Stream basics) — chỉ line đang xử lý ở heap.

Memory: ~KB cho buffer + 1 line Java object tại 1 thời điểm. File 10GB, 100GB, 1TB đều chạy được miễn có disk space.

Processing time linear với file size — I/O bound (đọc disk) không CPU bound.

Bắt buộc try-with-resources

// BAD
Files.lines(path).filter(...).count();   // Leak file descriptor

Stream<String> implement AutoCloseable. Khi scope kết thúc, JVM call .close() → close underlying BufferedReader → release FD.

Nếu không try-with-resources, FD giữ cho đến khi GC dọn stream object — timing không xác định, có thể lâu. Service 24/7 chạy leak → "Too many open files".

Javadoc của Files.lines ghi rõ: "The returned stream contains a reference to an open file. The file is closed by closing the stream."

Pipeline thực tế — phân tích log

Task: file log 1GB format [TIMESTAMP] LEVEL [REQUEST_ID] message. Tìm top 10 request ID có nhiều ERROR nhất.

record LogLine(Instant time, String level, String requestId, String message) {
    static final Pattern PATTERN = Pattern.compile("\\[([^\\]]+)\\] (\\w+) \\[([^\\]]+)\\] (.*)");

    static LogLine parse(String line) {
        Matcher m = PATTERN.matcher(line);
        if (!m.matches()) return null;
        try {
            return new LogLine(Instant.parse(m.group(1)), m.group(2), m.group(3), m.group(4));
        } catch (DateTimeException e) { return null; }   // date loi -> skip
    }
}

Map<String, Long> topErrors;
try (Stream<String> lines = Files.lines(Path.of("app.log"), StandardCharsets.UTF_8)) {
    topErrors = lines
        .map(LogLine::parse)
        .filter(Objects::nonNull)                         // Skip line khong match
        .filter(l -> "ERROR".equals(l.level()))
        .collect(Collectors.groupingBy(
            LogLine::requestId,
            Collectors.counting()))
        .entrySet().stream()
        .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
        .limit(10)
        .collect(Collectors.toMap(
            Map.Entry::getKey, Map.Entry::getValue,
            (a, b) -> a, LinkedHashMap::new));
}

Memory: ~few MB (JVM heap cho map counts + line buffer). File 1GB → OK. 100GB → OK. Chỉ phụ thuộc số unique requestId có ERROR — aggregated data, không toàn bộ file. Bản imperative tương đương cần 2 for loop lồng + HashMap thủ công, ~30 dòng.

2. Memory-mapped file — MappedByteBuffer

Concept

Memory-mapped file: OS map vùng địa chỉ virtual memory của process tới content file. Đọc/ghi vùng đó = đọc/ghi file.

Đối chiếu hai đường đi của cùng một byte: ch.read chép từ page cache sang ByteBuffer rồi sang mảng byte trong heap, còn ch.map gắn thẳng trang page cache vào không gian địa chỉ process nên heap không phình

Không phải đĩa đọc nhanh hơn — mà bỏ được hai bản sao và cú syscall mỗi lần nhảy vị trí.

try (FileChannel ch = FileChannel.open(
        Path.of("big.dat"), StandardOpenOption.READ)) {
    long size = ch.size();
    MappedByteBuffer buf = ch.map(
        FileChannel.MapMode.READ_ONLY, 0, size);

    byte[] first100 = new byte[100];
    buf.get(first100);

    buf.position(10_000_000);   // Seek
    byte b = buf.get();
}

Ưu điểm

1. Random access cực nhanh Với access pattern "đọc pos 1M, rồi pos 500M, rồi pos 100K" — mỗi đọc ~100ns (memory read), không ~3μs (syscall seek + read như FileChannel.read(buf, position)).

2. File lớn hơn JVM heap OK Map 10GB file với heap 1GB vẫn chạy — buffer là virtual memory reference tới page cache OS, không tính vào heap.

3. Share giữa process Nhiều process map cùng file → cùng page cache → share data không copy. Use case nâng cao: IPC (inter-process communication — trao đổi dữ liệu giữa các process), shared cache.

Nhược điểm

1. File vượt 2GB cần map nhiều phần Java MappedByteBuffer dùng int index — limit 2GB (Integer.MAX_VALUE). File lớn hơn phải chia map nhiều region:

long fileSize = ch.size();
long chunkSize = Integer.MAX_VALUE;
List<MappedByteBuffer> chunks = new ArrayList<>();
for (long offset = 0; offset < fileSize; offset += chunkSize) {
    long size = Math.min(chunkSize, fileSize - offset);
    chunks.add(ch.map(FileChannel.MapMode.READ_ONLY, offset, size));
}

Alternative hiện đại: MemorySegment của Foreign Function & Memory API — preview từ Java 19 (JEP 424), final ở Java 22 (JEP 454) — dùng long index, không có limit 2GB.

2. Unmap không control được MappedByteBuffer không có method unmap() chuẩn — buffer giữ page cache đến khi GC dọn. File đang map có thể không rename/delete được trên Windows.

Workaround (not standard): sun.nio.ch.FileChannelImpl.unmap(buffer) — private API, fragile.

3. Write durability không đảm bảo Write vào MappedByteBuffer đi vào OS page cache, chưa chắc xuống disk. buf.force() gọi fsync — đảm bảo disk.

Use case

  • Database index / B-tree: random seek, read-heavy — OS cache tự optimize.
  • Binary format parsing: header + pointer offset (ELF, DEX, serialization format).
  • Full-text search index: Lucene dùng memory-mapped cho segment file.

Không đáng cho sequential scan 1 lần — BufferedInputStream đơn giản hơn, perf tương đương.

3. FileChannel + ByteBuffer — NIO channel API

NIO thế hệ 1 (Java 1.4, 2002): introduces Channel + Buffer + Selector. Thiết kế cho non-blocking I/O.

Pattern cơ bản

try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
    ByteBuffer buf = ByteBuffer.allocate(4096);
    while (ch.read(buf) != -1) {
        buf.flip();                     // Switch to read mode
        while (buf.hasRemaining()) {
            byte b = buf.get();
            process(b);
        }
        buf.clear();                    // Reset to write mode
    }
}

Buffer lifecycle

ByteBuffer có 3 pointer: position, limit, capacity.

Empty buffer after allocate(1024):
position=0, limit=1024, capacity=1024
|=====================|
0                    1024

After writing 100 bytes:
position=100, limit=1024, capacity=1024
|....|================|
0   100             1024

After flip():
position=0, limit=100, capacity=1024
|====|                |
0    100            1024
(mode read: data tu 0 den 100)

Ngoài flip() (write → read) và clear() (read → write, bỏ toàn bộ) minh hoạ ở trên, còn compact() — về write mode nhưng giữ phần data chưa đọc. Verbose, dễ bug nếu quên flip() sau write hoặc clear() giữa iterations.

Khi nào dùng FileChannel thay BufferedInputStream?

BufferedInputStream đơn giản hơn cho sequential read. FileChannel có ưu thế cho:

1. Random access với position:

ByteBuffer buf = ByteBuffer.allocate(1024);
ch.read(buf, 10_000_000);   // Doc tu offset, khong seek thu cong

2. transferTo — zero-copy:

try (FileChannel src = FileChannel.open(source, StandardOpenOption.READ);
     FileChannel dst = FileChannel.open(dest, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
    src.transferTo(0, src.size(), dst);
}

transferTo dùng sendfile syscall (Linux) — OS copy data trực tiếp từ disk page cache sang disk page cache khác, không qua user space. Nhanh hơn read-write loop ~2-3×.

Dùng cho: file server, backup, streaming large file (HTTP response body).

3. Lock vùng file:

FileLock lock = ch.lock(offset, length, shared);
try {
    // Exclusive access to this region
} finally {
    lock.release();
}

Cross-process file locking — 2 process dùng cùng file.

4. Khi nào chọn API nào

Cây quyết định bốn câu hỏi chọn API xử lý file: text đọc cả vào RAM dùng Files.readString, text từng dòng dùng Files.lines, chỉ copy dùng FileChannel.transferTo, nhị phân đọc ngẫu nhiên dùng MappedByteBuffer, còn lại dùng Files.newInputStream kèm đệm

AsynchronousFileChannel nằm ngoài cây vì nó giải bài toán khác (callback/Future thay vì blocking), và virtual thread (Java 21) làm nó ít cần thiết hẳn.

5. Pattern thực tế — word count file lớn

Task: đọc file 10GB text, đếm tần suất mỗi từ, in top 20.

try (Stream<String> lines = Files.lines(Path.of("huge.txt"), StandardCharsets.UTF_8)) {
    Map<String, Long> wordCount = lines
        .flatMap(l -> Arrays.stream(l.split("\\s+")))
        .filter(w -> !w.isEmpty())
        .map(String::toLowerCase)
        .collect(Collectors.groupingBy(
            Function.identity(),
            Collectors.counting()));

    wordCount.entrySet().stream()
        .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
        .limit(20)
        .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue()));
}

Memory: phụ thuộc số distinct word (thường dưới 1 triệu cho text English). ~100MB heap cho map. File size không giới hạn.

6. AsynchronousFileChannel — async I/O

AsynchronousFileChannel ch = AsynchronousFileChannel.open(
    path, StandardOpenOption.READ);

ByteBuffer buf = ByteBuffer.allocate(4096);
Future<Integer> future = ch.read(buf, 0);

// Lam viec khac
doOtherStuff();

int n = future.get();   // Block khi can ket qua

Ngoài Future, còn variant nhận CompletionHandler callback (completed/failed).

Thiết kế cho high-concurrency server pre-virtual-thread. Java 21+ có virtual thread — pattern blocking API trên virtual thread thường dễ đọc hơn async callback. AsynchronousFileChannel còn lại cho: code base cũ, library expect CompletionHandler, mix với reactive stream (Reactor, RxJava).

7. Pitfall tổng hợp

Nhầm 1: Files.lines không close.

Files.lines(path).forEach(...);   // Leak FD

✅ Try-with-resources.

Nhầm 2: Files.readAllLines cho file lớn.

List<String> all = Files.readAllLines(Path.of("10gb.log"));   // OOM

Files.lines + stream, memory constant.

Nhầm 3: Quên flip() sau write buffer.

buf.put(data);
byte b = buf.get();   // Doc tu position sau write, get data khong dinh

flip() trước read:

buf.put(data);
buf.flip();
byte b = buf.get();

Nhầm 4: MappedByteBuffer cho file nhỏ.

ch.map(MapMode.READ_ONLY, 0, 1024);   // Overhead > loi ich

✅ File nhỏ: Files.readAllBytes hoặc ByteBuffer.wrap(readAllBytes).

Nhầm 5: Không force() khi cần durability.

buf.put(data);
// JVM crash -> data in OS cache, chua xuong disk

buf.force() với MappedByteBuffer; ch.force(true) với FileChannel.

Nhầm 6: ByteBuffer.allocate lặp trong loop.

while (reading) {
    ByteBuffer buf = ByteBuffer.allocate(8192);   // Allocate moi iteration
    ch.read(buf);
    // ...
}

✅ Allocate 1 lần ngoài loop, clear() reuse.

8. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: đọc benchmark của Oracle Java Magazine trước khi optimize file-heavy workload. Với Java 22+, cân nhắc MemorySegment thay MappedByteBuffer cho file lớn.

9. Tóm tắt

  • Files.lines(path) + Stream API = declarative xử lý file text lớn, memory constant. 90% use case text processing.
  • Luôn close Files.lines qua try-with-resources — stream giữ file handle.
  • MappedByteBuffer map file vào address space process — random access nhanh, không tốn JVM heap.
  • MappedByteBuffer limit 2GB (int index) — file lớn hơn chia multiple region hoặc dùng MemorySegment (FFM API, final Java 22 — JEP 454).
  • FileChannel + ByteBuffer — NIO channel API, verbose lifecycle (allocate → write → flip → read → clear).
  • FileChannel.transferTo zero-copy ở kernel level (sendfile) — tối ưu copy file, streaming response.
  • AsynchronousFileChannel cho async I/O pre-virtual-thread; Java 21+ virtual thread giảm nhu cầu.
  • Rule chọn API: default Files.lines/newInputStream sequential; random access → mapped; copy → transferTo; non-blocking → async.
  • Pattern count/filter/group trên log file → stream + collectors — replace 2-3× code imperative.

10. Tự kiểm tra

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Vì sao Files.lines scale được với file 10GB mà readAllLines thì không?
  2. Q2
    Khi nào dùng MappedByteBuffer thay BufferedInputStream?
  3. Q3
    Cơ chế buffer.flip() trong NIO là gì, và vì sao cần thiết?
  4. Q4
    Khi nào cần force/flush khi ghi file, và 2 tầng buffer là gì?
  5. Q5
    Đoạn sau có vấn đề gì? Stream<String> s = Files.lines(path); s.filter(...).count();
  6. Q6
    Vì sao FileChannel.transferTo nhanh hơn viết loop read/write?

Bài tiếp theo: Mini-challenge: Log aggregator với NIO.2 và Stream

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Mini-challenge: Log aggregator với NIO.2 và Stream