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 đó.
![]()
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.readAllLines và Files.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ầng | Type | Trả lời câu hỏi |
|---|---|---|
| Định danh | Path | Nó ở đâ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 filesystem | Files | Có 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 |
| Byte | FileChannel, ByteBuffer | Chuyể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ó Selector và SocketChannel 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:
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")); // truepath /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
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") là 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ờ:
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ĩ:
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.bakresolveSibling 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:
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/passwdDò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:
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;
}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/passwdnormalize() 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:
/srv/uploads2/x startsWith /srv/uploads = false
"/srv/uploads2/x".startsWith("/srv/uploads") = trueSo 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 . và .. 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:
normalize a/../b/./c b/c
normalize /var/app/../log /var/log
normalize ../../x ../../x
normalize /../../x /xChú ý 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:
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.txttoRealPath() 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:
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:
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.txtnormalize() 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.equalslà so sánh cú pháp.Path.of("/var/app/./conf").equals(Path.of("/var/app/conf"))làfalse, và chỉ thànhtruesau khinormalize(). 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ềtruecho một path relative so với chínhtoAbsolutePath()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 a và b, nó tạo ra path relative đưa bạn từ a tới b, sao cho a.resolve(a.relativize(b)) bằng b.
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")); // ../../etcNó 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:
relativize mixed java.lang.IllegalArgumentException: 'other' is different type of PathThô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
├── pom.xml
├── src
│ └── App.java
└── target
└── App.class
list, walk, find và newDirectoryStream
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);
}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.xmlNhững khác biệt thật sự quan trọng:
| Lời gọi | Độ sâu | Có kể thư mục gốc | Lọc | Trả về |
|---|---|---|---|---|
Files.list(dir) | một cấp | không | không | Stream<Path> |
Files.walk(dir) | không giới hạn | có | không | Stream<Path> |
Files.walk(dir, maxDepth) | chặn theo cấp | có | không | Stream<Path> |
Files.find(dir, d, matcher) | chặn theo cấp | có | một BiPredicate trên path và attribute | Stream<Path> |
Files.newDirectoryStream(dir, glob) | một cấp | không | một glob, do filesystem áp dụng | DirectoryStream<Path> |
Files.walkFileTree(dir, visitor) | không giới hạn | có | callback của bạn | khô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:
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.
iterator twice: java.lang.IllegalStateException: Iterator already obtainedwalkFileTree, 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":
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;
}
});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 treetarget đượ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:
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/backChú ý 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, walk và find 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:
for (int i = 0; i < 100_000; i++) {
Stream<Path> s = Files.list(dir); // never closed
s.findFirst();
}leaked streams opened: 61436
then: java.nio.file.FileSystemException: .: Too many open files61.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:
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.copy và Files.move mặc định từ chối ghi đè:
copy onto existing: java.nio.file.FileAlreadyExistsException: cm/b.txtStandardCopyOption đổi điều đó, và chỉ có ba option đáng nhớ:
| Option | Tác dụng |
|---|---|
REPLACE_EXISTING | ghi đè lên target thay vì throw |
COPY_ATTRIBUTES | mang 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_MOVE | chỉ 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:
copy of a directory -> children copied? falseJDK 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ự:
createDirectories twice: cm/box/inner
createDirectory existing: java.nio.file.FileAlreadyExistsException: cm/box
createDirectory no parent: java.nio.file.NoSuchFileException: cm/x/ycreateDirectories (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:
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.txtdeleteIfExists 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ó:
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);
}
}
}deleting the whole tree with a reverse-ordered walk:
cm exists after: falseBả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.
Files.mismatch = 6
Files.mismatch identical = -1File 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:
BasicFileAttributes b = Files.readAttributes(f, BasicFileAttributes.class);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:
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 acl và dos 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:
Map<String, Object> m = Files.readAttributes(f, "posix:*");posix:* keys [creationTime, fileKey, group, isDirectory, isOther, isRegularFile,
isSymbolicLink, lastAccessTime, lastModifiedTime, owner, permissions, size]Attribute cũng ghi được:
after setLastModified 2020-09-13T12:26:40Z
after chmod r--r-----
isWritable falseSymlink và NOFOLLOW_LINKS
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:
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à isSymbolicLink là true. 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:
exists(broken) false
exists(broken, NOFOLLOW) true
notExists(broken) trueFiles.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ề false và notExists 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:
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:
implementation = sun.nio.fs.PollingWatchServicesun.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:
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 ownENTRY_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ỡ:
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
gethoặcputtương đối sẽ dùng. - mark — một position được nhớ lại, đặt bằng
mark()và khôi phục bằngreset().
Mọi thao tác bạn từng gọi trên buffer đều là một cách cụ thể để dịch position và limit. In cả ba sau mỗi lời gọi thì mô hình hết bí ẩn:
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);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
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ọi | position | limit | Dữ liệu | Dùng khi |
|---|---|---|---|---|
flip() | 0 | position cũ | giữ nguyên | ghi xong và muốn rút ra |
clear() | 0 | capacity | giữ nguyên | rút xong và muốn ghi lại từ đầu |
compact() | số byte chưa đọc | capacity | byte chưa đọc dồn về đầu | mới rút một phần và muốn ghi thêm vào phần còn lại |
rewind() | 0 | không đổi | giữ nguyên | muốn đọc lại đúng vùng đó |
mark() / reset() | ghi nhớ / khôi phục | không đổi | giữ nguyên | muốn nhìn trước rồi quay lại |
Hai điều hay làm người ta vấp:
clear() và 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:
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=16Sau 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ữ L và O 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:
put past the limit java.nio.BufferOverflowException
get past the limit java.nio.BufferUnderflowExceptionmark mong manh hơn vẻ ngoài của nó. flip(), rewind() và clear() đều hủy nó, và reset() khi không có mark hợp lệ thì throw:
reset after rewind: java.nio.InvalidMarkException
reset after flip: java.nio.InvalidMarkException
reset with no mark: java.nio.InvalidMarkExceptionMột chi tiết cuối: các accessor tuyệt đối get(int) và 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:
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();
}
}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=45Ba 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:
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:
ByteBuffer at4 = ByteBuffer.allocate(5);
ch.read(at4, 4);
at4.flip();
ch.write(ByteBuffer.wrap("SLOW ".getBytes(StandardCharsets.UTF_8)), 4); read(buf, 4) = quick
channel position unchanged = 0
after write(buf, 4): The SLOW brown fox jumps over the lazy dog.
size() = 45Files.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.
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.UnsupportedOperationExceptionCá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:
--- 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=8192Lầ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ớ.
default order BIG_ENDIAN
big-endian 01 02 03 04
little-endian 04 03 02 01
asIntBuffer capacity 4
asCharBuffer capacity 8transferTo 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:
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]=beb75e9d18c49d72128 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:
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:
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();
}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:
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: aMap 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 ByteBuffer là int — 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:
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 đó:
return Files.readString(p);channel version = Xin chào\nHello\n
readString = Xin chào\nHello\n
identical = trueKế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 position và limit. 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 . và .. 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") là /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.find và Files.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, subpath và startsWith đề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, clear và compact 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 đó.