Java Internals & Concurrency/Path và Files — NIO.2 thay java.io.File
21/39
Bài 21 / 39~13 phútI/O & NIOMiễn phí lượt xem

Path và Files — NIO.2 thay java.io.File

Path immutable thay File, Files utility đồng nhất: readString, write, copy, move, delete với exception cụ thể. Vì sao API 1996 phải thay, và 90% task file I/O hằng ngày gói trong vài dòng.

TL;DR: java.io.File (1996) trả boolean khi fail, hành xử khác nhau giữa OS, và bắt bạn tự viết loop cho task phổ biến. Java 7 thêm NIO.2 (java.nio.file): Path immutable + platform-aware thay File; Files với 60+ static method (readString, writeString, copy, move, delete) throw exception cụ thể thay vì fail silent. Quy tắc: code mới luôn dùng NIO.2; đọc file nhỏ dùng readString/readAllBytes, file lớn dùng Files.lines lazy (nhớ try-with-resources); nối path bằng resolve, không string concat. java.io.File chỉ còn gặp khi interop với API legacy.

Java 1.0 (1996) có java.io.File — API cho file/directory. Dùng được, nhưng thiết kế có nhiều vấn đề tích luỹ qua 15 năm:

File file = new File("data/report.pdf");

// Check exist
boolean e = file.exists();

// Delete
boolean ok = file.delete();   // Return boolean. Fail -> khong biet ly do.

// Rename
boolean ok2 = file.renameTo(new File("archive/report.pdf"));
// Behavior khac nhau giua Windows va Linux
// Co the fail silent

Vấn đề:

  • delete() trả boolean. Fail thì sao? Permission? File đang mở? Directory không rỗng? Không biết — không có exception chi tiết.
  • renameTo() hoạt động khác nhau giữa OS. Cross-device rename có thể fail silent.
  • Không phân biệt file và symlink — operation trên symlink tùy vào JVM version.
  • API verbose cho task đơn giản: copy file cần viết loop read/write thủ công.
  • Không có utility cho task phổ biến: walk directory recursive, read all lines, compare files.

Java 7 (2011) thêm NIO.2 (java.nio.file): Path, Files, FileSystem. API modern với:

  • Path immutable thay File — thread-safe, cacheable, platform-aware.
  • Files static utility với 60+ method — copy, move, lines, readString, write.
  • Throw exception cụ thể với cause — không silent fail.
  • Symlink-aware, atomic operation support.

Hầu hết code mới nên dùng NIO.2. java.io.File chỉ còn gặp trong codebase cũ hoặc API legacy (vd MultipartFile.transferTo(File) của Spring).

Bài này đi qua Path và các method nền tảng của Files: check/metadata, create/delete, copy/move, đọc và ghi file. Phần duyệt cây thư mục, atomic swap và WatchService để dành cho bài kế tiếp — Duyệt thư mục và theo dõi file.

1. Path — thay File

Tạo Path

import java.nio.file.*;

Path p1 = Paths.get("data/log.txt");           // Java 7, factory cu
Path p2 = Path.of("data/log.txt");             // Java 11+, recommend
Path p3 = Path.of("/absolute/path/to/file");
Path p4 = Path.of("C:\\Users\\Alice\\doc.pdf");  // Windows
Path p5 = Path.of("data", "subdir", "file.txt"); // Join segments

Path.of (Java 11+) là factory hiện đại — thay cho Paths.get cũ.

Path là immutable

Path p = Path.of("/var/log");
Path child = p.resolve("app.log");
// p van la "/var/log"
// child la "/var/log/app.log" (path moi)

Path abs = p.toAbsolutePath();   // Path moi, p khong doi
Path norm = Path.of("/a/./b/../c").normalize();   // /a/c - path moi

Mỗi op trả path mớiPath không bao giờ mutate. Tương tự String.

Lợi ích:

  • Thread-safe: nhiều thread share path không lock.
  • Cacheable key: dùng Path làm key trong Map an toàn.
  • Method chain: base.resolve("x").normalize().toAbsolutePath() — rõ intent, không side-effect.

Operation cơ bản

Path p = Path.of("/var/log/app.log");

p.getFileName();     // "app.log" (Path)
p.getParent();       // "/var/log" (Path)
p.getRoot();         // "/" (Path) hoac null neu relative
p.getNameCount();    // 3
p.getName(0);        // "var"
p.toString();        // "/var/log/app.log"
p.toFile();          // java.io.File (convert qua lai neu can)

Join và normalize

Path base = Path.of("/home/user");
Path subdir = base.resolve("docs");              // /home/user/docs
Path file = subdir.resolve("report.pdf");        // /home/user/docs/report.pdf

// resolveSibling: thay filename giu parent
Path other = file.resolveSibling("resume.pdf"); // /home/user/docs/resume.pdf

// relativize: tu A ra B
Path rel = base.relativize(file);                // docs/report.pdf

// normalize: loai . va ..
Path.of("/a/./b/../c").normalize();              // /a/c

resolve platform-aware — dùng separator đúng (/ Linux, \ Windows). Tránh string concat + "/":

// BAD
Path p = Path.of(base + "/" + filename);   // Windows bug

// GOOD
Path p = base.resolve(filename);            // Platform correct

2. Files — static utility

Class java.nio.file.Files có 60+ static method cho mọi task file I/O. Thay cho viết loop thủ công.

Check / metadata

Files.exists(path);
Files.notExists(path);        // Khac !exists (co the khong determinable)
Files.isRegularFile(path);    // La file thuong (khong directory, symlink)
Files.isDirectory(path);
Files.isReadable(path);
Files.isWritable(path);
Files.isExecutable(path);
Files.isHidden(path);
Files.size(path);             // bytes
Files.getLastModifiedTime(path);   // FileTime
Files.isSameFile(p1, p2);     // Chieu sau - kiem tra cung file thuc su (symlink)

Khác biệt exists!notExists: tồn tại 3 trạng thái — tồn tại, không tồn tại, không biết (vd no permission). notExists true chỉ khi chắc chắn không tồn tại; !exists có thể là "không tồn tại hoặc không xác định".

Create / delete

Files.createDirectories(path);                    // mkdir -p (tao parent neu can)
Files.createDirectory(path);                       // throw neu parent khong ton tai
Files.createFile(path);                            // throw neu ton tai
Files.createTempFile("prefix-", ".tmp");           // tmp file he thong
Files.createTempFile(dir, "prefix-", ".tmp");      // tmp file trong dir

Files.delete(path);                                // throw neu khong ton tai
Files.deleteIfExists(path);                        // silent neu khong co

delete throw exception với cause cụ thể:

  • NoSuchFileException — file không tồn tại.
  • DirectoryNotEmptyException — directory có content.
  • AccessDeniedException — permission.
  • FileSystemException — general OS error.

Khác hẳn File.delete() trả boolean — không biết lý do.

Copy / move

Files.copy(src, dst);                                            // copy
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);       // overwrite
Files.copy(src, dst, StandardCopyOption.COPY_ATTRIBUTES);        // metadata

Files.move(src, dst);                                            // move/rename
Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE);            // atomic

COPY_ATTRIBUTES — giữ timestamp, owner, permission.

ATOMIC_MOVE — rename trong cùng filesystem là atomic (OS rename() syscall). Cross-filesystem không atomic được → throw AtomicMoveNotSupportedException. Pattern "ghi tmp rồi atomic rename" để ghi file an toàn được mổ chi tiết ở bài Duyệt thư mục và theo dõi file.

3. Đọc file — 3 cách phổ biến

Cách 1: Toàn file vào memory

// Text
String content = Files.readString(path, StandardCharsets.UTF_8);   // Java 11+
String contentDefault = Files.readString(path);                    // Default UTF-8

List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);

// Binary
byte[] bytes = Files.readAllBytes(path);

Đơn giản, 1 dòng. Nhưng load toàn bộ vào memory — file 1GB → OOM nếu heap dưới 1GB.

Dùng cho file nhỏ, biết size: config, template, document nhỏ dưới 100MB.

Cách 2: Stream line-by-line (Java 8+)

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

Files.lines trả Stream<String> lazy — đọc từng line khi pipeline yêu cầu. Memory constant (~KB cho buffer + current line).

Bắt buộc try-with-resources — stream giữ file handle, không close → leak FD.

Dùng cho: file text lớn, log, CSV lớn. Chi tiết ở bài Stream file — Files.lines.

Cách 3: BufferedReader (manual loop)

try (BufferedReader r = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    String line;
    while ((line = r.readLine()) != null) {
        process(line);
    }
}

Files.newBufferedReader default UTF-8 (fix bug FileReader platform default ở bài I/O cổ điển). Hỗ trợ readLine như java.io.

Dùng khi: cần control logic loop phức tạp (skip lines, lookahead, conditional parse).

4. Ghi file

Text

// Toan file
Files.writeString(path, "Hello\nWorld\n", StandardCharsets.UTF_8);   // Java 11+
Files.write(path, List.of("line1", "line2"), StandardCharsets.UTF_8);

// Append
Files.writeString(path, "more\n", StandardCharsets.UTF_8,
    StandardOpenOption.APPEND);

Binary

Files.write(path, byteArray);

Streaming write (file lớn)

try (BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
    for (int i = 0; i < 1_000_000; i++) {
        w.write("line " + i + "\n");
    }
}

OpenOption

Default Files.write* là: CREATE, TRUNCATE_EXISTING, WRITE. Tức tạo mới hoặc overwrite.

Option khác:

  • APPEND — ghi thêm vào cuối.
  • CREATE_NEW — throw nếu tồn tại (atomic check-create).
  • DELETE_ON_CLOSE — xoá file khi close.
  • SYNC — fsync sau mỗi write (durability).
  • DSYNC — fsync data only (không metadata).
Files.writeString(path, data, UTF_8,
    StandardOpenOption.CREATE_NEW,
    StandardOpenOption.SYNC);
// Tao moi, throw neu ton tai, fsync sau write

5. So sánh java.io.File và NIO.2

Taskjava.io.FileNIO.2
Tạo pathnew File("x.txt")Path.of("x.txt")
Đọc text toàn fileTự build stream chainFiles.readString(p)
Đọc text streamBufferedReader chainFiles.lines(p) + Stream API
CopyTự loop read/writeFiles.copy(src, dst, options...)
MoverenameTo() fragileFiles.move(src, dst, ATOMIC_MOVE)
Delete failboolean không biết lý doThrow exception cụ thể
CharsetDefault platform (bug Windows)Explicit trong API
SymlinkKhông phân biệtFiles.readSymbolicLink, NOFOLLOW_LINKS option
ImmutableFile mutable fieldPath immutable

Quy tắc: code mới → NIO.2. java.io.File chỉ còn gặp khi interop với legacy API (Spring MultipartFile, ImageIO, etc.) — convert qua Path.toFile() / File.toPath().

6. Pitfall tổng hợp

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

Files.lines(path).filter(...).count();   // Leak FD

✅ Try-with-resources:

try (Stream<String> lines = Files.lines(path)) {
    lines.filter(...).count();
}

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

byte[] data = Files.readAllBytes(path);   // OOM neu 10GB

✅ Stream-based: Files.newInputStream(path) hoặc Files.lines(path).

Nhầm 3: Path concat bằng +.

Path p = Path.of(base + "/" + filename);   // Bug separator Windows

base.resolve(filename).

Nhầm 4: Files.copy không option → throw khi đích tồn tại.

Files.copy(src, dst);   // FileAlreadyExistsException

Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING) nếu muốn overwrite.

Nhầm 5: Dùng File.delete() không check return.

file.delete();   // Return boolean - fail thi sao?

✅ NIO.2 throw exception cụ thể:

Files.delete(path);   // Throw NoSuchFileException / AccessDeniedException / ...

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: Javadoc Files class mô tả từng op đi kèm IOException cause cụ thể — đọc section "File Systems" để hiểu vì sao Files.move cross-filesystem có thể fail. Spec NIO.2 (JSR 203) cũng giải thích design rationale: vì sao tách Path (định danh) khỏi Files (operation).

8. Tóm tắt

  • Path (NIO.2) thay File (java.io): immutable, platform-aware, thread-safe.
  • Path.of (Java 11+) là factory hiện đại thay cho Paths.get.
  • Nối path bằng resolve — không string concat (bug separator Windows).
  • Files.readString/writeString cho text nhỏ; Files.readAllBytes/write cho binary nhỏ.
  • Files.lines stream lazy cho file lớn — phải close try-with-resources để không leak FD.
  • Files.copy/move với StandardCopyOption: REPLACE_EXISTING, ATOMIC_MOVE, COPY_ATTRIBUTES.
  • Files.newBufferedReader/Writer(path) — default UTF-8, không platform default.
  • Exception NIO.2 cụ thể (NoSuchFileException, AccessDeniedException) — khác File trả boolean.
  • java.io.File legacy — dùng NIO.2 cho code mới, convert qua lại bằng toPath() / toFile() khi cần interop.

9. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Files.lines(path).count() có thể leak file descriptor?

Files.lines mở file và trả Stream<String> giữ file handle bên dưới. Intermediate op (filter, map) không đóng. Terminal op (count) duyệt stream xong cũng không đóng — file descriptor giữ đến khi GC dọn object stream.

GC timing không xác định — có thể phút, giờ, hoặc khi heap pressure. Trong lúc đó, file handle chiếm 1 slot FD của process. Linux default limit 1024 FD, macOS thấp hơn.

Stream implement AutoCloseable chính vì lý do này. Phải try-with-resources:

try (Stream<String> lines = Files.lines(path)) {
  long count = lines.count();
}

Hậu quả leak: service 24/7 chạy lâu → "Too many open files" error → không mở được file/socket mới → crash. Hiếm gặp trong dev (chạy ngắn), phổ biến trong production.

Q2
Khi nào dùng Files.readAllBytes vs Files.lines?
  • readAllBytes / readAllLines / readString: file nhỏ, biết size (< 100MB tuỳ heap). Load toàn bộ vào memory, API đơn giản. Dùng cho config, template, document ngắn.
  • Files.lines / newBufferedReader / newInputStream: file lớn hoặc không biết size. Stream lazy, memory constant (~KB). Dùng cho log, CSV lớn, data file.

Rule thực tế:

  • Biết file nhỏ (hardcoded asset, config kiểm soát) → readString tiện.
  • File từ user upload, từ API không kiểm soát size → stream-based để tránh DoS qua file lớn.
  • Log, audit, data pipeline → stream-based mặc định.

Anti-pattern: Files.readAllBytes trên file log 10GB → OOM ngay.

Q3
Khác biệt giữa Files.copy(src, dst) không option và có REPLACE_EXISTING?
  • Không option: nếu dst tồn tại → throw FileAlreadyExistsException. Safe default — không overwrite ngẫu nhiên.
  • REPLACE_EXISTING: overwrite dst. Cần khi biết chắc muốn replace (vd cập nhật cache, sync file).

Default "fail khi conflict" là design đúng của NIO.2 — khác với Unix cp command line mặc định overwrite không hỏi. Buộc dev suy nghĩ rõ intent.

Nếu dùng API copy legacy (hay FileUtils của Commons IO) thì semantic khác nhau giữa library — dễ bug. NIO.2 nhất quán: option explicit.

Thêm option COPY_ATTRIBUTES nếu cần giữ timestamp, owner, permission. Không default vì performance — copy attribute tốn syscall thêm.

Q4
Vì sao Path.of immutable là thiết kế đúng?

3 lý do kỹ thuật:

  1. Thread safety: nhiều thread share cùng Path object không cần lock. Không có race condition vì không ai mutate được.
  2. Cacheable key: dùng Path làm key trong HashMap, ConcurrentHashMap an toàn — hash code không đổi. File mutable sẽ bug nếu modify khi đang làm key.
  3. Method chaining rõ: base.resolve("x").normalize().toAbsolutePath() — mỗi bước trả path mới, không side-effect. Reader hiểu ngay.

Tương tự String, Instant, LocalDate — API modern Java ưu tiên immutable.

Trade-off: mỗi op tạo object mới → tốn allocation. Với Path ngắn (< 10 segment), overhead không đáng kể. GC modern (G1, ZGC) xử lý short-lived object hiệu quả.

Nếu mutable: path.setName("x") sẽ ảnh hưởng code khác giữ reference cùng path — bug khó debug, không type-safe.

Q5
Vì sao NIO.2 chọn throw exception cụ thể thay vì trả boolean như File.delete()?

File.delete() trả false gộp chung mọi nguyên nhân fail: file không tồn tại, không có permission, directory chưa rỗng, file đang bị process khác giữ. Caller không phân biệt được → hoặc bỏ qua silent (bug âm thầm), hoặc đoán mò để retry.

NIO.2 tách từng nguyên nhân thành exception riêng: NoSuchFileException, AccessDeniedException, DirectoryNotEmptyException, FileSystemException. Caller xử lý đúng từng case:

  • Không tồn tại → có thể coi là thành công (idempotent delete) — hoặc dùng thẳng deleteIfExists.
  • Permission → log + báo lỗi cấu hình, không retry vô ích.
  • Directory chưa rỗng → xoá content trước rồi retry.

Đây cũng là lý do checked IOException trong chữ ký method: I/O fail là chuyện bình thường (disk đầy, file bị xoá bởi process khác) — API buộc caller đối diện với nó thay vì giả vờ mọi thứ luôn thành công.

Bài tiếp theo: Duyệt thư mục và theo dõi file — walk, atomic swap, WatchService

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