Command Palette

Search for a command to run...

[Advanced Java] Java NIO: Path, Files và Channel

Phần lớn code Java chạm tới file qua ba bốn method của Files rồi thôi. Thường thì như vậy là đúng. Nhưng ngay khi chương trình phải duyệt một cây thư mục, kiểm tra một tên file do người dùng gửi lên, đọc owner của một file, phản ứng với thay đổi trên disk, hoặc chuyển hàng gigabyte mà không trả giá bằng heap, thì ba method đó hết bài — và cái nằm bên dưới chúng là một API lớn hơn và được đặc tả chặt hơn nhiều so với những gì đa số tutorial nói.

Bài viết này đi sâu vào java.nio. Nó nói về algebra của Path — thao tác nào chạm disk, thao tác nào chỉ là xử lý chuỗi thuần túy, và thao tác nào âm thầm vứt bỏ thư mục gốc của bạn. Nó nói về năm cách khác nhau mà Files duyệt một cây thư mục và khi nào nên dùng cách nào. Nó nói về attribute, symlink và WatchService. Và nó nói về tầng mà lần đầu ai cũng thấy khó chịu: FileChannel, ByteBuffer, cùng mô hình position/limit/capacity mà mọi thứ còn lại trong NIO đều dựng lên trên đó.

Path, Files và Channel: ba tầng của java.nio

Mọi thứ bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6 (arm64) trên macOS, trong một thư mục tạm. Mọi khối output và mọi thông báo exception đều là bản chụp thật. Chỗ nào hành vi phụ thuộc hệ điều hành — POSIX attribute, symlink, WatchService — đều được ghi rõ, kèm theo máy này thật sự đã làm gì.

NIO thêm gì so với phần file cơ bản

Đọc và ghi một file text là bài toán đã xong: Files.readString, Files.writeString, Files.readAllLinesFiles.lines là đủ, và với một file config bình thường mà với tay xuống tầng thấp hơn là sai. Phần còn lại của package mới là thứ bạn cần ngay khi công việc không còn là "đọc đúng một file này".

java.nio thật ra là ba tầng chồng lên nhau, và tách chúng ra thì mọi thứ dễ hiểu hơn nhiều:

TầngTypeTrả lời câu hỏi
Định danhPathNó ở đâu? Parent, tên, root của nó là gì? Path này có nằm trong thư mục kia không?
Thao tác filesystemFilesCó tồn tại không, to bao nhiêu, ai sở hữu, trong thư mục này có gì, copy, move, delete, watch
ByteFileChannel, ByteBufferChuyển đúng những byte này tới đúng những offset này

Nguyên tắc xuyên suốt cả bài: Path không bao giờ chạm disk trừ hai method có ghi rõ điều đó trong hợp đồng của mình, Files thì luôn chạm, còn channel dành cho lúc bạn quan tâm tới offset của từng byte. java.nio.channels còn có SelectorSocketChannel cho non-blocking network I/O; đó là chủ đề khác và bài này chỉ nói về file.

Path algebra: những thao tác hay bị nhầm

Một Path không phải là string và cũng không phải là file. Nó là một dãy name element, có thể có root đứng trước, mà FileSystem mặc định biết cách diễn giải. Tách một path ra thì mọi accessor trở nên hiển nhiên:

Java
Path p = Path.of("/var/app/conf/server/app.properties");
 
System.out.println(p.getFileName());     // app.properties
System.out.println(p.getParent());       // /var/app/conf/server
System.out.println(p.getRoot());         // /
System.out.println(p.getNameCount());    // 5
System.out.println(p.getName(0));        // var
System.out.println(p.subpath(1, 3));     // app/conf
System.out.println(p.startsWith("/var/app"));                  // true
System.out.println(p.endsWith("server/app.properties"));       // true
Text
path                               /var/app/conf/server/app.properties
getFileName()                      app.properties
getParent()                        /var/app/conf/server
getRoot()                          /
getNameCount()                     5
getName(0)                         var
getName(4)                         app.properties
subpath(1, 3)                      app/conf
startsWith("/var/app")             true
startsWith("/var/ap")              false
endsWith("server/app.properties")  true

Mọi accessor của Path đặt lên cùng một path ví dụ, kèm cái bẫy của resolve và ranh giới với disk

Có ba chi tiết đáng dừng lại. getRoot() không nằm trong getNameCount() — số đếm là 5 cho năm name element, và index 0 là var chứ không phải /. subpath(1, 3) dùng đúng quy ước nửa mở như String.substring, nên nó trả về element 1 và 2, và không bao giờ mang theo root — đó là lý do app/conf quay về dạng relative. Và startsWith("/var/ap")false: Path.startsWith so sánh trọn từng name element chứ không so từng ký tự. Riêng chi tiết đó là khác biệt giữa một security check chạy đúng và một cái hỏng, như phần sau sẽ cho thấy.

Thêm hai trường hợp biên hay làm người ta bất ngờ:

Text
Path.of("")                      []
Path.of("").getNameCount()       1
Path.of("file.txt").getParent()  null
Path.of("/").getParent()         null
Path.of("/").getFileName()       null
Path.of("a/b").getRoot()         null
Path.of("a","b","c")             a/b/c
Path.of("a//b///c")              a/b/c
Path.of("a/b/").getNameCount()   2
relativize to itself             []

getParent() trả về null chứ không phải Path.of("") khi path chỉ có một element, và đó là nguồn gốc của vô số NullPointerException trong code viết Files.createDirectories(path.getParent()) mà không kiểm tra. Empty path là một path thật với đúng một name element rỗng, và nó chính là thứ relativize trả về khi hai path bằng nhau.

resolve và cái argument absolute nuốt mất base của bạn

resolve là phép nối. Cho một base và một argument relative, nó dán hai thứ lại đúng như bạn nghĩ:

Java
Path.of("/var/app").resolve("logs/app.log")   // /var/app/logs/app.log
Path.of("/var/app").resolve("")               // /var/app
p.resolveSibling("app.properties.bak")        // /var/app/conf/server/app.properties.bak

resolveSibling chính là getParent().resolve(...), và nó là lựa chọn đúng cho tình huống "cùng thư mục, khác tên file" — một file backup, một file tạm mà bạn sắp move atomic vào đúng chỗ.

Giờ tới phần không trực giác chút nào. Nếu argument là absolute, resolve trả về chính argument đó và vứt bỏ hoàn toàn base. Không exception, không cảnh báo:

Text
photo.png            naive -> /srv/uploads/photo.png
a/../b.png           naive -> /srv/uploads/a/../b.png
../../etc/passwd     naive -> /srv/uploads/../../etc/passwd
/etc/passwd          naive -> /etc/passwd

Dòng cuối cùng là một lỗ hổng path traversal gọn trong bốn ký tự. Một handler viết UPLOAD_ROOT.resolve(request.getFilename()) rồi mở kết quả sẽ vui vẻ đọc /etc/passwd nếu client gửi lên một tên file absolute, và vui vẻ leo ngược lên nếu client gửi ../... Đây không phải giả định: nó là bug xử lý file phổ biến nhất trong code web.

Cách sửa gọn trong hai dòng, và cần cả normalize lẫn startsWith:

Java
static final Path ROOT = Path.of("/srv/uploads");
 
static Path safe(String userInput) {
    Path candidate = ROOT.resolve(userInput).normalize();
    if (!candidate.startsWith(ROOT)) {
        throw new IllegalArgumentException("outside the upload root: " + candidate);
    }
    return candidate;
}
Text
photo.png            safe  -> /srv/uploads/photo.png
a/../b.png           safe  -> /srv/uploads/b.png
../../etc/passwd     safe  -> java.lang.IllegalArgumentException: outside the upload root: /etc/passwd
/etc/passwd          safe  -> java.lang.IllegalArgumentException: outside the upload root: /etc/passwd

normalize() trước, để các đoạn .. bị triệt tiêu trước khi kiểm tra; startsWith sau, gọi trên Path chứ tuyệt đối không phải trên String. Khác biệt là thật:

Text
/srv/uploads2/x startsWith /srv/uploads = false
"/srv/uploads2/x".startsWith("/srv/uploads") = true

So sánh chuỗi chấp nhận /srv/uploads2, một thư mục anh em chẳng liên quan gì tới upload root của bạn. Path.startsWith so từng element và loại nó ra. Nếu file có thể là symlink, siết thêm bằng cách so sánh giá trị toRealPath() — đó là phần ngay sau đây.

normalize là string algebra; toRealPath thì đi hỏi disk

normalize() loại bỏ các element ... thừa. Nó làm việc này hoàn toàn trong bộ nhớ. Nó không stat thứ gì, không đi theo symlink, và không quan tâm phần nào của path có tồn tại hay không:

Text
normalize a/../b/./c               b/c
normalize /var/app/../log          /var/log
normalize ../../x                  ../../x
normalize /../../x                 /x

Chú ý hai dòng cuối. Một .. đứng đầu trên path relative thì không thể giải được nếu chưa biết bạn đang ở đâu, nên normalize để nguyên. Một .. đứng đầu trên path absolute thì bị bỏ đi, vì root không có parent.

toAbsolutePath() cũng thuần túy: nó ghép thư mục làm việc của process vào trước, và chỉ vậy thôi. Nó đặc biệt không normalize:

Text
messy                          realdemo/./data/../data/inner/file.txt
normalize()                    realdemo/data/inner/file.txt
toAbsolutePath()               /private/tmp/claude-501/nio24/realdemo/./data/../data/inner/file.txt
toRealPath()                   /private/tmp/claude-501/nio24/realdemo/data/inner/file.txt

toRealPath() là method duy nhất ở đây có I/O. Nó biến path thành absolute, normalize, giải mọi symlink trên đường đi, và — vì phải nhìn vào filesystem để làm được điều đó — throw nếu bất kỳ thành phần nào không tồn tại:

Text
normalize() on ghost           realdemo/data/inner/file.txt
toRealPath() on ghost          java.nio.file.NoSuchFileException: realdemo/nope/../data/inner/file.txt
toRealPath() missing           java.nio.file.NoSuchFileException: realdemo/absent.txt

Đọc hai dòng đó cùng nhau. Path realdemo/nope/../data/inner/file.txt chứa thư mục nope không tồn tại. normalize() triệt tiêu nó với .. và trả về một path hoàn toàn dùng được. toRealPath() đi trên filesystem thật, không thấy cái gì tên nope, và throw. Cả hai câu trả lời đều đúng với những gì method đó hứa; chúng chỉ trả lời hai câu hỏi khác nhau.

Khi trong path có symlink thì khác biệt trở thành tuyệt đối. Ở đây realdemo/shortcut là symlink trỏ tới data/inner:

Text
via symlink                    realdemo/shortcut/file.txt
normalize()                    realdemo/shortcut/file.txt
toRealPath()                   /private/tmp/claude-501/nio24/realdemo/data/inner/file.txt
toRealPath NOFOLLOW            /private/tmp/claude-501/nio24/realdemo/shortcut/file.txt

normalize() hoàn toàn không thấy symlink. toRealPath() giải nó ra vị trí thật. toRealPath(LinkOption.NOFOLLOW_LINKS) biến path thành absolute và normalize nhưng giữ nguyên symlink.

⚠️ Path.equals là so sánh cú pháp. Path.of("/var/app/./conf").equals(Path.of("/var/app/conf"))false, và chỉ thành true sau khi normalize(). Hai path chỉ tới cùng một file thì chưa chắc bằng nhau; câu hỏi đó là Files.isSameFile(a, b), có I/O và so sánh file thật, và nó trả về true cho một path relative so với chính toAbsolutePath() của nó trong lần chạy trên.

relativize cần hai path cùng loại

relativize là phép ngược của resolve: cho ab, nó tạo ra path relative đưa bạn từ a tới b, sao cho a.resolve(a.relativize(b)) bằng b.

Java
Path base = Path.of("/var/app");
base.relativize(Path.of("/var/app/conf/server"));   // conf/server
base.relativize(Path.of("/var/log"));               // ../log
base.relativize(Path.of("/etc"));                   // ../../etc

Nó sẵn sàng sinh ra các đoạn .. để leo ra khỏi base, điều đáng nhớ nếu bạn dùng kết quả cho việc gì liên quan tới bảo mật.

Ràng buộc là hai path phải cùng loại — cùng absolute hoặc cùng relative. Trộn lẫn thì bạn nhận IllegalArgumentException, không phải IOException:

Text
relativize mixed   java.lang.IllegalArgumentException: 'other' is different type of Path

Thông báo đó nguyên văn từ OpenJDK 21, sai chính tả y như vậy. Nếu một trong hai vế của relativize có thể đến từ input người dùng hay từ config, hãy gọi toAbsolutePath() cho cả hai trước.

Duyệt một cây thư mục

Files cho bạn năm cách liệt kê một thư mục, khác nhau ở độ sâu, ở việc bao gồm những gì, ở cách lọc, và ở chỗ có trả về một resource bạn phải close hay không. Cây thư mục dùng bên dưới:

Tree
tree
├── pom.xml
├── src
│   └── App.java
└── target
    └── App.class

Cùng một cây nhìn qua Files.list, walk, newDirectoryStream và find, kèm thứ tự callback của walkFileTree

list, walk, find và newDirectoryStream

Java
try (Stream<Path> s = Files.list(root))                 { s.forEach(System.out::println); }
try (Stream<Path> s = Files.walk(root))                 { s.forEach(System.out::println); }
try (Stream<Path> s = Files.walk(root, 1))              { s.forEach(System.out::println); }
try (Stream<Path> s = Files.find(root, Integer.MAX_VALUE,
                                 (p, a) -> a.isRegularFile())) { s.forEach(System.out::println); }
try (DirectoryStream<Path> ds = Files.newDirectoryStream(root, "*.xml")) {
    for (Path p : ds) System.out.println(p);
}
Text
Files.list(tree)
  tree/target
  tree/pom.xml
  tree/src
Files.walk(tree)
  tree
  tree/target
  tree/target/App.class
  tree/pom.xml
  tree/src
  tree/src/App.java
Files.walk(tree, 1)
  tree
  tree/target
  tree/pom.xml
  tree/src
Files.find(tree, MAX_VALUE, (p, a) -> a.isRegularFile())
  tree/target/App.class
  tree/pom.xml
  tree/src/App.java
Files.newDirectoryStream(tree, "*.xml")
  tree/pom.xml

Những khác biệt thật sự quan trọng:

Lời gọiĐộ sâuCó kể thư mục gốcLọcTrả về
Files.list(dir)một cấpkhôngkhôngStream<Path>
Files.walk(dir)không giới hạnkhôngStream<Path>
Files.walk(dir, maxDepth)chặn theo cấpkhôngStream<Path>
Files.find(dir, d, matcher)chặn theo cấpmột BiPredicate trên path và attributeStream<Path>
Files.newDirectoryStream(dir, glob)một cấpkhôngmột glob, do filesystem áp dụngDirectoryStream<Path>
Files.walkFileTree(dir, visitor)không giới hạncallback của bạnkhông gì cả, đây là push API kiểu void

Bốn ghi chú cho bảng trên. Không có cái nào được sắp xếp — thứ tự là thứ filesystem trả về, và đó là lý do target đứng trước pom.xml ở trên. Cần thứ tự thì tự sort. walk trả về cả directory lẫn file, kể cả thư mục gốc, nên một đoạn "đếm số file" mà quên filter(Files::isRegularFile) sẽ đếm sai. find là cái đã có sẵn attribute: BiPredicate nhận một BasicFileAttributes mà quá trình duyệt dù sao cũng phải đọc, nên lọc theo size hay thời điểm sửa đổi không tốn thêm gì, trong khi walk(...).filter(p -> Files.size(p) > n) stat lại từng file lần nữa và bắt bạn xử lý checked exception ngay trong lambda. Và newDirectoryStream là rẻ nhất khi bạn chỉ cần một cấp với một glob, vì pattern được áp dụng ngay lúc đọc thư mục.

Cú pháp glob không phải regex. * khớp trong phạm vi một name element, ** vượt qua ranh giới element, ? là một ký tự, còn {a,b} là phép chọn:

Java
PathMatcher m = FileSystems.getDefault().getPathMatcher("glob:**/*.java");
try (Stream<Path> s = Files.walk(root)) {
    s.filter(m::matches).forEach(System.out::println);
}

Thêm một tính chất của DirectoryStream: nó là Iterable, nhưng chỉ đúng một lần.

Text
iterator twice:      java.lang.IllegalStateException: Iterator already obtained

walkFileTree, khi bạn cần cắt nhánh

Các method trả về Stream không cắt được subtree. Một khi Files.walk đã chui vào node_modules hay .git, thứ duy nhất bạn làm được là lọc kết quả — thư mục đã bị đọc mất rồi. walkFileTree là phiên bản cho phép bạn nói không, qua bốn callback mà SimpleFileVisitor đều cài sẵn với mặc định là "đi tiếp":

Java
Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
    @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes a) {
        if (dir.getFileName().toString().equals("target")) {
            return FileVisitResult.SKIP_SUBTREE;
        }
        System.out.println("preVisitDirectory  " + dir);
        return FileVisitResult.CONTINUE;
    }
    @Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
        System.out.println("visitFile          " + f + "  (" + a.size() + " bytes)");
        return FileVisitResult.CONTINUE;
    }
    @Override public FileVisitResult postVisitDirectory(Path dir, IOException e) {
        System.out.println("postVisitDirectory " + dir);
        return FileVisitResult.CONTINUE;
    }
});
Text
preVisitDirectory  tree
preVisitDirectory  tree/target  -> SKIP_SUBTREE
visitFile          tree/pom.xml  (11 bytes)
preVisitDirectory  tree/src
visitFile          tree/src/App.java  (13 bytes)
postVisitDirectory tree/src
postVisitDirectory tree

target được vào, bị cắt, và không bao giờ được rời khỏi — không có postVisitDirectory cho một subtree đã skip, và App.class không bao giờ được visit. Bốn giá trị trả về là CONTINUE, SKIP_SUBTREE (chỉ có nghĩa khi trả từ preVisitDirectory), SKIP_SIBLINGS (xong entry này, bỏ phần còn lại của thư mục hiện tại) và TERMINATE (dừng toàn bộ). Callback thứ tư, visitFileFailed, nhận IOException cho những entry không đọc được — một thư mục bị từ chối quyền nằm giữa đường duyệt — và SimpleFileVisitor mặc định ném lại nó, thường không phải điều một công cụ backup mong muốn.

Việc postVisitDirectory chạy sau các con là lý do walkFileTree là cách tự nhiên để xóa một cây, vì một thư mục chỉ xóa được khi đã rỗng.

Mặc định thì cả walk lẫn walkFileTree đều không đi theo symlink. Bảo chúng đi theo, thì một symlink trỏ về chính tổ tiên của nó sẽ bị phát hiện chứ không lặp vô tận:

Text
no-follow: loop
no-follow: loop/a
no-follow: loop/a/back
follow: loop
follow: loop/a
follow: java.io.UncheckedIOException: java.nio.file.FileSystemLoopException: loop/a/back

Chú ý lớp bọc: bên trong một Stream, checked exception FileSystemLoopException tới tay bạn dưới dạng UncheckedIOException.

Các method trả về Stream đang giữ handle mở

list, walkfind trả về một Stream được chống lưng bởi một directory handle đang mở. Interface Stream extends AutoCloseable chính là vì trường hợp này, và ba method đó là lý do. Không close một cái là rò một file descriptor, mà file descriptor là tài nguyên theo process và nó cạn:

Java
for (int i = 0; i < 100_000; i++) {
    Stream<Path> s = Files.list(dir);   // never closed
    s.findFirst();
}
Text
leaked streams opened: 61436
then: java.nio.file.FileSystemException: .: Too many open files

61.436 handle bị rò và process không mở thêm được thứ gì nữa — không file, không socket. Trên máy này sysctl kern.maxfilesperproc báo 61440, nên JVM cạn descriptor chỉ cách trần cứng của process đúng bốn cái, và lần chạy thứ hai hỏng ở đúng con số đó. Không có gì cứu được: directory stream không có finalizer, nên garbage collection cũng không dọn giúp.

Cách sửa vẫn như mọi resource khác, và đó là lý do mọi ví dụ ở trên đều nằm trong header của try:

Java
try (Stream<Path> s = Files.walk(root)) {
    return s.filter(Files::isRegularFile).count();
}

Compiler sẽ không cảnh báo bạn. Files.walk(dir).forEach(...) compile sạch sẽ và rò mỗi lần gọi.

Copy, move và delete

Files.copyFiles.move mặc định từ chối ghi đè:

Text
copy onto existing: java.nio.file.FileAlreadyExistsException: cm/b.txt

StandardCopyOption đổi điều đó, và chỉ có ba option đáng nhớ:

OptionTác dụng
REPLACE_EXISTINGghi đè lên target thay vì throw
COPY_ATTRIBUTESmang theo thời điểm sửa đổi cuối và, nơi nào hỗ trợ, phần còn lại của basic attribute
ATOMIC_MOVEchỉ cho move: hoặc đổi tên trọn vẹn hoặc không có gì xảy ra; throw AtomicMoveNotSupportedException khi vượt filesystem

ATOMIC_MOVE là cái đáng đồng tiền nhất. Ghi ra một file tạm trong cùng thư mục rồi move atomic vào đúng chỗ là cách cập nhật một file mà không reader nào thấy được bản viết dở — và "cùng thư mục" là điều kiện, vì một rename atomic không vượt được ranh giới filesystem.

Files.copy trên một directory không copy nội dung bên trong. Nó tạo một thư mục rỗng cùng tên rồi dừng:

Text
copy of a directory -> children copied? false

JDK không có copy đệ quy. Bạn phải tự viết bằng một vòng duyệt.

Tạo thư mục cũng có hình dạng tương tự:

Text
createDirectories twice: cm/box/inner
createDirectory existing: java.nio.file.FileAlreadyExistsException: cm/box
createDirectory no parent: java.nio.file.NoSuchFileException: cm/x/y

createDirectories (số nhiều) tạo luôn các parent còn thiếu và trả về path mà không phàn nàn nếu mọi thứ đã có sẵn — gần như lúc nào bạn cũng muốn cái này. createDirectory (số ít) đòi parent phải tồn tại và throw nếu thư mục đã có.

Xóa thì có cái bẫy dính ai cũng dính. delete throw khi file không tồn tại, deleteIfExists trả về boolean thay cho throw — nhưng không cái nào xóa được một thư mục còn nội dung:

Text
delete non-empty dir: java.nio.file.DirectoryNotEmptyException: cm/box
deleteIfExists non-empty: java.nio.file.DirectoryNotEmptyException: cm/box
deleteIfExists absent: false
delete absent: java.nio.file.NoSuchFileException: cm/ghost.txt

deleteIfExists nuốt trường hợp "không có ở đó"; nó không nuốt trường hợp "chưa rỗng". Muốn xóa cả cây thì phải duyệt từng entry và xóa con trước cha. Bản đúng ngắn nhất là một walk được tiêu thụ theo thứ tự ngược — vì phép duyệt là depth-first với cha đứng trước, đảo ngược lại sẽ đặt mọi con trước cha của nó:

Java
static void deleteRecursively(Path dir) throws IOException {
    try (Stream<Path> s = Files.walk(dir)) {
        for (Path p : s.sorted(Comparator.reverseOrder()).toList()) {
            Files.delete(p);
        }
    }
}
Text
deleting the whole tree with a reverse-ordered walk:
cm exists after: false

Bản walkFileTree làm đúng việc đó với visitFile xóa file và postVisitDirectory xóa thư mục vừa duyệt xong; nó dài hơn nhưng chạy theo kiểu stream thay vì dựng cả danh sách trong bộ nhớ, điều có ý nghĩa với một cây lớn.

Files.mismatch (Java 12) là một tiện ích nhỏ tiết kiệm rất nhiều code so sánh viết tay: nó trả về index của byte khác nhau đầu tiên, hoặc -1 khi hai file giống hệt.

Text
Files.mismatch = 6
Files.mismatch identical = -1

File attribute và những gì phụ thuộc nền tảng

Files.size, Files.getLastModifiedTime, Files.isRegularFile, Files.isDirectory, Files.isReadable và họ hàng — mỗi cái tự đi xuống filesystem một chuyến. Khi cần nhiều hơn một, hãy đọc chúng cùng lúc:

Java
BasicFileAttributes b = Files.readAttributes(f, BasicFileAttributes.class);
Text
size                     14
creationTime             2026-09-10T02:28:06Z
lastModifiedTime         2026-09-10T02:28:06.791635375Z
lastAccessTime           2026-09-10T02:28:06.790053706Z
isRegularFile            true
isSymbolicLink           false
fileKey                  (dev=1000012,ino=50907875)

Một lời gọi readAttributes thay cho sáu lời gọi Files.isX, và nó cũng là cách duy nhất để có một ảnh chụp nhất quán: sáu lời gọi riêng lẻ có thể nhìn thấy sáu trạng thái khác nhau của một file đang bị process khác sửa.

fileKey() là cặp device và inode trên filesystem Unix, tức là định danh thật của một file — hai path có cùng fileKey là cùng một file, kể cả qua hard link. Nó được phép trả về null trên filesystem không có khái niệm đó.

BasicFileAttributes là tập con khả chuyển. Hỏi PosixFileAttributes thì bạn có owner, group và permission, trên mọi nền tảng hỗ trợ POSIX view:

Text
owner                    hoangth
group                    wheel
permissions              rw-r--r--
permissions set          [OTHERS_READ, GROUP_READ, OWNER_READ, OWNER_WRITE]
supportedFileAttributeViews [owner, basic, posix, user, unix]

Dòng cuối là phép kiểm tra tính khả chuyển: FileSystems.getDefault().supportedFileAttributeViews() cho biết nền tảng đang chạy trả lời được những gì. Trên máy macOS này là [owner, basic, posix, user, unix]. Windows đưa ra acldos thay cho posix, nên ở đó Files.readAttributes(p, PosixFileAttributes.class) sẽ throw UnsupportedOperationException. Hãy phòng trước, hoặc ở lại với BasicFileAttributes.

Còn có dạng chuỗi, là cách đọc attribute mà không cần nêu tên class, và cũng là cách chạm tới các field riêng của từng view:

Java
Map<String, Object> m = Files.readAttributes(f, "posix:*");
Text
posix:* keys  [creationTime, fileKey, group, isDirectory, isOther, isRegularFile,
               isSymbolicLink, lastAccessTime, lastModifiedTime, owner, permissions, size]

Attribute cũng ghi được:

Text
after setLastModified    2020-09-13T12:26:40Z
after chmod              r--r-----
isWritable               false

Gần như mọi method của Files đều đi theo symlink mặc định, và nhận LinkOption.NOFOLLOW_LINKS để dừng lại. Khác biệt không hề hình thức:

Text
link follow: size        14
link follow: isSymlink   false
link NOFOLLOW: size      10
link NOFOLLOW: isSymlink true
same fileKey?            false

Đi theo symlink thì báo về target: 14 byte, không phải link. Không đi theo thì báo về chính symlink: 10 byte, đúng bằng độ dài của path target được lưu là report.txt, và isSymbolicLinktrue. Hai file khác nhau, fileKey khác nhau.

Một symlink gãy làm sự bất đối xứng lộ ra ngay ở exists:

Text
exists(broken)            false
exists(broken, NOFOLLOW)  true
notExists(broken)         true

Files.exists trả về false cho một symlink trỏ vào hư không, vì nó đi theo link và không thấy gì. Đây cũng là lý do Files.notExists không phải là !Files.exists: cả hai có thể cùng false khi câu trả lời thật sự là không biết. Bỏ execute bit của một thư mục rồi hỏi về một file thật sự nằm trong đó cho ra đúng điều này: exists trả về falsenotExists cũng trả về false.

Symlink phụ thuộc nền tảng theo cách còn khó chịu hơn attribute: trên Windows, Files.createSymbolicLink đòi Developer Mode hoặc quyền nâng cao, còn không thì throw. Hành vi ở trên là của một filesystem họ Unix; lần chạy này là trên APFS.

Theo dõi một thư mục bằng WatchService

WatchService cho phép bạn đăng ký một thư mục và nhận event khi entry bên trong được tạo, sửa hoặc xóa. API cố tình nhỏ: register, lấy key, rút hết event, reset key.

Cái bẫy là take(), vì nó block vô hạn. Tutorial nào cũng viết while (true) { WatchKey key = ws.take(); ... }, và chương trình kiểu đó treo cả bộ test và không tắt được. Hãy dùng poll có timeout và đặt deadline cho vòng lặp:

Java
Path dir = Path.of("watched");
Files.createDirectories(dir);
 
try (WatchService ws = FileSystems.getDefault().newWatchService()) {
    System.out.println("implementation = " + ws.getClass().getName());
    dir.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
 
    long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(12);
    while (System.nanoTime() < deadline) {
        WatchKey key = ws.poll(1, TimeUnit.SECONDS);
        if (key == null) {
            System.out.println("  poll -> null (nothing this second)");
            continue;
        }
        for (WatchEvent<?> ev : key.pollEvents()) {
            System.out.printf("  %-12s count=%d  %s%n",
                    ev.kind().name(), ev.count(), dir.resolve((Path) ev.context()));
        }
        if (!key.reset()) {
            System.out.println("  key invalid");
            break;
        }
    }
}

Bốn thứ rất dễ làm sai. ev.context() là một Path tương đối so với thư mục đang watch, không phải path absolute — bạn phải tự resolve nó theo thư mục đó. key.reset() là bắt buộc: thiếu nó thì key nằm mãi ở trạng thái đã báo hiệu và bạn không nhận thêm event nào nữa, còn trả về false nghĩa là thư mục đã biến mất. Việc đăng ký không đệ quy — watch cả cây nghĩa là đăng ký từng thư mục và đăng ký thêm khi có thư mục mới. Và bản thân service là một resource, nên nó thuộc về try-with-resources.

Giờ tới phần thành thật. Chạy nó trên macOS và in ra tên class cài đặt:

Text
implementation = sun.nio.fs.PollingWatchService

sun.nio.fs.PollingWatchService làm đúng như tên gọi: nó quét lại thư mục đã đăng ký theo chu kỳ và so timestamp sửa đổi cuối với lần quét trước. Trong OpenJDK 21 chu kỳ là một hằng số cứng, private static final int POLLING_INTERVAL = 2; giây, nằm trong chính class đó. Không có thông báo nào từ kernel ở đây cả. Trên Linux cài đặt mặc định là sun.nio.fs.LinuxWatchService dựa trên inotify, còn trên Windows thì dựa trên ReadDirectoryChangesW; bản polling là phương án dự phòng cho nền tảng không có hỗ trợ đó, và macOS rơi vào nhóm này. Chính JDK cũng nói vậy: BsdFileSystem.newWatchService có kèm comment // use polling implementation until we implement a BSD/kqueue one.

Hệ quả không chỉ là độ trễ. Hai thay đổi trong cùng một cửa sổ quét không cho ra hai event — trình quét chỉ thấy được trạng thái trước và sau, nên mọi thay đổi trung gian đều mất. Tạo một file, append vào nó, rồi xóa ngay sau đó cho ra kết quả sau trên máy này, hai lần liên tiếp như nhau:

Text
implementation = sun.nio.fs.PollingWatchService
  poll -> null (nothing this second)
  poll -> null (nothing this second)
  poll -> null (nothing this second)
  ENTRY_CREATE count=1  watched3/note.txt
  poll -> null (nothing this second)
  poll -> null (nothing this second)
  poll -> null (nothing this second)
  ENTRY_DELETE count=1  watched3/note.txt
  poll -> null (nothing this second)
  poll -> null (nothing this second)
  poll -> null (nothing this second)
  poll -> null (nothing this second)
watch loop finished on its own

ENTRY_MODIFY không bao giờ tới. Lần append và lần xóa đều rơi vào giữa hai lần quét, và dưới góc nhìn của trình quét thì file chỉ đơn giản là biến mất. Giãn đúng ba thao tác đó ra đủ xa để mỗi cái rơi vào một cửa sổ quét riêng thì cả ba event đều tới theo đúng thứ tự. Hãy tự tái hiện trước khi dựa vào bất cứ điều gì ở đây: riêng ENTRY_MODIFY là loại kém tin cậy nhất trong ba loại, vì trên nền tảng khác một lần save của editor có thể sinh ra vài cái, hoặc không cái nào nếu editor ghi ra file mới rồi rename đè lên file cũ.

Còn hai thứ nữa nên tính trước. OVERFLOW là một event kind có thật, tới khi cài đặt không theo kịp và đã bỏ rơi event; bạn phải xử lý bằng cách tự quét lại thư mục, và bạn nhận được nó dù có đăng ký hay không. Và WatchService là API báo thay đổi chứ không phải API mô tả trạng thái — mẫu đáng tin là coi mọi event là "có gì đó đổi rồi, đi mà xem", đừng bao giờ coi nó là mô tả nội dung hiện tại của file.

Channel và buffer

Bên dưới Files.readAllBytes là một FileChannel, và bên dưới mọi thao tác channel là một ByteBuffer. Đây là tầng bạn kiểm soát chính xác từng byte và từng offset, và cũng là phần của NIO mà ai cũng thấy rối — không phải vì nó phức tạp, mà vì một buffer có ba con trỏ mà mọi thứ khác trong API đều tự động dịch chuyển giúp bạn.

position, limit và capacity

Một ByteBuffer là một mảng byte kích thước cố định cộng ba số nguyên, cùng một bất biến không bao giờ vỡ:

Text
0 <= mark <= position <= limit <= capacity
  • capacity — buffer chứa được bao nhiêu byte. Đặt lúc allocate, không bao giờ đổi.
  • limit — index đầu tiên bạn không được phép chạm. Chỗ dữ liệu hữu ích kết thúc.
  • position — index của byte kế tiếp mà một get hoặc put tương đối sẽ dùng.
  • mark — một position được nhớ lại, đặt bằng mark() và khôi phục bằng reset().

Mọi thao tác bạn từng gọi trên buffer đều là một cách cụ thể để dịch positionlimit. In cả ba sau mỗi lời gọi thì mô hình hết bí ẩn:

Java
ByteBuffer b = ByteBuffer.allocate(16);
show("allocate(16)", b);
b.put("HELLO".getBytes(StandardCharsets.US_ASCII));
show("put(5 bytes)", b);
b.flip();
show("flip()", b);
byte[] two = new byte[2];
b.get(two);
show("get(2 bytes)", b);
Text
allocate(16)               pos=0   lim=16  cap=16  rem=16
put(5 bytes)               pos=5   lim=16  cap=16  rem=11
flip()                     pos=0   lim=5   cap=16  rem=5
get(2 bytes)               pos=2   lim=5   cap=16  rem=3
  read so far: HE
mark()                     pos=2   lim=5   cap=16  rem=3
get(), get()               pos=4   lim=5   cap=16  rem=1
reset()                    pos=2   lim=5   cap=16  rem=3
rewind()                   pos=0   lim=5   cap=16  rem=5
position(2)                pos=2   lim=5   cap=16  rem=3
compact()                  pos=3   lim=16  cap=16  rem=13
  bytes 0..2 now: LLO
clear()                    pos=0   lim=16  cap=16  rem=16
  clear() erased nothing: byte 0 is still L

Một buffer 16 byte đi qua allocate, put, flip, get và compact, có đánh dấu position và limit

Bảng đó chính là toàn bộ mô hình. Đọc nó như một câu chuyện: buffer bắt đầu rỗng và ở chế độ ghi với position bằng 0 và limit bằng capacity; năm byte đi vào và position chạy theo tới 5; flip() đặt limit bằng position hiện tại rồi đưa position về 0, tức là biến "tôi vừa ghi 5 byte" thành "có 5 byte để đọc"; mỗi get đẩy position lên; compact() dồn số byte chưa đọc về đầu và đưa buffer trở lại chế độ ghi với position nằm ngay sau chúng.

remaining() chỉ là limit - position, và hasRemaining() là điều kiện vòng lặp bạn muốn.

flip, clear, compact, rewind, mark và reset

Lời gọipositionlimitDữ liệuDùng khi
flip()0position cũgiữ nguyênghi xong và muốn rút ra
clear()0capacitygiữ nguyênrút xong và muốn ghi lại từ đầu
compact()số byte chưa đọccapacitybyte chưa đọc dồn về đầumới rút một phần và muốn ghi thêm vào phần còn lại
rewind()0không đổigiữ nguyênmuốn đọc lại đúng vùng đó
mark() / reset()ghi nhớ / khôi phụckhông đổigiữ nguyênmuốn nhìn trước rồi quay lại

Hai điều hay làm người ta vấp:

clear()compact() không xóa gì cả. clear() chỉ dịch hai số nguyên. Các byte vẫn nằm nguyên trong mảng, như bản trace ở trên cho thấy — byte 0 vẫn là L sau clear(). Nếu buffer đang giữ một password, bạn phải tự ghi đè lên nó.

compact() để lại byte cũ phía sau position mới. Dump toàn bộ mảng nền qua bản trace là thấy ngay:

Text
allocate(16)     [................] pos= 0 lim=16
put(HELLO)       [HELLO...........] pos= 5 lim=16
flip()           [HELLO...........] pos= 0 lim= 5
get(2)           [HELLO...........] pos= 2 lim= 5
compact()        [LLOLO...........] pos= 3 lim=16
clear()          [LLOLO...........] pos= 0 lim=16

Sau compact() ba byte chưa đọc LLO nằm ở index 0, 1, 2 và position là 3 — nhưng index 3 và 4 vẫn giữ LO cũ. Chúng nằm sau position nên sẽ không bao giờ được đọc như dữ liệu; chúng chỉ đơn giản là không bị xóa về 0.

Vượt quá một trong hai biên đều throw, và hai exception có tên khác nhau cho hai hướng:

Text
put past the limit         java.nio.BufferOverflowException
get past the limit         java.nio.BufferUnderflowException

mark mong manh hơn vẻ ngoài của nó. flip(), rewind()clear() đều hủy nó, và reset() khi không có mark hợp lệ thì throw:

Text
reset after rewind: java.nio.InvalidMarkException
reset after flip:   java.nio.InvalidMarkException
reset with no mark: java.nio.InvalidMarkException

Một chi tiết cuối: các accessor tuyệt đối get(int)put(int, byte) không dịch position — đó chính là công dụng của chúng — nhưng chúng được kiểm tra biên theo limit chứ không phải capacity. Sau một flip() đặt limit bằng 5, b.get(5) throw IndexOutOfBoundsException dù capacity của buffer là 16.

Đọc file qua FileChannel

Có mô hình trong đầu rồi thì vòng lặp channel tự viết ra. read đổ đầy buffer từ vị trí hiện tại của channel và trả về số byte đã chuyển, hoặc -1 khi hết file:

Java
try (FileChannel ch = FileChannel.open(p, StandardOpenOption.READ)) {
    ByteBuffer buf = ByteBuffer.allocate(16);
    int n;
    while ((n = ch.read(buf)) != -1) {
        buf.flip();
        byte[] got = new byte[buf.remaining()];
        buf.get(got);
        System.out.println(new String(got, StandardCharsets.UTF_8));
        buf.clear();
    }
}
Text
file size = 45
  read #1 returned 16 bytes, pos=16 : The quick brown
  read #2 returned 16 bytes, pos=32 : fox jumps over t
  read #3 returned 13 bytes, pos=45 : he lazy dog.\n
  reads=4 (last one returned -1), bytes=45

Ba lần read tối đa 16 byte cộng một lần trả về -1, và position của channel tự bám theo tổng số. (Ký tự xuống dòng cuối được in ra dưới dạng escape hai ký tự để mỗi dòng nằm gọn trên một dòng.) Chú ý là một buffer 16 byte sẽ cắt đôi một ký tự nhiều byte nếu văn bản có ký tự như vậy — channel chuyển byte, còn decode văn bản an toàn qua ranh giới buffer cần CharsetDecoder chứ không phải một new String cho mỗi mảnh.

Quên flip() thì không có gì xảy ra cả, và im lặng:

Text
the classic bug: reading without flip()
  read returned 16, pos=16 lim=16
  bytes taken without flip: 0 -> []

Channel đã đổ đầy buffer và để position ở 16, limit ở 16, nên remaining() bằng 0 và mảng byte[] bạn cấp phát từ đó có độ dài 0. Không exception nào cả. Một kết quả rỗng sau một lần read thành công gần như luôn luôn là thiếu flip().

FileChannel còn có dạng tuyệt đối, nhận offset file tường minh và không đụng tới position của channel. Đó là công cụ đúng cho truy cập ngẫu nhiên, và gọi được an toàn từ nhiều thread trên cùng một channel:

Java
ByteBuffer at4 = ByteBuffer.allocate(5);
ch.read(at4, 4);
at4.flip();
ch.write(ByteBuffer.wrap("SLOW ".getBytes(StandardCharsets.UTF_8)), 4);
Text
  read(buf, 4) = quick
  channel position unchanged = 0
  after write(buf, 4): The SLOW  brown fox jumps over the lazy dog.
  size() = 45

Files.newByteChannel(path) cho bạn cùng một object qua một interface hẹp hơn — trên JVM này nó trả về sun.nio.ch.FileChannelImpl — và SeekableByteChannel là type nên nhận trong API của chính bạn khi bạn chỉ cần read, write, position và size.

Heap buffer và direct buffer

ByteBuffer.allocate cho bạn một buffer nằm trên một byte[] trong Java heap. ByteBuffer.allocateDirect cho bạn một buffer nằm ngoài heap, trong vùng nhớ mà hệ điều hành có thể đưa thẳng cho một syscall read hay write.

Text
allocate class             java.nio.HeapByteBuffer
allocateDirect class       java.nio.DirectByteBuffer
heap.isDirect()            false
direct.isDirect()          true
heap.hasArray()            true
direct.hasArray()          false
direct.array()             java.lang.UnsupportedOperationException

Cách giải thích quen thuộc là "direct buffer tránh được một lần copy", đúng nhưng nhìn code thì không kiểm chứng được. Nó quan sát được trực tiếp qua BufferPoolMXBean, thứ báo cáo JVM đã cấp phát bao nhiêu bộ nhớ buffer ngoài heap. Hai lần chạy JVM riêng biệt, một bên ghi 100 lần từ heap buffer và một bên từ direct buffer cùng kích thước:

Text
--- ByteBuffer.allocate (heap) ---
at startup                  direct pool: count=0 memoryUsed=0
after allocating the buffer direct pool: count=0 memoryUsed=0
after 100 writes            direct pool: count=1 memoryUsed=8192
 
--- ByteBuffer.allocateDirect ---
at startup                  direct pool: count=0 memoryUsed=0
after allocating the buffer direct pool: count=1 memoryUsed=8192
after 100 writes            direct pool: count=1 memoryUsed=8192

Lần chạy heap không cấp phát byte direct nào cho tới lần write đầu tiên, rồi JVM tự cấp phát một direct buffer 8192 byte — đúng bằng kích thước buffer đang được ghi. Đó là sun.nio.ch.IOUtil copy byte từ heap của bạn vào một direct buffer tạm, vì syscall không nhận được một mảng Java mà garbage collector có quyền di dời. Nó cache buffer tạm đó theo thread, nên 100 lần write vẫn chỉ thấy một lần cấp phát. Lần chạy direct cấp phát đúng một buffer bạn yêu cầu và không cần gì thêm.

Đó mới là cái giá thật. Direct buffer đắt hơn khi tạo và không được giải phóng theo lịch bạn kiểm soát — nó được thu hồi khi object buffer bị collect, hoặc khi JVM chạm -XX:MaxDirectMemorySize và buộc phải dọn. Vậy nên direct buffer có lời khi nó sống lâu và được tái sử dụng qua nhiều thao tác, và lỗ khi nó sống ngắn. Cấp phát vài cái một lần rồi dùng lại, đừng bao giờ cấp phát một cái cho mỗi request.

Hai tính chất nữa đáng biết: ByteBuffer mặc định là big-endian và order(ByteOrder.LITTLE_ENDIAN) đổi cách các accessor nhiều byte diễn giải cùng dãy byte đó, còn asIntBuffer(), asCharBuffer() và họ hàng cho bạn một view có kiểu trên cùng vùng nhớ.

Text
default order  BIG_ENDIAN
big-endian     01 02 03 04
little-endian  04 03 02 01
asIntBuffer capacity       4
asCharBuffer capacity      8

transferTo và map file vào bộ nhớ

FileChannel.transferTo copy từ channel này sang channel khác mà byte không bao giờ đi vào bộ nhớ Java của process. Đếm số lời gọi thay vì đo thời gian cho thấy rõ cái gì thay đổi. Copy 8 MB bằng một direct buffer 64 KB, so với cùng 8 MB đó qua transferTo:

Text
source = 8388608 bytes, sha256[0:16] = beb75e9d18c49d72
copy loop      read calls=129  write calls=128  bytes=8388608
transferTo     calls=1               bytes=8388608
 
copy-loop.bin        8388608 bytes  sha256[0:16]=beb75e9d18c49d72
copy-transfer.bin    8388608 bytes  sha256[0:16]=beb75e9d18c49d72
copy-files.bin       8388608 bytes  sha256[0:16]=beb75e9d18c49d72

128 lần read và 128 lần write gói lại thành một lời gọi, và cả ba file có hash giống hệt nhau. Nhưng vẫn phải viết vòng lặp, vì transferTo được phép chuyển ít byte hơn số bạn yêu cầu:

Java
long size = in.size();
long sent = 0;
while (sent < size) {
    long n = in.transferTo(sent, size - sent, out);
    if (n == 0) break;
    sent += n;
}

Với việc copy file sang file thuần túy thì Files.copy mới là câu trả lời, và ở trên nó cho kết quả giống từng byte; transferTo đáng giá khi một đầu là socket, đó là cách một file server gửi file đi mà không cần đọc nó lên.

FileChannel.map đi xa hơn: nó nhờ hệ điều hành map một vùng của file vào không gian địa chỉ. Kết quả là một MappedByteBuffer — một ByteBuffer như mọi cái khác, trừ việc ghi vào nó là ghi vào file:

Java
try (FileChannel ch = FileChannel.open(p, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
    MappedByteBuffer m = ch.map(FileChannel.MapMode.READ_WRITE, 0, ch.size());
    m.position(8);
    m.put("done!!!".getBytes(StandardCharsets.US_ASCII));
    m.put(23, (byte) '4').put(24, (byte) '2');
    m.force();
}
Text
before: STATUS: pending | rows: 0000 |
mapped class     = java.nio.DirectByteBuffer
isDirect         = true
capacity         = 27
after : STATUS: done!!! | rows: 0420 |

Không có lời gọi write nào cả — byte được đổi trong bộ nhớ và file trên disk đổi theo. force() yêu cầu chúng được flush xuống; thiếu nó thì thay đổi vẫn hiển thị với process khác qua page cache, nhưng không có gì bảo đảm nó đã tới thiết bị lưu trữ.

Bốn tính chất của mapping không hề hiển nhiên:

Text
size before map = 3
size after map(0, 32) = 32, on disk = 32
channel closed; mapping still readable: a
write after close landed on disk: Z
delete while mapped: true
still readable through the mapping: a

Map một vùng lớn hơn file thì file được nới ra — một file 3 byte thành 32 byte. Mapping sống lâu hơn channel: đóng FileChannel không hề unmap, và đọc ghi qua buffer vẫn chạy sau đó. Không có unmap(); mapping được giải phóng khi buffer bị garbage collect, và đó là lý do một file đang được map rất khó xóa trên Windows dù lần xóa ở trên thành công trên macOS. Còn ba map mode hành xử đúng như tên: READ_ONLY throw java.nio.ReadOnlyBufferException khi put, còn PRIVATE cho bạn một view copy-on-write mà thay đổi chỉ thấy trong buffer và không bao giờ tới file — đã kiểm chứng ở trên, byte 0 đọc ra X qua buffer và S trên disk.

Mỗi mapping cũng bị giới hạn ở Integer.MAX_VALUE byte, vì index của ByteBufferint — xin thêm đúng một byte là nhận java.lang.IllegalArgumentException: Size exceeds Integer.MAX_VALUE. Một file lớn hơn 2 GB cần nhiều mapping.

Khi nào NIO là công cụ sai

Mọi thứ ở trên đều tồn tại vì một lý do, và không lý do nào trong số đó là "đọc một file config". Đây là một file text UTF-8 nhỏ được đọc đúng cách qua channel:

Java
try (FileChannel ch = FileChannel.open(p, StandardOpenOption.READ)) {
    ByteBuffer bytes = ByteBuffer.allocate((int) ch.size());
    while (bytes.hasRemaining() && ch.read(bytes) != -1) { }
    bytes.flip();
    CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder()
            .onMalformedInput(CodingErrorAction.REPORT);
    return dec.decode(bytes).toString();
}

Và cùng công việc đó:

Java
return Files.readString(p);
Text
channel version  = Xin chào\nHello\n
readString       = Xin chào\nHello\n
identical        = true

Kết quả giống hệt. Bản dùng channel dài chín dòng với ba chỗ có thể sai — quên flip(), đọc thiếu, một ký tự nhiều byte bị cắt qua ranh giới buffer — và nó chẳng đổi lại được gì, vì Files.readString vốn đã đọc cả file một lần vào một byte[] có kích thước lấy từ độ dài file.

Hãy dùng tầng channel và buffer khi bạn có lý do thật: truy cập ngẫu nhiên tại offset đã biết, một file quá lớn để giữ trong bộ nhớ, một định dạng nhị phân có layout cố định, chuyển zero-copy sang socket, hoặc một index memory-mapped. Còn lại thì cứ Files.

Những lỗi thường gặp

Gọi resolve trên input người dùng. Một argument absolute vứt sạch thư mục gốc của bạn và .. thì leo ra ngoài. Luôn normalize() rồi kiểm tra startsWith trên Path, không bao giờ trên String.

Dùng String.startsWith để kiểm tra chứa. /srv/uploads2/x qua được phép thử chuỗi và trượt phép thử Path. Chỉ cái thứ hai là đúng.

Trông chờ normalize() kiểm tra gì đó. Nó là string algebra thuần túy. Nó vui vẻ trả về path tới một file không tồn tại, và nó không nhìn thấy symlink. toRealPath() mới là cái kiểm tra, và nó throw NoSuchFileException khi không có gì ở đó.

Để một stream từ Files.walk, list hay find không close. Một directory handle bị rò thì không bao giờ được thu hồi; 61.436 cái đã làm cạn process trong lần chạy ở trên. Đưa từng cái vào try-with-resources.

Gọi Files.delete trên một thư mục còn nội dung. DirectoryNotEmptyException, và deleteIfExists cũng throw đúng cái đó. Xóa con trước cha bằng một walk đảo thứ tự hoặc một walkFileTree.

Tưởng Files.copy đi đệ quy. Nó tạo một thư mục rỗng rồi dừng. JDK không có copy đệ quy.

Đọc từ buffer mà không flip(). Bạn nhận được 0 byte và không có lỗi nào, vì remaining() bằng 0.

Tưởng clear() là xóa sạch. Nó dịch positionlimit. Các byte vẫn nằm nguyên đó.

Cấp phát một direct buffer cho mỗi thao tác. Chúng đắt khi tạo và chỉ được giải phóng bởi collector. Cấp phát một lần, dùng lại, và để mắt tới -XX:MaxDirectMemorySize.

Coi một event của WatchService là mô tả của file. Event bị gộp, ENTRY_MODIFY có thể mất hẳn trên bản polling, và OVERFLOW nghĩa là đã có event bị bỏ rơi. Hãy coi mọi event là "đi mà xem".

FAQ

normalize và toRealPath trong Java khác nhau thế nào?

normalize() loại bỏ các element ... khỏi chuỗi path và hoàn toàn không có I/O, nên nó chạy được trên path không tồn tại và không nhìn thấy symlink. toRealPath() đi xuống filesystem: biến path thành absolute, normalize, giải mọi symlink, và throw java.nio.file.NoSuchFileException nếu có thành phần nào không tồn tại. Dùng normalize() để kiểm tra và so sánh, dùng toRealPath() khi bạn cần vị trí chuẩn tắc của một file chắc chắn đang có.

Vì sao Path.resolve đôi khi bỏ qua base path?

Vì argument là absolute. resolve được đặc tả là trả về nguyên argument khi argument đã absolute, nên Path.of("/srv/uploads").resolve("/etc/passwd")/etc/passwd. Đây là thiết kế có chủ đích và là gốc rễ của rất nhiều bug path traversal. Nếu argument đến từ ngoài chương trình, hãy resolve, normalize(), rồi loại bỏ mọi kết quả có startsWith thư mục gốc trả về false.

Có bắt buộc phải close stream do Files.walk trả về không?

Có. Files.walk, Files.list, Files.findFiles.lines đều trả về một Stream đang giữ một directory hoặc file handle mở, và không cái nào được garbage collection dọn giúp. Rò chúng làm cạn giới hạn file descriptor của process — 61.436 directory stream bị rò là đủ trên máy dùng ở đây. Hãy dùng try-with-resources cho từng cái.

Khi nào nên dùng walkFileTree thay vì Files.walk?

Khi bạn cần cắt nhánh. Files.walk đã đọc xong một thư mục vào lúc bạn nhìn thấy entry của nó, nên lọc node_modules ra khỏi kết quả vẫn phải trả giá cho việc chui vào; preVisitDirectory trả về SKIP_SUBTREE thì tránh hẳn. walkFileTree cũng là hình dạng tự nhiên để xóa một cây, vì postVisitDirectory chạy sau các con, và nó là cái duy nhất cho phép xử lý IOException của từng entry qua visitFileFailed thay vì bỏ dở toàn bộ quá trình duyệt.

Vì sao WatchService không bắn event trên macOS?

Vì JDK không có bộ theo dõi native cho macOS và rơi về sun.nio.fs.PollingWatchService, thứ quét lại thư mục theo chu kỳ — một hằng số cứng hai giây trong OpenJDK 21 — rồi so timestamp. Thay đổi được phát hiện ở lần quét kế tiếp chứ không phải ngay lập tức, và hai thay đổi trong cùng một cửa sổ quét gộp lại thành một event hoặc biến mất: trong lần chạy ở trên một ENTRY_MODIFY mất hẳn vì file đã bị xóa trước lần quét sau. Trên Linux thì bản dựa trên inotify được dùng thay thế. Đừng bao giờ giả định bạn đã thấy mọi thay đổi; hãy quét lại ở mỗi event.

flip() thật sự làm gì với một ByteBuffer?

Nó đặt limit bằng position hiện tại rồi đặt position về 0, và không đụng tới byte nào. Việc đó biến "tôi vừa ghi N byte" thành "có N byte sẵn sàng để đọc", nên nó nằm giữa mọi lần đổ đầy và mọi lần rút ra. Hai người anh em của nó là clear(), đặt position về 0 và limit về capacity để ghi lại từ đầu, và compact(), dồn số byte chưa đọc về đầu rồi đặt position ngay sau chúng để bạn ghi thêm mà không mất chúng.

Direct ByteBuffer có luôn nhanh hơn heap buffer không?

Không. Một heap buffer đưa cho channel sẽ bị copy vào một direct buffer tạm trước — thấy được dưới dạng một lần cấp phát 8192 byte xuất hiện trong pool direct của BufferPoolMXBean ở lần write đầu tiên trong phép đo ở trên — nên direct buffer tránh được lần copy đó. Nhưng direct buffer đắt hơn khi cấp phát và chỉ được giải phóng khi object buffer bị collect, nên cấp phát một cái cho mỗi thao tác còn tệ hơn dùng heap buffer. Direct buffer thắng khi nó sống lâu và được dùng lại; thứ gì sống ngắn thì cứ để trên heap.

Kết luận

Path là algebra chứ không phải filesystem: resolve, resolveSibling, relativize, normalize, subpathstartsWith đều chạy trong bộ nhớ, và chỉ toRealPath — cái giải symlink và throw NoSuchFileException khi thiếu thành phần — mới thật sự đi hỏi disk. resolve trả về nguyên một argument absolute và vứt base của bạn, nên mọi path dựng từ input người dùng đều cần normalize() rồi một phép kiểm tra startsWith trên Path chứ không phải trên String. Files cho bạn năm cách duyệt cây với quy tắc khác nhau về độ sâu, lọc và đóng; ba cách trả về Stream đang giữ file descriptor thật và đã rò 61.436 cái trước khi process chết. Attribute lấy được trong một lời gọi readAttributes, symlink làm đổi mọi câu trả lời nếu không truyền NOFOLLOW_LINKS, và WatchService trên macOS là một bộ quét hai giây đã làm bay mất một ENTRY_MODIFY trong lần chạy ở trên. Bên dưới tất cả, một ByteBuffer là ba số nguyên với một bất biến — mark <= position <= limit <= capacity — còn flip, clearcompact chỉ là những cách khác nhau để dịch hai trong ba số đó; allocateDirect bỏ qua lần copy tạm mà JVM vẫn làm hộ bạn, transferTo gói 128 lần read và 128 lần write của vòng copy vào đúng một lời gọi, và một MappedByteBuffer đã đổi nội dung file trên disk mà không cần một lệnh write nào. Còn với một file text nhỏ thì không cần gì trong số đó: Files.readString cho ra kết quả y hệt trong một dòng.

Bài tiếp theo đi lên một tầng, từ byte trên disk sang dữ liệu có cấu trúc: làm việc với JSON và XML trong Java — parse, sinh ra, ánh xạ sang object, và các thư viện làm việc đó.

Bài viết liên quan

[Advanced Java] Stream API trong Java: map, filter, reduce và collect

Stream API của Java trên OpenJDK 21: pipeline gồm source, intermediate và terminal, tính lazy được chứng minh bằng trace println xen kẽ, map, filter, cả ba overload của reduce, collect cùng bộ Collectors, primitive stream và chi phí allocation của boxing, và các bẫy quanh peek, findAny, lambda có state cùng parallelStream.

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Thread trong Java: Thread, Runnable và virtual thread

Thread trong Java trên OpenJDK 21: thread là gì, stack riêng và heap dùng chung, tạo thread bằng Thread, Runnable và lambda, start khác run ra sao, join, daemon thread, tên và priority, output không xác định giữa các lần chạy, virtual thread với Thread.ofVirtual, và interrupt.

[Advanced Java] JDBC trong Java: Kết nối và thao tác Database

JDBC trong Java trên OpenJDK 21 với một database SQLite thật: kiến trúc driver phía sau DriverManager, try-with-resources cho Connection, Statement và ResultSet, một màn SQL injection chạy thật mà PreparedStatement chặn được, ResultSet như một cursor, transaction với commit, rollback và Savepoint, batch update, getGeneratedKeys và cách đọc một SQLException.